From f0927e57e9bf25d44dd4524b81b8e72a3b18f948 Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Fri, 14 Aug 2026 15:54:53 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Exports/SettlementExport.php | 74 -- .../Controllers/Recon/ReconItemController.php | 125 --- .../Recon/ReconciliationController.php | 235 ------ .../Recon/SettlementController.php | 53 -- .../Requests/Recon/ReconItemUpdateRequest.php | 38 - .../Recon/ReconciliationFormRequest.php | 46 -- app/Models/ReconciliationItemModel.php | 82 -- app/Models/ReconciliationModel.php | 75 -- app/Models/SettlementModel.php | 74 -- app/Services/BillNumberService.php | 4 +- app/Services/ReconciliationBuildService.php | 124 --- ..._23_030612_create_reconciliation_table.php | 94 --- database/seeders/PermissionSeeder.php | 26 - tests/Feature/ReconciliationTest.php | 191 ----- web/api/recon/list.ts | 60 -- web/api/recon/settlement.ts | 11 - web/domain/iReconciliation.ts | 70 -- web/domain/iSettlement.ts | 27 - web/pages/recon/list.tsx | 719 ------------------ web/pages/recon/settlement.tsx | 236 ------ 20 files changed, 1 insertion(+), 2363 deletions(-) delete mode 100644 app/Exports/SettlementExport.php delete mode 100644 app/Http/Controllers/Recon/ReconItemController.php delete mode 100644 app/Http/Controllers/Recon/ReconciliationController.php delete mode 100644 app/Http/Controllers/Recon/SettlementController.php delete mode 100644 app/Http/Requests/Recon/ReconItemUpdateRequest.php delete mode 100644 app/Http/Requests/Recon/ReconciliationFormRequest.php delete mode 100644 app/Models/ReconciliationItemModel.php delete mode 100644 app/Models/ReconciliationModel.php delete mode 100644 app/Models/SettlementModel.php delete mode 100644 app/Services/ReconciliationBuildService.php delete mode 100644 database/migrations/2026_07_23_030612_create_reconciliation_table.php delete mode 100644 tests/Feature/ReconciliationTest.php delete mode 100644 web/api/recon/list.ts delete mode 100644 web/api/recon/settlement.ts delete mode 100644 web/domain/iReconciliation.ts delete mode 100644 web/domain/iSettlement.ts delete mode 100644 web/pages/recon/list.tsx delete mode 100644 web/pages/recon/settlement.tsx diff --git a/app/Exports/SettlementExport.php b/app/Exports/SettlementExport.php deleted file mode 100644 index ee8fc68..0000000 --- a/app/Exports/SettlementExport.php +++ /dev/null @@ -1,74 +0,0 @@ -where('recon_id', $this->settlement->recon_id) - ->where('store_id', $this->settlement->store_id) - ->orderBy('sort') - ->get(); - } - - public function headings(): array - { - return ['品名', '数量', '称重', '公布金额', '实际金额', '差额', '对账状态', '门店备注']; - } - - public function map($item): array - { - return [ - $item->product_name, - (float) $item->quantity, - (float) $item->weight, - (float) $item->publish_amount, - (float) $item->actual_amount, - (float) $item->diff_amount, - $item->is_reconciled ? '已对账' : '未对账', - $item->store_remark, - ]; - } - - public function styles(Worksheet $sheet): array - { - $sheet->freezePane('A2'); - - return [ - 1 => ['font' => ['bold' => true]], - ]; - } - - /** - * PDF 模板视图数据 - * - * @return array{settlement: SettlementModel, storeName: string, reconNo: string, items: Collection} - */ - public function viewData(): array - { - return [ - 'settlement' => $this->settlement, - 'storeName' => $this->settlement->store?->name ?? '', - 'reconNo' => $this->settlement->recon?->recon_no ?? '', - 'items' => $this->collection(), - ]; - } -} diff --git a/app/Http/Controllers/Recon/ReconItemController.php b/app/Http/Controllers/Recon/ReconItemController.php deleted file mode 100644 index 188cf5d..0000000 --- a/app/Http/Controllers/Recon/ReconItemController.php +++ /dev/null @@ -1,125 +0,0 @@ - '[0-9]+'])] - public function update(int $id, ReconItemUpdateRequest $request): JsonResponse - { - $item = ReconciliationItemModel::find($id); - if (empty($item)) { - throw new RepositoryException('对账明细不存在'); - } - $this->assertEditable($item); - - $validated = $request->validated(); - if (isset($validated['product_name'])) { - $item->product_name = $validated['product_name']; - } - if (isset($validated['quantity'])) { - $item->quantity = $validated['quantity']; - } - if (isset($validated['weight'])) { - $item->weight = $validated['weight']; - } - if (isset($validated['publish_amount'])) { - $item->publish_amount = $validated['publish_amount']; - } - if (isset($validated['actual_amount'])) { - $item->actual_amount = $validated['actual_amount']; - } - // 重算本行差额 - $item->diff_amount = bcsub((string) $item->publish_amount, (string) $item->actual_amount, 2); - $item->save(); - - $this->refreshReconSummary((int) $item->recon_id); - - return $this->success(['diff_amount' => $item->diff_amount]); - } - - /** D8 对账状态标记翻转 */ - #[PutRoute(route: '/{id}/toggle', authorize: 'item.update', where: ['id' => '[0-9]+'])] - public function toggle(int $id): JsonResponse - { - $item = ReconciliationItemModel::find($id); - if (empty($item)) { - throw new RepositoryException('对账明细不存在'); - } - $this->assertEditable($item); - - $item->is_reconciled = $item->is_reconciled === ReconciliationItemModel::RECONCILED - ? ReconciliationItemModel::NOT_RECONCILED - : ReconciliationItemModel::RECONCILED; - $item->save(); - - return $this->success(['is_reconciled' => $item->is_reconciled]); - } - - /** D6 单品级门店备注 */ - #[PutRoute(route: '/{id}/remark', authorize: 'item.update', where: ['id' => '[0-9]+'])] - public function remark(int $id, Request $request): JsonResponse - { - $data = $request->validate([ - 'store_remark' => 'nullable|string|max:255', - ], [ - 'store_remark.max' => '备注最长 255 个字符', - ]); - $item = ReconciliationItemModel::find($id); - if (empty($item)) { - throw new RepositoryException('对账明细不存在'); - } - $this->assertEditable($item); - - $item->store_remark = (string) ($data['store_remark'] ?? ''); - $item->save(); - - return $this->success(); - } - - /** - * 已结算的对账单明细不允许修改 - */ - private function assertEditable(ReconciliationItemModel $item): void - { - $recon = ReconciliationModel::find($item->recon_id); - if ($recon !== null && $recon->status === ReconciliationModel::STATUS_SETTLED) { - throw new RepositoryException('对账单已结算,明细不能修改'); - } - } - - /** - * 明细变更后重算对账单头汇总(publish / actual / diff) - */ - private function refreshReconSummary(int $reconId): void - { - $sums = ReconciliationItemModel::query() - ->where('recon_id', $reconId) - ->selectRaw('COALESCE(SUM(publish_amount), 0) as publish_total, COALESCE(SUM(actual_amount), 0) as actual_total') - ->first(); - - ReconciliationModel::whereKey($reconId)->update([ - 'publish_amount' => $sums->publish_total, - 'actual_amount' => $sums->actual_total, - 'diff_amount' => bcsub((string) $sums->publish_total, (string) $sums->actual_total, 2), - ]); - } -} diff --git a/app/Http/Controllers/Recon/ReconciliationController.php b/app/Http/Controllers/Recon/ReconciliationController.php deleted file mode 100644 index a707a78..0000000 --- a/app/Http/Controllers/Recon/ReconciliationController.php +++ /dev/null @@ -1,235 +0,0 @@ - '=', - 'category_id' => '=', - 'supplier_id' => '=', - 'title' => 'like', - 'period_start' => 'date', - ]; - - /** 对账单列表 */ - #[GetRoute(authorize: 'query')] - public function query(Request $request): JsonResponse - { - $params = $request->all(); - $pageSize = $params['pageSize'] ?? 10; - $data = $this->buildSearch($params, ReconciliationModel::query()->with('operator:id,nickname')) - ->orderBy('id', 'desc') - ->paginate($pageSize) - ->toArray(); - return $this->success($data); - } - - /** 创建对账单(草稿,recon_no = RC…) */ - #[PostRoute(authorize: 'create')] - public function create(ReconciliationFormRequest $request): JsonResponse - { - $validated = $request->validated(); - $recon = ReconciliationModel::create([ - 'recon_no' => app(BillNumberService::class)->make('RC'), - 'title' => $validated['title'], - 'period_start' => $validated['period_start'], - 'period_end' => $validated['period_end'], - 'category_id' => $validated['category_id'], - 'supplier_id' => $validated['supplier_id'], - 'publish_amount' => 0, - 'actual_amount' => 0, - 'diff_amount' => 0, - 'status' => ReconciliationModel::STATUS_DRAFT, - 'operator_id' => (int) $request->user()->id, - 'remark' => $validated['remark'] ?? '', - ]); - return $this->success(['id' => $recon->id]); - } - - /** 编辑对账单(仅草稿/对账中) */ - #[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])] - public function update(int $id, ReconciliationFormRequest $request): JsonResponse - { - $recon = ReconciliationModel::find($id); - if (empty($recon)) { - throw new RepositoryException('对账单不存在'); - } - if ($recon->status === ReconciliationModel::STATUS_SETTLED) { - throw new RepositoryException('对账单已结算,不能编辑'); - } - $validated = $request->validated(); - $recon->update([ - 'title' => $validated['title'], - 'period_start' => $validated['period_start'], - 'period_end' => $validated['period_end'], - 'category_id' => $validated['category_id'], - 'supplier_id' => $validated['supplier_id'], - 'remark' => $validated['remark'] ?? '', - ]); - return $this->success(); - } - - /** 删除对账单(仅草稿可删,连带明细) */ - #[DeleteRoute(route: '/{id}', authorize: 'delete', where: ['id' => '[0-9]+'])] - public function delete(int $id): JsonResponse - { - $recon = ReconciliationModel::find($id); - if (empty($recon)) { - throw new RepositoryException('对账单不存在'); - } - if ($recon->status !== ReconciliationModel::STATUS_DRAFT) { - throw new RepositoryException('仅草稿状态的对账单可以删除'); - } - DB::transaction(function () use ($recon) { - $recon->items()->delete(); - $recon->delete(); - }); - return $this->success(); - } - - /** 生成对账明细(按周期 + 品类 + 供应商拉取已完成订单明细;可重复生成) */ - #[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])] - public function build(int $id): JsonResponse - { - $recon = ReconciliationModel::find($id); - if (empty($recon)) { - throw new RepositoryException('对账单不存在'); - } - if ($recon->status === ReconciliationModel::STATUS_SETTLED) { - throw new RepositoryException('对账单已结算,不能重新生成明细'); - } - $count = app(ReconciliationBuildService::class)->build($recon); - return $this->success(['count' => $count], '对账明细已生成'); - } - - /** - * D5 差额对比视图:按门店 / 按商品两个维度 + 合计行 - */ - #[GetRoute(route: '/{id}/diff', authorize: 'query', where: ['id' => '[0-9]+'])] - public function diff(int $id): JsonResponse - { - $recon = ReconciliationModel::find($id); - if (empty($recon)) { - throw new RepositoryException('对账单不存在'); - } - - $items = $recon->items()->with('store:id,name')->get(); - - $byStore = $items->groupBy('store_id')->map(function ($group) { - $first = $group->first(); - $publish = $group->sum('publish_amount'); - $actual = $group->sum('actual_amount'); - return [ - 'store_id' => $first->store_id, - 'store_name' => $first->store?->name ?? '', - 'publish' => (float) $publish, - 'actual' => (float) $actual, - 'diff' => (float) bcsub((string) $publish, (string) $actual, 2), - ]; - })->values(); - - $byProduct = $items->groupBy('product_id')->map(function ($group) { - $first = $group->first(); - $publish = $group->sum('publish_amount'); - $actual = $group->sum('actual_amount'); - return [ - 'product_id' => $first->product_id, - 'product_name' => $first->product_name, - 'publish' => (float) $publish, - 'actual' => (float) $actual, - 'diff' => (float) bcsub((string) $publish, (string) $actual, 2), - ]; - })->values(); - - $publishTotal = (string) $items->sum('publish_amount'); - $actualTotal = (string) $items->sum('actual_amount'); - - return $this->success([ - 'by_store' => $byStore->toArray(), - 'by_product' => $byProduct->toArray(), - 'total' => [ - 'publish' => (float) $publishTotal, - 'actual' => (float) $actualTotal, - 'diff' => (float) bcsub($publishTotal, $actualTotal, 2), - ], - ]); - } - - /** - * D9 生成结算表:按门店聚合明细生成 settlement 记录,对账单 status → 已结算 - * (回框统计表规则待业务确认,本次仅预留结构) - */ - #[PostRoute(route: '/{id}/settle', authorize: 'settle', where: ['id' => '[0-9]+'])] - public function settle(int $id, Request $request): JsonResponse - { - $recon = ReconciliationModel::find($id); - if (empty($recon)) { - throw new RepositoryException('对账单不存在'); - } - if ($recon->status !== ReconciliationModel::STATUS_WORKING) { - throw new RepositoryException('仅「对账中」的对账单可以生成结算表'); - } - - $count = DB::transaction(function () use ($recon, $request) { - $groups = $recon->items()->get()->groupBy('store_id'); - if ($groups->isEmpty()) { - throw new RepositoryException('对账单无明细,请先生成对账明细'); - } - - $billNumber = app(BillNumberService::class); - foreach ($groups as $storeId => $items) { - $publish = $items->reduce( - static fn (string $carry, $item): string => bcadd($carry, (string) $item->publish_amount, 2), - '0' - ); - $actual = $items->reduce( - static fn (string $carry, $item): string => bcadd($carry, (string) $item->actual_amount, 2), - '0' - ); - - SettlementModel::create([ - 'settlement_no' => $billNumber->make('JS'), - 'recon_id' => $recon->id, - 'store_id' => (int) $storeId, - 'period_start' => $recon->period_start, - 'period_end' => $recon->period_end, - 'total_amount' => $publish, - 'actual_amount' => $actual, - 'diff_amount' => bcsub($publish, $actual, 2), - 'status' => SettlementModel::STATUS_SETTLED, - 'operator_id' => (int) $request->user()->id, - 'settled_at' => now(), - ]); - } - - $recon->status = ReconciliationModel::STATUS_SETTLED; - $recon->save(); - - return $groups->count(); - }); - - return $this->success(['count' => $count], '结算表已生成'); - } -} diff --git a/app/Http/Controllers/Recon/SettlementController.php b/app/Http/Controllers/Recon/SettlementController.php deleted file mode 100644 index 9eeda77..0000000 --- a/app/Http/Controllers/Recon/SettlementController.php +++ /dev/null @@ -1,53 +0,0 @@ - 'like', - 'recon_id' => '=', - 'store_id' => '=', - 'status' => '=', - ]; - - /** 结算表列表 */ - #[GetRoute(authorize: 'query')] - public function query(Request $request): JsonResponse - { - $params = $request->all(); - $pageSize = $params['pageSize'] ?? 10; - $data = $this->buildSearch( - $params, - SettlementModel::query()->with(['store:id,name', 'recon:id,recon_no,title']) - ) - ->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 - { - $settlement = SettlementModel::with(['store:id,name', 'recon:id,recon_no,title', 'operator:id,nickname']) - ->find($id); - if (empty($settlement)) { - throw new RepositoryException('结算表不存在'); - } - return $this->success($settlement->toArray()); - } -} diff --git a/app/Http/Requests/Recon/ReconItemUpdateRequest.php b/app/Http/Requests/Recon/ReconItemUpdateRequest.php deleted file mode 100644 index d3e5054..0000000 --- a/app/Http/Requests/Recon/ReconItemUpdateRequest.php +++ /dev/null @@ -1,38 +0,0 @@ - 'nullable|string|max:100', - 'quantity' => 'nullable|numeric|min:0', - 'weight' => 'nullable|numeric|min:0', - 'publish_amount' => 'nullable|numeric|min:0', - 'actual_amount' => 'nullable|numeric|min:0', - ]; - } - - public function messages(): array - { - return [ - 'quantity.numeric' => '订货量必须为数字', - 'quantity.min' => '订货量不能小于 0', - 'weight.numeric' => '称重必须为数字', - 'weight.min' => '称重不能小于 0', - 'publish_amount.numeric' => '公布金额必须为数字', - 'publish_amount.min' => '公布金额不能小于 0', - 'actual_amount.numeric' => '实际金额必须为数字', - 'actual_amount.min' => '实际金额不能小于 0', - ]; - } -} diff --git a/app/Http/Requests/Recon/ReconciliationFormRequest.php b/app/Http/Requests/Recon/ReconciliationFormRequest.php deleted file mode 100644 index 8611b3a..0000000 --- a/app/Http/Requests/Recon/ReconciliationFormRequest.php +++ /dev/null @@ -1,46 +0,0 @@ -merge([ - 'category_id' => (int) ($this->input('category_id') ?? 0), - 'supplier_id' => (int) ($this->input('supplier_id') ?? 0), - ]); - } - - public function rules(): array - { - return [ - 'title' => 'required|string|max:100', - 'period_start' => 'required|date_format:Y-m-d', - 'period_end' => 'required|date_format:Y-m-d|after_or_equal:period_start', - 'category_id' => 'required|integer|min:0', - 'supplier_id' => 'required|integer|min:0', - 'remark' => 'nullable|string|max:255', - ]; - } - - public function messages(): array - { - return [ - 'title.required' => '对账标题不能为空', - 'title.max' => '对账标题最长 100 个字符', - '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' => '结束日期不能早于开始日期', - ]; - } -} diff --git a/app/Models/ReconciliationItemModel.php b/app/Models/ReconciliationItemModel.php deleted file mode 100644 index d70cbe0..0000000 --- a/app/Models/ReconciliationItemModel.php +++ /dev/null @@ -1,82 +0,0 @@ - 'integer', - 'store_id' => 'integer', - 'order_item_id' => 'integer', - 'product_id' => 'integer', - 'quantity' => 'decimal:2', - 'weight' => 'decimal:3', - 'publish_amount' => 'decimal:2', - 'actual_amount' => 'decimal:2', - 'diff_amount' => 'decimal:2', - 'is_reconciled' => 'integer', - 'sort' => 'integer', - ]; - - /** - * 所属对账单 - */ - public function recon(): BelongsTo - { - return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id'); - } - - /** - * 所属门店 - */ - public function store(): BelongsTo - { - return $this->belongsTo(StoreModel::class, 'store_id', 'id'); - } - - /** - * 对账商品 - */ - public function product(): BelongsTo - { - return $this->belongsTo(ProductModel::class, 'product_id', 'id'); - } - - /** - * 溯源订货明细 - */ - public function orderItem(): BelongsTo - { - return $this->belongsTo(StoreOrderItemModel::class, 'order_item_id', 'id'); - } -} diff --git a/app/Models/ReconciliationModel.php b/app/Models/ReconciliationModel.php deleted file mode 100644 index f0cc30e..0000000 --- a/app/Models/ReconciliationModel.php +++ /dev/null @@ -1,75 +0,0 @@ - 'date:Y-m-d', - 'period_end' => 'date:Y-m-d', - 'category_id' => 'integer', - 'supplier_id' => 'integer', - 'publish_amount' => 'decimal:2', - 'actual_amount' => 'decimal:2', - 'diff_amount' => 'decimal:2', - 'status' => 'integer', - 'operator_id' => 'integer', - ]; - - /** - * 制单人(后台系统用户) - */ - public function operator(): BelongsTo - { - return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); - } - - /** - * 对账明细 - */ - public function items(): HasMany - { - return $this->hasMany(ReconciliationItemModel::class, 'recon_id', 'id')->orderBy('sort'); - } - - /** - * 结算表 - */ - public function settlements(): HasMany - { - return $this->hasMany(SettlementModel::class, 'recon_id', 'id'); - } -} diff --git a/app/Models/SettlementModel.php b/app/Models/SettlementModel.php deleted file mode 100644 index c4d44dc..0000000 --- a/app/Models/SettlementModel.php +++ /dev/null @@ -1,74 +0,0 @@ - 'integer', - 'store_id' => 'integer', - 'period_start' => 'date:Y-m-d', - 'period_end' => 'date:Y-m-d', - 'total_amount' => 'decimal:2', - 'actual_amount' => 'decimal:2', - 'diff_amount' => 'decimal:2', - 'status' => 'integer', - 'operator_id' => 'integer', - 'settled_at' => 'datetime', - ]; - - /** - * 来源对账单 - */ - public function recon(): BelongsTo - { - return $this->belongsTo(ReconciliationModel::class, 'recon_id', 'id'); - } - - /** - * 结算门店 - */ - public function store(): BelongsTo - { - return $this->belongsTo(StoreModel::class, 'store_id', 'id'); - } - - /** - * 制单人(后台系统用户) - */ - public function operator(): BelongsTo - { - return $this->belongsTo(SysUserModel::class, 'operator_id', 'id'); - } -} diff --git a/app/Services/BillNumberService.php b/app/Services/BillNumberService.php index 3501a10..16b86d2 100644 --- a/app/Services/BillNumberService.php +++ b/app/Services/BillNumberService.php @@ -24,8 +24,6 @@ class BillNumberService private const NUMBER_SOURCES = [ 'PO' => ['purchase_order', 'purchase_no'], 'SO' => ['store_order', 'order_no'], - 'RC' => ['reconciliation', 'recon_no'], - 'JS' => ['settlement', 'settlement_no'], 'ZD' => ['bill', 'bill_no'], 'ZF' => ['payment', 'payment_no'], ]; @@ -33,7 +31,7 @@ class BillNumberService /** * 生成业务单号 * - * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / JS 结算 / ZD 账单 / ZF 支付 + * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / ZD 账单 / ZF 支付 * @return string 如 PO202607230001 */ public function make(string $prefix): string diff --git a/app/Services/ReconciliationBuildService.php b/app/Services/ReconciliationBuildService.php deleted file mode 100644 index 18a7196..0000000 --- a/app/Services/ReconciliationBuildService.php +++ /dev/null @@ -1,124 +0,0 @@ -join('store_order', 'store_order.id', '=', 'store_order_item.order_id') - ->where('store_order.status', StoreOrderModel::STATUS_COMPLETED) - ->whereNull('store_order.deleted_at') - ->whereDate('store_order.order_date', '>=', $recon->period_start) - ->whereDate('store_order.order_date', '<=', $recon->period_end) - ->select('store_order_item.*'); - - if ((int) $recon->supplier_id > 0) { - $itemQuery->where('store_order_item.supplier_id', $recon->supplier_id); - } - if ((int) $recon->category_id > 0) { - $itemQuery->whereIn( - 'store_order_item.category_id', - $this->descendantCategoryIds((int) $recon->category_id) - ); - } - - $orderItems = $itemQuery->get()->makeVisible('cost_price'); - if ($orderItems->isEmpty()) { - throw new RepositoryException('周期内无符合筛选条件的已完成订单数据,无法生成对账明细'); - } - - // 2. 先清后建(幂等) - ReconciliationItemModel::where('recon_id', $recon->id)->delete(); - - $publishTotal = '0'; - $actualTotal = '0'; - $rows = []; - $sort = 1; - $now = now(); - foreach ($orderItems as $orderItem) { - $publish = (string) $orderItem->amount; - $actual = bcmul((string) $orderItem->quantity, (string) ($orderItem->cost_price ?? '0'), 2); - $publishTotal = bcadd($publishTotal, $publish, 2); - $actualTotal = bcadd($actualTotal, $actual, 2); - - $rows[] = [ - 'recon_id' => $recon->id, - 'store_id' => $orderItem->store_id, - 'order_item_id' => $orderItem->id, - 'product_id' => $orderItem->product_id, - 'product_name' => $orderItem->product_name, - 'quantity' => $orderItem->quantity, - 'weight' => $orderItem->weight, - 'publish_amount' => $publish, - 'actual_amount' => $actual, - 'diff_amount' => bcsub($publish, $actual, 2), - 'is_reconciled' => ReconciliationItemModel::NOT_RECONCILED, - 'store_remark' => '', - 'sort' => $sort++, - 'created_at' => $now, - 'updated_at' => $now, - ]; - } - ReconciliationItemModel::insert($rows); - - // 3. 汇总写回头 + 状态流转 - $recon->publish_amount = $publishTotal; - $recon->actual_amount = $actualTotal; - $recon->diff_amount = bcsub($publishTotal, $actualTotal, 2); - $recon->status = ReconciliationModel::STATUS_WORKING; - $recon->save(); - - return count($rows); - }); - } - - /** - * 分类自身 + 全部子孙分类ID(多级分类下按顶级分类筛选) - * - * @return array - */ - private function descendantCategoryIds(int $categoryId): array - { - $parentMap = ProductCategoryModel::pluck('parent_id', 'id'); - $ids = [$categoryId]; - $queue = [$categoryId]; - while ($queue !== []) { - $current = array_shift($queue); - foreach ($parentMap as $id => $parentId) { - if ((int) $parentId === $current && ! in_array((int) $id, $ids, true)) { - $ids[] = (int) $id; - $queue[] = (int) $id; - } - } - } - - return $ids; - } -} diff --git a/database/migrations/2026_07_23_030612_create_reconciliation_table.php b/database/migrations/2026_07_23_030612_create_reconciliation_table.php deleted file mode 100644 index 8ffd562..0000000 --- a/database/migrations/2026_07_23_030612_create_reconciliation_table.php +++ /dev/null @@ -1,94 +0,0 @@ -increments('id')->comment('对账ID'); - $table->string('recon_no', 32)->unique()->comment('对账单编号'); - $table->string('title', 100)->comment('对账单标题'); - $table->date('period_start')->comment('对账周期开始'); - $table->date('period_end')->comment('对账周期结束'); - $table->integer('category_id')->default(0)->comment('按品类筛选(0为全部,D1)'); - $table->integer('supplier_id')->default(0)->comment('按供应商筛选(0为全部,D2)'); - $table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额合计(D5)'); - $table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额合计(D5)'); - $table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计(D5)'); - $table->integer('status')->default(0)->comment('状态(0对账中 1已完成 2已生成结算表)'); - $table->integer('operator_id')->default(0)->comment('对账员(系统用户ID)'); - $table->string('remark', 255)->default('')->comment('备注'); - $table->timestamps(); - $table->index(['period_start', 'period_end'], 'reconciliation_period_index'); - $table->comment('财务对账单表'); - }); - } - - // 财务对账明细表(D4 数据修改、D5 差额对比、D6 单品级门店备注、D8 对账状态标记) - if (! Schema::hasTable('reconciliation_item')) { - Schema::create('reconciliation_item', function (Blueprint $table) { - $table->increments('id')->comment('明细ID'); - $table->integer('recon_id')->comment('财务对账单ID'); - $table->integer('store_id')->comment('门店ID'); - $table->integer('order_item_id')->default(0)->comment('门店订货明细ID'); - $table->integer('product_id')->comment('商品ID'); - $table->string('product_name', 100)->comment('品名(快照)'); - $table->decimal('quantity', 10, 2)->default(0)->comment('数量(D4可修改)'); - $table->decimal('weight', 10, 3)->default(0)->comment('称重数据(D4可修改)'); - $table->decimal('publish_amount', 10, 2)->default(0)->comment('公布金额(门店订货金额)'); - $table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购金额(分摊)'); - $table->decimal('diff_amount', 10, 2)->default(0)->comment('差额'); - $table->integer('is_reconciled')->default(0)->comment('对账状态(1已对账 0未对账,D8)'); - $table->string('store_remark', 255)->default('')->comment('单品级门店备注(D6)'); - $table->integer('sort')->default(0)->comment('排序'); - $table->timestamps(); - $table->index(['recon_id'], 'reconciliation_item_recon_index'); - $table->index(['store_id', 'is_reconciled'], 'reconciliation_item_store_index'); - $table->comment('财务对账明细表'); - }); - } - - // 结算表(D9 对账结束后生成结算表/回框统计表,D10 下载存档) - if (! Schema::hasTable('settlement')) { - Schema::create('settlement', function (Blueprint $table) { - $table->increments('id')->comment('结算ID'); - $table->string('settlement_no', 32)->unique()->comment('结算单编号'); - $table->integer('recon_id')->default(0)->comment('关联财务对账单ID'); - $table->integer('store_id')->default(0)->comment('门店ID(0为汇总结算)'); - $table->date('period_start')->comment('结算周期开始'); - $table->date('period_end')->comment('结算周期结束'); - $table->decimal('total_amount', 10, 2)->default(0)->comment('结算总金额(公布)'); - $table->decimal('actual_amount', 10, 2)->default(0)->comment('实际采购总金额'); - $table->decimal('diff_amount', 10, 2)->default(0)->comment('差额合计'); - $table->integer('status')->default(0)->comment('状态(0待结算 1已结算)'); - $table->string('file_path', 255)->default('')->comment('导出文件路径(Excel/PDF,D10)'); - $table->integer('operator_id')->default(0)->comment('操作人(系统用户ID)'); - $table->timestamp('settled_at')->nullable()->comment('结算时间'); - $table->string('remark', 255)->default('')->comment('备注'); - $table->timestamps(); - $table->index(['store_id', 'status'], 'settlement_store_status_index'); - $table->comment('结算表'); - }); - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('reconciliation'); - Schema::dropIfExists('reconciliation_item'); - Schema::dropIfExists('settlement'); - } -}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 455f756..d48dbd6 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -205,22 +205,6 @@ class PermissionSeeder extends Seeder 'name' => '财务管理', 'icon' => 'AccountBookOutlined', 'children' => [ - [ - 'type' => 'route', - 'key' => 'recon.list', - 'name' => '财务对账', - 'path' => '/recon/list', - 'children' => [ - ['type' => 'rule', 'key' => 'recon.list.query', 'name' => '查询'], - ['type' => 'rule', 'key' => 'recon.list.create', 'name' => '新增'], - ['type' => 'rule', 'key' => 'recon.list.update', 'name' => '编辑'], - ['type' => 'rule', 'key' => 'recon.list.delete', 'name' => '删除'], - ['type' => 'rule', 'key' => 'recon.list.build', 'name' => '生成明细'], - ['type' => 'rule', 'key' => 'recon.list.settle', 'name' => '生成结算表'], - // 对账明细操作权限点在独立控制器 recon.item 下(D4/D6/D8) - ['type' => 'rule', 'key' => 'recon.item.item.update', 'name' => '对账明细操作'], - ], - ], [ 'type' => 'route', 'key' => 'recon.bill', @@ -252,16 +236,6 @@ class PermissionSeeder extends Seeder ['type' => 'rule', 'key' => 'recon.payment.audit', 'name' => '审核'], ], ], - [ - 'type' => 'route', - 'key' => 'recon.settlement', - 'name' => '结算表', - 'path' => '/recon/settlement', - 'children' => [ - ['type' => 'rule', 'key' => 'recon.settlement.query', 'name' => '查询'], - ['type' => 'rule', 'key' => 'recon.settlement.download', 'name' => '下载导出'], - ], - ], ], ], [ diff --git a/tests/Feature/ReconciliationTest.php b/tests/Feature/ReconciliationTest.php deleted file mode 100644 index e4d91a7..0000000 --- a/tests/Feature/ReconciliationTest.php +++ /dev/null @@ -1,191 +0,0 @@ -0 ? 称重×单价 : 数量×单价,单价 = 成本/包规) - */ -class ReconciliationTest extends ProcurementTestCase -{ - /** - * 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成 - * 预期:publish 20/30,actual 16/24,diff 4/6 - * - * @return array{0: array, 1: SupplierModel} - */ - private function buildCompletedOrders(): array - { - $level = CustomerLevelModel::factory()->create(); - $supplier = SupplierModel::factory()->create(); - $product = ProductModel::factory()->create([ - 'status' => ProductModel::STATUS_ON, - 'supplier_id' => $supplier->id, - 'cost_price' => 8, - 'spec' => '1斤', - ]); - ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); - - $stores = []; - foreach ([2, 3] as $qty) { - $store = StoreModel::factory()->create(['level_id' => $level->id]); - $stores[] = $store; - $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); - $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]]) - ->assertJsonPath('success', true); - } - - // 订单完成(对账数据源为已完成订单的订货明细) - StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_COMPLETED]); - - return [$stores, $supplier]; - } - - private function createRecon(array $extra = []): int - { - $response = $this->postJson('/recon/list', array_merge([ - 'title' => '测试对账', - 'period_start' => now()->toDateString(), - 'period_end' => now()->toDateString(), - ], $extra)); - $response->assertJsonPath('success', true); - - return (int) $response->json('data.id'); - } - - /** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */ - public function test_build_creates_reconciliation_items(): void - { - [$stores] = $this->buildCompletedOrders(); - $this->actingAsSysUser(); - - $reconId = $this->createRecon(); - $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); - - $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); - $this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细'); - - $byStore = $items->keyBy('store_id'); - $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10'); - $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount, '采购成本 2×8'); - $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); - $this->assertSame('30.00', (string) $byStore[$stores[1]->id]->publish_amount); - $this->assertSame('24.00', (string) $byStore[$stores[1]->id]->actual_amount); - - $recon = ReconciliationModel::find($reconId); - $this->assertSame('50.00', (string) $recon->publish_amount); - $this->assertSame('40.00', (string) $recon->actual_amount); - $this->assertSame('10.00', (string) $recon->diff_amount); - $this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status); - } - - /** 未完成订单不计入对账 */ - public function test_build_excludes_unfinished_orders(): void - { - [$stores] = $this->buildCompletedOrders(); - // 第二家门店订单回退为配送中 → 仅第一家进入对账 - StoreOrderModel::where('store_id', $stores[1]->id) - ->update(['status' => StoreOrderModel::STATUS_DISTRIBUTION]); - $this->actingAsSysUser(); - - $reconId = $this->createRecon(); - $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); - - $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); - $this->assertCount(1, $items); - $this->assertSame($stores[0]->id, $items->first()->store_id); - } - - /** 供应商筛选:仅拉取该供应商的订单数据 */ - public function test_build_filters_by_supplier(): void - { - [, $supplier] = $this->buildCompletedOrders(); - $this->actingAsSysUser(); - - // 无关供应商 → 无数据报错 - $other = SupplierModel::factory()->create(); - $reconId = $this->createRecon(['supplier_id' => $other->id]); - $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', false); - - // 正确供应商 → 构建成功 - $reconId2 = $this->createRecon(['supplier_id' => $supplier->id]); - $this->postJson("/recon/list/{$reconId2}/build")->assertJsonPath('success', true); - $this->assertSame(2, ReconciliationItemModel::where('recon_id', $reconId2)->count()); - } - - /** D4 修改明细:自动重算本行 diff 与对账单头汇总 */ - public function test_update_item_recalculates_diff_and_header(): void - { - $this->buildCompletedOrders(); - $this->actingAsSysUser(); - - $reconId = $this->createRecon(); - $this->postJson("/recon/list/{$reconId}/build"); - - $item = ReconciliationItemModel::where('recon_id', $reconId)->orderBy('id')->first(); - $this->putJson("/recon/item/{$item->id}", ['actual_amount' => '25.00']) - ->assertJsonPath('success', true); - - $item = $item->fresh(); - $this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00'); - - $recon = ReconciliationModel::find($reconId); - $this->assertSame('49.00', (string) $recon->actual_amount, '25 + 24'); - $this->assertSame('1.00', (string) $recon->diff_amount, '50 - 49'); - } - - /** D8 对账状态标记翻转 */ - public function test_toggle_reconciled_flag(): void - { - $this->buildCompletedOrders(); - $this->actingAsSysUser(); - - $reconId = $this->createRecon(); - $this->postJson("/recon/list/{$reconId}/build"); - $item = ReconciliationItemModel::where('recon_id', $reconId)->first(); - $this->assertSame(0, $item->is_reconciled); - - $this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true); - $this->assertSame(1, $item->fresh()->is_reconciled); - - $this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true); - $this->assertSame(0, $item->fresh()->is_reconciled); - } - - /** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */ - public function test_settle_creates_settlements_per_store(): void - { - [$stores] = $this->buildCompletedOrders(); - $this->actingAsSysUser(); - - $reconId = $this->createRecon(); - $this->postJson("/recon/list/{$reconId}/build"); - - $this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', true); - - $settlements = SettlementModel::where('recon_id', $reconId)->get(); - $this->assertCount(2, $settlements, '按门店各生成一张结算表'); - - $byStore = $settlements->keyBy('store_id'); - $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount); - $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount); - $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); - $this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no); - - $this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status); - - // 已结算不可重复结算 - $this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', false); - } -} diff --git a/web/api/recon/list.ts b/web/api/recon/list.ts deleted file mode 100644 index 6efe320..0000000 --- a/web/api/recon/list.ts +++ /dev/null @@ -1,60 +0,0 @@ -import createAxios from '@/utils/request'; -import type { IReconDiff } from '@/domain/iReconciliation.ts'; - -export interface ReconItemUpdateParams { - product_name?: string; - quantity?: number | string; - weight?: number | string; - publish_amount?: number | string; - actual_amount?: number | string; -} - -/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据) */ -export async function buildRecon(id: number) { - return createAxios<{ count: number }>({ - url: `/recon/list/${id}/build`, - method: 'post', - }); -} - -/** D4 修改对账明细(diff 与头汇总后端重算) */ -export async function updateReconItem(id: number, data: ReconItemUpdateParams) { - return createAxios<{ diff_amount: string }>({ - url: `/recon/item/${id}`, - method: 'put', - data, - }); -} - -/** D8 对账状态标记翻转 */ -export async function toggleReconItem(id: number) { - return createAxios<{ is_reconciled: number }>({ - url: `/recon/item/${id}/toggle`, - method: 'put', - }); -} - -/** D6 单品级门店备注 */ -export async function remarkReconItem(id: number, store_remark: string) { - return createAxios({ - url: `/recon/item/${id}/remark`, - method: 'put', - data: { store_remark }, - }); -} - -/** D5 差额对比视图(按门店 / 按商品 + 合计) */ -export async function getReconDiff(id: number) { - return createAxios({ - url: `/recon/list/${id}/diff`, - method: 'get', - }); -} - -/** D9 生成结算表 */ -export async function settleRecon(id: number) { - return createAxios<{ count: number }>({ - url: `/recon/list/${id}/settle`, - method: 'post', - }); -} diff --git a/web/api/recon/settlement.ts b/web/api/recon/settlement.ts deleted file mode 100644 index d871a1e..0000000 --- a/web/api/recon/settlement.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExportFormat } from '@/domain/iPurchaseOrder.ts'; -import { downloadBlob } from '@/api/common/download.ts'; - -/** D10 结算表下载(blob,成功后后端回写 file_path 存档标记) */ -export async function downloadSettlement(id: number, format: ExportFormat) { - return downloadBlob( - `/recon/settlement/${id}/download`, - { format }, - `结算表_${id}.${format}` - ); -} diff --git a/web/domain/iReconciliation.ts b/web/domain/iReconciliation.ts deleted file mode 100644 index ee7345a..0000000 --- a/web/domain/iReconciliation.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** 对账明细 */ -export interface IReconciliationItem { - id?: number; - recon_id?: number; - store_id?: number; - order_item_id?: number; - product_id?: number; - product_name?: string; - quantity?: string; - weight?: string; - /** 公布金额(订货金额) */ - publish_amount?: string; - /** 实际金额(采购成本) */ - actual_amount?: string; - /** 差额 = publish − actual */ - diff_amount?: string; - is_reconciled?: number; - store_remark?: string; - sort?: number; - store?: { id: number; name: string }; -} - -/** 对账单 */ -export default interface IReconciliation { - id?: number; - recon_no?: string; - title?: string; - period_start?: string; - period_end?: string; - category_id?: number; - supplier_id?: number; - publish_amount?: string; - actual_amount?: string; - diff_amount?: string; - /** 0草稿 1对账中 2已结算 */ - status?: number; - operator_id?: number; - operator?: { id: number; nickname: string }; - remark?: string; - items?: IReconciliationItem[]; - created_at?: string; -} - -export const RECON_STATUS_MAP: Record = { - 0: { text: '草稿', color: 'default' }, - 1: { text: '对账中', color: 'processing' }, - 2: { text: '已结算', color: 'success' }, -}; - -export const RECONCILED_MAP: Record = { - 0: { text: '未对账', color: 'warning' }, - 1: { text: '已对账', color: 'success' }, -}; - -/** D5 差额对比视图 */ -export interface IReconDiffRow { - store_id?: number; - store_name?: string; - product_id?: number; - product_name?: string; - publish: number; - actual: number; - diff: number; -} - -export interface IReconDiff { - by_store: IReconDiffRow[]; - by_product: IReconDiffRow[]; - total: { publish: number; actual: number; diff: number }; -} diff --git a/web/domain/iSettlement.ts b/web/domain/iSettlement.ts deleted file mode 100644 index 1d6b8ef..0000000 --- a/web/domain/iSettlement.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** 结算表 */ -export default interface ISettlement { - id?: number; - settlement_no?: string; - recon_id?: number; - store_id?: number; - store?: { id: number; name: string }; - recon?: { id: number; recon_no: string; title: string }; - period_start?: string; - period_end?: string; - total_amount?: string; - actual_amount?: string; - diff_amount?: string; - /** 0待结算 1已结算 */ - status?: number; - file_path?: string; - operator_id?: number; - operator?: { id: number; nickname: string }; - settled_at?: string; - remark?: string; - created_at?: string; -} - -export const SETTLEMENT_STATUS_MAP: Record = { - 0: { text: '待结算', color: 'default' }, - 1: { text: '已结算', color: 'success' }, -}; diff --git a/web/pages/recon/list.tsx b/web/pages/recon/list.tsx deleted file mode 100644 index 12e3751..0000000 --- a/web/pages/recon/list.tsx +++ /dev/null @@ -1,719 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Button, - Descriptions, - Drawer, - Input, - InputNumber, - message, - Modal, - Popconfirm, - Space, - Switch, - Table, - Tabs, - Tag, - Typography, -} from 'antd'; -import { - CheckSquareOutlined, - FileDoneOutlined, - ToolOutlined, -} from '@ant-design/icons'; -import type { TableProps } from 'antd'; -import XinTable from '@/components/XinTable'; -import type { - XinTableColumn, - XinTableInstance, - XinTableProps, -} from '@/components/XinTable/typings.ts'; -import type IReconciliation from '@/domain/iReconciliation.ts'; -import type { IReconDiff, IReconciliationItem } from '@/domain/iReconciliation.ts'; -import { RECON_STATUS_MAP } from '@/domain/iReconciliation.ts'; -import type IProductCategory from '@/domain/iProductCategory.ts'; -import type ISupplier from '@/domain/iSupplier.ts'; -import { getCategoryTree } from '@/api/product/category.ts'; -import { getSupplierOptions } from '@/api/customer/supplier.ts'; -import { - buildRecon, - getReconDiff, - remarkReconItem, - settleRecon, - toggleReconItem, - updateReconItem, -} from '@/api/recon/list.ts'; -import { Get } from '@/api/common/table.ts'; -import AuthButton from '@/components/AuthButton'; - -const { Title, Text } = Typography; - -interface EditingItem { - product_name: string; - quantity: number; - weight: number; - publish_amount: number; - actual_amount: number; -} - -/** - * 财务对账(D1/D2 筛选建单、D4 明细修改、D5 差额对比、D6 备注、D8 标记、D9 结算) - */ -const ReconListPage: React.FC = () => { - const tableRef = useRef>(null); - const [categoryTree, setCategoryTree] = useState([]); - const [suppliers, setSuppliers] = useState([]); - - // 工作台抽屉 - const [workOpen, setWorkOpen] = useState(false); - const [workLoading, setWorkLoading] = useState(false); - const [recon, setRecon] = useState(null); - const [editing, setEditing] = useState>({}); - const [savingItemId, setSavingItemId] = useState(null); - const [diff, setDiff] = useState(null); - - // 备注弹窗 - const [remarkOpen, setRemarkOpen] = useState(false); - const [remarkTarget, setRemarkTarget] = useState(null); - const [remarkValue, setRemarkValue] = useState(''); - - useEffect(() => { - getCategoryTree().then((res) => setCategoryTree(res.data.data ?? [])); - getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); - }, []); - - const loadRecon = async (id: number) => { - const res = await Get('/recon/list', id); - const data = res.data.data ?? null; - setRecon(data); - const editingMap: Record = {}; - data?.items?.forEach((item) => { - if (item.id !== undefined) { - editingMap[item.id] = { - product_name: item.product_name ?? '', - quantity: Number(item.quantity ?? 0), - weight: Number(item.weight ?? 0), - publish_amount: Number(item.publish_amount ?? 0), - actual_amount: Number(item.actual_amount ?? 0), - }; - } - }); - setEditing(editingMap); - return data; - }; - - const openWorkbench = async (id: number) => { - setWorkOpen(true); - setWorkLoading(true); - setDiff(null); - try { - await loadRecon(id); - } finally { - setWorkLoading(false); - } - }; - - const loadDiff = async (id: number) => { - const res = await getReconDiff(id); - setDiff(res.data.data ?? null); - }; - - const handleBuild = async (record: IReconciliation) => { - const res = await buildRecon(record.id!); - message.success(`已生成 ${res.data.data?.count} 条对账明细`); - await tableRef.current?.reload(); - }; - - const handleSettle = async (record: IReconciliation) => { - const res = await settleRecon(record.id!); - message.success(`已生成 ${res.data.data?.count} 张结算表`); - await tableRef.current?.reload(); - }; - - const isItemDirty = (item: IReconciliationItem): boolean => { - const edit = editing[item.id!]; - if (!edit) { - return false; - } - return ( - edit.product_name !== (item.product_name ?? '') || - edit.quantity !== Number(item.quantity ?? 0) || - edit.weight !== Number(item.weight ?? 0) || - edit.publish_amount !== Number(item.publish_amount ?? 0) || - edit.actual_amount !== Number(item.actual_amount ?? 0) - ); - }; - - const saveItem = async (item: IReconciliationItem) => { - const edit = editing[item.id!]; - if (!edit || !isItemDirty(item)) { - return; - } - setSavingItemId(item.id!); - try { - const res = await updateReconItem(item.id!, { - product_name: edit.product_name, - quantity: edit.quantity, - weight: edit.weight, - publish_amount: edit.publish_amount, - actual_amount: edit.actual_amount, - }); - message.success(`已保存,差额 ¥${res.data.data?.diff_amount}`); - await loadRecon(recon!.id!); - await loadDiff(recon!.id!); - } finally { - setSavingItemId(null); - } - }; - - const handleToggle = async (item: IReconciliationItem) => { - await toggleReconItem(item.id!); - await loadRecon(recon!.id!); - }; - - const openRemark = (item: IReconciliationItem) => { - setRemarkTarget(item); - setRemarkValue(item.store_remark ?? ''); - setRemarkOpen(true); - }; - - const saveRemark = async () => { - await remarkReconItem(remarkTarget!.id!, remarkValue); - message.success('备注已保存'); - setRemarkOpen(false); - await loadRecon(recon!.id!); - }; - - const readonly = recon?.status === 2; - - const itemColumns: TableProps['columns'] = [ - { - title: '品名', - dataIndex: 'product_name', - width: 160, - render: (_, record) => - readonly ? ( - record.product_name - ) : ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], product_name: e.target.value }, - })) - } - /> - ), - }, - { - title: '门店', - dataIndex: 'store', - width: 130, - render: (_, record) => record.store?.name ?? `门店#${record.store_id}`, - }, - { - title: '订货量', - dataIndex: 'quantity', - width: 110, - render: (_, record) => - readonly ? ( - record.quantity - ) : ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], quantity: v ?? 0 }, - })) - } - className="!w-20" - /> - ), - }, - { - title: '称重', - dataIndex: 'weight', - width: 110, - render: (_, record) => - readonly ? ( - record.weight - ) : ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], weight: v ?? 0 }, - })) - } - className="!w-20" - /> - ), - }, - { - title: '公布金额', - dataIndex: 'publish_amount', - width: 120, - render: (_, record) => - readonly ? ( - `¥${record.publish_amount}` - ) : ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], publish_amount: v ?? 0 }, - })) - } - className="!w-24" - /> - ), - }, - { - title: '实际金额', - dataIndex: 'actual_amount', - width: 120, - render: (_, record) => - readonly ? ( - `¥${record.actual_amount}` - ) : ( - - setEditing((prev) => ({ - ...prev, - [record.id!]: { ...prev[record.id!], actual_amount: v ?? 0 }, - })) - } - className="!w-24" - /> - ), - }, - { - title: '差额', - dataIndex: 'diff_amount', - width: 100, - align: 'right', - render: (v) => { - const num = Number(v ?? 0); - return ( - - ¥{String(v)} - - ); - }, - }, - { - title: '对账', - dataIndex: 'is_reconciled', - width: 80, - align: 'center', - render: (_, record) => ( - handleToggle(record)} - /> - ), - }, - { - title: '门店备注', - dataIndex: 'store_remark', - width: 120, - ellipsis: true, - render: (_, record) => - record.store_remark || , - }, - { - title: '操作', - key: 'action', - width: 130, - fixed: 'right', - render: (_, record) => - readonly ? null : ( - - - - - - - - - ), - }, - ]; - - const diffColumns = (nameTitle: string, nameKey: 'store_name' | 'product_name') => [ - { title: nameTitle, dataIndex: nameKey, render: (v: string) => v || '-' }, - { title: '公布金额', dataIndex: 'publish', align: 'right' as const, render: (v: number) => `¥${v}` }, - { title: '实际金额', dataIndex: 'actual', align: 'right' as const, render: (v: number) => `¥${v}` }, - { - title: '差额', - dataIndex: 'diff', - align: 'right' as const, - render: (v: number) => ( - - ¥{v} - - ), - }, - ]; - - const columns: XinTableColumn[] = [ - { - title: '对账单号', - dataIndex: 'recon_no', - valueType: 'text', - hideInForm: true, - }, - { - title: '标题', - dataIndex: 'title', - valueType: 'text', - required: true, - rules: [{ required: true, message: '请输入对账标题' }], - }, - { - title: '对账周期', - dataIndex: 'period', - hideInForm: true, - hideInSearch: true, - render: (_, record) => `${record.period_start} ~ ${record.period_end}`, - }, - { - title: '开始日期', - dataIndex: 'period_start', - valueType: 'date', - hideInTable: true, - required: true, - rules: [{ required: true, message: '请选择开始日期' }], - }, - { - title: '结束日期', - dataIndex: 'period_end', - valueType: 'date', - hideInTable: true, - required: true, - rules: [{ required: true, message: '请选择结束日期' }], - }, - { - title: '商品分类', - dataIndex: 'category_id', - valueType: 'treeSelect', - hideInTable: true, - initialValue: 0, - fieldProps: { - treeData: [{ id: 0, name: '全部分类', children: categoryTree }], - fieldNames: { label: 'name', value: 'id', children: 'children' }, - treeDefaultExpandAll: true, - }, - }, - { - title: '供应商', - dataIndex: 'supplier_id', - valueType: 'select', - hideInTable: true, - initialValue: 0, - fieldProps: { - options: [ - { label: '全部供应商', value: 0 }, - ...suppliers.map((s) => ({ label: s.name, value: s.id })), - ], - }, - }, - { - title: '公布金额', - dataIndex: 'publish_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => `¥${record.publish_amount}`, - }, - { - title: '实际金额', - dataIndex: 'actual_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => `¥${record.actual_amount}`, - }, - { - title: '差额', - dataIndex: 'diff_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => { - const num = Number(record.diff_amount ?? 0); - return ( - - ¥{record.diff_amount} - - ); - }, - }, - { - title: '状态', - dataIndex: 'status', - valueType: 'select', - hideInForm: true, - fieldProps: { - options: Object.entries(RECON_STATUS_MAP).map(([value, item]) => ({ - value: Number(value), - label: item.text, - })), - }, - render: (_, record) => { - const item = RECON_STATUS_MAP[record.status ?? 0]; - return {item?.text}; - }, - align: 'center', - }, - { - title: '备注', - dataIndex: 'remark', - valueType: 'textarea', - hideInTable: true, - hideInSearch: true, - fieldProps: { rows: 2 }, - }, - ]; - - const operateRender: XinTableProps['operateRender'] = (record, dom) => [ - - handleBuild(record)} - > - - - , - , - - handleSettle(record)} - > - - - , - // 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截 - dom.edit, - dom.del, - ]; - - const tableProps: XinTableProps = { - api: '/recon/list', - columns, - rowKey: 'id', - accessName: 'recon.list', - tableRef, - operateRender, - scroll: { x: 1300 }, - formProps: { - grid: true, - colProps: { span: 12 }, - layout: 'vertical', - }, - modalProps: { width: 720 }, - }; - - return ( - <> -
- 财务对账 - - 按周期/品类/供应商建立对账单 → 生成明细(采购分摊数据)→ 核对修改 → 差额对比 → 生成结算表。 - -
- {...tableProps} /> - - {/* 对账工作台 */} - setWorkOpen(false)} - width={1200} - loading={workLoading} - > - {recon ? ( - <> - - {recon.title} - - {recon.period_start} ~ {recon.period_end} - - - - {RECON_STATUS_MAP[recon.status ?? 0]?.text} - - - - - ¥{recon.diff_amount} - - - - - - 明细核对({recon.items?.length ?? 0}) - - ), - children: ( - <> - {!readonly ? ( -
- 可修改订货量/称重/公布金额/实际金额,保存后自动重算差额与对账单汇总;开关标记单品对账状态。 -
- ) : null} - - rowKey="id" - size="small" - columns={itemColumns} - dataSource={recon.items ?? []} - pagination={{ pageSize: 15, showSizeChanger: false }} - scroll={{ x: 1250 }} - /> - - ), - }, - { - key: 'diff', - label: '差额对比', - children: ( - <> - - - {diff ? ( - - 合计:公布 ¥{diff.total.publish} / 实际 ¥{diff.total.actual} /{' '} - - 差额 ¥{diff.total.diff} - - - ) : null} - - {diff ? ( -
-
- 按门店 - String(row.store_id)} - size="small" - columns={diffColumns('门店', 'store_name')} - dataSource={diff.by_store} - pagination={false} - /> - -
- 按商品 -
String(row.product_id)} - size="small" - columns={diffColumns('商品', 'product_name')} - dataSource={diff.by_product} - pagination={false} - /> - - - ) : ( - - )} - - ), - }, - ]} - /> - - ) : null} - - - {/* 门店备注弹窗 */} - setRemarkOpen(false)} - onOk={saveRemark} - okText="保存备注" - destroyOnHidden - > -
- {remarkTarget?.product_name} - {remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''} -
- setRemarkValue(e.target.value)} - placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)" - /> -
- - ); -}; - -export default ReconListPage; diff --git a/web/pages/recon/settlement.tsx b/web/pages/recon/settlement.tsx deleted file mode 100644 index 453150e..0000000 --- a/web/pages/recon/settlement.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Button, - Descriptions, - Drawer, - Dropdown, - Tag, - Typography, -} from 'antd'; -import { DownloadOutlined } from '@ant-design/icons'; -import XinTable from '@/components/XinTable'; -import type { - XinTableColumn, - XinTableInstance, - XinTableProps, -} from '@/components/XinTable/typings.ts'; -import type ISettlement from '@/domain/iSettlement.ts'; -import { SETTLEMENT_STATUS_MAP } from '@/domain/iSettlement.ts'; -import { getStoreOptions } from '@/api/customer/store.ts'; -import type IStore from '@/domain/iStore.ts'; -import { downloadSettlement } from '@/api/recon/settlement.ts'; -import { Get } from '@/api/common/table.ts'; -import AuthButton from '@/components/AuthButton'; - -const { Title, Text } = Typography; - -/** - * 结算表(D9 生成于对账结算,D10 导出存档) - */ -const SettlementPage: 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/settlement', id); - setDetail(res.data.data ?? null); - } finally { - setDetailLoading(false); - } - }; - - const columns: XinTableColumn[] = [ - { - title: '结算单号', - dataIndex: 'settlement_no', - valueType: 'text', - hideInForm: true, - render: (_, record) => {record.settlement_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: 'recon', - hideInForm: true, - hideInSearch: true, - render: (_, record) => - record.recon ? ( - - {record.recon.recon_no} - ({record.recon.title}) - - ) : ( - '-' - ), - }, - { - title: '结算周期', - dataIndex: 'period', - hideInForm: true, - hideInSearch: 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: 'actual_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => ¥{record.actual_amount}, - }, - { - title: '差额', - dataIndex: 'diff_amount', - hideInForm: true, - hideInSearch: true, - align: 'right', - render: (_, record) => { - const num = Number(record.diff_amount ?? 0); - return ( - - ¥{record.diff_amount} - - ); - }, - }, - { - title: '状态', - dataIndex: 'status', - valueType: 'select', - hideInForm: true, - fieldProps: { - options: Object.entries(SETTLEMENT_STATUS_MAP).map(([value, item]) => ({ - value: Number(value), - label: item.text, - })), - }, - render: (_, record) => { - const item = SETTLEMENT_STATUS_MAP[record.status ?? 0]; - return {item?.text}; - }, - align: 'center', - }, - { - title: '结算时间', - dataIndex: 'settled_at', - hideInForm: true, - hideInSearch: true, - align: 'center', - render: (_, record) => record.settled_at ?? '-', - }, - ]; - - const operateRender: XinTableProps['operateRender'] = (record) => [ - , - - downloadSettlement(record.id!, 'xlsx') }, - { key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') }, - ], - }} - > - - - , - ]; - - const tableProps: XinTableProps = { - api: '/recon/settlement', - columns, - rowKey: 'id', - accessName: 'recon.settlement', - tableRef, - operateRender, - formProps: false, - scroll: { x: 1200 }, - }; - - return ( - <> -
- 结算表 - - 由财务对账结算按门店聚合生成;支持 Excel / PDF 导出存档(回框统计表规则待业务确认后补充)。 - -
- {...tableProps} /> - - setDetailOpen(false)} - width={640} - loading={detailLoading} - > - {detail ? ( - - {detail.store?.name} - - - {SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text} - - - - {detail.recon?.recon_no ?? '-'} - - - {detail.period_start} ~ {detail.period_end} - - ¥{detail.total_amount} - ¥{detail.actual_amount} - ¥{detail.diff_amount} - - {detail.settled_at ?? '-'} - - - {detail.file_path ?? 未导出} - - {detail.remark ? ( - - {detail.remark} - - ) : null} - - ) : null} - - - ); -}; - -export default SettlementPage;