Compare commits
5 Commits
0211e5e98d
...
61a70319a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a70319a0 | |||
| 099e92b1ff | |||
| 79e3ed4a8a | |||
| 91ea91784d | |||
| 6c794d0732 |
File diff suppressed because one or more lines are too long
@@ -16,8 +16,7 @@ use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类)
|
||||
* 数据源为订货明细按商品聚合(无独立采购明细表),每门店一列显示数量
|
||||
* 采购单导出
|
||||
*/
|
||||
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
||||
{
|
||||
@@ -135,7 +134,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
|
||||
return array_merge(
|
||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)),
|
||||
array_values($this->storeNames),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,23 +83,4 @@ class StatementController extends BaseMiniController
|
||||
|
||||
return $this->success($statement->toArray());
|
||||
}
|
||||
|
||||
/** 导出对账单:?format=xlsx|pdf */
|
||||
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
|
||||
public function export(int $id, Request $request): Response
|
||||
{
|
||||
$user = $this->currentUser($request);
|
||||
$store = $this->ensureStoreBound($user);
|
||||
|
||||
$statement = StatementModel::where('store_id', $store->id)->find($id);
|
||||
if ($statement === null) {
|
||||
throw new RepositoryException('对账单不存在');
|
||||
}
|
||||
|
||||
return app(ExportService::class)->download(
|
||||
'statement',
|
||||
$statement,
|
||||
(string) $request->query('format', ExportService::FORMAT_XLSX),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class StoreOrderController extends BaseController
|
||||
$query = StoreOrderModel::query()->with([
|
||||
'store:id,name,address,contact,phone',
|
||||
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
|
||||
'purchase:id,purchase_no,purchase_date,status'
|
||||
]);
|
||||
|
||||
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
|
||||
@@ -206,60 +207,6 @@ class StoreOrderController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改周转框/周转托盘数量
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function container(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'box_num' => 'required|integer|min:0',
|
||||
'tray_num' => 'required|integer|min:0',
|
||||
], [
|
||||
'box_num.required' => '周转框数量不能为空',
|
||||
'box_num.integer' => '周转框数量必须为整数',
|
||||
'box_num.min' => '周转框数量不能小于 0',
|
||||
'tray_num.required' => '周转托盘数量不能为空',
|
||||
'tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'tray_num.min' => '周转托盘数量不能小于 0',
|
||||
]);
|
||||
|
||||
$order = StoreOrderModel::find($id);
|
||||
if (empty($order)) {
|
||||
throw new RepositoryException('订单不存在');
|
||||
}
|
||||
|
||||
$editable = [
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
];
|
||||
if (! in_array($order->status, $editable, true)) {
|
||||
throw new RepositoryException(
|
||||
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
|
||||
);
|
||||
}
|
||||
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $trayPrice, 2);
|
||||
|
||||
// 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立
|
||||
$productAmount = (float) $order->product_amount;
|
||||
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
||||
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
||||
}
|
||||
|
||||
$order->box_num = (int) $data['box_num'];
|
||||
$order->tray_num = (int) $data['tray_num'];
|
||||
$order->product_amount = $productAmount;
|
||||
$order->added_amount = $addedAmount;
|
||||
$order->total_amount = round($productAmount + $addedAmount, 2);
|
||||
$order->save();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态流转合法路径
|
||||
*
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\PurchaseOrderExport;
|
||||
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
@@ -12,19 +14,21 @@ use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Services\ItemImageResolver;
|
||||
use App\Services\PurchaseGenerateService;
|
||||
use App\Services\StoreOrderContainerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\PostRoute;
|
||||
use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改)
|
||||
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id)
|
||||
* 采购单管理
|
||||
*/
|
||||
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
||||
class PurchaseOrderController extends BaseController
|
||||
@@ -131,6 +135,45 @@ class PurchaseOrderController extends BaseController
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
// 周转框/托盘合并记录:按门店聚合全部订单(附底层订单明细,供采购单完成前修改)
|
||||
$storeOrders = StoreOrderModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get(['id', 'order_no', 'store_id', 'box_num', 'tray_num']);
|
||||
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$storeSort = $stores->pluck('id')->flip();
|
||||
$containers = [];
|
||||
foreach ($storeOrders->groupBy('store_id') as $storeId => $orders) {
|
||||
$boxNum = (int) $orders->sum('box_num');
|
||||
$trayNum = (int) $orders->sum('tray_num');
|
||||
$containers[] = [
|
||||
'store_id' => (int) $storeId,
|
||||
'store_name' => $stores->firstWhere('id', (int) $storeId)['name'] ?? '门店#' . $storeId,
|
||||
'box_num' => $boxNum,
|
||||
'tray_num' => $trayNum,
|
||||
'box_price' => number_format($boxPrice, 2),
|
||||
'tray_price' => number_format($trayPrice, 2),
|
||||
'added_amount' => number_format($boxNum * $boxPrice + $trayNum * $trayPrice, 2),
|
||||
'order_count' => $orders->count(),
|
||||
'orders' => $orders->map(static fn (StoreOrderModel $order) => [
|
||||
'order_id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'box_num' => (int) $order->box_num,
|
||||
'tray_num' => (int) $order->tray_num,
|
||||
])->values()->toArray(),
|
||||
'store_sort' => (int) ($storeSort[$storeId] ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($containers, static fn (array $a, array $b): int =>
|
||||
[$a['store_sort'], $a['store_id']] <=> [$b['store_sort'], $b['store_id']]);
|
||||
$containers = array_map(static function (array $row): array {
|
||||
unset($row['store_sort']);
|
||||
return $row;
|
||||
}, $containers);
|
||||
|
||||
return $this->success([
|
||||
'purchase' => $purchase->toArray(),
|
||||
'stores' => $stores->toArray(),
|
||||
@@ -138,29 +181,65 @@ class PurchaseOrderController extends BaseController
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows),
|
||||
'containers' => $containers,
|
||||
]);
|
||||
}
|
||||
|
||||
/** C4 修改采购单头信息(采购日期、状态、备注) */
|
||||
/** 修改采购单信息(采购日期、实际金额、备注) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchase_date' => 'nullable|date_format:Y-m-d',
|
||||
'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED,
|
||||
'actual_amount' => 'required|numeric|min:0',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
||||
'status.in' => '采购单状态不正确',
|
||||
'actual_amount.required' => '实际金额不能为空',
|
||||
'actual_amount.numeric' => '实际金额格式错误',
|
||||
'actual_amount.min' => '实际金额不能小于0',
|
||||
'remark.max' => '备注超过最大长度',
|
||||
]);
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改信息');
|
||||
}
|
||||
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成采购单
|
||||
* @throws
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/finish', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function finish(int $id): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改信息');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($purchase) {
|
||||
// 修改采购单状态
|
||||
$purchase->status = PurchaseOrderModel::STATUS_COMPLETED;
|
||||
$purchase->save();
|
||||
// 修改所有订单状态为配送中
|
||||
StoreOrderModel::where('purchase_id', $purchase->id)->update([
|
||||
'status' => StoreOrderModel::STATUS_DISTRIBUTION
|
||||
]);
|
||||
// 生成并发送账单
|
||||
|
||||
return $this->success();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成采购单
|
||||
*/
|
||||
@@ -190,6 +269,29 @@ class PurchaseOrderController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出采购单 Excel 表格
|
||||
*
|
||||
* @throws
|
||||
*/
|
||||
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
|
||||
public function export(int $id, Request $request): Response
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
$type = (string) $request->query('type', 'all');
|
||||
if (! in_array($type, ['all', 'category'], true)) {
|
||||
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
|
||||
}
|
||||
|
||||
return Excel::download(
|
||||
new PurchaseOrderExport($purchase, $type),
|
||||
$purchase->purchase_no . '_采购单.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格下钻:采购单中某门店某商品的全部订货明细
|
||||
*/
|
||||
@@ -243,8 +345,6 @@ class PurchaseOrderController extends BaseController
|
||||
'amount' => (string) $item->amount,
|
||||
'remark' => (string) $item->remark,
|
||||
'image_ids' => $item->image_ids,
|
||||
'editable' => $purchase->status === PurchaseOrderModel::STATUS_PENDING
|
||||
&& in_array((int) $item->order_status, StoreOrderItemModel::ITEM_EDITABLE_STATUS, true),
|
||||
];
|
||||
}
|
||||
app(ItemImageResolver::class)->resolve($rows);
|
||||
@@ -259,6 +359,152 @@ class PurchaseOrderController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
|
||||
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
|
||||
*/
|
||||
#[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function storeSummary(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$storeId = (int) $request->query('store_id', 0);
|
||||
if ($storeId <= 0) {
|
||||
throw new RepositoryException('缺少门店参数');
|
||||
}
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
|
||||
$items = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $purchase->id)
|
||||
->where('store_order_item.store_id', $storeId)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->orderBy('store_order_item.id')
|
||||
->get();
|
||||
|
||||
// 排序键:分类 sort → 商品 sort(与明细矩阵同序)
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', $items->pluck('product_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($items->groupBy('product_id') as $productId => $group) {
|
||||
$product = $products->get((int) $productId);
|
||||
$first = $group->first();
|
||||
// 采购总数量(包数)
|
||||
$quantity = 0;
|
||||
// 预计金额 = Σ 明细 amount
|
||||
$amount = '0';
|
||||
// 总重量
|
||||
$weight = '0';
|
||||
foreach ($group as $item) {
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
$quantity += (int) $item->quantity;
|
||||
$amount = bcadd($amount, (string) $item->amount, 2);
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'product_id' => (int) $productId,
|
||||
'product_name' => $first->product_name,
|
||||
'product_spec' => $first->product_spec, // 包规
|
||||
'unit' => $first->unit, // 单位
|
||||
'price' => $quantity > 0 // 加权平均单价
|
||||
? bcdiv($amount, (string) $quantity, 2)
|
||||
: (string) $first->price,
|
||||
'quantity' => $quantity,
|
||||
'amount' => $amount,
|
||||
'weight' => $weight,
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
$store = StoreModel::withTrashed()->find($storeId);
|
||||
|
||||
return $this->success([
|
||||
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
|
||||
'items' => array_map(static function (array $row): array {
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改)
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PutRoute(route: '/{id}/container/{storeId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
|
||||
public function updateContainer(int $id, int $storeId, PurchaseContainerUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
|
||||
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
|
||||
}
|
||||
|
||||
$submitted = $request->validated()['orders'];
|
||||
|
||||
return DB::transaction(function () use ($purchase, $storeId, $submitted) {
|
||||
$orders = StoreOrderModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->where('store_id', $storeId)
|
||||
->whereNull('deleted_at')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
throw new RepositoryException('该采购单下无此门店的订单');
|
||||
}
|
||||
|
||||
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差
|
||||
$actualIds = $orders->pluck('id')->map(static fn ($orderId) => (int) $orderId)->all();
|
||||
$submittedIds = array_map(static fn (array $row) => (int) $row['order_id'], $submitted);
|
||||
$missing = array_diff($actualIds, $submittedIds);
|
||||
if ($missing !== []) {
|
||||
throw new RepositoryException('提交不完整,缺少订单:' . implode('、', $missing));
|
||||
}
|
||||
if (array_diff($submittedIds, $actualIds) !== []) {
|
||||
throw new RepositoryException('包含不属于该采购单该门店的订单');
|
||||
}
|
||||
|
||||
foreach ($orders as $order) {
|
||||
if (! in_array($order->status, [
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
], true)) {
|
||||
throw new RepositoryException(
|
||||
'订单 ' . $order->order_no . ' 当前状态为「'
|
||||
. (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status)
|
||||
. '」,不允许修改周转框/托盘数量'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$ordersById = $orders->keyBy('id');
|
||||
$containerService = app(StoreOrderContainerService::class);
|
||||
foreach ($submitted as $row) {
|
||||
$containerService->update(
|
||||
$ordersById->get($row['order_id']),
|
||||
(int) $row['box_num'],
|
||||
(int) $row['tray_num'],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->success();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品行修改:品名/供应商/包规/单位/成本
|
||||
* @throws Throwable
|
||||
|
||||
@@ -4,13 +4,11 @@ namespace App\Http\Controllers\Recon;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Services\ExportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\AnnoRoute\Attribute\GetRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
|
||||
@@ -52,26 +50,4 @@ class SettlementController extends BaseController
|
||||
}
|
||||
return $this->success($settlement->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
|
||||
*/
|
||||
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
|
||||
public function download(int $id, Request $request): Response
|
||||
{
|
||||
$settlement = SettlementModel::find($id);
|
||||
if (empty($settlement)) {
|
||||
throw new RepositoryException('结算表不存在');
|
||||
}
|
||||
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
|
||||
|
||||
$response = app(ExportService::class)->download('settlement', $settlement, $format);
|
||||
|
||||
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
|
||||
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
|
||||
$settlement->save();
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购单周转框/托盘合并记录修改 验证(按门店覆盖全部订单,逐笔重算附加金额)
|
||||
*/
|
||||
class PurchaseContainerUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'orders' => 'required|array|min:1',
|
||||
'orders.*.order_id' => 'required|integer|distinct',
|
||||
'orders.*.box_num' => 'required|integer|min:0',
|
||||
'orders.*.tray_num' => 'required|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'orders.required' => '请提交门店订单周转框/托盘数量',
|
||||
'orders.array' => '订单数据格式错误',
|
||||
'orders.min' => '请提交门店订单周转框/托盘数量',
|
||||
'orders.*.order_id.required' => '订单ID不能为空',
|
||||
'orders.*.order_id.integer' => '订单ID格式错误',
|
||||
'orders.*.order_id.distinct' => '存在重复订单',
|
||||
'orders.*.box_num.required' => '周转框数量不能为空',
|
||||
'orders.*.box_num.integer' => '周转框数量必须为整数',
|
||||
'orders.*.box_num.min' => '周转框数量不能小于 0',
|
||||
'orders.*.tray_num.required' => '周转托盘数量不能为空',
|
||||
'orders.*.tray_num.integer' => '周转托盘数量必须为整数',
|
||||
'orders.*.tray_num.min' => '周转托盘数量不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class PurchaseOrderModel extends Model
|
||||
'estimate_amount' => 'decimal:2',
|
||||
'actual_amount' => 'decimal:2',
|
||||
'operator_id' => 'integer',
|
||||
'created_at' => 'datetime:Y-m-d H:i:s',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,4 +88,12 @@ class StoreOrderModel extends Model
|
||||
{
|
||||
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Exports\PurchaseOrderExport;
|
||||
use App\Exports\SettlementExport;
|
||||
use App\Exports\StatementExport;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\SettlementModel;
|
||||
use App\Models\StatementModel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
|
||||
*
|
||||
* - Excel 分支:Excel::download(new XxxExport(...))
|
||||
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
|
||||
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
|
||||
*/
|
||||
class ExportService
|
||||
{
|
||||
/** 导出格式:Excel */
|
||||
public const FORMAT_XLSX = 'xlsx';
|
||||
/** 导出格式:PDF */
|
||||
public const FORMAT_PDF = 'pdf';
|
||||
|
||||
/**
|
||||
* 导出下载
|
||||
*
|
||||
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
|
||||
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
|
||||
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
|
||||
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
|
||||
* @return Response 文件流响应(blob)
|
||||
*/
|
||||
public function download(
|
||||
string $business,
|
||||
mixed $subject,
|
||||
string $format = self::FORMAT_XLSX,
|
||||
?string $type = null,
|
||||
): Response {
|
||||
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
|
||||
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf)');
|
||||
}
|
||||
|
||||
return match ($business) {
|
||||
'purchase' => $this->exportPurchase($subject, $format, $type),
|
||||
'statement' => $this->exportStatement($subject, $format),
|
||||
'settlement' => $this->exportSettlement($subject, $format),
|
||||
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C2/C3 采购单导出
|
||||
*/
|
||||
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
|
||||
{
|
||||
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $purchase->purchase_no . '_采购单.' . $extension;
|
||||
|
||||
$export = new PurchaseOrderExport($purchase, $type);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.purchase', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店对账单导出
|
||||
*/
|
||||
private function exportStatement(StatementModel $statement, string $format): Response
|
||||
{
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $statement->statement_no . '_对账单.' . $extension;
|
||||
|
||||
$export = new StatementExport($statement);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.statement', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* D10 结算表导出
|
||||
*/
|
||||
private function exportSettlement(SettlementModel $settlement, string $format): Response
|
||||
{
|
||||
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
|
||||
$filename = $settlement->settlement_no . '_结算表.' . $extension;
|
||||
|
||||
$export = new SettlementExport($settlement);
|
||||
if ($format === self::FORMAT_PDF) {
|
||||
return Pdf::loadView('exports.settlement', $export->viewData())
|
||||
->setPaper('a4')
|
||||
->download($filename);
|
||||
}
|
||||
|
||||
return Excel::download($export, $filename);
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,9 @@ readonly class PurchaseGenerateService
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
|
||||
'0'
|
||||
);
|
||||
// 预估金额 = Σ订货金额(明细金额 price×quantity),与采购单修改重算口径一致
|
||||
// 预估成本
|
||||
$estimateAmount = $items->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
|
||||
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StoreOrderModel;
|
||||
|
||||
/**
|
||||
* 周转框/周转托盘数量修改(订单附加金额重算)
|
||||
*/
|
||||
readonly class StoreOrderContainerService
|
||||
{
|
||||
/**
|
||||
* 更新订单周转框/托盘数量并重算附加金额与订单总金额。
|
||||
* 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立。
|
||||
*/
|
||||
public function update(StoreOrderModel $order, int $boxNum, int $trayNum): void
|
||||
{
|
||||
$boxPrice = (float) site_config('services.box_amount', 0);
|
||||
$trayPrice = (float) site_config('services.tray_amount', 0);
|
||||
$addedAmount = round($boxNum * $boxPrice + $trayNum * $trayPrice, 2);
|
||||
|
||||
$productAmount = (float) $order->product_amount;
|
||||
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
|
||||
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
|
||||
}
|
||||
|
||||
$order->box_num = $boxNum;
|
||||
$order->tray_num = $trayNum;
|
||||
$order->product_amount = $productAmount;
|
||||
$order->added_amount = $addedAmount;
|
||||
$order->total_amount = round($productAmount + $addedAmount, 2);
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ use App\Models\UserModel;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
/**
|
||||
* 采购单导出:xlsx Content-Type / 蔬果分类过滤 / PDF / format 非法拒绝 / 权限拦截 /
|
||||
* 中文文件名 RFC 5987 编码
|
||||
* 采购单导出(仅 Excel 表格):xlsx Content-Type / 蔬果分类过滤 / type 非法拒绝 /
|
||||
* 权限拦截 / 中文文件名 RFC 5987 编码
|
||||
*/
|
||||
class ExportTest extends ProcurementTestCase
|
||||
{
|
||||
@@ -56,7 +56,7 @@ class ExportTest extends ProcurementTestCase
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?type=all&format=xlsx");
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export");
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
@@ -78,7 +78,7 @@ class ExportTest extends ProcurementTestCase
|
||||
$this->actingAsSysUser();
|
||||
|
||||
Excel::fake();
|
||||
$this->get("/purchase/order/{$purchase->id}/export?type=category&format=xlsx")->assertOk();
|
||||
$this->get("/purchase/order/{$purchase->id}/export?type=category")->assertOk();
|
||||
|
||||
Excel::assertDownloaded(
|
||||
$purchase->purchase_no . '_采购单.xlsx',
|
||||
@@ -90,25 +90,22 @@ class ExportTest extends ProcurementTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF 导出:application/pdf */
|
||||
public function test_export_pdf(): void
|
||||
/** type 参数非法 → 拒绝 */
|
||||
public function test_export_invalid_type_rejected(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=pdf");
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('application/pdf', (string) $response->headers->get('Content-Type'));
|
||||
$this->assertStringStartsWith('%PDF', (string) $response->getContent());
|
||||
$this->get("/purchase/order/{$purchase->id}/export?type=doc")
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** format 参数非法 → 拒绝 */
|
||||
public function test_export_invalid_format_rejected(): void
|
||||
/** 采购单不存在 → 拒绝 */
|
||||
public function test_export_missing_purchase_rejected(): void
|
||||
{
|
||||
$purchase = $this->buildPurchaseWithItems();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->get("/purchase/order/{$purchase->id}/export?format=doc")
|
||||
$this->get('/purchase/order/99999/export')
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
@@ -119,7 +116,7 @@ class ExportTest extends ProcurementTestCase
|
||||
// 仅持有查询权限的用户
|
||||
$this->actingAsSysUser(['purchase.order.query']);
|
||||
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export?format=xlsx");
|
||||
$response = $this->get("/purchase/order/{$purchase->id}/export");
|
||||
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
|
||||
/**
|
||||
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
|
||||
@@ -52,6 +55,23 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||
}
|
||||
|
||||
/** 播种站点配置:周转框单价 2.00、周转托盘单价 10.00 */
|
||||
private function seedContainerConfig(): void
|
||||
{
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
||||
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'type' => 'InputNumber',
|
||||
'values' => $value,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
Cache::forget('site_config');
|
||||
}
|
||||
|
||||
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
||||
public function test_detail_returns_store_matrix(): void
|
||||
{
|
||||
@@ -335,6 +355,87 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
|
||||
}
|
||||
|
||||
/** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */
|
||||
public function test_store_summary_returns_aggregated_items(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[0]->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertSame($stores[0]->name, $data['store']['name']);
|
||||
$this->assertCount(1, $data['items']);
|
||||
|
||||
$row = $data['items'][0];
|
||||
$this->assertSame($product->id, $row['product_id']);
|
||||
$this->assertSame('10斤/箱', $row['product_spec']);
|
||||
$this->assertSame('斤', $row['unit']);
|
||||
$this->assertSame('10.00', (string) $row['price']);
|
||||
$this->assertSame(2, $row['quantity']);
|
||||
$this->assertSame('20.00', (string) $row['amount']);
|
||||
|
||||
// 另一门店只见自身明细
|
||||
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[1]->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.quantity', 3)
|
||||
->assertJsonPath('data.items.0.amount', '30.00');
|
||||
}
|
||||
|
||||
/** 门店购买详情:同一商品多笔订单不同单价 → 数量合计、金额求和、单价为加权平均 */
|
||||
public function test_store_summary_aggregates_multiple_orders_with_weighted_price(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 20,
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
foreach ([2, 3] as $qty) {
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
// 第二笔明细单价改为 20.00,构造同商品双单价:2×10 + 3×20
|
||||
$secondItem = StoreOrderItemModel::orderBy('id')->skip(1)->first();
|
||||
$this->putJson("/purchase/order/cell/{$secondItem->id}", ['quantity' => 3, 'price' => 20])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$store->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.quantity', 5)
|
||||
->assertJsonPath('data.items.0.price', '16.00', '(20+60) ÷ 5 加权平均')
|
||||
->assertJsonPath('data.items.0.amount', '80.00');
|
||||
}
|
||||
|
||||
/** 门店购买详情参数校验:缺少门店参数 / 采购单不存在均拒绝 */
|
||||
public function test_store_summary_rejects_missing_or_invalid_params(): void
|
||||
{
|
||||
[$purchase] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->getJson("/purchase/order/{$purchase->id}/store")
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '缺少门店参数');
|
||||
|
||||
$this->getJson('/purchase/order/99999/store?store_id=1')
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '采购单不存在');
|
||||
}
|
||||
|
||||
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
|
||||
public function test_edits_rejected_when_purchase_completed(): void
|
||||
{
|
||||
@@ -359,4 +460,148 @@ class PurchaseEditTest extends ProcurementTestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.editable', false);
|
||||
}
|
||||
|
||||
/** 详情返回周转框/托盘合并记录:按门店聚合全部订单(含底层订单明细与单价) */
|
||||
public function test_detail_returns_container_summary(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
|
||||
StoreOrderModel::where('store_id', $stores[0]->id)->update(['box_num' => 2, 'tray_num' => 1]);
|
||||
StoreOrderModel::where('store_id', $stores[1]->id)->update(['box_num' => 3, 'tray_num' => 0]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$containers = $this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->json('data.containers');
|
||||
$this->assertCount(2, $containers);
|
||||
$this->assertSame($stores[0]->id, $containers[0]['store_id'], '与门店列同序');
|
||||
|
||||
$row = $containers[0];
|
||||
$this->assertSame($stores[0]->name, $row['store_name']);
|
||||
$this->assertSame(2, $row['box_num']);
|
||||
$this->assertSame(1, $row['tray_num']);
|
||||
$this->assertSame('2.00', $row['box_price']);
|
||||
$this->assertSame('10.00', $row['tray_price']);
|
||||
$this->assertSame('14.00', $row['added_amount'], '2×2.00 + 1×10.00');
|
||||
$this->assertSame(1, $row['order_count']);
|
||||
$this->assertCount(1, $row['orders']);
|
||||
|
||||
$order = $row['orders'][0];
|
||||
$this->assertStringStartsWith('SO', $order['order_no']);
|
||||
$this->assertSame(2, $order['box_num']);
|
||||
$this->assertSame(1, $order['tray_num']);
|
||||
|
||||
$this->assertSame(3, $containers[1]['box_num']);
|
||||
$this->assertSame('6.00', $containers[1]['added_amount']);
|
||||
}
|
||||
|
||||
/** 合并记录修改:逐笔更新订单数量并重算附加金额与订单总金额,其他门店不受影响 */
|
||||
public function test_update_container_syncs_order_amounts(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->assertSame('20.00', (string) $order->product_amount, '下单时应写入商品金额');
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => 3, 'tray_num' => 1]],
|
||||
])->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(3, $order->box_num);
|
||||
$this->assertSame(1, $order->tray_num);
|
||||
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
||||
$this->assertSame('20.00', (string) $order->product_amount);
|
||||
$this->assertSame('36.00', (string) $order->total_amount, '20.00 + 16.00');
|
||||
|
||||
$other = StoreOrderModel::where('store_id', $stores[1]->id)->first();
|
||||
$this->assertSame(0, $other->box_num, '其他门店订单不受影响');
|
||||
$this->assertSame('30.00', (string) $other->total_amount);
|
||||
}
|
||||
|
||||
/** 采购单已完成:合并记录修改拒绝,数量与金额不变 */
|
||||
public function test_update_container_rejected_when_purchase_completed(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => 5, 'tray_num' => 5]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '采购单已完成,不允许修改周转框/托盘');
|
||||
|
||||
$this->assertSame(0, $order->fresh()->box_num, '被拒绝后数量不变');
|
||||
}
|
||||
|
||||
/** 提交必须覆盖该门店全部订单;跨门店/跨采购单订单拒绝 */
|
||||
public function test_update_container_rejects_incomplete_or_foreign_orders(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
foreach ([1, 2] as $qty) {
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$orders = StoreOrderModel::where('purchase_id', $purchase->id)->orderBy('id')->get();
|
||||
$this->assertCount(2, $orders, '同一门店两笔订单');
|
||||
|
||||
// 只提交一笔 → 缺少另一笔,整批拒绝
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||
'orders' => [['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '提交不完整,缺少订单:' . $orders[1]->id);
|
||||
|
||||
// 混入其他门店订单(同一采购单另一门店)→ 拒绝
|
||||
$otherStore = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($otherStore->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
|
||||
->assertJsonPath('success', true);
|
||||
$foreign = StoreOrderModel::where('store_id', $otherStore->id)->first();
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
|
||||
'orders' => [
|
||||
['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $orders[1]->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $foreign->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '包含不属于该采购单该门店的订单');
|
||||
|
||||
$this->assertSame(0, $orders[0]->fresh()->box_num, '被拒绝后数量不变');
|
||||
}
|
||||
|
||||
/** 数量为负/重复订单时校验失败 */
|
||||
public function test_update_container_validates_negative_and_duplicate_orders(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [['order_id' => $order->id, 'box_num' => -1, 'tray_num' => 0]],
|
||||
])->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '周转框数量不能小于 0');
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
|
||||
'orders' => [
|
||||
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
|
||||
],
|
||||
])->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价)
|
||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00
|
||||
*/
|
||||
private function seedAcceptedOrders(): ProductModel
|
||||
{
|
||||
@@ -27,7 +27,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$levelLow = CustomerLevelModel::factory()->create();
|
||||
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '6.00']);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
|
||||
|
||||
@@ -43,7 +43,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
return $product;
|
||||
}
|
||||
|
||||
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */
|
||||
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ数量×成本价(预估成本) */
|
||||
public function test_generate_aggregates_order_items_into_header(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
@@ -61,7 +61,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
||||
|
||||
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
|
||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00');
|
||||
$this->assertSame('60.00', (string) $purchase->estimate_amount, '10 件 × 每包成本 6.00');
|
||||
$this->assertSame('0.00', (string) $purchase->actual_amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ use App\Models\ProductPriceModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
|
||||
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
|
||||
|
||||
/**
|
||||
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
|
||||
@@ -133,23 +130,6 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
|
||||
}
|
||||
|
||||
/** 种子化周转框/托盘单价配置(框 2.00、托盘 10.00) */
|
||||
private function seedContainerConfig(): void
|
||||
{
|
||||
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
|
||||
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
|
||||
SysSiteConfigItemsModel::create([
|
||||
'group_id' => $group->id,
|
||||
'key' => $key,
|
||||
'title' => $title,
|
||||
'type' => 'InputNumber',
|
||||
'values' => $value,
|
||||
'sort' => 0,
|
||||
]);
|
||||
}
|
||||
Cache::forget('site_config');
|
||||
}
|
||||
|
||||
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00) */
|
||||
private function makeOrderWithStatus(int $status): StoreOrderModel
|
||||
{
|
||||
@@ -163,94 +143,6 @@ class StoreOrderTest extends ProcurementTestCase
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** 修改周转框/托盘:自动重算附加金额与订单总金额 */
|
||||
public function test_container_update_recalculates_amounts(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$this->assertSame('10.00', (string) $order->product_amount, '下单时应写入商品金额');
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 3, 'tray_num' => 1])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame(3, $order->box_num);
|
||||
$this->assertSame(1, $order->tray_num);
|
||||
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
|
||||
$this->assertSame('10.00', (string) $order->product_amount);
|
||||
$this->assertSame('26.00', (string) $order->total_amount, '10.00 + 16.00');
|
||||
}
|
||||
|
||||
/** 已接单、采购中、配送中均可修改 */
|
||||
public function test_container_update_allowed_in_editable_statuses(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
foreach ([
|
||||
StoreOrderModel::STATUS_SUMMARIZED,
|
||||
StoreOrderModel::STATUS_DELIVERING,
|
||||
StoreOrderModel::STATUS_DISTRIBUTION,
|
||||
] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 1, 'tray_num' => 0])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
$this->assertSame('2.00', (string) $order->fresh()->added_amount);
|
||||
}
|
||||
}
|
||||
|
||||
/** 待接单、已完成、已取消不允许修改 */
|
||||
public function test_container_update_rejected_in_other_statuses(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_PENDING);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
foreach ([
|
||||
StoreOrderModel::STATUS_PENDING,
|
||||
StoreOrderModel::STATUS_COMPLETED,
|
||||
StoreOrderModel::STATUS_CANCELLED,
|
||||
] as $status) {
|
||||
$order->update(['status' => $status]);
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 5, 'tray_num' => 5])
|
||||
->assertOk()->assertJsonPath('success', false);
|
||||
}
|
||||
$order->refresh();
|
||||
$this->assertSame(0, $order->box_num, '被拒绝后数量不变');
|
||||
$this->assertSame('10.00', (string) $order->total_amount, '被拒绝后总金额不变');
|
||||
}
|
||||
|
||||
/** 历史订单未写商品金额时按「总额 - 附加」反推回写 */
|
||||
public function test_container_update_heals_legacy_product_amount(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
$order->update(['product_amount' => 0]); // 模拟历史数据
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 2, 'tray_num' => 0])
|
||||
->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$order->refresh();
|
||||
$this->assertSame('10.00', (string) $order->product_amount, '反推回写商品金额');
|
||||
$this->assertSame('4.00', (string) $order->added_amount, '2×2.00');
|
||||
$this->assertSame('14.00', (string) $order->total_amount);
|
||||
}
|
||||
|
||||
/** 数量为负时校验失败 */
|
||||
public function test_container_update_validates_negative_numbers(): void
|
||||
{
|
||||
$this->seedContainerConfig();
|
||||
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/order/store/{$order->id}/container", ['box_num' => -1, 'tray_num' => 0])
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', '周转框数量不能小于 0');
|
||||
}
|
||||
|
||||
/** 软删除:仅已取消订单可由后台删除,删除后后台/小程序端均不可见 */
|
||||
public function test_soft_delete_only_cancelled_orders(): void
|
||||
{
|
||||
|
||||
@@ -27,15 +27,6 @@ export async function batchUpdateOrderStatus(ids: number[], status: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改周转框/周转托盘数量(自动重算附加金额与订单总金额,仅已接单/采购中/配送中可改) */
|
||||
export async function updateOrderContainer(id: number, data: { box_num: number; tray_num: number }) {
|
||||
return createAxios({
|
||||
url: `/order/store/${id}/container`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除订单 */
|
||||
export async function deleteStoreOrder(id: number) {
|
||||
return createAxios({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import { downloadBlob } from '@/api/common/download.ts';
|
||||
import type {
|
||||
IPurchaseCell,
|
||||
IPurchaseDetail,
|
||||
IPurchaseStoreSummary,
|
||||
PurchaseExportType,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
|
||||
/** 订单商品参数修改 */
|
||||
@@ -20,6 +23,13 @@ export interface PurchaseCellUpdateParams {
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */
|
||||
export interface PurchaseContainerOrderParams {
|
||||
order_id: number;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
}
|
||||
|
||||
|
||||
/** 生成采购单 */
|
||||
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
|
||||
@@ -64,3 +74,34 @@ export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellU
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 门店购买详情:采购单内某门店的采购汇总(按商品聚合) */
|
||||
export async function getPurchaseStoreSummary(purchaseId: number, storeId: number) {
|
||||
return createAxios<IPurchaseStoreSummary>({
|
||||
url: `/purchase/order/${purchaseId}/store`,
|
||||
method: 'get',
|
||||
params: { store_id: storeId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */
|
||||
export async function updatePurchaseContainer(
|
||||
purchaseId: number,
|
||||
storeId: number,
|
||||
orders: PurchaseContainerOrderParams[],
|
||||
) {
|
||||
return createAxios({
|
||||
url: `/purchase/order/${purchaseId}/container/${storeId}`,
|
||||
method: 'put',
|
||||
data: { orders },
|
||||
});
|
||||
}
|
||||
|
||||
/** C2/C3 导出采购单(Excel 表格):type=all 全品类 / category 仅蔬果分类 */
|
||||
export async function exportPurchase(id: number, type: PurchaseExportType = 'all') {
|
||||
return downloadBlob(
|
||||
`/purchase/order/${id}/export`,
|
||||
{ type },
|
||||
`采购单_${id}.xlsx`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,34 @@ export interface IPurchaseDetail {
|
||||
purchase: IPurchaseOrder;
|
||||
stores: { id: number; name: string }[];
|
||||
items: IPurchaseDetailRow[];
|
||||
/** 周转框/托盘合并记录(按门店聚合) */
|
||||
containers: IPurchaseContainerStore[];
|
||||
}
|
||||
|
||||
/** 合并记录中的底层门店订单(订单号 + 框/托盘数量) */
|
||||
export interface IPurchaseContainerOrder {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
box_num: number;
|
||||
tray_num: number;
|
||||
}
|
||||
|
||||
/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计) */
|
||||
export interface IPurchaseContainerStore {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
/** 周转框合计 */
|
||||
box_num: number;
|
||||
/** 周转托盘合计 */
|
||||
tray_num: number;
|
||||
/** 周转框单价(站点配置) */
|
||||
box_price: string;
|
||||
/** 周转托盘单价(站点配置) */
|
||||
tray_price: string;
|
||||
/** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */
|
||||
added_amount: string;
|
||||
order_count: number;
|
||||
orders: IPurchaseContainerOrder[];
|
||||
}
|
||||
|
||||
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
|
||||
@@ -73,8 +101,6 @@ export interface IPurchaseCellItem {
|
||||
remark: string;
|
||||
/** 首图 */
|
||||
image?: string;
|
||||
/** 是否可编辑/同步(采购单进行中且订货单未锁定) */
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
/** 单元格下钻数据(门店 + 商品 + 全部订货明细) */
|
||||
@@ -84,6 +110,28 @@ export interface IPurchaseCell {
|
||||
items: IPurchaseCellItem[];
|
||||
}
|
||||
|
||||
/** 门店购买详情行(门店采购汇总,按商品聚合) */
|
||||
export interface IPurchaseStoreItem {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
/** 规格/包规 */
|
||||
product_spec: string;
|
||||
unit: string;
|
||||
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
|
||||
price: string;
|
||||
/** 数量(包数) */
|
||||
quantity: number;
|
||||
/** 预计金额 = Σ 明细 amount */
|
||||
amount: string;
|
||||
weight: string;
|
||||
}
|
||||
|
||||
/** 门店购买详情(采购单内某门店的采购汇总) */
|
||||
export interface IPurchaseStoreSummary {
|
||||
store: { id: number; name: string } | null;
|
||||
items: IPurchaseStoreItem[];
|
||||
}
|
||||
|
||||
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '进行中', color: 'processing' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
|
||||
@@ -73,6 +73,12 @@ export default interface IStoreOrder {
|
||||
remark?: string;
|
||||
items?: IStoreOrderItem[];
|
||||
created_at?: string;
|
||||
purchase?: {
|
||||
id: number;
|
||||
purchase_no: string;
|
||||
purchase_date: string;
|
||||
status: number;
|
||||
}
|
||||
}
|
||||
|
||||
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
@@ -83,13 +89,3 @@ export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: strin
|
||||
4: { text: '已完成', color: 'success' },
|
||||
9: { text: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
/** 待汇总预览行 */
|
||||
export interface IOrderSummaryRow {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_spec: string;
|
||||
unit: string;
|
||||
total_quantity: string;
|
||||
store_count: number;
|
||||
}
|
||||
|
||||
+21
-98
@@ -6,7 +6,6 @@ import {
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
@@ -27,7 +26,6 @@ import type IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import {
|
||||
getStoreOrder,
|
||||
updateOrderContainer,
|
||||
updateOrderStatus,
|
||||
batchUpdateOrderStatus,
|
||||
deleteStoreOrder,
|
||||
@@ -36,13 +34,11 @@ import { generatePurchase } from '@/api/purchase/order.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
import { DeleteOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import {PURCHASE_STATUS_MAP} from "@/domain/iPurchaseOrder.ts";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
|
||||
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
|
||||
|
||||
|
||||
/**
|
||||
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
|
||||
@@ -73,11 +69,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
const [detail, setDetail] = useState<IStoreOrder | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const [containerOpen, setContainerOpen] = useState(false);
|
||||
const [containerOrder, setContainerOrder] = useState<IStoreOrder | null>(null);
|
||||
const [containerSaving, setContainerSaving] = useState(false);
|
||||
const [containerForm] = Form.useForm<{ box_num: number; tray_num: number }>();
|
||||
|
||||
/** 勾选行(批量流转/生成采购单用) */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
@@ -146,25 +137,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openContainer = (record: IStoreOrder) => {
|
||||
setContainerOrder(record);
|
||||
containerForm.setFieldsValue({ box_num: record.box_num, tray_num: record.tray_num });
|
||||
setContainerOpen(true);
|
||||
};
|
||||
|
||||
const handleContainerSave = async (values: { box_num: number; tray_num: number }) => {
|
||||
if (!containerOrder?.id) return;
|
||||
setContainerSaving(true);
|
||||
try {
|
||||
await updateOrderContainer(containerOrder.id, values);
|
||||
message.success('周转框/托盘数量已更新');
|
||||
setContainerOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setContainerSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteStoreOrder(id);
|
||||
@@ -173,13 +145,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
|
||||
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
|
||||
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
|
||||
const previewAdded = watchBoxNum * Number(containerOrder?.box_price ?? 0)
|
||||
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
|
||||
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
|
||||
|
||||
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||
{
|
||||
title: '订单号',
|
||||
@@ -344,10 +309,28 @@ const StoreOrderPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '采购单ID',
|
||||
title: '采购单',
|
||||
dataIndex: 'purchase_id',
|
||||
valueType: 'digit',
|
||||
hideInForm: true,
|
||||
render: (_, record) => record.purchase ? (
|
||||
<Space orientation={'vertical'}>
|
||||
<div>
|
||||
<Text type={'secondary'}>采购单号:</Text>
|
||||
{record.purchase.purchase_no}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>采购单日期:</Text>
|
||||
{record.purchase.purchase_date}
|
||||
</div>
|
||||
<div>
|
||||
<Text type={'secondary'}>采购单状态:</Text>
|
||||
<Tag color={PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.color}>
|
||||
{PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</div>
|
||||
</Space>
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
title: '账单ID',
|
||||
@@ -368,15 +351,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => openDetail(record.id!)}
|
||||
/>
|
||||
{ CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) && (
|
||||
<AuthButton key="container" auth="order.store.update">
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => openContainer(record)}
|
||||
/>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ NEXT_STATUS[record.status!] && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
@@ -468,13 +442,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
footer={
|
||||
detail ? (
|
||||
<Space className="flex justify-end" wrap>
|
||||
{CONTAINER_EDITABLE_STATUS.includes(detail.status ?? -1) && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Button icon={<SettingOutlined />} onClick={() => openContainer(detail)}>
|
||||
设置附加信息
|
||||
</Button>
|
||||
</AuthButton>
|
||||
)}
|
||||
{ NEXT_STATUS[detail.status!] && (
|
||||
<AuthButton auth="order.store.update">
|
||||
<Popconfirm
|
||||
@@ -696,50 +663,6 @@ const StoreOrderPage: React.FC = () => {
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
|
||||
{/* 修改周转框/托盘数量 */}
|
||||
<Modal
|
||||
title="修改周转框/托盘"
|
||||
open={containerOpen}
|
||||
onCancel={() => setContainerOpen(false)}
|
||||
onOk={() => containerForm.submit()}
|
||||
confirmLoading={containerSaving}
|
||||
okText="保存"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
订单 {containerOrder?.order_no},保存后将按单价自动重算附加金额与订单总金额。
|
||||
</div>
|
||||
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
||||
<Form.Item
|
||||
label={`周转框数量(单价 ¥${containerOrder?.box_price ?? '0.00'})`}
|
||||
name="box_num"
|
||||
rules={[{ required: true, message: '请输入周转框数量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转框数量" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={`周转托盘数量(单价 ¥${containerOrder?.tray_price ?? '0.00'})`}
|
||||
name="tray_num"
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转托盘数量" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space orientation="vertical" className="w-full rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<Text type="secondary">商品总金额:</Text>¥{containerOrder?.product_amount ?? '0.00'}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">附加总金额:</Text>
|
||||
<Text strong>¥{previewAdded.toFixed(2)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">订单总金额:</Text>
|
||||
<Text strong type="danger">¥{previewTotal.toFixed(2)}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+477
-55
@@ -3,6 +3,7 @@ import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Image,
|
||||
@@ -15,10 +16,11 @@ import {
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
@@ -30,15 +32,22 @@ import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
||||
import type {
|
||||
IPurchaseCell,
|
||||
IPurchaseCellItem,
|
||||
IPurchaseContainerStore,
|
||||
IPurchaseDetail,
|
||||
IPurchaseDetailRow,
|
||||
IPurchaseStoreItem,
|
||||
IPurchaseStoreSummary,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import {
|
||||
exportPurchase,
|
||||
getPurchaseCell,
|
||||
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
|
||||
getPurchaseDetail,
|
||||
getPurchaseStoreSummary,
|
||||
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
|
||||
updatePurchaseCellItem,
|
||||
updatePurchaseContainer,
|
||||
updatePurchaseRow,
|
||||
} from '@/api/purchase/order.ts';
|
||||
import { Update } from '@/api/common/table.ts';
|
||||
@@ -48,10 +57,10 @@ import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 参考单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
||||
const calcUnitCost = (cost: number, spec: string): number => {
|
||||
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
|
||||
const calcUnitRefPrice = (total: number, spec: string): number => {
|
||||
const pack = parseFloat(spec);
|
||||
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
|
||||
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -85,6 +94,32 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const [cellItemSaving, setCellItemSaving] = useState(false);
|
||||
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
|
||||
|
||||
// 门店购买详情(按商品聚合的门店采购汇总)
|
||||
const [detailTab, setDetailTab] = useState('items');
|
||||
const [storeId, setStoreId] = useState<number>(0);
|
||||
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
|
||||
const [storeLoading, setStoreLoading] = useState(false);
|
||||
|
||||
// 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改)
|
||||
const [containerOpen, setContainerOpen] = useState(false);
|
||||
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
|
||||
const [containerSaving, setContainerSaving] = useState(false);
|
||||
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
|
||||
|
||||
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价
|
||||
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
|
||||
const previewContainerBox = watchContainerOrders.reduce(
|
||||
(sum, order) => sum + Number(order?.box_num ?? 0),
|
||||
0,
|
||||
);
|
||||
const previewContainerTray = watchContainerOrders.reduce(
|
||||
(sum, order) => sum + Number(order?.tray_num ?? 0),
|
||||
0,
|
||||
);
|
||||
const previewContainerAdded =
|
||||
previewContainerBox * Number(containerStore?.box_price ?? 0) +
|
||||
previewContainerTray * Number(containerStore?.tray_price ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
@@ -96,6 +131,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
}, [cellOpen, cellQuery]);
|
||||
|
||||
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
|
||||
useEffect(() => {
|
||||
if (detail && detail.stores.length > 0) {
|
||||
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
// 切到「门店购买详情」页签或切换门店时加载汇总
|
||||
useEffect(() => {
|
||||
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
|
||||
void loadStoreSummary();
|
||||
}
|
||||
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
@@ -107,10 +156,26 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailTab('items');
|
||||
setStoreSummary(null);
|
||||
setDetailOpen(true);
|
||||
await loadDetail(id);
|
||||
};
|
||||
|
||||
/** 加载门店购买详情(门店采购汇总) */
|
||||
const loadStoreSummary = async () => {
|
||||
if (!detail || storeId <= 0) {
|
||||
return;
|
||||
}
|
||||
setStoreLoading(true);
|
||||
try {
|
||||
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
|
||||
setStoreSummary(res.data.data ?? null);
|
||||
} finally {
|
||||
setStoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
|
||||
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
|
||||
setCellQuery({ productId: row.product_id, storeId: store.id });
|
||||
@@ -199,15 +264,42 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!detail) {
|
||||
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
|
||||
const openContainerEdit = (store: IPurchaseContainerStore) => {
|
||||
setContainerStore(store);
|
||||
containerForm.setFieldsValue({
|
||||
orders: store.orders.map((order) => ({
|
||||
order_id: order.order_id,
|
||||
box_num: order.box_num,
|
||||
tray_num: order.tray_num,
|
||||
})),
|
||||
});
|
||||
setContainerOpen(true);
|
||||
};
|
||||
|
||||
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */
|
||||
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
|
||||
if (!detail || !containerStore) {
|
||||
return;
|
||||
}
|
||||
setContainerSaving(true);
|
||||
try {
|
||||
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
|
||||
message.success('周转框/托盘已更新,订单金额已重算');
|
||||
setContainerOpen(false);
|
||||
setContainerStore(null);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setContainerSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async (id: number) => {
|
||||
setCompleting(true);
|
||||
try {
|
||||
await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 });
|
||||
await Update(`/purchase/order/${id}/finish`, { status: 3 });
|
||||
message.success('采购单已标记完成');
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setCompleting(false);
|
||||
@@ -226,11 +318,11 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
render: (_, row) => row.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '参考单价',
|
||||
title: '参考成本单价',
|
||||
key: 'unit_cost',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
|
||||
render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
|
||||
},
|
||||
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
||||
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
||||
@@ -267,16 +359,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<>
|
||||
{ detail?.purchase.status === 0 ? (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
修改
|
||||
</Button>
|
||||
</AuthButton>
|
||||
) : '-' }
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -317,20 +413,137 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
|
||||
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
|
||||
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
|
||||
{
|
||||
title: '参考零售价',
|
||||
key: 'retail_price',
|
||||
width: 130,
|
||||
align: 'center',
|
||||
render: (_, row) => `¥${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
|
||||
},
|
||||
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
|
||||
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (v) => <Text strong>{v}</Text>,
|
||||
},
|
||||
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}斤` },
|
||||
{
|
||||
title: '预计金额',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
/** 门店购买详情合计行:总数量 + 总预计金额 */
|
||||
const renderStoreTotal = () => {
|
||||
const items = storeSummary?.items ?? [];
|
||||
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
|
||||
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={5} align="center">
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} align="center">
|
||||
<Text strong>{totalQuantity}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={6} align="center">
|
||||
<Text strong>{totalWeight.toFixed(3)}斤</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={7} align="center">
|
||||
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
};
|
||||
|
||||
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
|
||||
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
|
||||
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
|
||||
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
|
||||
{
|
||||
title: '周转框合计',
|
||||
dataIndex: 'box_num',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '周转托盘合计',
|
||||
dataIndex: 'tray_num',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '附加金额',
|
||||
dataIndex: 'added_amount',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
/** 周转框/托盘合并记录合计行 */
|
||||
const renderContainerSummary = () => {
|
||||
const containers = detail?.containers ?? [];
|
||||
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
|
||||
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
|
||||
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={2} align="center">
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="center">
|
||||
<Text strong>{totalBox}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="center">
|
||||
<Text strong>{totalTray}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="center">
|
||||
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||
{
|
||||
title: '采购单号',
|
||||
dataIndex: 'purchase_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
width: 260,
|
||||
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
|
||||
},
|
||||
{
|
||||
title: '采购日期',
|
||||
dataIndex: 'purchase_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
valueType: 'date',
|
||||
align: 'center',
|
||||
hideInForm: true,
|
||||
render: (_, record) => record.purchase_date,
|
||||
},
|
||||
{
|
||||
@@ -338,15 +551,20 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
dataIndex: 'estimate_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
align: 'center',
|
||||
render: (_, record) => `¥${record.estimate_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际成本',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
valueType: "digit",
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
precision: 3,
|
||||
placeholder: "请输入单价"
|
||||
},
|
||||
align: 'center',
|
||||
render: (_, record) =>
|
||||
Number(record.actual_amount) > 0 ? (
|
||||
<Text strong>¥{record.actual_amount}</Text>
|
||||
@@ -354,6 +572,28 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<Text type="secondary">未录入</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总件数',
|
||||
dataIndex: 'total_quantity',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: "textarea",
|
||||
hideInTable: true,
|
||||
hideInSearch: true
|
||||
},
|
||||
{
|
||||
title: '总重量',
|
||||
dataIndex: 'total_weight',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => `${record.total_weight}斤`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -381,10 +621,18 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>
|
||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record, dom) => [
|
||||
record.status === 0 ? dom.edit : null,
|
||||
<Button key="detail" size="small" icon={<UnorderedListOutlined />} type={'primary'} onClick={() => openDetail(record.id!)} />,
|
||||
record.status === 0 ? (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Popconfirm title="确认该采购单已完成?" description="已完成采购单将锁定,不能再修改订单信息" onConfirm={() => handleComplete(record.id!)}>
|
||||
<Button type={'primary'} size="small" variant={'solid'} color={'green'} loading={completing}>
|
||||
完成采购
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
) : null
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||
@@ -394,6 +642,9 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
accessName: 'purchase.order',
|
||||
tableRef,
|
||||
operateRender,
|
||||
operateProps: {
|
||||
width: 300
|
||||
},
|
||||
formProps: false,
|
||||
};
|
||||
|
||||
@@ -414,19 +665,10 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
onClose={() => setDetailOpen(false)}
|
||||
size={1200}
|
||||
loading={detailLoading}
|
||||
extra={detail?.purchase.status === 0 && (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
|
||||
<Button size="small" icon={<CheckOutlined />} loading={completing}>
|
||||
标记完成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Title level={5} className="mt-5! mb-3!">
|
||||
<Title level={5} className="mb-3!">
|
||||
采购单信息
|
||||
</Title>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
@@ -439,24 +681,109 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<Descriptions.Item label="制单人">
|
||||
{detail.purchase.operator?.nickname ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估金额">¥{detail.purchase.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.purchase.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="预估成本">¥{detail.purchase.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际成本">¥{detail.purchase.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="总件数">{detail.purchase.total_quantity}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>{detail.purchase.created_at}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" span={3}>{detail.purchase.remark}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Title level={5} className="mt-5! mb-3!">
|
||||
商品明细
|
||||
</Title>
|
||||
<Table<IPurchaseDetailRow>
|
||||
rowKey="product_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={buildItemColumns()}
|
||||
dataSource={detail.items}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
summary={renderSummary}
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
className="mt-3!"
|
||||
items={[
|
||||
{ key: 'items', label: '商品明细' },
|
||||
{ key: 'stores', label: '门店购买详情' },
|
||||
{ key: 'containers', label: '周转框/托盘' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{ detailTab === 'stores' ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Text>选择门店:</Text>
|
||||
<Select
|
||||
value={storeId || undefined}
|
||||
onChange={(value) => setStoreId(value)}
|
||||
placeholder="选择门店"
|
||||
className="w-60!"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</div>
|
||||
<Spin spinning={storeLoading}>
|
||||
{storeSummary && storeSummary.items.length > 0 ? (
|
||||
<Table<IPurchaseStoreItem>
|
||||
rowKey="product_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={storeColumns}
|
||||
dataSource={storeSummary.items}
|
||||
pagination={false}
|
||||
summary={renderStoreTotal}
|
||||
/>
|
||||
) : (
|
||||
!storeLoading && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该门店在此采购单中无采购商品"
|
||||
className="py-8!"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Spin>
|
||||
</>
|
||||
) : detailTab === 'containers' ? (
|
||||
detail.containers.length > 0 ? (
|
||||
<Table<IPurchaseContainerStore>
|
||||
rowKey="store_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={containerColumns}
|
||||
dataSource={detail.containers}
|
||||
pagination={false}
|
||||
summary={renderContainerSummary}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该采购单暂无门店订单"
|
||||
className="py-8!"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Space style={{ marginBottom: 20 }}>
|
||||
<AuthButton key="export" auth="purchase.order.export">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'all', label: '导出全品类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'all') },
|
||||
{ key: 'category', label: '仅导出蔬果分类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'category') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="primary" ghost icon={<DownloadOutlined />}>
|
||||
导出
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
<Table<IPurchaseDetailRow>
|
||||
rowKey="product_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={buildItemColumns()}
|
||||
dataSource={detail.items}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
summary={renderSummary}
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
@@ -598,7 +925,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
{item.weight ?? '-'} 斤
|
||||
</div>
|
||||
<div className="w-40 shrink-0 text-center">
|
||||
{item.editable ? (
|
||||
{detail?.purchase.status === 0 ? (
|
||||
<Space size={0}>
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
@@ -677,6 +1004,101 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */}
|
||||
<Modal
|
||||
title={
|
||||
containerStore
|
||||
? `${containerStore.store_name} · 周转框/托盘`
|
||||
: '周转框/托盘'
|
||||
}
|
||||
open={containerOpen}
|
||||
onCancel={() => {
|
||||
setContainerOpen(false);
|
||||
setContainerStore(null);
|
||||
}}
|
||||
onOk={() => containerForm.submit()}
|
||||
confirmLoading={containerSaving}
|
||||
okText="保存"
|
||||
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
|
||||
width={760}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
该门店在本采购单下的全部订单({containerStore?.order_count ?? 0} 笔):
|
||||
{detail?.purchase.status === 0
|
||||
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
|
||||
: '采购单已完成,仅可查看。'}
|
||||
</div>
|
||||
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
|
||||
<Form.List name="orders">
|
||||
{(fields) => (
|
||||
<div className="overflow-hidden rounded border border-gray-200">
|
||||
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
|
||||
<div className="flex-1">订单号</div>
|
||||
<div className="w-36 shrink-0 text-center">
|
||||
周转框(单价 ¥{containerStore?.box_price ?? '0.00'})
|
||||
</div>
|
||||
<div className="w-36 shrink-0 text-center">
|
||||
周转托盘(单价 ¥{containerStore?.tray_price ?? '0.00'})
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field) => {
|
||||
const order = containerStore?.orders[field.name];
|
||||
return (
|
||||
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
|
||||
<div className="min-w-0 flex-1 pr-2 text-sm">
|
||||
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
|
||||
</div>
|
||||
<Form.Item name={[field.name, 'order_id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0"
|
||||
name={[field.name, 'box_num']}
|
||||
rules={[{ required: true, message: '请输入周转框数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={detail?.purchase.status !== 0}
|
||||
placeholder="周转框数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="m-0! w-36 shrink-0"
|
||||
name={[field.name, 'tray_num']}
|
||||
rules={[{ required: true, message: '请输入周转托盘数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
min={0}
|
||||
precision={0}
|
||||
disabled={detail?.purchase.status !== 0}
|
||||
placeholder="周转托盘数量"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
|
||||
<div>
|
||||
<Text type="secondary">周转框合计:</Text>
|
||||
<Text strong>{previewContainerBox}</Text>
|
||||
<Text type="secondary" className="ml-6!">周转托盘合计:</Text>
|
||||
<Text strong>{previewContainerTray}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">附加金额合计:</Text>
|
||||
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user