From bf8ac6839122e3deaddb8721c0894ff83b484d4b Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Fri, 14 Aug 2026 01:19:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E7=AD=90=E3=80=81=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Exports/StatementExport.php | 67 --- app/Http/Controllers/Mini/BillController.php | 72 +++ app/Http/Controllers/Mini/OrderController.php | 1 - .../Controllers/Mini/StatementController.php | 86 ---- app/Http/Controllers/Recon/BillController.php | 116 +++++ .../Recon/ContainerReturnController.php | 125 ++++++ .../Controllers/Recon/StatementController.php | 49 -- .../Recon/ContainerReturnFormRequest.php | 41 ++ app/Models/BillModel.php | 34 ++ app/Models/ContainerReturnModel.php | 76 ++++ app/Models/StatementItemModel.php | 78 ---- app/Models/StatementModel.php | 65 --- app/Models/StoreModel.php | 8 +- app/Models/StoreOrderItemModel.php | 2 + app/Models/StoreOrderModel.php | 1 - app/Services/BillDetailService.php | 71 +++ app/Services/BillGenerateService.php | 24 +- app/Services/BillNumberService.php | 3 +- app/Services/StatementGenerateService.php | 101 ----- .../2026_07_23_030608_create_store_table.php | 2 + ..._07_23_030610_create_store_order_table.php | 2 + ..._23_030612_create_reconciliation_table.php | 46 +- .../2026_08_13_000001_create_bill_table.php | 5 + ...4_000001_create_container_return_table.php | 41 ++ database/seeders/PermissionSeeder.php | 46 +- resources/views/exports/statement.blade.php | 58 --- tests/Feature/StatementTest.php | 105 ----- web/api/recon/bill.ts | 19 + web/domain/iBill.ts | 41 ++ web/domain/iContainerReturn.ts | 26 ++ web/domain/iPurchaseOrder.ts | 19 + web/domain/iStatement.ts | 43 -- web/domain/iStore.ts | 4 + web/domain/iStoreOrder.ts | 1 - web/pages/customer/store.tsx | 24 + web/pages/purchase/order.tsx | 15 +- web/pages/recon/bill.tsx | 422 ++++++++++++++++++ web/pages/recon/container-return.tsx | 177 ++++++++ web/pages/recon/statement.tsx | 239 ---------- 39 files changed, 1392 insertions(+), 963 deletions(-) delete mode 100644 app/Exports/StatementExport.php create mode 100644 app/Http/Controllers/Mini/BillController.php delete mode 100644 app/Http/Controllers/Mini/StatementController.php create mode 100644 app/Http/Controllers/Recon/BillController.php create mode 100644 app/Http/Controllers/Recon/ContainerReturnController.php delete mode 100644 app/Http/Controllers/Recon/StatementController.php create mode 100644 app/Http/Requests/Recon/ContainerReturnFormRequest.php create mode 100644 app/Models/ContainerReturnModel.php delete mode 100644 app/Models/StatementItemModel.php delete mode 100644 app/Models/StatementModel.php create mode 100644 app/Services/BillDetailService.php delete mode 100644 app/Services/StatementGenerateService.php create mode 100644 database/migrations/2026_08_14_000001_create_container_return_table.php delete mode 100644 resources/views/exports/statement.blade.php delete mode 100644 tests/Feature/StatementTest.php create mode 100644 web/api/recon/bill.ts create mode 100644 web/domain/iBill.ts create mode 100644 web/domain/iContainerReturn.ts delete mode 100644 web/domain/iStatement.ts create mode 100644 web/pages/recon/bill.tsx create mode 100644 web/pages/recon/container-return.tsx delete mode 100644 web/pages/recon/statement.tsx diff --git a/app/Exports/StatementExport.php b/app/Exports/StatementExport.php deleted file mode 100644 index 128a684..0000000 --- a/app/Exports/StatementExport.php +++ /dev/null @@ -1,67 +0,0 @@ -statement->items()->orderBy('id')->get(); - } - - public function headings(): array - { - return ['品名', '单价', '数量', '重量', '金额', '对账状态', '备注']; - } - - public function map($item): array - { - return [ - $item->product_name, - (float) $item->price, - (float) $item->quantity, - (float) $item->weight, - (float) $item->amount, - $item->is_reconciled ? '已对账' : '未对账', - $item->store_remark, - ]; - } - - public function styles(Worksheet $sheet): array - { - $sheet->freezePane('A2'); - - return [ - 1 => ['font' => ['bold' => true]], - ]; - } - - /** - * PDF 模板视图数据 - * - * @return array{statement: StatementModel, storeName: string, items: Collection} - */ - public function viewData(): array - { - return [ - 'statement' => $this->statement, - 'storeName' => $this->statement->store?->name ?? '', - 'items' => $this->collection(), - ]; - } -} diff --git a/app/Http/Controllers/Mini/BillController.php b/app/Http/Controllers/Mini/BillController.php new file mode 100644 index 0000000..dba827e --- /dev/null +++ b/app/Http/Controllers/Mini/BillController.php @@ -0,0 +1,72 @@ +currentUser($request); + $store = $this->ensureStoreBound($user); + + $data = BillModel::query() + ->where('store_id', $store->id) + ->with('purchase:id,purchase_no,purchase_date') + ->orderBy('bill_date', 'desc') + ->orderBy('id', 'desc') + ->paginate((int) $request->input('pageSize', 10)) + ->toArray(); + + return $this->success($data); + } + + /** 账单详情(校验归属:仅能查看本店账单;含合并后的商品明细与关联订单) */ + #[GetRoute('/bill/{id}', authorize: true, where: ['id' => '[0-9]+'])] + public function detail(int $id, Request $request): JsonResponse + { + $user = $this->currentUser($request); + $store = $this->ensureStoreBound($user); + + $bill = BillModel::with('purchase:id,purchase_no,purchase_date') + ->where('store_id', $store->id) + ->find($id); + if ($bill === null) { + throw new RepositoryException('账单不存在'); + } + + $orders = $bill->orders() + ->orderBy('id') + ->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status']) + ->toArray(); + + return $this->success([ + 'bill' => $bill->toArray(), + 'items' => app(BillDetailService::class)->mergedItems($bill), + 'orders' => $orders, + ]); + } + + /** + * 在线支付(预留接口,本次不实现;当前为线下收款,由后台手动登记) + */ + #[PostRoute('/bill/{id}/pay', authorize: true, where: ['id' => '[0-9]+'])] + public function pay(int $id, Request $request): JsonResponse + { + throw new RepositoryException('在线支付暂未开通,请线下付款后由商家登记收款'); + } +} diff --git a/app/Http/Controllers/Mini/OrderController.php b/app/Http/Controllers/Mini/OrderController.php index 7bc28bf..ddece83 100644 --- a/app/Http/Controllers/Mini/OrderController.php +++ b/app/Http/Controllers/Mini/OrderController.php @@ -108,7 +108,6 @@ class OrderController extends BaseMiniController 'order_date' => $now->toDateString(), 'total_quantity' => $totalQuantity, 'total_weight' => 0, - 'product_amount' => $totalAmount, 'total_amount' => $totalAmount, 'status' => StoreOrderModel::STATUS_PENDING, 'remark' => $remark, diff --git a/app/Http/Controllers/Mini/StatementController.php b/app/Http/Controllers/Mini/StatementController.php deleted file mode 100644 index 3d2c0d7..0000000 --- a/app/Http/Controllers/Mini/StatementController.php +++ /dev/null @@ -1,86 +0,0 @@ -currentUser($request); - $store = $this->ensureStoreBound($user); - - $data = StatementModel::query() - ->where('store_id', $store->id) - ->orderBy('id', 'desc') - ->paginate((int) $request->input('pageSize', 10)) - ->toArray(); - - return $this->success($data); - } - - /** 生成对账单:快照当前回款周期,settlement_date = period_end + cycle 天 */ - #[PostRoute('/statement/generate', authorize: true)] - public function generate(Request $request): JsonResponse - { - $data = $request->validate([ - 'period_start' => 'required|date_format:Y-m-d', - 'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start', - ], [ - 'period_start.required' => '请选择对账周期开始日期', - 'period_start.date_format' => '开始日期格式为 Y-m-d', - 'period_end.required' => '请选择对账周期结束日期', - 'period_end.date_format' => '结束日期格式为 Y-m-d', - 'period_end.after_or_equal' => '结束日期不能早于开始日期', - ]); - - $user = $this->currentUser($request); - $store = $this->ensureStoreBound($user); - - $statement = app(StatementGenerateService::class)->generate( - $store, - $data['period_start'], - $data['period_end'], - ); - - return $this->success([ - 'id' => $statement->id, - 'statement_no' => $statement->statement_no, - 'total_amount' => $statement->total_amount, - 'settlement_date' => $statement->settlement_date?->toDateString(), - ], '对账单已生成'); - } - - /** 对账单详情(校验归属,含单品对账状态标识) */ - #[GetRoute('/statement/{id}', authorize: true, where: ['id' => '[0-9]+'])] - public function detail(int $id, Request $request): JsonResponse - { - $user = $this->currentUser($request); - $store = $this->ensureStoreBound($user); - - $statement = StatementModel::with('items') - ->where('store_id', $store->id) - ->find($id); - if ($statement === null) { - throw new RepositoryException('对账单不存在'); - } - - return $this->success($statement->toArray()); - } -} diff --git a/app/Http/Controllers/Recon/BillController.php b/app/Http/Controllers/Recon/BillController.php new file mode 100644 index 0000000..c39a19f --- /dev/null +++ b/app/Http/Controllers/Recon/BillController.php @@ -0,0 +1,116 @@ + '=', + 'status' => '=', + 'bill_no' => 'like', + 'bill_date' => 'betweenDate', + ]; + + /** 账单列表 */ + #[GetRoute(authorize: 'query')] + public function query(Request $request): JsonResponse + { + $params = $request->all(); + $pageSize = $params['pageSize'] ?? 10; + $query = BillModel::query()->with([ + 'store:id,name', + 'purchase:id,purchase_no,purchase_date', + 'operator:id,nickname', + ])->withCount('orders'); + + // 按采购单号搜索 + $purchaseNo = trim((string) ($params['purchase_no'] ?? '')); + if ($purchaseNo !== '') { + $keyword = '%' . str_replace('%', '\%', $purchaseNo) . '%'; + $query->whereHas('purchase', static function ($purchaseQuery) use ($keyword) { + $purchaseQuery->where('purchase_no', 'like', $keyword); + }); + } + + $data = $this->buildSearch($params, $query) + ->orderBy('bill_date', 'desc') + ->orderBy('id', 'desc') + ->paginate($pageSize) + ->toArray(); + + return $this->success($data); + } + + /** + * 账单详情:账单信息 + 合并后的商品明细(按商品聚合)+ 关联订单 + */ + #[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])] + public function detail(int $id): JsonResponse + { + $bill = BillModel::with([ + 'store:id,name,address,contact,phone', + 'purchase:id,purchase_no,purchase_date,status', + 'operator:id,nickname', + 'paidOperator:id,nickname', + ])->find($id); + if (empty($bill)) { + throw new RepositoryException('账单不存在'); + } + + $orders = $bill->orders() + ->orderBy('id') + ->get(['id', 'order_no', 'order_date', 'total_quantity', 'total_weight', 'total_amount', 'status']) + ->toArray(); + + return $this->success([ + 'bill' => $bill->toArray(), + 'items' => app(BillDetailService::class)->mergedItems($bill), + 'orders' => $orders, + ]); + } + + /** + * 确认收款:线下收款后手动登记付款信息,支付状态置为已支付 + */ + #[PutRoute(route: '/{id}/pay', authorize: 'pay', where: ['id' => '[0-9]+'])] + public function pay(int $id, Request $request): JsonResponse + { + $data = $request->validate([ + 'paid_at' => 'sometimes|date_format:Y-m-d H:i:s', + 'pay_remark' => 'nullable|string|max:255', + ], [ + 'paid_at.date_format' => '付款时间格式为 Y-m-d H:i:s', + 'pay_remark.max' => '付款备注超过最大长度', + ]); + + $bill = BillModel::find($id); + if (empty($bill)) { + throw new RepositoryException('账单不存在'); + } + if ($bill->status === BillModel::STATUS_PAID) { + throw new RepositoryException('账单已支付,请勿重复收款'); + } + + $bill->status = BillModel::STATUS_PAID; + $bill->paid_at = $data['paid_at'] ?? now(); + $bill->pay_remark = (string) ($data['pay_remark'] ?? ''); + $bill->paid_operator_id = (int) $request->user()->id; + $bill->save(); + + return $this->success([], '收款已登记'); + } +} diff --git a/app/Http/Controllers/Recon/ContainerReturnController.php b/app/Http/Controllers/Recon/ContainerReturnController.php new file mode 100644 index 0000000..9ae3bb9 --- /dev/null +++ b/app/Http/Controllers/Recon/ContainerReturnController.php @@ -0,0 +1,125 @@ + '=', + 'type' => '=', + 'return_date' => 'betweenDate', + ]; + + /** 回筐记录列表 */ + #[GetRoute(authorize: 'query')] + public function query(Request $request): JsonResponse + { + $params = $request->all(); + $pageSize = $params['pageSize'] ?? 10; + $data = $this->buildSearch($params, ContainerReturnModel::query()->with([ + 'store:id,name,pending_box_num,pending_tray_num', + 'bill:id,bill_no', + 'operator:id,nickname', + ])) + ->orderBy('return_date', 'desc') + ->orderBy('id', 'desc') + ->paginate($pageSize) + ->toArray(); + return $this->success($data); + } + + /** + * 回筐登记:门店退回周转筐/托盘,扣减门店待回数量(超回拒绝) + * @throws Throwable + */ + #[PostRoute(authorize: 'create')] + public function create(ContainerReturnFormRequest $request): JsonResponse + { + $data = $request->validated(); + $boxNum = (int) $data['box_num']; + $trayNum = (int) $data['tray_num']; + if ($boxNum === 0 && $trayNum === 0) { + throw new RepositoryException('周转筐与周转托盘数量不能同时为 0'); + } + + return DB::transaction(function () use ($data, $boxNum, $trayNum, $request) { + $store = StoreModel::query()->lockForUpdate()->find((int) $data['store_id']); + if (empty($store)) { + throw new RepositoryException('门店不存在'); + } + if ($boxNum > (int) $store->pending_box_num) { + throw new RepositoryException( + '回筐数量超过该门店待回筐数量(当前待回 ' . (int) $store->pending_box_num . ' 个)' + ); + } + if ($trayNum > (int) $store->pending_tray_num) { + throw new RepositoryException( + '回托盘数量超过该门店待回托盘数量(当前待回 ' . (int) $store->pending_tray_num . ' 个)' + ); + } + + $store->pending_box_num = (int) $store->pending_box_num - $boxNum; + $store->pending_tray_num = (int) $store->pending_tray_num - $trayNum; + $store->save(); + + ContainerReturnModel::create([ + 'store_id' => $store->id, + 'bill_id' => 0, + 'type' => ContainerReturnModel::TYPE_RETURN, + 'box_num' => $boxNum, + 'tray_num' => $trayNum, + 'return_date' => $data['return_date'], + 'operator_id' => (int) $request->user()->id, + 'remark' => (string) ($data['remark'] ?? ''), + ]); + + return $this->success([], '回筐已登记'); + }); + } + + /** + * 删除回筐记录(仅手动登记的回筐记录可删,删除后恢复门店待回数量;压筐记录随账单生成不允许删除) + * @throws Throwable + */ + #[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])] + public function delete(int $id): JsonResponse + { + $record = ContainerReturnModel::find($id); + if (empty($record)) { + throw new RepositoryException('回筐记录不存在'); + } + if ($record->type !== ContainerReturnModel::TYPE_RETURN) { + throw new RepositoryException('压筐记录由账单生成,不允许删除'); + } + + return DB::transaction(function () use ($record) { + $store = StoreModel::query()->lockForUpdate()->find($record->store_id); + if ($store !== null) { + $store->pending_box_num = (int) $store->pending_box_num + (int) $record->box_num; + $store->pending_tray_num = (int) $store->pending_tray_num + (int) $record->tray_num; + $store->save(); + } + $record->delete(); + + return $this->success([], '回筐记录已删除,门店待回数量已恢复'); + }); + } +} diff --git a/app/Http/Controllers/Recon/StatementController.php b/app/Http/Controllers/Recon/StatementController.php deleted file mode 100644 index 281a567..0000000 --- a/app/Http/Controllers/Recon/StatementController.php +++ /dev/null @@ -1,49 +0,0 @@ - 'like', - 'store_id' => '=', - 'status' => '=', - 'period_start' => 'betweenDate', - ]; - - /** 对账单列表 */ - #[GetRoute(authorize: 'query')] - public function query(Request $request): JsonResponse - { - $params = $request->all(); - $pageSize = $params['pageSize'] ?? 10; - $data = $this->buildSearch($params, StatementModel::query()->with('store:id,name')) - ->orderBy('id', 'desc') - ->paginate($pageSize) - ->toArray(); - return $this->success($data); - } - - /** 对账单详情(含明细) */ - #[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])] - public function detail(int $id): JsonResponse - { - $statement = StatementModel::with(['store:id,name', 'items'])->find($id); - if (empty($statement)) { - throw new RepositoryException('对账单不存在'); - } - return $this->success($statement->toArray()); - } -} diff --git a/app/Http/Requests/Recon/ContainerReturnFormRequest.php b/app/Http/Requests/Recon/ContainerReturnFormRequest.php new file mode 100644 index 0000000..e25c492 --- /dev/null +++ b/app/Http/Requests/Recon/ContainerReturnFormRequest.php @@ -0,0 +1,41 @@ + 'required|integer|exists:store,id', + 'box_num' => 'required|integer|min:0', + 'tray_num' => 'required|integer|min:0', + 'return_date' => 'required|date_format:Y-m-d', + 'remark' => 'nullable|string|max:255', + ]; + } + + public function messages(): array + { + return [ + 'store_id.required' => '请选择门店', + 'store_id.exists' => '门店不存在', + 'box_num.required' => '周转筐数量不能为空', + 'box_num.integer' => '周转筐数量必须为整数', + 'box_num.min' => '周转筐数量不能小于 0', + 'tray_num.required' => '周转托盘数量不能为空', + 'tray_num.integer' => '周转托盘数量必须为整数', + 'tray_num.min' => '周转托盘数量不能小于 0', + 'return_date.required' => '请选择退回日期', + 'return_date.date_format' => '退回日期格式为 Y-m-d', + 'remark.max' => '备注超过最大长度', + ]; + } +} diff --git a/app/Models/BillModel.php b/app/Models/BillModel.php index 7783c92..10db8b7 100644 --- a/app/Models/BillModel.php +++ b/app/Models/BillModel.php @@ -15,6 +15,17 @@ class BillModel extends Model { use HasFactory; + /** 支付状态:未支付 */ + public const int STATUS_UNPAID = 0; + /** 支付状态:已支付 */ + public const int STATUS_PAID = 1; + + /** 支付状态中文名 */ + public const array STATUS_NAMES = [ + self::STATUS_UNPAID => '未支付', + self::STATUS_PAID => '已支付', + ]; + protected $table = 'bill'; protected $primaryKey = 'id'; @@ -31,6 +42,10 @@ class BillModel extends Model 'tray_price', 'added_amount', 'total_amount', + 'status', + 'paid_at', + 'pay_remark', + 'paid_operator_id', 'operator_id', 'remark', ]; @@ -47,6 +62,9 @@ class BillModel extends Model 'tray_price' => 'decimal:2', 'added_amount' => 'decimal:2', 'total_amount' => 'decimal:2', + 'status' => 'integer', + 'paid_at' => 'datetime:Y-m-d H:i:s', + 'paid_operator_id' => 'integer', 'operator_id' => 'integer', 'created_at' => 'datetime:Y-m-d H:i:s', ]; @@ -75,6 +93,14 @@ class BillModel extends Model return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); } + /** + * 收款操作人(后台系统用户,线下收款登记) + */ + public function paidOperator(): BelongsTo + { + return $this->belongsTo(SysUserModel::class, 'paid_operator_id', 'id'); + } + /** * 本账单关联的门店订单 */ @@ -82,4 +108,12 @@ class BillModel extends Model { return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id'); } + + /** + * 本账单关联的门店订单明细 + */ + public function items(): HasMany + { + return $this->hasMany(StoreOrderItemModel::class, 'bill_id', 'id'); + } } diff --git a/app/Models/ContainerReturnModel.php b/app/Models/ContainerReturnModel.php new file mode 100644 index 0000000..5d98ef5 --- /dev/null +++ b/app/Models/ContainerReturnModel.php @@ -0,0 +1,76 @@ + '压筐', + self::TYPE_RETURN => '回筐', + ]; + + protected $table = 'container_return'; + protected $primaryKey = 'id'; + + protected $fillable = [ + 'store_id', + 'bill_id', + 'type', + 'box_num', + 'tray_num', + 'return_date', + 'operator_id', + 'remark', + ]; + + protected $casts = [ + 'store_id' => 'integer', + 'bill_id' => 'integer', + 'type' => 'integer', + 'box_num' => 'integer', + 'tray_num' => 'integer', + 'return_date' => 'date:Y-m-d', + '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 bill(): BelongsTo + { + return $this->belongsTo(BillModel::class, 'bill_id', 'id'); + } + + /** + * 操作人(后台系统用户) + */ + public function operator(): BelongsTo + { + return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); + } +} diff --git a/app/Models/StatementItemModel.php b/app/Models/StatementItemModel.php deleted file mode 100644 index db9d21f..0000000 --- a/app/Models/StatementItemModel.php +++ /dev/null @@ -1,78 +0,0 @@ - 'integer', - 'order_id' => 'integer', - 'order_item_id' => 'integer', - 'product_id' => 'integer', - 'price' => 'decimal:2', - 'quantity' => 'decimal:2', - 'weight' => 'decimal:3', - 'amount' => 'decimal:2', - 'is_reconciled' => 'integer', - ]; - - /** - * 所属对账单 - */ - public function statement(): BelongsTo - { - return $this->belongsTo(StatementModel::class, 'statement_id', 'id'); - } - - /** - * 来源订单 - */ - public function order(): BelongsTo - { - return $this->belongsTo(StoreOrderModel::class, 'order_id', 'id'); - } - - /** - * 来源订单明细 - */ - public function orderItem(): BelongsTo - { - return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id'); - } - - /** - * 对账商品 - */ - public function product(): BelongsTo - { - return $this->belongsTo(ProductModel::class, 'product_id', 'id'); - } -} diff --git a/app/Models/StatementModel.php b/app/Models/StatementModel.php deleted file mode 100644 index a5d8acf..0000000 --- a/app/Models/StatementModel.php +++ /dev/null @@ -1,65 +0,0 @@ - 'integer', - 'period_start' => 'date:Y-m-d', - 'period_end' => 'date:Y-m-d', - 'total_amount' => 'decimal:2', - 'payment_cycle_days' => 'integer', - 'settlement_date' => 'date:Y-m-d', - 'status' => 'integer', - 'reconciled_at' => 'datetime', - 'settled_at' => 'datetime', - ]; - - /** - * 所属门店 - */ - public function store(): BelongsTo - { - return $this->belongsTo(StoreModel::class, 'store_id', 'id'); - } - - /** - * 对账单明细 - */ - public function items(): HasMany - { - return $this->hasMany(StatementItemModel::class, 'statement_id', 'id'); - } -} diff --git a/app/Models/StoreModel.php b/app/Models/StoreModel.php index f24b298..3d4ea32 100644 --- a/app/Models/StoreModel.php +++ b/app/Models/StoreModel.php @@ -38,6 +38,8 @@ class StoreModel extends Model protected $casts = [ 'level_id' => 'integer', 'payment_cycle_days' => 'integer', + 'pending_box_num' => 'integer', + 'pending_tray_num' => 'integer', 'status' => 'integer', ]; @@ -66,10 +68,10 @@ class StoreModel extends Model } /** - * 门店对账单 + * 门店账单(采购单完成后按门店生成) */ - public function statements(): HasMany + public function bills(): HasMany { - return $this->hasMany(StatementModel::class, 'store_id', 'id'); + return $this->hasMany(BillModel::class, 'store_id', 'id'); } } diff --git a/app/Models/StoreOrderItemModel.php b/app/Models/StoreOrderItemModel.php index 442fe1c..70f40ec 100644 --- a/app/Models/StoreOrderItemModel.php +++ b/app/Models/StoreOrderItemModel.php @@ -21,6 +21,7 @@ class StoreOrderItemModel extends Model protected $fillable = [ 'order_id', 'purchase_id', + 'bill_id', 'store_id', 'product_id', 'category_id', @@ -42,6 +43,7 @@ class StoreOrderItemModel extends Model protected $casts = [ 'order_id' => 'integer', 'purchase_id' => 'integer', + 'bill_id' => 'integer', 'store_id' => 'integer', 'product_id' => 'integer', 'category_id' => 'integer', diff --git a/app/Models/StoreOrderModel.php b/app/Models/StoreOrderModel.php index a2aa379..8da6ae9 100644 --- a/app/Models/StoreOrderModel.php +++ b/app/Models/StoreOrderModel.php @@ -57,7 +57,6 @@ class StoreOrderModel extends Model protected $casts = [ 'store_id' => 'integer', 'purchase_id' => 'integer', - 'statement_id' => 'integer', 'bill_id' => 'integer', 'order_date' => 'date:Y-m-d', 'total_quantity' => 'integer', diff --git a/app/Services/BillDetailService.php b/app/Services/BillDetailService.php new file mode 100644 index 0000000..f5fea04 --- /dev/null +++ b/app/Services/BillDetailService.php @@ -0,0 +1,71 @@ + + */ + public function mergedItems(BillModel $bill): array + { + $items = StoreOrderItemModel::query() + ->where('bill_id', $bill->id) + ->orderBy('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; + $weight = '0'; + $amount = '0'; + foreach ($group as $item) { + $quantity += (int) $item->quantity; + $weight = bcadd($weight, (string) $item->weight, 3); + $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, + 'weight' => $weight, + 'amount' => $amount, + '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']]); + + return array_map(static function (array $row): array { + unset($row['category_sort'], $row['product_sort']); + return $row; + }, $rows); + } +} diff --git a/app/Services/BillGenerateService.php b/app/Services/BillGenerateService.php index 0de80fd..5454b66 100644 --- a/app/Services/BillGenerateService.php +++ b/app/Services/BillGenerateService.php @@ -4,8 +4,10 @@ namespace App\Services; use App\Exceptions\RepositoryException; use App\Models\BillModel; +use App\Models\ContainerReturnModel; use App\Models\PurchaseOrderModel; use App\Models\StoreModel; +use App\Models\StoreOrderItemModel; use App\Models\StoreOrderModel; use Illuminate\Support\Facades\DB; use Throwable; @@ -109,10 +111,30 @@ readonly class BillGenerateService 'operator_id' => $operatorId, ]); - // 关联该门店在采购单中的全部订单到账单 + // 关联该门店在采购单中的全部订单与订单明细到账单 StoreOrderModel::query() ->whereIn('id', $storeOrders->pluck('id')) ->update(['bill_id' => $bill->id]); + StoreOrderItemModel::query() + ->whereIn('order_id', $storeOrders->pluck('id')) + ->update(['bill_id' => $bill->id]); + + // 压筐:累加门店待回筐/托盘(行锁防并发),并写入压筐记录 + $store = StoreModel::query()->lockForUpdate()->find((int) $storeId); + if ($store !== null) { + $store->pending_box_num = (int) $store->pending_box_num + (int) $row['box_num']; + $store->pending_tray_num = (int) $store->pending_tray_num + (int) $row['tray_num']; + $store->save(); + } + ContainerReturnModel::create([ + 'store_id' => (int) $storeId, + 'bill_id' => $bill->id, + 'type' => ContainerReturnModel::TYPE_PRESS, + 'box_num' => (int) $row['box_num'], + 'tray_num' => (int) $row['tray_num'], + 'return_date' => $billDate, + 'operator_id' => $operatorId, + ]); $bills[] = $bill; } diff --git a/app/Services/BillNumberService.php b/app/Services/BillNumberService.php index aa93e39..7b0c30b 100644 --- a/app/Services/BillNumberService.php +++ b/app/Services/BillNumberService.php @@ -25,7 +25,6 @@ class BillNumberService 'PO' => ['purchase_order', 'purchase_no'], 'SO' => ['store_order', 'order_no'], 'RC' => ['reconciliation', 'recon_no'], - 'ST' => ['statement', 'statement_no'], 'JS' => ['settlement', 'settlement_no'], 'ZD' => ['bill', 'bill_no'], ]; @@ -33,7 +32,7 @@ class BillNumberService /** * 生成业务单号 * - * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 / ZD 账单 + * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 * @return string 如 PO202607230001 */ public function make(string $prefix): string diff --git a/app/Services/StatementGenerateService.php b/app/Services/StatementGenerateService.php deleted file mode 100644 index a20d268..0000000 --- a/app/Services/StatementGenerateService.php +++ /dev/null @@ -1,101 +0,0 @@ -join('store_order', 'store_order.id', '=', 'store_order_item.order_id') - ->where('store_order_item.store_id', $store->id) - ->whereDate('store_order.order_date', '>=', $periodStart) - ->whereDate('store_order.order_date', '<=', $periodEnd) - ->where('store_order.status', '<>', StoreOrderModel::STATUS_CANCELLED) - ->whereNull('store_order.deleted_at') - ->select('store_order_item.*') - ->get(); - - if ($orderItems->isEmpty()) { - throw new RepositoryException('周期内本店无订单数据,无法生成对账单'); - } - - // 防重复入账:剔除已计入过对账单的订单明细 - $usedItemIds = StatementItemModel::query() - ->whereIn('statement_id', StatementModel::where('store_id', $store->id)->pluck('id')) - ->pluck('order_item_id'); - $orderItems = $orderItems->reject(fn ($item) => $usedItemIds->contains($item->id)); - if ($orderItems->isEmpty()) { - throw new RepositoryException('周期内的订单明细均已生成过对账单'); - } - - // 快照回款周期 → 应结算日期 - $cycleDays = (int) $store->payment_cycle_days; - $totalAmount = $orderItems->reduce( - static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2), - '0' - ); - - $statement = StatementModel::create([ - 'statement_no' => $this->billNumberService->make('ST'), - 'store_id' => $store->id, - 'period_start' => $periodStart, - 'period_end' => $periodEnd, - 'total_amount' => $totalAmount, - 'payment_cycle_days' => $cycleDays, - 'settlement_date' => Carbon::parse($periodEnd)->addDays($cycleDays)->toDateString(), - 'status' => StatementModel::STATUS_PENDING, - ]); - - $rows = []; - $now = now(); - foreach ($orderItems as $item) { - $rows[] = [ - 'statement_id' => $statement->id, - 'order_id' => $item->order_id, - 'order_item_id' => $item->id, - 'product_id' => $item->product_id, - 'product_name' => $item->product_name, - 'price' => $item->price, - 'quantity' => $item->quantity, - 'weight' => $item->weight, - 'amount' => $item->amount, - 'is_reconciled' => StatementItemModel::NOT_RECONCILED, - 'store_remark' => '', - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - StatementItemModel::insert($rows); - - return $statement; - }); - } -} diff --git a/database/migrations/2026_07_23_030608_create_store_table.php b/database/migrations/2026_07_23_030608_create_store_table.php index 5d7b3c1..dd551d9 100644 --- a/database/migrations/2026_07_23_030608_create_store_table.php +++ b/database/migrations/2026_07_23_030608_create_store_table.php @@ -60,6 +60,8 @@ return new class extends Migration $table->string('phone', 20)->default('')->comment('联系电话'); $table->string('address', 255)->default('')->comment('门店地址'); $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)'); + $table->integer('pending_box_num')->default(0)->comment('待回筐数量(生成账单压筐累加,回筐登记扣减)'); + $table->integer('pending_tray_num')->default(0)->comment('待回托盘数量'); $table->integer('status')->default(1)->comment('状态(1正常 0停用)'); $table->string('remark', 255)->nullable()->default('')->comment('备注'); $table->timestamps(); 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 e718ba5..6db2a30 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 @@ -41,6 +41,7 @@ return new class extends Migration $table->integer('order_id')->comment('订单ID'); $table->integer('store_id')->comment('门店ID'); $table->integer('purchase_id')->default(0)->comment('归属采购单ID(0=未归集)'); + $table->integer('bill_id')->default(0)->comment('归属账单ID(0=未出账)'); $table->integer('product_id')->comment('商品ID'); $table->integer('category_id')->default(0)->comment('分类ID'); $table->integer('supplier_id')->default(0)->comment('供应商ID'); @@ -59,6 +60,7 @@ return new class extends Migration $table->timestamps(); $table->index(['order_id'], 'store_order_item_order_index'); $table->index(['purchase_id'], 'store_order_item_purchase_index'); + $table->index(['bill_id'], 'store_order_item_bill_index'); $table->index(['store_id', 'product_id'], 'store_order_item_store_product_index'); $table->comment('门店订货明细表'); }); diff --git a/database/migrations/2026_07_23_030612_create_reconciliation_table.php b/database/migrations/2026_07_23_030612_create_reconciliation_table.php index bc9c2d6..8ffd562 100644 --- a/database/migrations/2026_07_23_030612_create_reconciliation_table.php +++ b/database/migrations/2026_07_23_030612_create_reconciliation_table.php @@ -8,7 +8,7 @@ return new class extends Migration { /** * Run the migrations. - * 对账管理(D1-D10):财务对账、门店对账单、结算表 + * 财务管理(D1-D10):财务对账、结算表(门店对账单已下线,由采购单账单 bill 表替代) */ public function up(): void { @@ -58,48 +58,6 @@ return new class extends Migration }); } - // 门店对账单表(门店在小程序端自助生成,回款周期快照决定应结算日期) - if (! Schema::hasTable('statement')) { - Schema::create('statement', function (Blueprint $table) { - $table->increments('id')->comment('对账单ID'); - $table->string('statement_no', 32)->unique()->comment('对账单编号'); - $table->integer('store_id')->comment('门店ID'); - $table->date('period_start')->comment('对账周期开始'); - $table->date('period_end')->comment('对账周期结束'); - $table->decimal('total_amount', 10, 2)->default(0)->comment('对账总金额'); - $table->integer('payment_cycle_days')->default(0)->comment('回款周期(天),生成时从门店快照'); - $table->date('settlement_date')->nullable()->comment('应结算日期(按回款周期计算)'); - $table->integer('status')->default(0)->comment('状态(0未对账 1已对账 2已结算)'); - $table->timestamp('reconciled_at')->nullable()->comment('对账完成时间'); - $table->timestamp('settled_at')->nullable()->comment('结算时间'); - $table->string('remark', 255)->default('')->comment('备注'); - $table->timestamps(); - $table->index(['store_id', 'period_start'], 'statement_store_period_index'); - $table->comment('门店对账单表'); - }); - } - - // 门店对账单明细表(每个单品/订单的对账状态标识) - if (! Schema::hasTable('statement_item')) { - Schema::create('statement_item', function (Blueprint $table) { - $table->increments('id')->comment('明细ID'); - $table->integer('statement_id')->comment('对账单ID'); - $table->integer('order_id')->comment('订单ID'); - $table->integer('order_item_id')->comment('订货明细ID'); - $table->integer('product_id')->comment('商品ID'); - $table->string('product_name', 100)->comment('品名(快照)'); - $table->decimal('price', 10, 2)->default(0)->comment('单价'); - $table->decimal('quantity', 10, 2)->default(0)->comment('订货量'); - $table->decimal('weight', 10, 3)->default(0)->comment('重量'); - $table->decimal('amount', 10, 2)->default(0)->comment('单品金额'); - $table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账)'); - $table->string('store_remark', 255)->default('')->comment('门店备注'); - $table->timestamps(); - $table->index(['statement_id'], 'statement_item_statement_index'); - $table->comment('门店对账单明细表'); - }); - } - // 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档) if (! Schema::hasTable('settlement')) { Schema::create('settlement', function (Blueprint $table) { @@ -131,8 +89,6 @@ return new class extends Migration { Schema::dropIfExists('reconciliation'); Schema::dropIfExists('reconciliation_item'); - Schema::dropIfExists('statement'); - Schema::dropIfExists('statement_item'); Schema::dropIfExists('settlement'); } }; diff --git a/database/migrations/2026_08_13_000001_create_bill_table.php b/database/migrations/2026_08_13_000001_create_bill_table.php index 85db8d1..f17dcc8 100644 --- a/database/migrations/2026_08_13_000001_create_bill_table.php +++ b/database/migrations/2026_08_13_000001_create_bill_table.php @@ -29,11 +29,16 @@ return new class extends Migration $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('status')->default(0)->comment('支付状态(0未支付 1已支付)'); + $table->timestamp('paid_at')->nullable()->comment('付款时间(线下收款手动登记)'); + $table->string('pay_remark', 255)->default('')->comment('付款备注(线下收款信息)'); + $table->integer('paid_operator_id')->default(0)->comment('收款操作人(后台系统用户ID)'); $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->index(['status'], 'bill_status_index'); $table->comment('门店账单表(采购单完成后按门店生成)'); }); } diff --git a/database/migrations/2026_08_14_000001_create_container_return_table.php b/database/migrations/2026_08_14_000001_create_container_return_table.php new file mode 100644 index 0000000..3e7c760 --- /dev/null +++ b/database/migrations/2026_08_14_000001_create_container_return_table.php @@ -0,0 +1,41 @@ +increments('id')->comment('记录ID'); + $table->integer('store_id')->comment('门店ID'); + $table->integer('bill_id')->default(0)->comment('关联账单ID(0=手动回筐登记)'); + $table->integer('type')->comment('类型(1压筐-账单生成 2回筐-退回登记)'); + $table->integer('box_num')->default(0)->comment('周转筐数量'); + $table->integer('tray_num')->default(0)->comment('周转托盘数量'); + $table->date('return_date')->comment('记录日期(压筐=账单日期,回筐=退回日期)'); + $table->integer('operator_id')->default(0)->comment('操作人(后台系统用户ID)'); + $table->string('remark', 255)->default('')->comment('备注'); + $table->timestamps(); + $table->index(['store_id', 'type'], 'container_return_store_type_index'); + $table->index(['bill_id'], 'container_return_bill_index'); + $table->comment('门店回筐记录表(压筐=生成账单压出,回筐=门店退回登记)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('container_return'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 91aeaf9..27028ba 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -91,6 +91,18 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'product.goods.batchPrice', 'name' => '批量调价'], ], ], + [ + 'type' => 'route', + 'key' => 'customer.supplier', + 'name' => '供应商', + 'path' => '/customer/supplier', + 'children' => [ + ['type' => 'rule', 'key' => 'customer.supplier.query', 'name' => '查询'], + ['type' => 'rule', 'key' => 'customer.supplier.create', 'name' => '新增'], + ['type' => 'rule', 'key' => 'customer.supplier.update', 'name' => '编辑'], + ['type' => 'rule', 'key' => 'customer.supplier.delete', 'name' => '删除'], + ], + ], ], ], [ @@ -123,18 +135,6 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'customer.level.delete', 'name' => '删除'], ], ], - [ - 'type' => 'route', - 'key' => 'customer.supplier', - 'name' => '供应商', - 'path' => '/customer/supplier', - 'children' => [ - ['type' => 'rule', 'key' => 'customer.supplier.query', 'name' => '查询'], - ['type' => 'rule', 'key' => 'customer.supplier.create', 'name' => '新增'], - ['type' => 'rule', 'key' => 'customer.supplier.update', 'name' => '编辑'], - ['type' => 'rule', 'key' => 'customer.supplier.delete', 'name' => '删除'], - ], - ], [ 'type' => 'route', 'key' => 'customer.miniUser', @@ -202,7 +202,7 @@ class PermissionSeeder extends Seeder [ 'type' => 'menu', 'key' => 'procurement.recon', - 'name' => '对账管理', + 'name' => '财务管理', 'icon' => 'AccountBookOutlined', 'children' => [ [ @@ -223,11 +223,23 @@ class PermissionSeeder extends Seeder ], [ 'type' => 'route', - 'key' => 'recon.statement', - 'name' => '门店对账单', - 'path' => '/recon/statement', + 'key' => 'recon.bill', + 'name' => '门店账单', + 'path' => '/recon/bill', 'children' => [ - ['type' => 'rule', 'key' => 'recon.statement.query', 'name' => '查询'], + ['type' => 'rule', 'key' => 'recon.bill.query', 'name' => '查询'], + ['type' => 'rule', 'key' => 'recon.bill.pay', 'name' => '确认收款'], + ], + ], + [ + 'type' => 'route', + 'key' => 'recon.containerReturn', + 'name' => '回筐记录', + 'path' => '/recon/container-return', + 'children' => [ + ['type' => 'rule', 'key' => 'recon.containerReturn.query', 'name' => '查询'], + ['type' => 'rule', 'key' => 'recon.containerReturn.create', 'name' => '回筐登记'], + ['type' => 'rule', 'key' => 'recon.containerReturn.delete', 'name' => '删除'], ], ], [ diff --git a/resources/views/exports/statement.blade.php b/resources/views/exports/statement.blade.php deleted file mode 100644 index 88d1546..0000000 --- a/resources/views/exports/statement.blade.php +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - -

