diff --git a/app/Http/Controllers/Order/StoreOrderController.php b/app/Http/Controllers/Order/StoreOrderController.php index c411433..89cb66f 100644 --- a/app/Http/Controllers/Order/StoreOrderController.php +++ b/app/Http/Controllers/Order/StoreOrderController.php @@ -68,14 +68,6 @@ class StoreOrderController extends BaseController } unset($order); - $box_amount = site_config('services.box_amount'); - $tray_amount = site_config('services.tray_amount'); - foreach ($data['data'] as &$item) { - $item['box_price'] = number_format($box_amount, 2); - $item['tray_price'] = number_format($tray_amount, 2); - $item['box_amount'] = number_format($box_amount * $item['box_num'], 2); - $item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2); - } return $this->success($data); } @@ -96,14 +88,8 @@ class StoreOrderController extends BaseController $data = $order->toArray(); - // 明细首图 + 附加金额(与列表接口一致) + // 明细首图 app(ItemImageResolver::class)->resolve($data['items']); - $boxAmount = site_config('services.box_amount'); - $trayAmount = site_config('services.tray_amount'); - $data['box_price'] = number_format($boxAmount, 2); - $data['tray_price'] = number_format($trayAmount, 2); - $data['box_amount'] = number_format($boxAmount * $data['box_num'], 2); - $data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2); return $this->success($data); } diff --git a/app/Http/Controllers/Purchase/PurchaseOrderController.php b/app/Http/Controllers/Purchase/PurchaseOrderController.php index 7de4277..231032a 100644 --- a/app/Http/Controllers/Purchase/PurchaseOrderController.php +++ b/app/Http/Controllers/Purchase/PurchaseOrderController.php @@ -4,17 +4,18 @@ namespace App\Http\Controllers\Purchase; use App\Exceptions\RepositoryException; use App\Exports\PurchaseOrderExport; +use App\Http\Requests\Purchase\PurchaseBillGenerateRequest; use App\Http\Requests\Purchase\PurchaseCellUpdateRequest; -use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest; use App\Http\Requests\Purchase\PurchaseRowUpdateRequest; +use App\Models\BillModel; use App\Models\ProductModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; +use App\Services\BillGenerateService; 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; @@ -135,44 +136,21 @@ 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() + // 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见) + $bills = BillModel::query() ->where('purchase_id', $purchase->id) - ->whereNull('deleted_at') + ->with('store:id,name') ->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); + ->get() + ->map(static function (BillModel $bill): array { + $row = $bill->toArray(); + $row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id); + $row['order_count'] = $bill->orders()->count(); + unset($row['store']); + return $row; + }) + ->values() + ->toArray(); return $this->success([ 'purchase' => $purchase->toArray(), @@ -181,7 +159,7 @@ class PurchaseOrderController extends BaseController unset($row['category_sort'], $row['product_sort']); return $row; }, $rows), - 'containers' => $containers, + 'bills' => $bills, ]); } @@ -232,7 +210,6 @@ class PurchaseOrderController extends BaseController StoreOrderModel::where('purchase_id', $purchase->id)->update([ 'status' => StoreOrderModel::STATUS_DISTRIBUTION ]); - // 生成并发送账单 return $this->success(); }); @@ -438,71 +415,81 @@ class PurchaseOrderController extends BaseController } /** - * 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改) - * @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 + #[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])] + public function billPrepare(int $id): JsonResponse { $purchase = PurchaseOrderModel::find($id); if (empty($purchase)) { throw new RepositoryException('采购单不存在'); } - if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) { - throw new RepositoryException('采购单已完成,不允许修改周转框/托盘'); + + $orders = StoreOrderModel::query() + ->where('purchase_id', $purchase->id) + ->whereNull('deleted_at') + ->orderBy('id') + ->get(['id', 'store_id', 'total_amount']); + + $stores = StoreModel::withTrashed() + ->whereIn('id', $orders->pluck('store_id')->unique()) + ->get(['id', 'name']) + ->keyBy('id'); + + $bills = BillModel::query() + ->where('purchase_id', $purchase->id) + ->get() + ->keyBy('store_id'); + + $boxPrice = number_format((float) site_config('services.box_amount', 0), 2); + $trayPrice = number_format((float) site_config('services.tray_amount', 0), 2); + + $rows = []; + foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) { + $productAmount = $storeOrders->reduce( + static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2), + '0' + ); + $bill = $bills->get((int) $storeId); + $rows[] = [ + 'store_id' => (int) $storeId, + 'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId), + 'order_count' => $storeOrders->count(), + 'product_amount' => $productAmount, + 'box_price' => $boxPrice, + 'tray_price' => $trayPrice, + 'bill' => $bill?->toArray(), + ]; } - $submitted = $request->validated()['orders']; + return $this->success([ + 'purchase' => $purchase->only(['id', 'purchase_no', 'status']), + 'stores' => $rows, + ]); + } - 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(); + /** + * 生成账单 + * @throws Throwable + */ + #[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])] + public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse + { + $purchase = PurchaseOrderModel::find($id); + if (empty($purchase)) { + throw new RepositoryException('采购单不存在'); + } + if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) { + throw new RepositoryException('采购单未完成,不允许生成账单'); + } - if ($orders->isEmpty()) { - throw new RepositoryException('该采购单下无此门店的订单'); - } + $bills = app(BillGenerateService::class)->generate( + $purchase, + $request->validated()['stores'], + (int) $request->user()->id, + ); - // 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差 - $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(); - }); + return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单'); } /** @@ -575,9 +562,8 @@ class PurchaseOrderController extends BaseController // 重算订单金额 $order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id); - $order->product_amount = $order->items()->sum('amount'); $order->total_weight = $order->items()->sum('weight'); - $order->total_amount = bcadd($order->product_amount, $order->added_amount, 2); + $order->total_amount = $order->items()->sum('amount'); $order->save(); // 重算采购单重量 diff --git a/app/Http/Requests/Purchase/PurchaseBillGenerateRequest.php b/app/Http/Requests/Purchase/PurchaseBillGenerateRequest.php new file mode 100644 index 0000000..e82e70f --- /dev/null +++ b/app/Http/Requests/Purchase/PurchaseBillGenerateRequest.php @@ -0,0 +1,45 @@ + 'required|array|min:1', + 'stores.*.store_id' => 'required|integer|distinct', + 'stores.*.delivery_fee' => 'required|numeric|min:0', + 'stores.*.box_num' => 'required|integer|min:0', + 'stores.*.tray_num' => 'required|integer|min:0', + ]; + } + + public function messages(): array + { + return [ + 'stores.required' => '请提交门店账单信息', + 'stores.array' => '门店账单数据格式错误', + 'stores.min' => '请提交门店账单信息', + 'stores.*.store_id.required' => '门店ID不能为空', + 'stores.*.store_id.integer' => '门店ID格式错误', + 'stores.*.store_id.distinct' => '存在重复门店', + 'stores.*.delivery_fee.required' => '配送费不能为空', + 'stores.*.delivery_fee.numeric' => '配送费格式错误', + 'stores.*.delivery_fee.min' => '配送费不能小于 0', + 'stores.*.box_num.required' => '周转筐数量不能为空', + 'stores.*.box_num.integer' => '周转筐数量必须为整数', + 'stores.*.box_num.min' => '周转筐数量不能小于 0', + 'stores.*.tray_num.required' => '周转托盘数量不能为空', + 'stores.*.tray_num.integer' => '周转托盘数量必须为整数', + 'stores.*.tray_num.min' => '周转托盘数量不能小于 0', + ]; + } +} diff --git a/app/Http/Requests/Purchase/PurchaseContainerUpdateRequest.php b/app/Http/Requests/Purchase/PurchaseContainerUpdateRequest.php deleted file mode 100644 index 63acda2..0000000 --- a/app/Http/Requests/Purchase/PurchaseContainerUpdateRequest.php +++ /dev/null @@ -1,41 +0,0 @@ - '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', - ]; - } -} diff --git a/app/Models/BillModel.php b/app/Models/BillModel.php new file mode 100644 index 0000000..7783c92 --- /dev/null +++ b/app/Models/BillModel.php @@ -0,0 +1,85 @@ + 'integer', + 'store_id' => 'integer', + 'bill_date' => 'date:Y-m-d', + 'product_amount' => 'decimal:2', + 'delivery_fee' => 'decimal:2', + 'box_num' => 'integer', + 'tray_num' => 'integer', + 'box_price' => 'decimal:2', + 'tray_price' => 'decimal:2', + 'added_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'operator_id' => 'integer', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; + + /** + * 所属门店(含软删除门店,保证历史账单可见) + */ + public function store(): BelongsTo + { + return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed(); + } + + /** + * 关联采购单 + */ + public function purchase(): BelongsTo + { + return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id'); + } + + /** + * 生成人(后台系统用户) + */ + public function operator(): BelongsTo + { + return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); + } + + /** + * 本账单关联的门店订单 + */ + public function orders(): HasMany + { + return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id'); + } +} diff --git a/app/Models/StoreOrderModel.php b/app/Models/StoreOrderModel.php index 592d040..a2aa379 100644 --- a/app/Models/StoreOrderModel.php +++ b/app/Models/StoreOrderModel.php @@ -48,27 +48,21 @@ class StoreOrderModel extends Model 'total_quantity', 'total_weight', 'total_amount', - 'product_amount', - 'added_amount', - 'box_num', - 'tray_num', 'status', 'remark', 'purchase_id', + 'bill_id', ]; protected $casts = [ 'store_id' => 'integer', 'purchase_id' => 'integer', 'statement_id' => 'integer', + 'bill_id' => 'integer', 'order_date' => 'date:Y-m-d', 'total_quantity' => 'integer', 'total_weight' => 'decimal:3', 'total_amount' => 'decimal:2', - 'product_amount' => 'decimal:2', - 'added_amount' => 'decimal:2', - 'box_num' => 'integer', - 'tray_num' => 'integer', 'status' => 'integer', 'created_at' => 'datetime:Y-m-d H:i:s', ]; @@ -96,4 +90,12 @@ class StoreOrderModel extends Model { return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id'); } + + /** + * 关联账单(采购单完成后按门店生成) + */ + public function bill(): BelongsTo + { + return $this->belongsTo(BillModel::class, 'bill_id', 'id'); + } } diff --git a/app/Services/BillGenerateService.php b/app/Services/BillGenerateService.php new file mode 100644 index 0000000..0de80fd --- /dev/null +++ b/app/Services/BillGenerateService.php @@ -0,0 +1,123 @@ + $stores 按门店提交的配送费/周转筐/托盘数量 + * @param int $operatorId 生成人(后台系统用户ID) + * @return BillModel[] 生成的账单列表 + * @throws Throwable + */ + public function generate(PurchaseOrderModel $purchase, array $stores, int $operatorId): array + { + return DB::transaction(function () use ($purchase, $stores, $operatorId) { + // 1. 行锁采购单全部有效订单(并发防护),按门店分组 + $orders = StoreOrderModel::query() + ->where('purchase_id', $purchase->id) + ->whereNull('deleted_at') + ->lockForUpdate() + ->get(); + if ($orders->isEmpty()) { + throw new RepositoryException('采购单下无门店订单,无法生成账单'); + } + $ordersByStore = $orders->groupBy('store_id'); + + // 提交的门店必须完整覆盖采购单门店,避免漏门店造成订单未入账 + $submitted = []; + foreach ($stores as $row) { + $submitted[(int) $row['store_id']] = $row; + } + $missing = array_diff(array_map('intval', $ordersByStore->keys()->all()), array_keys($submitted)); + if ($missing !== []) { + throw new RepositoryException('提交不完整,缺少门店账单信息:' . implode('、', $missing)); + } + if (array_diff(array_keys($submitted), array_map('intval', $ordersByStore->keys()->all())) !== []) { + throw new RepositoryException('包含不属于该采购单的门店'); + } + + // 2. 防重复生成:任一门店已出账则整批拒绝 + $billedStoreIds = BillModel::query() + ->where('purchase_id', $purchase->id) + ->pluck('store_id') + ->map(static fn ($id) => (int) $id) + ->all(); + if ($billedStoreIds !== []) { + $names = StoreModel::withTrashed() + ->whereIn('id', $billedStoreIds) + ->pluck('name') + ->implode('、'); + throw new RepositoryException('以下门店已生成账单,不允许重复生成:' . $names); + } + + // 3. 逐门店生成账单并关联订单 + $boxPrice = (string) site_config('services.box_amount', 0); + $trayPrice = (string) site_config('services.tray_amount', 0); + $billDate = now()->toDateString(); + $bills = []; + foreach ($ordersByStore as $storeId => $storeOrders) { + $row = $submitted[(int) $storeId]; + + $productAmount = $storeOrders->reduce( + static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2), + '0' + ); + $deliveryFee = bcadd((string) $row['delivery_fee'], '0', 2); + $addedAmount = bcadd( + bcmul((string) (int) $row['box_num'], $boxPrice, 2), + bcmul((string) (int) $row['tray_num'], $trayPrice, 2), + 2 + ); + $totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2); + + $bill = BillModel::create([ + 'bill_no' => $this->billNumberService->make('ZD'), + 'purchase_id' => $purchase->id, + 'store_id' => (int) $storeId, + 'bill_date' => $billDate, + 'product_amount' => $productAmount, + 'delivery_fee' => $deliveryFee, + 'box_num' => (int) $row['box_num'], + 'tray_num' => (int) $row['tray_num'], + 'box_price' => bcadd($boxPrice, '0', 2), + 'tray_price' => bcadd($trayPrice, '0', 2), + 'added_amount' => $addedAmount, + 'total_amount' => $totalAmount, + 'operator_id' => $operatorId, + ]); + + // 关联该门店在采购单中的全部订单到账单 + StoreOrderModel::query() + ->whereIn('id', $storeOrders->pluck('id')) + ->update(['bill_id' => $bill->id]); + + $bills[] = $bill; + } + + return $bills; + }); + } +} diff --git a/app/Services/BillNumberService.php b/app/Services/BillNumberService.php index 4bfed77..aa93e39 100644 --- a/app/Services/BillNumberService.php +++ b/app/Services/BillNumberService.php @@ -27,12 +27,13 @@ class BillNumberService 'RC' => ['reconciliation', 'recon_no'], 'ST' => ['statement', 'statement_no'], 'JS' => ['settlement', 'settlement_no'], + 'ZD' => ['bill', 'bill_no'], ]; /** * 生成业务单号 * - * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 + * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 / ZD 账单 * @return string 如 PO202607230001 */ public function make(string $prefix): string diff --git a/app/Services/StoreOrderContainerService.php b/app/Services/StoreOrderContainerService.php deleted file mode 100644 index 26bc045..0000000 --- a/app/Services/StoreOrderContainerService.php +++ /dev/null @@ -1,34 +0,0 @@ -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(); - } -} diff --git a/database/migrations/2026_07_23_030610_create_store_order_table.php b/database/migrations/2026_07_23_030610_create_store_order_table.php index 26bb23c..e718ba5 100644 --- a/database/migrations/2026_07_23_030610_create_store_order_table.php +++ b/database/migrations/2026_07_23_030610_create_store_order_table.php @@ -19,15 +19,11 @@ return new class extends Migration $table->string('order_no', 32)->unique()->comment('订单编号'); $table->integer('store_id')->comment('门店ID'); $table->integer('purchase_id')->nullable()->comment('关联采购单ID'); - $table->integer('statement_id')->nullable()->comment('关联账单ID'); + $table->integer('bill_id')->nullable()->comment('关联账单ID'); $table->date('order_date')->comment('订货日期'); $table->integer('total_quantity')->default(0)->comment('订货总量'); $table->decimal('total_weight', 10, 3)->default(0)->comment('总重量'); $table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额'); - $table->decimal('product_amount', 10, 2)->default(0)->comment('商品总金额'); - $table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额'); - $table->decimal('box_num', 10, 2)->default(0)->comment('周转框数量'); - $table->decimal('tray_num', 10, 2)->default(0)->comment('周转托盘数量'); $table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)'); $table->string('remark', 255)->default('')->comment('订单备注'); $table->softDeletes(); diff --git a/database/migrations/2026_08_13_000001_create_bill_table.php b/database/migrations/2026_08_13_000001_create_bill_table.php new file mode 100644 index 0000000..85db8d1 --- /dev/null +++ b/database/migrations/2026_08_13_000001_create_bill_table.php @@ -0,0 +1,49 @@ +increments('id')->comment('账单ID'); + $table->string('bill_no', 32)->unique()->comment('账单编号'); + $table->integer('purchase_id')->comment('关联采购单ID'); + $table->integer('store_id')->comment('门店ID'); + $table->date('bill_date')->comment('账单日期'); + $table->decimal('product_amount', 10, 2)->default(0)->comment('商品金额(订单商品金额汇总,不可修改)'); + $table->decimal('delivery_fee', 10, 2)->default(0)->comment('配送费(生成账单时填写)'); + $table->integer('box_num')->default(0)->comment('周转筐数量'); + $table->integer('tray_num')->default(0)->comment('周转托盘数量'); + $table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)'); + $table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)'); + $table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)'); + $table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额'); + $table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID)'); + $table->string('remark', 255)->default('')->comment('备注'); + $table->timestamps(); + $table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique'); + $table->index(['store_id', 'bill_date'], 'bill_store_date_index'); + $table->comment('门店账单表(采购单完成后按门店生成)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bill'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index febc8f7..91aeaf9 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -193,6 +193,7 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'purchase.order.query', 'name' => '查询'], ['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'], ['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'], + ['type' => 'rule', 'key' => 'purchase.order.bill', 'name' => '生成账单'], ['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'], ], ], diff --git a/tests/Feature/PurchaseEditTest.php b/tests/Feature/PurchaseEditTest.php index 341499c..7857186 100644 --- a/tests/Feature/PurchaseEditTest.php +++ b/tests/Feature/PurchaseEditTest.php @@ -11,9 +11,6 @@ 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 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本, @@ -55,23 +52,6 @@ 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 { @@ -460,148 +440,4 @@ 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); - } } diff --git a/tests/Feature/StoreOrderItemTest.php b/tests/Feature/StoreOrderItemTest.php index f0fe7bb..0c764f6 100644 --- a/tests/Feature/StoreOrderItemTest.php +++ b/tests/Feature/StoreOrderItemTest.php @@ -122,12 +122,11 @@ class StoreOrderItemTest extends ProcurementTestCase ->assertJsonPath('data.items.0.supplier.name', $supplier->name); } - /** 修改明细:重算单品金额与订单总量/总额(总额 = 商品 + 附加) */ + /** 修改明细:重算单品金额与订单总量/总额(总额 = 商品金额,附加金额已迁入账单) */ public function test_update_item_recalculates_order_totals(): void { [$store, $product, $user] = $this->makeStoreWithProduct('5.00'); $order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); - $order->update(['added_amount' => '6.00']); // 已有附加金额 $item = $order->items->first(); $supplier = SupplierModel::factory()->create(); @@ -158,7 +157,7 @@ class StoreOrderItemTest extends ProcurementTestCase $this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计'); $this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计'); $this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计'); - $this->assertSame('36.00', (string) $order->total_amount, '订单总金额 = 商品 30 + 附加 6'); + $this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 商品金额'); } /** 已完成/已取消订单不允许修改明细 */ diff --git a/web/api/purchase/order.ts b/web/api/purchase/order.ts index d4fff14..f66a08a 100644 --- a/web/api/purchase/order.ts +++ b/web/api/purchase/order.ts @@ -1,6 +1,7 @@ import createAxios from '@/utils/request'; import { downloadBlob } from '@/api/common/download.ts'; import type { + IBillPrepare, IPurchaseCell, IPurchaseDetail, IPurchaseStoreSummary, @@ -23,9 +24,10 @@ export interface PurchaseCellUpdateParams { weight?: number; } -/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */ -export interface PurchaseContainerOrderParams { - order_id: number; +/** 生成账单:单个门店的配送费/周转筐/托盘数量 */ +export interface BillGenerateStoreParams { + store_id: number; + delivery_fee: number; box_num: number; tray_num: number; } @@ -84,16 +86,20 @@ export async function getPurchaseStoreSummary(purchaseId: number, storeId: numbe }); } -/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */ -export async function updatePurchaseContainer( - purchaseId: number, - storeId: number, - orders: PurchaseContainerOrderParams[], -) { - return createAxios({ - url: `/purchase/order/${purchaseId}/container/${storeId}`, - method: 'put', - data: { orders }, +/** 账单生成预览:按门店汇总商品金额(金额只读) */ +export async function getBillPrepare(purchaseId: number) { + return createAxios({ + url: `/purchase/order/${purchaseId}/bill/prepare`, + method: 'get', + }); +} + +/** 生成账单:已完成采购单按门店各生成一张(关联采购单全部订单,金额由系统汇总不可修改) */ +export async function generateBill(purchaseId: number, stores: BillGenerateStoreParams[]) { + return createAxios<{ count: number }>({ + url: `/purchase/order/${purchaseId}/bill`, + method: 'post', + data: { stores }, }); } diff --git a/web/domain/iPurchaseOrder.ts b/web/domain/iPurchaseOrder.ts index 7506cc5..2446901 100644 --- a/web/domain/iPurchaseOrder.ts +++ b/web/domain/iPurchaseOrder.ts @@ -48,34 +48,56 @@ export interface IPurchaseDetail { purchase: IPurchaseOrder; stores: { id: number; name: string }[]; items: IPurchaseDetailRow[]; - /** 周转框/托盘合并记录(按门店聚合) */ - containers: IPurchaseContainerStore[]; + /** 门店账单(采购单完成后按门店生成) */ + bills: IBill[]; } -/** 合并记录中的底层门店订单(订单号 + 框/托盘数量) */ -export interface IPurchaseContainerOrder { - order_id: number; - order_no: string; +/** 门店账单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改) */ +export interface IBill { + id: number; + bill_no: string; + purchase_id: number; + store_id: number; + store_name?: string; + bill_date: string; + /** 商品金额(订单商品金额汇总) */ + product_amount: string; + /** 配送费(生成账单时填写) */ + delivery_fee: string; box_num: number; tray_num: number; + /** 周转筐单价(生成时快照) */ + box_price: string; + /** 周转托盘单价(生成时快照) */ + tray_price: string; + /** 附加金额 = 周转筐×筐单价 + 托盘×托盘单价 */ + added_amount: string; + /** 账单总金额 = 商品金额 + 配送费 + 附加金额 */ + total_amount: string; + order_count?: number; + remark?: string; + created_at?: string; } -/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计) */ -export interface IPurchaseContainerStore { +/** 账单生成预览行(按门店汇总,金额只读) */ +export interface IBillPrepareStore { store_id: number; store_name: string; - /** 周转框合计 */ - box_num: number; - /** 周转托盘合计 */ - tray_num: number; - /** 周转框单价(站点配置) */ + order_count: number; + /** 商品金额(订单汇总,不可修改) */ + product_amount: string; + /** 周转筐单价(站点配置) */ box_price: string; /** 周转托盘单价(站点配置) */ tray_price: string; - /** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */ - added_amount: string; - order_count: number; - orders: IPurchaseContainerOrder[]; + /** 已生成的账单(未生成为 null) */ + bill: IBill | null; +} + +/** 账单生成预览(采购单 + 门店行) */ +export interface IBillPrepare { + purchase: { id: number; purchase_no: string; status: number }; + stores: IBillPrepareStore[]; } /** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */ diff --git a/web/domain/iStoreOrder.ts b/web/domain/iStoreOrder.ts index 9e91911..5ef8bcf 100644 --- a/web/domain/iStoreOrder.ts +++ b/web/domain/iStoreOrder.ts @@ -45,6 +45,8 @@ export default interface IStoreOrder { store_id?: number; purchase_id?: number; statement_id?: number; + /** 关联账单ID(采购单完成后按门店生成账单时回写) */ + bill_id?: number; store?: { id: number; name: string; @@ -56,18 +58,6 @@ export default interface IStoreOrder { total_quantity?: number; total_weight?: string; total_amount?: string; - product_amount: string; - added_amount: string; - box_num: number; - tray_num: number; - /** 周转框单价(列表接口附加) */ - box_price?: string; - /** 周转托盘单价(列表接口附加) */ - tray_price?: string; - /** 周转框金额(列表接口附加) */ - box_amount?: string; - /** 周转托盘金额(列表接口附加) */ - tray_amount?: string; /** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */ status?: number; remark?: string; diff --git a/web/pages/order/store.tsx b/web/pages/order/store.tsx index 0ac2454..16e22de 100644 --- a/web/pages/order/store.tsx +++ b/web/pages/order/store.tsx @@ -261,12 +261,8 @@ const StoreOrderPage: React.FC = () => { {record.total_quantity}
- 周转框数量: - {record.box_num} -
-
- 周转托盘数量: - {record.tray_num} + 总重量: + {record.total_weight}斤
) @@ -274,24 +270,11 @@ const StoreOrderPage: React.FC = () => { { title: '订单金额', hideInForm: true, - dataIndex: 'box_num', + dataIndex: 'total_amount', hideInSearch: true, width: 200, - render: (_, record) => ( - -
- 商品总金额: - {record.product_amount} ¥ -
-
- 附加总金额: - {record.added_amount} ¥ -
-
- 订单总金额: - {record.total_amount} ¥ -
-
+ render: (text) => ( + {text} ¥ ) }, { @@ -334,7 +317,7 @@ const StoreOrderPage: React.FC = () => { }, { title: '账单ID', - dataIndex: 'statement_id', + dataIndex: 'bill_id', valueType: 'digit', hideInForm: true, }, @@ -507,8 +490,8 @@ const StoreOrderPage: React.FC = () => { {detail.total_quantity} - {detail.box_num} - {detail.tray_num} + {detail.total_weight}斤 + {detail.bill_id ?? '-'} {detail.remark ? ( {detail.remark} @@ -568,6 +551,14 @@ const StoreOrderPage: React.FC = () => { ))} +
+
总计
+
+
+
{detail.total_quantity}
+
¥{detail.total_amount}
+
{detail.total_weight}斤
+
{(detail.items ?? []).length === 0 && ( { /> )} - - {/* 附加信息 */} -
-
-
- - 周转框金额({detail.box_num} × ¥{detail.box_price ?? '0.00'}) - - ¥{detail.box_amount ?? '0.00'} -
-
- - 周转托盘金额({detail.tray_num} × ¥{detail.tray_price ?? '0.00'}) - - ¥{detail.tray_amount ?? '0.00'} -
-
- 商品总金额 - ¥{detail.product_amount} -
-
- 订单总金额 - - ¥{detail.total_amount} - -
-
-
) : null} diff --git a/web/pages/purchase/order.tsx b/web/pages/purchase/order.tsx index cf651c7..b7450e2 100644 --- a/web/pages/purchase/order.tsx +++ b/web/pages/purchase/order.tsx @@ -20,7 +20,7 @@ import { Tag, Typography, } from 'antd'; -import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons'; +import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons'; import type { TableProps } from 'antd'; import XinTable from '@/components/XinTable'; import type { @@ -30,9 +30,10 @@ import type { } from '@/components/XinTable/typings.ts'; import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts'; import type { + IBill, + IBillPrepare, IPurchaseCell, IPurchaseCellItem, - IPurchaseContainerStore, IPurchaseDetail, IPurchaseDetailRow, IPurchaseStoreItem, @@ -42,12 +43,15 @@ import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { exportPurchase, + generateBill, + getBillPrepare, getPurchaseCell, getPurchaseDetail, getPurchaseStoreSummary, - type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams, + type BillGenerateStoreParams, + type PurchaseCellUpdateParams, + type PurchaseRowUpdateParams, updatePurchaseCellItem, - updatePurchaseContainer, updatePurchaseRow, } from '@/api/purchase/order.ts'; import { Update } from '@/api/common/table.ts'; @@ -100,25 +104,28 @@ const PurchaseOrderPage: React.FC = () => { const [storeSummary, setStoreSummary] = useState(null); const [storeLoading, setStoreLoading] = useState(false); - // 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改) - const [containerOpen, setContainerOpen] = useState(false); - const [containerStore, setContainerStore] = useState(null); - const [containerSaving, setContainerSaving] = useState(false); - const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>(); + // 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读) + const [billOpen, setBillOpen] = useState(false); + const [billPrepare, setBillPrepare] = useState(null); + const [billLoading, setBillLoading] = useState(false); + const [billSaving, setBillSaving] = useState(false); + const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>(); - // 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价 - 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); + // 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加 + const watchBillStores = Form.useWatch('stores', billForm) ?? []; + const billPreview = (billPrepare?.stores ?? []).map((row, index) => { + const input = watchBillStores[index] ?? {}; + const deliveryFee = Number(input.delivery_fee ?? 0); + const added = + Number(input.box_num ?? 0) * Number(row.box_price) + + Number(input.tray_num ?? 0) * Number(row.tray_price); + return { + added, + total: Number(row.product_amount) + deliveryFee + added, + }; + }); + const billAllGenerated = + (billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null); useEffect(() => { getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); @@ -264,34 +271,45 @@ const PurchaseOrderPage: React.FC = () => { } }; - /** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */ - 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 openBillGenerate = async (id: number) => { + setBillOpen(true); + setBillLoading(true); + setBillPrepare(null); + try { + const res = await getBillPrepare(id); + const data = res.data.data ?? null; + setBillPrepare(data); + billForm.setFieldsValue({ + stores: (data?.stores ?? []).map((row) => ({ + store_id: row.store_id, + delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0, + box_num: row.bill ? row.bill.box_num : 0, + tray_num: row.bill ? row.bill.tray_num : 0, + })), + }); + } finally { + setBillLoading(false); + } }; - /** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */ - const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => { - if (!detail || !containerStore) { + /** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */ + const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => { + if (!billPrepare) { return; } - setContainerSaving(true); + setBillSaving(true); try { - await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders); - message.success('周转框/托盘已更新,订单金额已重算'); - setContainerOpen(false); - setContainerStore(null); - await loadDetail(detail.purchase.id!); + const res = await generateBill(billPrepare.purchase.id, values.stores); + message.success(`已生成 ${res.data.data?.count ?? 0} 张门店账单,采购单订单已关联`); + setBillOpen(false); + setBillPrepare(null); await tableRef.current?.reload(); + if (detail && detail.purchase.id === billPrepare.purchase.id) { + await loadDetail(detail.purchase.id!); + } } finally { - setContainerSaving(false); + setBillSaving(false); } }; @@ -473,56 +491,87 @@ const PurchaseOrderPage: React.FC = () => { ); }; - /** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */ - const containerColumns: TableProps['columns'] = [ - { title: '门店', dataIndex: 'store_name', width: 180, align: 'center' }, - { title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' }, + /** 门店账单列:门店/账单号/账单日期/商品金额/配送费/周转筐/托盘/附加金额/总金额 */ + const billColumns: TableProps['columns'] = [ + { title: '门店', dataIndex: 'store_name', width: 140, align: 'center' }, { - title: '周转框合计', - dataIndex: 'box_num', + title: '账单号', + dataIndex: 'bill_no', + width: 180, + align: 'center', + render: (v) => {v}, + }, + { title: '账单日期', dataIndex: 'bill_date', width: 110, align: 'center' }, + { + title: '商品金额', + dataIndex: 'product_amount', width: 110, align: 'center', - render: (_, row) => ( - openContainerEdit(row)}>{row.box_num} - ), + render: (v) => `¥${Number(v).toFixed(2)}`, }, { - title: '周转托盘合计', - dataIndex: 'tray_num', - width: 120, + title: '配送费', + dataIndex: 'delivery_fee', + width: 100, align: 'center', - render: (_, row) => ( - openContainerEdit(row)}>{row.tray_num} - ), + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '周转筐', + key: 'box', + width: 110, + align: 'center', + render: (_, row) => `${row.box_num} × ¥${Number(row.box_price).toFixed(2)}`, + }, + { + title: '周转托盘', + key: 'tray', + width: 110, + align: 'center', + render: (_, row) => `${row.tray_num} × ¥${Number(row.tray_price).toFixed(2)}`, }, { title: '附加金额', dataIndex: 'added_amount', - width: 110, + width: 100, align: 'center', - render: (_, row) => ¥{row.added_amount}, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '账单总金额', + dataIndex: 'total_amount', + width: 120, + align: 'center', + render: (v) => ¥{Number(v).toFixed(2)}, }, ]; - /** 周转框/托盘合并记录合计行 */ - 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); + /** 门店账单合计行 */ + const renderBillSummary = () => { + const bills = detail?.bills ?? []; + const totalProduct = bills.reduce((sum, row) => sum + Number(row.product_amount), 0); + const totalDelivery = bills.reduce((sum, row) => sum + Number(row.delivery_fee), 0); + const totalAdded = bills.reduce((sum, row) => sum + Number(row.added_amount), 0); + const totalAmount = bills.reduce((sum, row) => sum + Number(row.total_amount), 0); return ( - + 合计 - - {totalBox} - - {totalTray} + ¥{totalProduct.toFixed(2)} - ¥{totalAdded.toFixed(2)} + ¥{totalDelivery.toFixed(2)} + + + - + + + ¥{totalAdded.toFixed(2)} + + + ¥{totalAmount.toFixed(2)} ); @@ -632,7 +681,20 @@ const PurchaseOrderPage: React.FC = () => { - ) : null + ) : ( + + + + ) ]; const tableProps: XinTableProps = { @@ -696,7 +758,7 @@ const PurchaseOrderPage: React.FC = () => { items={[ { key: 'items', label: '商品明细' }, { key: 'stores', label: '门店购买详情' }, - { key: 'containers', label: '周转框/托盘' }, + { key: 'bills', label: '门店账单' }, ]} /> @@ -736,21 +798,21 @@ const PurchaseOrderPage: React.FC = () => { )} - ) : detailTab === 'containers' ? ( - detail.containers.length > 0 ? ( - - rowKey="store_id" + ) : detailTab === 'bills' ? ( + detail.bills.length > 0 ? ( + + rowKey="id" size="small" bordered - columns={containerColumns} - dataSource={detail.containers} + columns={billColumns} + dataSource={detail.bills} pagination={false} - summary={renderContainerSummary} + summary={renderBillSummary} /> ) : ( ) @@ -1005,99 +1067,119 @@ const PurchaseOrderPage: React.FC = () => { - {/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */} + {/* 生成账单:按门店填写配送费/周转筐/托盘数量(商品金额只读,由订单汇总) */} { - setContainerOpen(false); - setContainerStore(null); + setBillOpen(false); + setBillPrepare(null); }} - onOk={() => containerForm.submit()} - confirmLoading={containerSaving} - okText="保存" - okButtonProps={{ disabled: detail?.purchase.status !== 0 }} - width={760} + onOk={() => billForm.submit()} + confirmLoading={billSaving} + okText="确认生成" + okButtonProps={{ disabled: billAllGenerated }} + width={960} destroyOnHidden > -
- 该门店在本采购单下的全部订单({containerStore?.order_count ?? 0} 笔): - {detail?.purchase.status === 0 - ? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。' - : '采购单已完成,仅可查看。'} -
-
- - {(fields) => ( -
-
-
订单号
-
- 周转框(单价 ¥{containerStore?.box_price ?? '0.00'}) -
-
- 周转托盘(单价 ¥{containerStore?.tray_price ?? '0.00'}) -
-
- {fields.map((field) => { - const order = containerStore?.orders[field.name]; - return ( -
-
- {order?.order_no ?? '-'} -
- - - - - - - -
- ); - })} + + {billPrepare && ( + <> +
+ 每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费与周转筐/托盘数量。 + {billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
- )} - - - -
- 周转框合计: - {previewContainerBox} - 周转托盘合计: - {previewContainerTray} -
-
- 附加金额合计: - ¥{previewContainerAdded.toFixed(2)} -
-
+
+ + {(fields) => ( +
+
+
门店
+
商品金额
+
配送费(元)
+
周转筐(¥{billPrepare.stores[0]?.box_price ?? '0.00'})
+
周转托盘(¥{billPrepare.stores[0]?.tray_price ?? '0.00'})
+
附加金额
+
账单总金额
+
+ {fields.map((field) => { + const row = billPrepare.stores[field.name]; + const billed = row?.bill != null; + return ( +
+
+
{row?.store_name ?? '-'}
+
+ {row?.order_count ?? 0} 笔订单 + {billed && 已生成} +
+
+
+ ¥{row?.product_amount ?? '0.00'} +
+ + + + + + + + + + +
+ ¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)} +
+
+ + ¥{(billed ? Number(row.bill!.total_amount) : billPreview[field.name]?.total ?? 0).toFixed(2)} + + {billed && ( +
{row.bill!.bill_no}
+ )} +
+
+ ); + })} +
+ )} +
+
+ + )} +
);