移除对账

This commit is contained in:
liu
2026-08-14 15:54:53 +08:00
parent 629d28a369
commit f0927e57e9
20 changed files with 1 additions and 2363 deletions
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Exports;
use App\Models\ReconciliationItemModel;
use App\Models\SettlementModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* D10 结算表导出(结算头 + 来源对账单中该门店的明细)
*/
class SettlementExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
public function __construct(private readonly SettlementModel $settlement)
{
}
public function collection(): Collection
{
return ReconciliationItemModel::query()
->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(),
];
}
}
@@ -1,125 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconItemUpdateRequest;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 对账明细操作(D4 修改 / D6 单品级门店备注 / D8 对账状态标记)
* 权限点前缀 recon.itemauthorize: item.update → recon.item.item.update
*/
#[RequestAttribute('/recon/item', 'recon.item')]
class ReconItemController extends BaseController
{
/**
* D4 修改订货量/称重/数量/金额/商品名,自动重算本行 diff + 头汇总
*/
#[PutRoute(route: '/{id}', authorize: 'item.update', where: ['id' => '[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),
]);
}
}
@@ -1,235 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Http\Requests\Recon\ReconciliationFormRequest;
use App\Models\ReconciliationModel;
use App\Models\SettlementModel;
use App\Services\BillNumberService;
use App\Services\ReconciliationBuildService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 财务对账管理(D1 品类 / D2 供应商筛选、D5 差额对比、D9 结算表生成)
* 对账明细的 D4/D6/D8 操作见 ReconItemController
*/
#[RequestAttribute('/recon/list', 'recon.list')]
class ReconciliationController extends BaseController
{
protected array $searchField = [
'status' => '=',
'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], '结算表已生成');
}
}
@@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\SettlementModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
/**
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
*/
#[RequestAttribute('/recon/settlement', 'recon.settlement')]
class SettlementController extends BaseController
{
protected array $searchField = [
'settlement_no' => '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());
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 对账明细修改 验证(D4:订货量/称重/数量/金额/商品信息;diff 与头汇总由后端重算)
*/
class ReconItemUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'product_name' => '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',
];
}
}
@@ -1,46 +0,0 @@
<?php
namespace App\Http\Requests\Recon;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 财务对账单 创建/编辑 验证
*/
class ReconciliationFormRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
protected function prepareForValidation(): void
{
$this->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' => '结束日期不能早于开始日期',
];
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 对账明细模型(订货量/称重/数量/金额可修改,diff = publish actual
*/
class ReconciliationItemModel extends Model
{
/** 未对账 */
public const NOT_RECONCILED = 0;
/** 已对账 */
public const RECONCILED = 1;
protected $table = 'reconciliation_item';
protected $primaryKey = 'id';
protected $fillable = [
'recon_id',
'store_id',
'order_item_id',
'product_id',
'product_name',
'quantity',
'weight',
'publish_amount',
'actual_amount',
'diff_amount',
'is_reconciled',
'store_remark',
'sort',
];
protected $casts = [
'recon_id' => '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');
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 财务对账模型(公布金额 vs 实际金额 vs 差额,按品类/供应商筛选)
*/
class ReconciliationModel extends Model
{
/** 状态:草稿 */
public const STATUS_DRAFT = 0;
/** 状态:对账中 */
public const STATUS_WORKING = 1;
/** 状态:已结算 */
public const STATUS_SETTLED = 2;
protected $table = 'reconciliation';
protected $primaryKey = 'id';
protected $fillable = [
'recon_no',
'title',
'period_start',
'period_end',
'category_id',
'supplier_id',
'publish_amount',
'actual_amount',
'diff_amount',
'status',
'operator_id',
'remark',
];
protected $casts = [
'period_start' => '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');
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\SystemUser\Models\SysUserModel;
/**
* 结算表模型(对账结算按门店聚合生成,可导出存档)
*/
class SettlementModel extends Model
{
/** 状态:待结算 */
public const STATUS_PENDING = 0;
/** 状态:已结算 */
public const STATUS_SETTLED = 1;
protected $table = 'settlement';
protected $primaryKey = 'id';
protected $fillable = [
'settlement_no',
'recon_id',
'store_id',
'period_start',
'period_end',
'total_amount',
'actual_amount',
'diff_amount',
'status',
'file_path',
'operator_id',
'settled_at',
'remark',
];
protected $casts = [
'recon_id' => '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');
}
}
+1 -3
View File
@@ -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
-124
View File
@@ -1,124 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
/**
* 对账明细构建(D1 品类 / D2 供应商筛选)
*
* 流程(事务内,可重复 build:先清后建):
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取「已完成」门店订单的订货明细
* 2. 每条订货明细 → 一条对账明细:
* publish_amount = 订货金额(order_item.amount = 每包等级价×数量)
* actual_amount = 采购成本(数量 × 每包成本价;单价/包规不参与金额计算)
* diff = publish actual,冗余 product_name / store_id
* 3. 汇总写回头的 publish/actual/diff_amountstatus → 对账中
*/
class ReconciliationBuildService
{
/**
* @return int 生成的对账明细数
*/
public function build(ReconciliationModel $recon): int
{
return DB::transaction(function () use ($recon) {
// 1. 按周期 + 品类 + 供应商拉取已完成订单的订货明细
$itemQuery = StoreOrderItemModel::query()
->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<int, int>
*/
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;
}
}
@@ -1,94 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 财务管理(D1-D10):财务对账、结算表(门店对账单已下线,由采购单账单 bill 表替代)
*/
public function up(): void
{
// 财务对账单表(D1 按品类对账、D2 按供应商筛选)
if (! Schema::hasTable('reconciliation')) {
Schema::create('reconciliation', function (Blueprint $table) {
$table->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('门店ID0为汇总结算)');
$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/PDFD10');
$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');
}
};
-26
View File
@@ -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' => '下载导出'],
],
],
],
],
[
-191
View File
@@ -1,191 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\ProductPriceModel;
use App\Models\ReconciliationItemModel;
use App\Models\ReconciliationModel;
use App\Models\SettlementModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
/**
* 财务对账:明细构建(已完成订单直读,品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
* publish = 订货金额;actual = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规)
*/
class ReconciliationTest extends ProcurementTestCase
{
/**
* 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成
* 预期:publish 20/30actual 16/24diff 4/6
*
* @return array{0: array<int, StoreModel>, 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);
}
}
-60
View File
@@ -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<IReconDiff>({
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',
});
}
-11
View File
@@ -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}`
);
}
-70
View File
@@ -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<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '对账中', color: 'processing' },
2: { text: '已结算', color: 'success' },
};
export const RECONCILED_MAP: Record<number, { text: string; color: string }> = {
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 };
}
-27
View File
@@ -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<number, { text: string; color: string }> = {
0: { text: '待结算', color: 'default' },
1: { text: '已结算', color: 'success' },
};
-719
View File
@@ -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<XinTableInstance<IReconciliation>>(null);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 工作台抽屉
const [workOpen, setWorkOpen] = useState(false);
const [workLoading, setWorkLoading] = useState(false);
const [recon, setRecon] = useState<IReconciliation | null>(null);
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
const [savingItemId, setSavingItemId] = useState<number | null>(null);
const [diff, setDiff] = useState<IReconDiff | null>(null);
// 备注弹窗
const [remarkOpen, setRemarkOpen] = useState(false);
const [remarkTarget, setRemarkTarget] = useState<IReconciliationItem | null>(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<IReconciliation>('/recon/list', id);
const data = res.data.data ?? null;
setRecon(data);
const editingMap: Record<number, EditingItem> = {};
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<IReconciliationItem>['columns'] = [
{
title: '品名',
dataIndex: 'product_name',
width: 160,
render: (_, record) =>
readonly ? (
record.product_name
) : (
<Input
size="small"
value={editing[record.id!]?.product_name}
onChange={(e) =>
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
) : (
<InputNumber
size="small"
min={0}
precision={2}
value={editing[record.id!]?.quantity}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '称重',
dataIndex: 'weight',
width: 110,
render: (_, record) =>
readonly ? (
record.weight
) : (
<InputNumber
size="small"
min={0}
precision={3}
value={editing[record.id!]?.weight}
onChange={(v) =>
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}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.publish_amount}
onChange={(v) =>
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}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.actual_amount}
onChange={(v) =>
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 (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{String(v)}
</Text>
);
},
},
{
title: '对账',
dataIndex: 'is_reconciled',
width: 80,
align: 'center',
render: (_, record) => (
<Switch
size="small"
disabled={readonly}
checked={record.is_reconciled === 1}
onChange={() => handleToggle(record)}
/>
),
},
{
title: '门店备注',
dataIndex: 'store_remark',
width: 120,
ellipsis: true,
render: (_, record) =>
record.store_remark || <Text type="secondary"></Text>,
},
{
title: '操作',
key: 'action',
width: 130,
fixed: 'right',
render: (_, record) =>
readonly ? null : (
<Space size={4}>
<AuthButton auth="recon.item.item.update">
<Button
size="small"
type="link"
disabled={!isItemDirty(record)}
loading={savingItemId === record.id}
onClick={() => saveItem(record)}
>
</Button>
</AuthButton>
<AuthButton auth="recon.item.item.update">
<Button size="small" type="link" onClick={() => openRemark(record)}>
</Button>
</AuthButton>
</Space>
),
},
];
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) => (
<Text type={v === 0 ? 'secondary' : 'danger'} strong={v !== 0}>
¥{v}
</Text>
),
},
];
const columns: XinTableColumn<IReconciliation>[] = [
{
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 (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
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 <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInTable: true,
hideInSearch: true,
fieldProps: { rows: 2 },
},
];
const operateRender: XinTableProps<IReconciliation>['operateRender'] = (record, dom) => [
<AuthButton key="build" auth="recon.list.build">
<Popconfirm
title="生成对账明细?"
description="按周期/品类/供应商拉取已分摊的采购数据,重复生成会清空现有明细。"
disabled={record.status === 2}
onConfirm={() => handleBuild(record)}
>
<Button size="small" disabled={record.status === 2}>
</Button>
</Popconfirm>
</AuthButton>,
<Button
key="workbench"
size="small"
type="primary"
ghost
icon={<ToolOutlined />}
disabled={record.status === 0}
onClick={() => openWorkbench(record.id!)}
>
</Button>,
<AuthButton key="settle" auth="recon.list.settle">
<Popconfirm
title="生成结算表?"
description="按门店聚合对账明细生成结算表,对账单将变为已结算且不可再修改。"
disabled={record.status !== 1}
onConfirm={() => handleSettle(record)}
>
<Button
size="small"
type="primary"
icon={<FileDoneOutlined />}
disabled={record.status !== 1}
>
</Button>
</Popconfirm>
</AuthButton>,
// 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截
dom.edit,
dom.del,
];
const tableProps: XinTableProps<IReconciliation> = {
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 (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
//
</Text>
</div>
<XinTable<IReconciliation> {...tableProps} />
{/* 对账工作台 */}
<Drawer
title={recon ? `对账工作台 · ${recon.recon_no}` : '对账工作台'}
open={workOpen}
onClose={() => setWorkOpen(false)}
width={1200}
loading={workLoading}
>
{recon ? (
<>
<Descriptions column={4} size="small" bordered>
<Descriptions.Item label="标题">{recon.title}</Descriptions.Item>
<Descriptions.Item label="周期">
{recon.period_start} ~ {recon.period_end}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={RECON_STATUS_MAP[recon.status ?? 0]?.color}>
{RECON_STATUS_MAP[recon.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="差额">
<Text
type={Number(recon.diff_amount) === 0 ? 'secondary' : 'danger'}
strong
>
¥{recon.diff_amount}
</Text>
</Descriptions.Item>
</Descriptions>
<Tabs
className="mt-4"
items={[
{
key: 'items',
label: (
<span>
<CheckSquareOutlined /> {recon.items?.length ?? 0}
</span>
),
children: (
<>
{!readonly ? (
<div className="mb-2 text-gray-500">
///
</div>
) : null}
<Table<IReconciliationItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={recon.items ?? []}
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 1250 }}
/>
</>
),
},
{
key: 'diff',
label: '差额对比',
children: (
<>
<Space className="mb-3">
<Button onClick={() => loadDiff(recon.id!)}></Button>
{diff ? (
<Text type="secondary">
¥{diff.total.publish} / ¥{diff.total.actual} /{' '}
<Text type={diff.total.diff === 0 ? 'secondary' : 'danger'} strong>
¥{diff.total.diff}
</Text>
</Text>
) : null}
</Space>
{diff ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.store_id)}
size="small"
columns={diffColumns('门店', 'store_name')}
dataSource={diff.by_store}
pagination={false}
/>
</div>
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.product_id)}
size="small"
columns={diffColumns('商品', 'product_name')}
dataSource={diff.by_product}
pagination={false}
/>
</div>
</div>
) : (
<Button type="primary" onClick={() => loadDiff(recon.id!)}>
</Button>
)}
</>
),
},
]}
/>
</>
) : null}
</Drawer>
{/* 门店备注弹窗 */}
<Modal
title="单品门店备注"
open={remarkOpen}
onCancel={() => setRemarkOpen(false)}
onOk={saveRemark}
okText="保存备注"
destroyOnHidden
>
<div className="mb-2 text-gray-500">
{remarkTarget?.product_name}
{remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''}
</div>
<Input.TextArea
rows={3}
maxLength={255}
showCount
value={remarkValue}
onChange={(e) => setRemarkValue(e.target.value)}
placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)"
/>
</Modal>
</>
);
};
export default ReconListPage;
-236
View File
@@ -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<XinTableInstance<ISettlement>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<ISettlement | null>(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<ISettlement>('/recon/settlement', id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const columns: XinTableColumn<ISettlement>[] = [
{
title: '结算单号',
dataIndex: 'settlement_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.settlement_no }}>{record.settlement_no}</Text>,
},
{
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 ? (
<span>
{record.recon.recon_no}
<Text type="secondary">{record.recon.title}</Text>
</span>
) : (
'-'
),
},
{
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) => <Text strong>¥{record.actual_amount}</Text>,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
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 <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '结算时间',
dataIndex: 'settled_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => record.settled_at ?? '-',
},
];
const operateRender: XinTableProps<ISettlement>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
<AuthButton key="download" auth="recon.settlement.download">
<Dropdown
menu={{
items: [
{ key: 'xlsx', label: '下载 Excel', onClick: () => downloadSettlement(record.id!, 'xlsx') },
{ key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') },
],
}}
>
<Button size="small" type="primary" ghost icon={<DownloadOutlined />}>
</Button>
</Dropdown>
</AuthButton>,
];
const tableProps: XinTableProps<ISettlement> = {
api: '/recon/settlement',
columns,
rowKey: 'id',
accessName: 'recon.settlement',
tableRef,
operateRender,
formProps: false,
scroll: { x: 1200 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
Excel / PDF
</Text>
</div>
<XinTable<ISettlement> {...tableProps} />
<Drawer
title={detail ? `结算表 ${detail.settlement_no}` : '结算表详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={640}
loading={detailLoading}
>
{detail ? (
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
{SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源对账单">
{detail.recon?.recon_no ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="结算周期">
{detail.period_start} ~ {detail.period_end}
</Descriptions.Item>
<Descriptions.Item label="公布金额">¥{detail.total_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
<Descriptions.Item label="差额">¥{detail.diff_amount}</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.settled_at ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="存档文件" span={2}>
{detail.file_path ?? <Text type="secondary"></Text>}
</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</>
);
};
export default SettlementPage;