门店对账单

-
- 对账单号:{{ $statement->statement_no }}  门店:{{ $storeName }}
- 对账周期:{{ $statement->period_start?->format('Y-m-d') }} 至 {{ $statement->period_end?->format('Y-m-d') }} - 回款周期:{{ $statement->payment_cycle_days }} 天 - 应结算日期:{{ $statement->settlement_date?->format('Y-m-d') }} -
- - - - - - - - - - - - - - @foreach ($items as $item) - - - - - - - - - - @endforeach - - - - - - - - -
品名单价数量重量金额对账状态备注
{{ $item->product_name }}{{ $item->price }}{{ $item->quantity }}{{ $item->weight }}{{ $item->amount }}{{ $item->is_reconciled ? '已对账' : '未对账' }}{{ $item->store_remark }}
合计金额¥{{ $statement->total_amount }}
- - diff --git a/tests/Feature/StatementTest.php b/tests/Feature/StatementTest.php deleted file mode 100644 index 8e398d1..0000000 --- a/tests/Feature/StatementTest.php +++ /dev/null @@ -1,105 +0,0 @@ -create(); - $store = StoreModel::factory()->create([ - 'level_id' => $level->id, - 'payment_cycle_days' => $cycleDays, - ]); - $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); - - $user = UserModel::factory()->forStore($store->id)->create(); - $this->actingAsMiniUser($user); - $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]]) - ->assertJsonPath('success', true); - - return [$store, $user, $product]; - } - - /** 回款周期快照:settlement_date = period_end + cycle 天 */ - public function test_generate_snapshots_payment_cycle(): void - { - [$store] = $this->makeStoreWithOrder('2.00', 7); - $today = now()->toDateString(); - - $response = $this->postJson('/mini/statement/generate', [ - 'period_start' => $today, - 'period_end' => $today, - ]); - $response->assertOk()->assertJsonPath('success', true); - - $statement = StatementModel::where('store_id', $store->id)->first(); - $this->assertNotNull($statement); - $this->assertStringStartsWith('ST', $statement->statement_no); - $this->assertSame(7, $statement->payment_cycle_days, '快照生成时的回款周期'); - $this->assertSame(now()->addDays(7)->toDateString(), $statement->settlement_date->toDateString()); - $this->assertSame('20.00', (string) $statement->total_amount); - $this->assertSame(1, $statement->items()->count()); - - // 生成后门店修改回款周期,不影响已生成的对账单(快照语义) - $store->update(['payment_cycle_days' => 30]); - $this->assertSame(7, $statement->fresh()->payment_cycle_days); - } - - /** 门店隔离:只能查看与生成本店对账单 */ - public function test_statement_isolated_between_stores(): void - { - [$storeA] = $this->makeStoreWithOrder(); - $today = now()->toDateString(); - $this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today]) - ->assertJsonPath('success', true); - $statementOfA = StatementModel::where('store_id', $storeA->id)->first(); - - // 门店 B 用户 - $storeB = StoreModel::factory()->create(['level_id' => $storeA->level_id]); - $this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create()); - - $this->getJson('/mini/statement')->assertJsonPath('data.total', 0); - $this->getJson("/mini/statement/{$statementOfA->id}")->assertJsonPath('success', false); - } - - /** 已取消订单不计入;重复生成时已入账明细被排除 */ - public function test_generate_excludes_cancelled_and_used_items(): void - { - [$store] = $this->makeStoreWithOrder('2.00'); - // 再下一单并取消 - $this->postJson('/mini/order', ['items' => [['product_id' => ProductModel::first()->id, 'quantity' => 5]]]); - $cancelledOrder = StoreOrderModel::where('store_id', $store->id) - ->orderBy('id', 'desc') - ->first(); - $this->putJson("/mini/order/{$cancelledOrder->id}/cancel")->assertJsonPath('success', true); - - $today = now()->toDateString(); - $this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today]) - ->assertJsonPath('success', true); - - $statement = StatementModel::where('store_id', $store->id)->first(); - $this->assertSame('20.00', (string) $statement->total_amount, '已取消订单不计入'); - $this->assertSame(1, $statement->items()->count()); - - // 同周期重复生成 → 明细已全部入账,拒绝 - $this->postJson('/mini/statement/generate', ['period_start' => $today, 'period_end' => $today]) - ->assertJsonPath('success', false); - $this->assertSame(1, StatementModel::where('store_id', $store->id)->count()); - } -} diff --git a/web/api/recon/bill.ts b/web/api/recon/bill.ts new file mode 100644 index 0000000..0347054 --- /dev/null +++ b/web/api/recon/bill.ts @@ -0,0 +1,19 @@ +import createAxios from '@/utils/request'; +import type { IBillDetail } from '@/domain/iBill.ts'; + +/** 账单详情(账单信息 + 合并商品明细 + 关联订单) */ +export async function getBillDetail(id: number) { + return createAxios({ + url: `/recon/bill/${id}`, + method: 'get', + }); +} + +/** 确认收款:线下收款后手动登记付款信息,支付状态置为已支付 */ +export async function payBill(id: number, data: { paid_at: string; pay_remark?: string }) { + return createAxios({ + url: `/recon/bill/${id}/pay`, + method: 'put', + data, + }); +} diff --git a/web/domain/iBill.ts b/web/domain/iBill.ts new file mode 100644 index 0000000..ba0be4c --- /dev/null +++ b/web/domain/iBill.ts @@ -0,0 +1,41 @@ +import type { IBill } from '@/domain/iPurchaseOrder.ts'; + +/** 门店账单(采购单完成后按门店生成),类型定义在 iPurchaseOrder.ts */ +export type { IBill }; +export { BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; + +/** 账单合并商品明细行(按商品聚合账单关联的全部订单明细) */ +export interface IBillGoodsItem { + product_id: number; + product_name: string; + /** 规格/包规 */ + product_spec: string; + unit: string; + /** 单价(加权平均:Σ金额÷Σ数量) */ + price: string; + /** 数量合计 */ + quantity: number; + /** 重量合计 */ + weight: string; + /** 金额合计 = Σ 明细金额 */ + amount: string; +} + +/** 账单关联的门店订单 */ +export interface IBillOrder { + id: number; + order_no: string; + order_date: string; + total_quantity: number; + total_weight: string; + total_amount: string; + /** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */ + status: number; +} + +/** 账单详情(账单 + 合并商品明细 + 关联订单) */ +export interface IBillDetail { + bill: IBill; + items: IBillGoodsItem[]; + orders: IBillOrder[]; +} diff --git a/web/domain/iContainerReturn.ts b/web/domain/iContainerReturn.ts new file mode 100644 index 0000000..4322adf --- /dev/null +++ b/web/domain/iContainerReturn.ts @@ -0,0 +1,26 @@ +/** 门店回筐记录(压筐=生成账单自动写入只读;回筐=门店退回手动登记) */ +export default interface IContainerReturn { + id?: number; + store_id?: number; + /** 关联账单ID(0=手动回筐登记) */ + bill_id?: number; + /** 类型:1压筐 2回筐 */ + type?: number; + box_num?: number; + tray_num?: number; + /** 记录日期(压筐=账单日期,回筐=退回日期) */ + return_date?: string; + operator_id?: number; + remark?: string; + created_at?: string; + /** 列表接口附带 */ + store?: { id: number; name: string; pending_box_num?: number; pending_tray_num?: number } | null; + bill?: { id: number; bill_no: string } | null; + operator?: { id: number; nickname: string } | null; +} + +/** 回筐记录类型映射 */ +export const CONTAINER_TYPE_MAP: Record = { + 1: { text: '压筐', color: 'processing' }, + 2: { text: '回筐', color: 'success' }, +}; diff --git a/web/domain/iPurchaseOrder.ts b/web/domain/iPurchaseOrder.ts index 2446901..515698b 100644 --- a/web/domain/iPurchaseOrder.ts +++ b/web/domain/iPurchaseOrder.ts @@ -74,11 +74,30 @@ export interface IBill { added_amount: string; /** 账单总金额 = 商品金额 + 配送费 + 附加金额 */ total_amount: string; + /** 支付状态:0未支付 1已支付 */ + status?: number; + /** 付款时间(线下收款手动登记) */ + paid_at?: string | null; + /** 付款备注(线下收款信息) */ + pay_remark?: string; + paid_operator_id?: number; order_count?: number; remark?: string; created_at?: string; + /** 门店账单列表/详情接口附带 */ + store?: { id: number; name: string; address?: string; contact?: string; phone?: string } | null; + purchase?: { id: number; purchase_no: string; purchase_date: string; status?: number } | null; + operator?: { id: number; nickname: string } | null; + paid_operator?: { id: number; nickname: string } | null; + orders_count?: number; } +/** 账单支付状态映射 */ +export const BILL_STATUS_MAP: Record = { + 0: { text: '未支付', color: 'warning' }, + 1: { text: '已支付', color: 'success' }, +}; + /** 账单生成预览行(按门店汇总,金额只读) */ export interface IBillPrepareStore { store_id: number; diff --git a/web/domain/iStatement.ts b/web/domain/iStatement.ts deleted file mode 100644 index 1049e49..0000000 --- a/web/domain/iStatement.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** 门店对账单明细 */ -export interface IStatementItem { - id?: number; - statement_id?: number; - order_id?: number; - order_item_id?: number; - product_id?: number; - product_name?: string; - price?: string; - quantity?: string; - weight?: string; - amount?: string; - is_reconciled?: number; - store_remark?: string; -} - -/** 门店对账单 */ -export default interface IStatement { - id?: number; - statement_no?: string; - store_id?: number; - store?: { id: number; name: string }; - period_start?: string; - period_end?: string; - total_amount?: string; - /** 回款周期快照(天) */ - payment_cycle_days?: number; - /** 应结算日期 = period_end + 回款周期 */ - settlement_date?: string; - /** 0待对账 1已对账 2已结算 */ - status?: number; - reconciled_at?: string; - settled_at?: string; - remark?: string; - items?: IStatementItem[]; - created_at?: string; -} - -export const STATEMENT_STATUS_MAP: Record = { - 0: { text: '待对账', color: 'default' }, - 1: { text: '已对账', color: 'processing' }, - 2: { text: '已结算', color: 'success' }, -}; diff --git a/web/domain/iStore.ts b/web/domain/iStore.ts index aa565c3..a208e68 100644 --- a/web/domain/iStore.ts +++ b/web/domain/iStore.ts @@ -14,6 +14,10 @@ export default interface IStore { address?: string; /** 回款周期(天) */ payment_cycle_days?: number; + /** 待回筐数量(生成账单压筐累加,回筐登记扣减) */ + pending_box_num?: number; + /** 待回托盘数量 */ + pending_tray_num?: number; status?: number; remark?: string; created_at?: string; diff --git a/web/domain/iStoreOrder.ts b/web/domain/iStoreOrder.ts index 5ef8bcf..1e082cc 100644 --- a/web/domain/iStoreOrder.ts +++ b/web/domain/iStoreOrder.ts @@ -44,7 +44,6 @@ export default interface IStoreOrder { order_no?: string; store_id?: number; purchase_id?: number; - statement_id?: number; /** 关联账单ID(采购单完成后按门店生成账单时回写) */ bill_id?: number; store?: { diff --git a/web/pages/customer/store.tsx b/web/pages/customer/store.tsx index ad6861a..a4c62d6 100644 --- a/web/pages/customer/store.tsx +++ b/web/pages/customer/store.tsx @@ -86,6 +86,30 @@ const StorePage: React.FC = () => { fieldProps: { min: 0, precision: 0 }, align: 'center', }, + { + title: '待回筐数量', + dataIndex: 'pending_box_num', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => ( + 0 ? 'warning' : undefined}> + {record.pending_box_num ?? 0} + + ), + }, + { + title: '待回托盘数量', + dataIndex: 'pending_tray_num', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => ( + 0 ? 'warning' : undefined}> + {record.pending_tray_num ?? 0} + + ), + }, { title: '状态', dataIndex: 'status', diff --git a/web/pages/purchase/order.tsx b/web/pages/purchase/order.tsx index b7450e2..aa2fd52 100644 --- a/web/pages/purchase/order.tsx +++ b/web/pages/purchase/order.tsx @@ -39,7 +39,7 @@ import type { IPurchaseStoreItem, IPurchaseStoreSummary, } from '@/domain/iPurchaseOrder.ts'; -import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; +import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { exportPurchase, @@ -544,6 +544,16 @@ const PurchaseOrderPage: React.FC = () => { align: 'center', render: (v) => ¥{Number(v).toFixed(2)}, }, + { + title: '支付状态', + dataIndex: 'status', + width: 100, + align: 'center', + render: (v) => { + const item = BILL_STATUS_MAP[Number(v ?? 0)]; + return {item?.text}; + }, + }, ]; /** 门店账单合计行 */ @@ -573,6 +583,9 @@ const PurchaseOrderPage: React.FC = () => { ¥{totalAmount.toFixed(2)} + + - + ); }; diff --git a/web/pages/recon/bill.tsx b/web/pages/recon/bill.tsx new file mode 100644 index 0000000..9dac626 --- /dev/null +++ b/web/pages/recon/bill.tsx @@ -0,0 +1,422 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + message, + Modal, + Table, + Tag, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { UnorderedListOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import XinTable from '@/components/XinTable'; +import type { + XinTableColumn, + XinTableInstance, + XinTableProps, +} from '@/components/XinTable/typings.ts'; +import type { IBill, IBillDetail, IBillGoodsItem, IBillOrder } from '@/domain/iBill.ts'; +import { BILL_STATUS_MAP } from '@/domain/iBill.ts'; +import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; +import { getBillDetail, payBill } from '@/api/recon/bill.ts'; +import { getStoreOptions } from '@/api/customer/store.ts'; +import type IStore from '@/domain/iStore.ts'; +import AuthButton from '@/components/AuthButton'; + +const { Title, Text } = Typography; + +/** 确认收款表单 */ +interface PayFormValues { + paid_at: dayjs.Dayjs; + pay_remark?: string; +} + +/** + * 门店账单(采购单完成后按门店生成;详情含合并后的商品明细与关联订单;线下收款手动登记) + */ +const BillPage: React.FC = () => { + const tableRef = useRef>(null); + const [stores, setStores] = useState([]); + + const [detailOpen, setDetailOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + // 确认收款(线下收款手动登记) + const [payTarget, setPayTarget] = useState(null); + const [paySaving, setPaySaving] = useState(false); + const [payForm] = Form.useForm(); + + useEffect(() => { + getStoreOptions().then((res) => setStores(res.data.data ?? [])); + }, []); + + const openDetail = async (id: number) => { + setDetailOpen(true); + setDetailLoading(true); + try { + const res = await getBillDetail(id); + setDetail(res.data.data ?? null); + } finally { + setDetailLoading(false); + } + }; + + /** 打开确认收款弹窗(默认付款时间为当前) */ + const openPay = (record: IBill) => { + setPayTarget(record); + payForm.setFieldsValue({ paid_at: dayjs(), pay_remark: '' }); + }; + + /** 提交确认收款:登记付款信息并置为已支付 */ + const handlePaySave = async (values: PayFormValues) => { + if (!payTarget?.id) { + return; + } + setPaySaving(true); + try { + await payBill(payTarget.id, { + paid_at: values.paid_at.format('YYYY-MM-DD HH:mm:ss'), + pay_remark: values.pay_remark ?? '', + }); + message.success('收款已登记,账单已置为已支付'); + setPayTarget(null); + await tableRef.current?.reload(); + } finally { + setPaySaving(false); + } + }; + + /** 合并商品明细列:品名/包规/单位/单价(加权平均)/数量/重量/金额 */ + const itemColumns: TableProps['columns'] = [ + { title: '品名', dataIndex: 'product_name', width: 160, align: 'center' }, + { title: '包规', dataIndex: 'product_spec', width: 100, align: 'center', render: (v) => v || '-' }, + { title: '单位', dataIndex: 'unit', width: 80, 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) => {v}, + }, + { title: '重量', dataIndex: 'weight', width: 100, align: 'center', render: (v) => `${v}斤` }, + { + title: '金额', + dataIndex: 'amount', + width: 110, + align: 'center', + render: (v) => ¥{Number(v).toFixed(2)}, + }, + ]; + + /** 合并商品明细合计行 */ + const renderItemSummary = () => { + const items = detail?.items ?? []; + const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0); + const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0); + const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0); + return ( + + + 合计 + + + {totalQuantity} + + + {totalWeight.toFixed(3)}斤 + + + ¥{totalAmount.toFixed(2)} + + + ); + }; + + /** 关联订单列 */ + const orderColumns: TableProps['columns'] = [ + { + title: '订单号', + dataIndex: 'order_no', + align: 'center', + render: (v) => {v}, + }, + { title: '订货日期', dataIndex: 'order_date', align: 'center' }, + { title: '订货数量', dataIndex: 'total_quantity', align: 'center' }, + { title: '总重量', dataIndex: 'total_weight', align: 'center', render: (v) => `${v}斤` }, + { + title: '订单金额', + dataIndex: 'total_amount', + align: 'center', + render: (v) => ¥{v}, + }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + render: (v) => { + const item = STORE_ORDER_STATUS_MAP[Number(v ?? 0)]; + return {item?.text}; + }, + }, + ]; + + const columns: XinTableColumn[] = [ + { + title: '账单号', + dataIndex: 'bill_no', + valueType: 'text', + hideInForm: true, + width: 210, + render: (_, record) => {record.bill_no}, + }, + { + title: '门店', + dataIndex: 'store_id', + valueType: 'select', + hideInForm: true, + fieldProps: { + options: stores.map((s) => ({ label: s.name, value: s.id })), + showSearch: true, + optionFilterProp: 'label', + }, + render: (_, record) => record.store?.name ?? `门店#${record.store_id}`, + }, + { + title: '采购单号', + dataIndex: 'purchase_no', + valueType: 'text', + hideInForm: true, + render: (_, record) => record.purchase?.purchase_no ?? '-', + }, + { + title: '账单日期', + dataIndex: 'bill_date', + valueType: 'dateRange', + hideInForm: true, + align: 'center', + render: (_, record) => record.bill_date, + }, + { + title: '商品金额', + dataIndex: 'product_amount', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => `¥${record.product_amount}`, + }, + { + title: '配送费', + dataIndex: 'delivery_fee', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => `¥${record.delivery_fee}`, + }, + { + title: '附加金额', + dataIndex: 'added_amount', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => ( + + ¥{record.added_amount} + + ), + }, + { + title: '账单总金额', + dataIndex: 'total_amount', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => ¥{record.total_amount}, + }, + { + title: '支付状态', + dataIndex: 'status', + valueType: 'select', + hideInForm: true, + align: 'center', + fieldProps: { + options: Object.entries(BILL_STATUS_MAP).map(([value, item]) => ({ + value: Number(value), + label: item.text, + })), + }, + render: (_, record) => { + const item = BILL_STATUS_MAP[record.status ?? 0]; + return {item?.text}; + }, + }, + { + title: '关联订单', + dataIndex: 'orders_count', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => `${record.orders_count ?? 0} 笔`, + }, + { + title: '生成时间', + dataIndex: 'created_at', + hideInForm: true, + hideInSearch: true, + align: 'center', + }, + ]; + + const operateRender: XinTableProps['operateRender'] = (record) => [ + + + ) : null, + ]; + + const tableProps: XinTableProps = { + api: '/recon/bill', + columns, + rowKey: 'id', + accessName: 'recon.bill', + tableRef, + operateRender, + formProps: false, + actionBarRender: (dom) => [dom.search, dom.keywordSearch], + }; + + return ( + <> +
+ 门店账单 + + 采购单完成后在「采购单」页生成,每个门店单独一张;账单总金额 = 商品金额 + 配送费 + 附加金额(周转筐/托盘)。 + +
+ {...tableProps} /> + + setDetailOpen(false)} + size={1000} + loading={detailLoading} + > + {detail ? ( + <> + + {detail.bill.store?.name ?? `门店#${detail.bill.store_id}`} + {detail.bill.purchase?.purchase_no ?? '-'} + {detail.bill.bill_date} + ¥{detail.bill.product_amount} + ¥{detail.bill.delivery_fee} + + ¥{detail.bill.added_amount} + + (筐 {detail.bill.box_num}×¥{detail.bill.box_price},托盘 {detail.bill.tray_num}×¥{detail.bill.tray_price}) + + + + ¥{detail.bill.total_amount} + + + + {BILL_STATUS_MAP[detail.bill.status ?? 0]?.text} + + + {detail.bill.paid_at ?? '-'} + {detail.bill.paid_operator?.nickname ?? '-'} + {detail.bill.operator?.nickname ?? '-'} + {detail.bill.created_at} + {detail.bill.pay_remark ? ( + {detail.bill.pay_remark} + ) : null} + {detail.bill.remark ? ( + {detail.bill.remark} + ) : null} + + + + 商品明细(按商品合并) + + + rowKey="product_id" + size="small" + bordered + columns={itemColumns} + dataSource={detail.items} + pagination={false} + summary={renderItemSummary} + /> + + + 关联订单({detail.orders.length} 笔) + + + rowKey="id" + size="small" + bordered + columns={orderColumns} + dataSource={detail.orders} + pagination={false} + /> + + ) : null} + + + {/* 确认收款:线下收款后手动登记付款信息 */} + setPayTarget(null)} + onOk={() => payForm.submit()} + confirmLoading={paySaving} + okText="确认收款" + destroyOnHidden + > +
+ 应收金额 ¥{payTarget?.total_amount ?? '0.00'} + (商品 ¥{payTarget?.product_amount ?? '0.00'} + 配送费 ¥{payTarget?.delivery_fee ?? '0.00'} + 附加 ¥{payTarget?.added_amount ?? '0.00'}); + 线下收款完成后登记付款信息,账单支付状态将置为「已支付」。 +
+
+ + + + + + +
+
+ + ); +}; + +export default BillPage; diff --git a/web/pages/recon/container-return.tsx b/web/pages/recon/container-return.tsx new file mode 100644 index 0000000..199a888 --- /dev/null +++ b/web/pages/recon/container-return.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useState } from 'react'; +import { message, Tag, Typography } from 'antd'; +import dayjs from 'dayjs'; +import XinTable from '@/components/XinTable'; +import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts'; +import type IContainerReturn from '@/domain/iContainerReturn.ts'; +import { CONTAINER_TYPE_MAP } from '@/domain/iContainerReturn.ts'; +import { getStoreOptions } from '@/api/customer/store.ts'; +import { Create } from '@/api/common/table.ts'; +import type IStore from '@/domain/iStore.ts'; + +const { Title, Text } = Typography; + +/** + * 回筐记录(压筐=生成账单时自动写入,只读;回筐=门店退回手动登记,删除后恢复门店待回数量) + */ +const ContainerReturnPage: React.FC = () => { + const [stores, setStores] = useState([]); + + useEffect(() => { + getStoreOptions().then((res) => setStores(res.data.data ?? [])); + }, []); + + const columns: XinTableColumn[] = [ + { + title: '门店', + dataIndex: 'store_id', + valueType: 'select', + rules: [{ required: true, message: '请选择门店' }], + fieldProps: { + options: stores.map((s) => ({ label: s.name, value: s.id })), + showSearch: true, + optionFilterProp: 'label', + }, + render: (_, record) => record.store?.name ?? `门店#${record.store_id}`, + }, + { + title: '类型', + dataIndex: 'type', + valueType: 'select', + hideInForm: true, + align: 'center', + fieldProps: { + options: Object.entries(CONTAINER_TYPE_MAP).map(([value, item]) => ({ + value: Number(value), + label: item.text, + })), + }, + render: (_, record) => { + const item = CONTAINER_TYPE_MAP[record.type ?? 0]; + return {item?.text ?? '-'}; + }, + }, + { + title: '关联账单', + dataIndex: 'bill_id', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => + record.bill ? {record.bill.bill_no} : '-', + }, + { + title: '周转筐数量', + dataIndex: 'box_num', + valueType: 'digit', + hideInSearch: true, + initialValue: 0, + rules: [{ required: true, message: '请输入周转筐数量' }], + fieldProps: { min: 0, precision: 0 }, + align: 'center', + render: (_, record) => {record.box_num}, + }, + { + title: '周转托盘数量', + dataIndex: 'tray_num', + valueType: 'digit', + hideInSearch: true, + initialValue: 0, + rules: [{ required: true, message: '请输入周转托盘数量' }], + fieldProps: { min: 0, precision: 0 }, + align: 'center', + render: (_, record) => {record.tray_num}, + }, + { + title: '当前待回(筐/托盘)', + key: 'pending', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => + record.store + ? `${record.store.pending_box_num ?? 0} / ${record.store.pending_tray_num ?? 0}` + : '-', + }, + { + title: '退回日期', + dataIndex: 'return_date', + valueType: 'dateRange', + hideInTable: true, + hideInForm: true, + align: 'center', + }, + { + title: '退回日期', + dataIndex: 'return_date', + valueType: 'date', + hideInSearch: true, + initialValue: dayjs(), + rules: [{ required: true, message: '请选择退回日期' }], + align: 'center', + }, + { + title: '操作人', + dataIndex: 'operator', + hideInForm: true, + hideInSearch: true, + align: 'center', + render: (_, record) => record.operator?.nickname ?? '-', + }, + { + title: '登记时间', + dataIndex: 'created_at', + hideInForm: true, + hideInSearch: true, + align: 'center', + }, + { + title: '备注', + dataIndex: 'remark', + valueType: 'textarea', + hideInSearch: true, + fieldProps: { rows: 2 }, + render: (v) => v || '-', + }, + ]; + + const tableProps: XinTableProps = { + api: '/recon/container-return', + columns, + rowKey: 'id', + accessName: 'recon.containerReturn', + // 压筐记录由账单生成,仅回筐记录可删除;均不允许编辑 + editShow: false, + deleteShow: (record) => record.type === 2, + // 自定义提交:DatePicker 值为 dayjs 对象,格式化为 Y-m-d 后再提交 + handleFinish: async (values) => { + await Create('/recon/container-return', { + ...values, + return_date: dayjs(values.return_date).format('YYYY-MM-DD'), + }); + message.success('回筐已登记'); + return true; + }, + formProps: { + grid: true, + colProps: { span: 12 }, + rowProps: { gutter: 20 }, + layout: 'vertical', + }, + modalProps: { width: 640, title: '回筐登记' }, + }; + + return ( + <> +
+ 回筐记录 + + 生成账单时自动写入「压筐」记录并累加门店待回数量;门店退回周转筐/托盘时手动新增「回筐」登记并扣减待回,删除登记将恢复待回数量。 + +
+ {...tableProps} /> + + ); +}; + +export default ContainerReturnPage; diff --git a/web/pages/recon/statement.tsx b/web/pages/recon/statement.tsx deleted file mode 100644 index 9bb771c..0000000 --- a/web/pages/recon/statement.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Button, - Descriptions, - Drawer, - Table, - Tag, - Typography, -} from 'antd'; -import type { TableProps } from 'antd'; -import XinTable from '@/components/XinTable'; -import type { - XinTableColumn, - XinTableInstance, - XinTableProps, -} from '@/components/XinTable/typings.ts'; -import type IStatement from '@/domain/iStatement.ts'; -import type { IStatementItem } from '@/domain/iStatement.ts'; -import { STATEMENT_STATUS_MAP } from '@/domain/iStatement.ts'; -import { RECONCILED_MAP } from '@/domain/iReconciliation.ts'; -import { getStoreOptions } from '@/api/customer/store.ts'; -import type IStore from '@/domain/iStore.ts'; -import { Get } from '@/api/common/table.ts'; - -const { Title, Text } = Typography; - -/** - * 门店对账单(后台只读视角;生成/导出在小程序端) - */ -const StatementPage: React.FC = () => { - const tableRef = useRef>(null); - const [stores, setStores] = useState([]); - - const [detailOpen, setDetailOpen] = useState(false); - const [detail, setDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - - useEffect(() => { - getStoreOptions().then((res) => setStores(res.data.data ?? [])); - }, []); - - const openDetail = async (id: number) => { - setDetailOpen(true); - setDetailLoading(true); - try { - const res = await Get('/recon/statement', id); - setDetail(res.data.data ?? null); - } finally { - setDetailLoading(false); - } - }; - - const itemColumns: TableProps['columns'] = [ - { title: '品名', dataIndex: 'product_name' }, - { title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` }, - { title: '数量', dataIndex: 'quantity', align: 'right' }, - { title: '重量', dataIndex: 'weight', align: 'right' }, - { title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` }, - { - title: '对账状态', - dataIndex: 'is_reconciled', - align: 'center', - render: (v) => { - const item = RECONCILED_MAP[Number(v ?? 0)]; - return {item?.text}; - }, - }, - { title: '备注', dataIndex: 'store_remark', render: (v) => v || '-' }, - ]; - - const columns: XinTableColumn[] = [ - { - title: '对账单号', - dataIndex: 'statement_no', - valueType: 'text', - hideInForm: true, - render: (_, record) => {record.statement_no}, - }, - { - title: '门店', - dataIndex: 'store_id', - valueType: 'select', - hideInForm: true, - fieldProps: { - options: stores.map((s) => ({ label: s.name, value: s.id })), - showSearch: true, - optionFilterProp: 'label', - }, - render: (_, record) => record.store?.name ?? '-', - }, - { - title: '对账周期', - dataIndex: 'period_start', - valueType: 'dateRange', - hideInForm: true, - render: (_, record) => `${record.period_start} ~ ${record.period_end}`, - }, - { - title: '总金额', - dataIndex: 'total_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => ¥{record.total_amount}, - }, - { - title: '回款周期', - dataIndex: 'payment_cycle_days', - hideInForm: true, - hideInSearch: true, - align: 'center', - render: (_, record) => `${record.payment_cycle_days} 天`, - }, - { - title: '应结算日期', - dataIndex: 'settlement_date', - hideInForm: true, - hideInSearch: true, - align: 'center', - render: (_, record) => { - const overdue = - record.status !== 2 && record.settlement_date - ? new Date(record.settlement_date).getTime() < Date.now() - : false; - return ( - - {record.settlement_date} - {overdue ? '(逾期)' : ''} - - ); - }, - }, - { - title: '状态', - dataIndex: 'status', - valueType: 'select', - hideInForm: true, - fieldProps: { - options: Object.entries(STATEMENT_STATUS_MAP).map(([value, item]) => ({ - value: Number(value), - label: item.text, - })), - }, - render: (_, record) => { - const item = STATEMENT_STATUS_MAP[record.status ?? 0]; - return {item?.text}; - }, - align: 'center', - }, - ]; - - const operateRender: XinTableProps['operateRender'] = (record) => [ - , - ]; - - const tableProps: XinTableProps = { - api: '/recon/statement', - columns, - rowKey: 'id', - accessName: 'recon.statement', - tableRef, - operateRender, - formProps: false, - actionBarRender: (dom) => [dom.search, dom.keywordSearch], - }; - - return ( - <> -
- 门店对账单 - - 门店在小程序端自助生成;应结算日期 = 对账周期结束日 + 门店回款周期(生成时快照),逾期高亮提示。 - -
- {...tableProps} /> - - setDetailOpen(false)} - width={860} - loading={detailLoading} - > - {detail ? ( - <> - - {detail.store?.name} - - - {STATEMENT_STATUS_MAP[detail.status ?? 0]?.text} - - - - {detail.period_start} ~ {detail.period_end} - - - {detail.payment_cycle_days} 天 - - - {detail.settlement_date} - - ¥{detail.total_amount} - {detail.remark ? ( - - {detail.remark} - - ) : null} - - - 对账明细({detail.items?.length ?? 0}) - - - rowKey="id" - size="small" - columns={itemColumns} - dataSource={detail.items ?? []} - pagination={false} - summary={() => ( - - - 合计 - - - ¥{detail.total_amount} - - - - )} - /> - - ) : null} - - - ); -}; - -export default StatementPage;