账单生成

This commit is contained in:
liu
2026-08-14 00:15:28 +08:00
parent 61a70319a0
commit e35c951a59
19 changed files with 728 additions and 631 deletions
@@ -68,14 +68,6 @@ class StoreOrderController extends BaseController
} }
unset($order); unset($order);
$box_amount = site_config('services.box_amount');
$tray_amount = site_config('services.tray_amount');
foreach ($data['data'] as &$item) {
$item['box_price'] = number_format($box_amount, 2);
$item['tray_price'] = number_format($tray_amount, 2);
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
}
return $this->success($data); return $this->success($data);
} }
@@ -96,14 +88,8 @@ class StoreOrderController extends BaseController
$data = $order->toArray(); $data = $order->toArray();
// 明细首图 + 附加金额(与列表接口一致) // 明细首图
app(ItemImageResolver::class)->resolve($data['items']); app(ItemImageResolver::class)->resolve($data['items']);
$boxAmount = site_config('services.box_amount');
$trayAmount = site_config('services.tray_amount');
$data['box_price'] = number_format($boxAmount, 2);
$data['tray_price'] = number_format($trayAmount, 2);
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
return $this->success($data); return $this->success($data);
} }
@@ -4,17 +4,18 @@ namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport; use App\Exports\PurchaseOrderExport;
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest; use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest; use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
use App\Models\BillModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
use App\Models\StoreModel; use App\Models\StoreModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Services\BillGenerateService;
use App\Services\ItemImageResolver; use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService; use App\Services\PurchaseGenerateService;
use App\Services\StoreOrderContainerService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -135,44 +136,21 @@ class PurchaseOrderController extends BaseController
[$a['category_sort'], $a['product_sort'], $a['product_id']] [$a['category_sort'], $a['product_sort'], $a['product_id']]
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
// 周转框/托盘合并记录:按门店聚合全部订单(附底层订单明细,供采购单完成前修改 // 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见
$storeOrders = StoreOrderModel::query() $bills = BillModel::query()
->where('purchase_id', $purchase->id) ->where('purchase_id', $purchase->id)
->whereNull('deleted_at') ->with('store:id,name')
->orderBy('id') ->orderBy('id')
->get(['id', 'order_no', 'store_id', 'box_num', 'tray_num']); ->get()
->map(static function (BillModel $bill): array {
$boxPrice = (float) site_config('services.box_amount', 0); $row = $bill->toArray();
$trayPrice = (float) site_config('services.tray_amount', 0); $row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id);
$storeSort = $stores->pluck('id')->flip(); $row['order_count'] = $bill->orders()->count();
$containers = []; unset($row['store']);
foreach ($storeOrders->groupBy('store_id') as $storeId => $orders) {
$boxNum = (int) $orders->sum('box_num');
$trayNum = (int) $orders->sum('tray_num');
$containers[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->firstWhere('id', (int) $storeId)['name'] ?? '门店#' . $storeId,
'box_num' => $boxNum,
'tray_num' => $trayNum,
'box_price' => number_format($boxPrice, 2),
'tray_price' => number_format($trayPrice, 2),
'added_amount' => number_format($boxNum * $boxPrice + $trayNum * $trayPrice, 2),
'order_count' => $orders->count(),
'orders' => $orders->map(static fn (StoreOrderModel $order) => [
'order_id' => $order->id,
'order_no' => $order->order_no,
'box_num' => (int) $order->box_num,
'tray_num' => (int) $order->tray_num,
])->values()->toArray(),
'store_sort' => (int) ($storeSort[$storeId] ?? 9999),
];
}
usort($containers, static fn (array $a, array $b): int =>
[$a['store_sort'], $a['store_id']] <=> [$b['store_sort'], $b['store_id']]);
$containers = array_map(static function (array $row): array {
unset($row['store_sort']);
return $row; return $row;
}, $containers); })
->values()
->toArray();
return $this->success([ return $this->success([
'purchase' => $purchase->toArray(), 'purchase' => $purchase->toArray(),
@@ -181,7 +159,7 @@ class PurchaseOrderController extends BaseController
unset($row['category_sort'], $row['product_sort']); unset($row['category_sort'], $row['product_sort']);
return $row; return $row;
}, $rows), }, $rows),
'containers' => $containers, 'bills' => $bills,
]); ]);
} }
@@ -232,7 +210,6 @@ class PurchaseOrderController extends BaseController
StoreOrderModel::where('purchase_id', $purchase->id)->update([ StoreOrderModel::where('purchase_id', $purchase->id)->update([
'status' => StoreOrderModel::STATUS_DISTRIBUTION 'status' => StoreOrderModel::STATUS_DISTRIBUTION
]); ]);
// 生成并发送账单
return $this->success(); return $this->success();
}); });
@@ -438,71 +415,81 @@ class PurchaseOrderController extends BaseController
} }
/** /**
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改) * 账单生成预览
* @throws Throwable
*/ */
#[PutRoute(route: '/{id}/container/{storeId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])] #[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])]
public function updateContainer(int $id, int $storeId, PurchaseContainerUpdateRequest $request): JsonResponse public function billPrepare(int $id): JsonResponse
{ {
$purchase = PurchaseOrderModel::find($id); $purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) { if (empty($purchase)) {
throw new RepositoryException('采购单不存在'); throw new RepositoryException('采购单不存在');
} }
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
}
$submitted = $request->validated()['orders'];
return DB::transaction(function () use ($purchase, $storeId, $submitted) {
$orders = StoreOrderModel::query() $orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id) ->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->whereNull('deleted_at') ->whereNull('deleted_at')
->lockForUpdate() ->orderBy('id')
->get(); ->get(['id', 'store_id', 'total_amount']);
if ($orders->isEmpty()) { $stores = StoreModel::withTrashed()
throw new RepositoryException('该采购单下无此门店的订单'); ->whereIn('id', $orders->pluck('store_id')->unique())
} ->get(['id', 'name'])
->keyBy('id');
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差 $bills = BillModel::query()
$actualIds = $orders->pluck('id')->map(static fn ($orderId) => (int) $orderId)->all(); ->where('purchase_id', $purchase->id)
$submittedIds = array_map(static fn (array $row) => (int) $row['order_id'], $submitted); ->get()
$missing = array_diff($actualIds, $submittedIds); ->keyBy('store_id');
if ($missing !== []) {
throw new RepositoryException('提交不完整,缺少订单:' . implode('、', $missing));
}
if (array_diff($submittedIds, $actualIds) !== []) {
throw new RepositoryException('包含不属于该采购单该门店的订单');
}
foreach ($orders as $order) { $boxPrice = number_format((float) site_config('services.box_amount', 0), 2);
if (! in_array($order->status, [ $trayPrice = number_format((float) site_config('services.tray_amount', 0), 2);
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING, $rows = [];
StoreOrderModel::STATUS_DISTRIBUTION, foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) {
], true)) { $productAmount = $storeOrders->reduce(
throw new RepositoryException( static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'订单 ' . $order->order_no . ' 当前状态为「' '0'
. (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status)
. '」,不允许修改周转框/托盘数量'
); );
} $bill = $bills->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
'tray_price' => $trayPrice,
'bill' => $bill?->toArray(),
];
} }
$ordersById = $orders->keyBy('id'); return $this->success([
$containerService = app(StoreOrderContainerService::class); 'purchase' => $purchase->only(['id', 'purchase_no', 'status']),
foreach ($submitted as $row) { 'stores' => $rows,
$containerService->update( ]);
$ordersById->get($row['order_id']), }
(int) $row['box_num'],
(int) $row['tray_num'], /**
* 生成账单
* @throws Throwable
*/
#[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])]
public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) {
throw new RepositoryException('采购单未完成,不允许生成账单');
}
$bills = app(BillGenerateService::class)->generate(
$purchase,
$request->validated()['stores'],
(int) $request->user()->id,
); );
}
return $this->success(); return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单');
});
} }
/** /**
@@ -575,9 +562,8 @@ class PurchaseOrderController extends BaseController
// 重算订单金额 // 重算订单金额
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id); $order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
$order->product_amount = $order->items()->sum('amount');
$order->total_weight = $order->items()->sum('weight'); $order->total_weight = $order->items()->sum('weight');
$order->total_amount = bcadd($order->product_amount, $order->added_amount, 2); $order->total_amount = $order->items()->sum('amount');
$order->save(); $order->save();
// 重算采购单重量 // 重算采购单重量
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改)
*/
class PurchaseBillGenerateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'stores' => 'required|array|min:1',
'stores.*.store_id' => 'required|integer|distinct',
'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer|min:0',
'stores.*.tray_num' => 'required|integer|min:0',
];
}
public function messages(): array
{
return [
'stores.required' => '请提交门店账单信息',
'stores.array' => '门店账单数据格式错误',
'stores.min' => '请提交门店账单信息',
'stores.*.store_id.required' => '门店ID不能为空',
'stores.*.store_id.integer' => '门店ID格式错误',
'stores.*.store_id.distinct' => '存在重复门店',
'stores.*.delivery_fee.required' => '配送费不能为空',
'stores.*.delivery_fee.numeric' => '配送费格式错误',
'stores.*.delivery_fee.min' => '配送费不能小于 0',
'stores.*.box_num.required' => '周转筐数量不能为空',
'stores.*.box_num.integer' => '周转筐数量必须为整数',
'stores.*.box_num.min' => '周转筐数量不能小于 0',
'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数',
'stores.*.tray_num.min' => '周转托盘数量不能小于 0',
];
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单周转框/托盘合并记录修改 验证(按门店覆盖全部订单,逐笔重算附加金额)
*/
class PurchaseContainerUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'orders' => 'required|array|min:1',
'orders.*.order_id' => 'required|integer|distinct',
'orders.*.box_num' => 'required|integer|min:0',
'orders.*.tray_num' => 'required|integer|min:0',
];
}
public function messages(): array
{
return [
'orders.required' => '请提交门店订单周转框/托盘数量',
'orders.array' => '订单数据格式错误',
'orders.min' => '请提交门店订单周转框/托盘数量',
'orders.*.order_id.required' => '订单ID不能为空',
'orders.*.order_id.integer' => '订单ID格式错误',
'orders.*.order_id.distinct' => '存在重复订单',
'orders.*.box_num.required' => '周转框数量不能为空',
'orders.*.box_num.integer' => '周转框数量必须为整数',
'orders.*.box_num.min' => '周转框数量不能小于 0',
'orders.*.tray_num.required' => '周转托盘数量不能为空',
'orders.*.tray_num.integer' => '周转托盘数量必须为整数',
'orders.*.tray_num.min' => '周转托盘数量不能小于 0',
];
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\SystemUser\Models\SysUserModel;
/**
* 门店账单模型(采购单完成后按门店生成;商品金额由订单汇总快照,生成后不可修改)
*/
class BillModel extends Model
{
use HasFactory;
protected $table = 'bill';
protected $primaryKey = 'id';
protected $fillable = [
'bill_no',
'purchase_id',
'store_id',
'bill_date',
'product_amount',
'delivery_fee',
'box_num',
'tray_num',
'box_price',
'tray_price',
'added_amount',
'total_amount',
'operator_id',
'remark',
];
protected $casts = [
'purchase_id' => 'integer',
'store_id' => 'integer',
'bill_date' => 'date:Y-m-d',
'product_amount' => 'decimal:2',
'delivery_fee' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'box_price' => 'decimal:2',
'tray_price' => 'decimal:2',
'added_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 所属门店(含软删除门店,保证历史账单可见)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
}
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 生成人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 本账单关联的门店订单
*/
public function orders(): HasMany
{
return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id');
}
}
+10 -8
View File
@@ -48,27 +48,21 @@ class StoreOrderModel extends Model
'total_quantity', 'total_quantity',
'total_weight', 'total_weight',
'total_amount', 'total_amount',
'product_amount',
'added_amount',
'box_num',
'tray_num',
'status', 'status',
'remark', 'remark',
'purchase_id', 'purchase_id',
'bill_id',
]; ];
protected $casts = [ protected $casts = [
'store_id' => 'integer', 'store_id' => 'integer',
'purchase_id' => 'integer', 'purchase_id' => 'integer',
'statement_id' => 'integer', 'statement_id' => 'integer',
'bill_id' => 'integer',
'order_date' => 'date:Y-m-d', 'order_date' => 'date:Y-m-d',
'total_quantity' => 'integer', 'total_quantity' => 'integer',
'total_weight' => 'decimal:3', 'total_weight' => 'decimal:3',
'total_amount' => 'decimal:2', 'total_amount' => 'decimal:2',
'product_amount' => 'decimal:2',
'added_amount' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'status' => 'integer', 'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s', 'created_at' => 'datetime:Y-m-d H:i:s',
]; ];
@@ -96,4 +90,12 @@ class StoreOrderModel extends Model
{ {
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id'); return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
} }
/**
* 关联账单(采购单完成后按门店生成)
*/
public function bill(): BelongsTo
{
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
}
} }
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 门店账单生成(采购单完成后,按门店各生成一张账单)
*
* 流程(事务内):
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照)
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联
*/
readonly class BillGenerateService
{
public function __construct(private BillNumberService $billNumberService)
{
}
/**
* @param PurchaseOrderModel $purchase 已完成采购单
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量
* @param int $operatorId 生成人(后台系统用户ID)
* @return BillModel[] 生成的账单列表
* @throws Throwable
*/
public function generate(PurchaseOrderModel $purchase, array $stores, int $operatorId): array
{
return DB::transaction(function () use ($purchase, $stores, $operatorId) {
// 1. 行锁采购单全部有效订单(并发防护),按门店分组
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->lockForUpdate()
->get();
if ($orders->isEmpty()) {
throw new RepositoryException('采购单下无门店订单,无法生成账单');
}
$ordersByStore = $orders->groupBy('store_id');
// 提交的门店必须完整覆盖采购单门店,避免漏门店造成订单未入账
$submitted = [];
foreach ($stores as $row) {
$submitted[(int) $row['store_id']] = $row;
}
$missing = array_diff(array_map('intval', $ordersByStore->keys()->all()), array_keys($submitted));
if ($missing !== []) {
throw new RepositoryException('提交不完整,缺少门店账单信息:' . implode('、', $missing));
}
if (array_diff(array_keys($submitted), array_map('intval', $ordersByStore->keys()->all())) !== []) {
throw new RepositoryException('包含不属于该采购单的门店');
}
// 2. 防重复生成:任一门店已出账则整批拒绝
$billedStoreIds = BillModel::query()
->where('purchase_id', $purchase->id)
->pluck('store_id')
->map(static fn ($id) => (int) $id)
->all();
if ($billedStoreIds !== []) {
$names = StoreModel::withTrashed()
->whereIn('id', $billedStoreIds)
->pluck('name')
->implode('、');
throw new RepositoryException('以下门店已生成账单,不允许重复生成:' . $names);
}
// 3. 逐门店生成账单并关联订单
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
$productAmount = $storeOrders->reduce(
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'0'
);
$deliveryFee = bcadd((string) $row['delivery_fee'], '0', 2);
$addedAmount = bcadd(
bcmul((string) (int) $row['box_num'], $boxPrice, 2),
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2
);
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
$bill = BillModel::create([
'bill_no' => $this->billNumberService->make('ZD'),
'purchase_id' => $purchase->id,
'store_id' => (int) $storeId,
'bill_date' => $billDate,
'product_amount' => $productAmount,
'delivery_fee' => $deliveryFee,
'box_num' => (int) $row['box_num'],
'tray_num' => (int) $row['tray_num'],
'box_price' => bcadd($boxPrice, '0', 2),
'tray_price' => bcadd($trayPrice, '0', 2),
'added_amount' => $addedAmount,
'total_amount' => $totalAmount,
'operator_id' => $operatorId,
]);
// 关联该门店在采购单中的全部订单到账单
StoreOrderModel::query()
->whereIn('id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->id]);
$bills[] = $bill;
}
return $bills;
});
}
}
+2 -1
View File
@@ -27,12 +27,13 @@ class BillNumberService
'RC' => ['reconciliation', 'recon_no'], 'RC' => ['reconciliation', 'recon_no'],
'ST' => ['statement', 'statement_no'], 'ST' => ['statement', 'statement_no'],
'JS' => ['settlement', 'settlement_no'], 'JS' => ['settlement', 'settlement_no'],
'ZD' => ['bill', 'bill_no'],
]; ];
/** /**
* 生成业务单号 * 生成业务单号
* *
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 * @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 / ZD 账单
* @return string 如 PO202607230001 * @return string 如 PO202607230001
*/ */
public function make(string $prefix): string public function make(string $prefix): string
@@ -1,34 +0,0 @@
<?php
namespace App\Services;
use App\Models\StoreOrderModel;
/**
* 周转框/周转托盘数量修改(订单附加金额重算)
*/
readonly class StoreOrderContainerService
{
/**
* 更新订单周转框/托盘数量并重算附加金额与订单总金额。
* 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立。
*/
public function update(StoreOrderModel $order, int $boxNum, int $trayNum): void
{
$boxPrice = (float) site_config('services.box_amount', 0);
$trayPrice = (float) site_config('services.tray_amount', 0);
$addedAmount = round($boxNum * $boxPrice + $trayNum * $trayPrice, 2);
$productAmount = (float) $order->product_amount;
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
}
$order->box_num = $boxNum;
$order->tray_num = $trayNum;
$order->product_amount = $productAmount;
$order->added_amount = $addedAmount;
$order->total_amount = round($productAmount + $addedAmount, 2);
$order->save();
}
}
@@ -19,15 +19,11 @@ return new class extends Migration
$table->string('order_no', 32)->unique()->comment('订单编号'); $table->string('order_no', 32)->unique()->comment('订单编号');
$table->integer('store_id')->comment('门店ID'); $table->integer('store_id')->comment('门店ID');
$table->integer('purchase_id')->nullable()->comment('关联采购单ID'); $table->integer('purchase_id')->nullable()->comment('关联采购单ID');
$table->integer('statement_id')->nullable()->comment('关联账单ID'); $table->integer('bill_id')->nullable()->comment('关联账单ID');
$table->date('order_date')->comment('订货日期'); $table->date('order_date')->comment('订货日期');
$table->integer('total_quantity')->default(0)->comment('订货总量'); $table->integer('total_quantity')->default(0)->comment('订货总量');
$table->decimal('total_weight', 10, 3)->default(0)->comment('总重量'); $table->decimal('total_weight', 10, 3)->default(0)->comment('总重量');
$table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额'); $table->decimal('total_amount', 10, 2)->default(0)->comment('订单总金额');
$table->decimal('product_amount', 10, 2)->default(0)->comment('商品总金额');
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额');
$table->decimal('box_num', 10, 2)->default(0)->comment('周转框数量');
$table->decimal('tray_num', 10, 2)->default(0)->comment('周转托盘数量');
$table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)'); $table->integer('status')->default(0)->comment('订单状态(0待接单 1已接单 2采购中 3配送中 4已完成 9已取消)');
$table->string('remark', 255)->default('')->comment('订单备注'); $table->string('remark', 255)->default('')->comment('订单备注');
$table->softDeletes(); $table->softDeletes();
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
* 门店账单:采购单完成后按门店生成;周转筐/托盘与附加金额从门店订单迁入账单
* store_order 的列变动已按约定折进 2026_07_23_030610 原迁移文件,本文件仅建账单表)
*/
public function up(): void
{
// 门店账单表(每个客户单独一张,商品金额由订单汇总不可修改)
if (! Schema::hasTable('bill')) {
Schema::create('bill', function (Blueprint $table) {
$table->increments('id')->comment('账单ID');
$table->string('bill_no', 32)->unique()->comment('账单编号');
$table->integer('purchase_id')->comment('关联采购单ID');
$table->integer('store_id')->comment('门店ID');
$table->date('bill_date')->comment('账单日期');
$table->decimal('product_amount', 10, 2)->default(0)->comment('商品金额(订单商品金额汇总,不可修改)');
$table->decimal('delivery_fee', 10, 2)->default(0)->comment('配送费(生成账单时填写)');
$table->integer('box_num')->default(0)->comment('周转筐数量');
$table->integer('tray_num')->default(0)->comment('周转托盘数量');
$table->decimal('box_price', 10, 2)->default(0)->comment('周转筐单价(生成时快照)');
$table->decimal('tray_price', 10, 2)->default(0)->comment('周转托盘单价(生成时快照)');
$table->decimal('added_amount', 10, 2)->default(0)->comment('附加金额(周转筐/托盘金额)');
$table->decimal('total_amount', 10, 2)->default(0)->comment('账单总金额 = 商品金额 + 配送费 + 附加金额');
$table->integer('operator_id')->default(0)->comment('生成人(后台系统用户ID');
$table->string('remark', 255)->default('')->comment('备注');
$table->timestamps();
$table->unique(['purchase_id', 'store_id'], 'bill_purchase_store_unique');
$table->index(['store_id', 'bill_date'], 'bill_store_date_index');
$table->comment('门店账单表(采购单完成后按门店生成)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bill');
}
};
+1
View File
@@ -193,6 +193,7 @@ class PermissionSeeder extends Seeder
['type' => 'rule', 'key' => 'purchase.order.query', 'name' => '查询'], ['type' => 'rule', 'key' => 'purchase.order.query', 'name' => '查询'],
['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'], ['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'],
['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'], ['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'],
['type' => 'rule', 'key' => 'purchase.order.bill', 'name' => '生成账单'],
['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'], ['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'],
], ],
], ],
-164
View File
@@ -11,9 +11,6 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use App\Models\UserModel; use App\Models\UserModel;
use Illuminate\Support\Facades\Cache;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
/** /**
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本, * C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
@@ -55,23 +52,6 @@ class PurchaseEditTest extends ProcurementTestCase
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]]; return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
} }
/** 播种站点配置:周转框单价 2.00、周转托盘单价 10.00 */
private function seedContainerConfig(): void
{
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => $key,
'title' => $title,
'type' => 'InputNumber',
'values' => $value,
'sort' => 0,
]);
}
Cache::forget('site_config');
}
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */ /** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
public function test_detail_returns_store_matrix(): void public function test_detail_returns_store_matrix(): void
{ {
@@ -460,148 +440,4 @@ class PurchaseEditTest extends ProcurementTestCase
->assertOk() ->assertOk()
->assertJsonPath('data.items.0.editable', false); ->assertJsonPath('data.items.0.editable', false);
} }
/** 详情返回周转框/托盘合并记录:按门店聚合全部订单(含底层订单明细与单价) */
public function test_detail_returns_container_summary(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
StoreOrderModel::where('store_id', $stores[0]->id)->update(['box_num' => 2, 'tray_num' => 1]);
StoreOrderModel::where('store_id', $stores[1]->id)->update(['box_num' => 3, 'tray_num' => 0]);
$this->actingAsSysUser();
$containers = $this->getJson("/purchase/order/{$purchase->id}")
->assertOk()
->assertJsonPath('success', true)
->json('data.containers');
$this->assertCount(2, $containers);
$this->assertSame($stores[0]->id, $containers[0]['store_id'], '与门店列同序');
$row = $containers[0];
$this->assertSame($stores[0]->name, $row['store_name']);
$this->assertSame(2, $row['box_num']);
$this->assertSame(1, $row['tray_num']);
$this->assertSame('2.00', $row['box_price']);
$this->assertSame('10.00', $row['tray_price']);
$this->assertSame('14.00', $row['added_amount'], '2×2.00 + 1×10.00');
$this->assertSame(1, $row['order_count']);
$this->assertCount(1, $row['orders']);
$order = $row['orders'][0];
$this->assertStringStartsWith('SO', $order['order_no']);
$this->assertSame(2, $order['box_num']);
$this->assertSame(1, $order['tray_num']);
$this->assertSame(3, $containers[1]['box_num']);
$this->assertSame('6.00', $containers[1]['added_amount']);
}
/** 合并记录修改:逐笔更新订单数量并重算附加金额与订单总金额,其他门店不受影响 */
public function test_update_container_syncs_order_amounts(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$this->assertSame('20.00', (string) $order->product_amount, '下单时应写入商品金额');
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => 3, 'tray_num' => 1]],
])->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertSame(3, $order->box_num);
$this->assertSame(1, $order->tray_num);
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
$this->assertSame('20.00', (string) $order->product_amount);
$this->assertSame('36.00', (string) $order->total_amount, '20.00 + 16.00');
$other = StoreOrderModel::where('store_id', $stores[1]->id)->first();
$this->assertSame(0, $other->box_num, '其他门店订单不受影响');
$this->assertSame('30.00', (string) $other->total_amount);
}
/** 采购单已完成:合并记录修改拒绝,数量与金额不变 */
public function test_update_container_rejected_when_purchase_completed(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => 5, 'tray_num' => 5]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改周转框/托盘');
$this->assertSame(0, $order->fresh()->box_num, '被拒绝后数量不变');
}
/** 提交必须覆盖该门店全部订单;跨门店/跨采购单订单拒绝 */
public function test_update_container_rejects_incomplete_or_foreign_orders(): void
{
$this->seedContainerConfig();
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
foreach ([1, 2] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$orders = StoreOrderModel::where('purchase_id', $purchase->id)->orderBy('id')->get();
$this->assertCount(2, $orders, '同一门店两笔订单');
// 只提交一笔 → 缺少另一笔,整批拒绝
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
'orders' => [['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '提交不完整,缺少订单:' . $orders[1]->id);
// 混入其他门店订单(同一采购单另一门店)→ 拒绝
$otherStore = StoreModel::factory()->create(['level_id' => $level->id]);
$this->actingAsMiniUser(UserModel::factory()->forStore($otherStore->id)->create());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$foreign = StoreOrderModel::where('store_id', $otherStore->id)->first();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
'orders' => [
['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $orders[1]->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $foreign->id, 'box_num' => 1, 'tray_num' => 0],
],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '包含不属于该采购单该门店的订单');
$this->assertSame(0, $orders[0]->fresh()->box_num, '被拒绝后数量不变');
}
/** 数量为负/重复订单时校验失败 */
public function test_update_container_validates_negative_and_duplicate_orders(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => -1, 'tray_num' => 0]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '周转框数量不能小于 0');
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
],
])->assertJsonPath('success', false);
}
} }
+2 -3
View File
@@ -122,12 +122,11 @@ class StoreOrderItemTest extends ProcurementTestCase
->assertJsonPath('data.items.0.supplier.name', $supplier->name); ->assertJsonPath('data.items.0.supplier.name', $supplier->name);
} }
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品 + 附加 */ /** 修改明细:重算单品金额与订单总量/总额(总额 = 商品金额,附加金额已迁入账单 */
public function test_update_item_recalculates_order_totals(): void public function test_update_item_recalculates_order_totals(): void
{ {
[$store, $product, $user] = $this->makeStoreWithProduct('5.00'); [$store, $product, $user] = $this->makeStoreWithProduct('5.00');
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED); $order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
$order->update(['added_amount' => '6.00']); // 已有附加金额
$item = $order->items->first(); $item = $order->items->first();
$supplier = SupplierModel::factory()->create(); $supplier = SupplierModel::factory()->create();
@@ -158,7 +157,7 @@ class StoreOrderItemTest extends ProcurementTestCase
$this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计'); $this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计');
$this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计'); $this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计');
$this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计'); $this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计');
$this->assertSame('36.00', (string) $order->total_amount, '订单总金额 = 商品 30 + 附加 6'); $this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 商品金额');
} }
/** 已完成/已取消订单不允许修改明细 */ /** 已完成/已取消订单不允许修改明细 */
+19 -13
View File
@@ -1,6 +1,7 @@
import createAxios from '@/utils/request'; import createAxios from '@/utils/request';
import { downloadBlob } from '@/api/common/download.ts'; import { downloadBlob } from '@/api/common/download.ts';
import type { import type {
IBillPrepare,
IPurchaseCell, IPurchaseCell,
IPurchaseDetail, IPurchaseDetail,
IPurchaseStoreSummary, IPurchaseStoreSummary,
@@ -23,9 +24,10 @@ export interface PurchaseCellUpdateParams {
weight?: number; weight?: number;
} }
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */ /** 生成账单:单个门店的配送费/周转筐/托盘数量 */
export interface PurchaseContainerOrderParams { export interface BillGenerateStoreParams {
order_id: number; store_id: number;
delivery_fee: number;
box_num: number; box_num: number;
tray_num: number; tray_num: number;
} }
@@ -84,16 +86,20 @@ export async function getPurchaseStoreSummary(purchaseId: number, storeId: numbe
}); });
} }
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额 */ /** 账单生成预览:按门店汇总商品金额(金额只读 */
export async function updatePurchaseContainer( export async function getBillPrepare(purchaseId: number) {
purchaseId: number, return createAxios<IBillPrepare>({
storeId: number, url: `/purchase/order/${purchaseId}/bill/prepare`,
orders: PurchaseContainerOrderParams[], method: 'get',
) { });
return createAxios({ }
url: `/purchase/order/${purchaseId}/container/${storeId}`,
method: 'put', /** 生成账单:已完成采购单按门店各生成一张(关联采购单全部订单,金额由系统汇总不可修改) */
data: { orders }, export async function generateBill(purchaseId: number, stores: BillGenerateStoreParams[]) {
return createAxios<{ count: number }>({
url: `/purchase/order/${purchaseId}/bill`,
method: 'post',
data: { stores },
}); });
} }
+39 -17
View File
@@ -48,34 +48,56 @@ export interface IPurchaseDetail {
purchase: IPurchaseOrder; purchase: IPurchaseOrder;
stores: { id: number; name: string }[]; stores: { id: number; name: string }[];
items: IPurchaseDetailRow[]; items: IPurchaseDetailRow[];
/** 周转框/托盘合并记录(按门店聚合 */ /** 门店账单(采购单完成后按门店生成 */
containers: IPurchaseContainerStore[]; bills: IBill[];
} }
/** 合并记录中的底层门店单(订单号 + 框/托盘数量 */ /** 门店单(采购单完成后按门店生成,商品金额为订单汇总快照不可修改 */
export interface IPurchaseContainerOrder { export interface IBill {
order_id: number; id: number;
order_no: string; bill_no: string;
purchase_id: number;
store_id: number;
store_name?: string;
bill_date: string;
/** 商品金额(订单商品金额汇总) */
product_amount: string;
/** 配送费(生成账单时填写) */
delivery_fee: string;
box_num: number; box_num: number;
tray_num: number; tray_num: number;
/** 周转筐单价(生成时快照) */
box_price: string;
/** 周转托盘单价(生成时快照) */
tray_price: string;
/** 附加金额 = 周转筐×筐单价 + 托盘×托盘单价 */
added_amount: string;
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
total_amount: string;
order_count?: number;
remark?: string;
created_at?: string;
} }
/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计 */ /** 账单生成预览行(按门店汇总,金额只读 */
export interface IPurchaseContainerStore { export interface IBillPrepareStore {
store_id: number; store_id: number;
store_name: string; store_name: string;
/** 周转框合计 */ order_count: number;
box_num: number; /** 商品金额(订单汇总,不可修改) */
/** 周转托盘合计 */ product_amount: string;
tray_num: number; /** 周转筐单价(站点配置) */
/** 周转框单价(站点配置) */
box_price: string; box_price: string;
/** 周转托盘单价(站点配置) */ /** 周转托盘单价(站点配置) */
tray_price: string; tray_price: string;
/** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */ /** 已生成的账单(未生成为 null */
added_amount: string; bill: IBill | null;
order_count: number; }
orders: IPurchaseContainerOrder[];
/** 账单生成预览(采购单 + 门店行) */
export interface IBillPrepare {
purchase: { id: number; purchase_no: string; status: number };
stores: IBillPrepareStore[];
} }
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */ /** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
+2 -12
View File
@@ -45,6 +45,8 @@ export default interface IStoreOrder {
store_id?: number; store_id?: number;
purchase_id?: number; purchase_id?: number;
statement_id?: number; statement_id?: number;
/** 关联账单ID(采购单完成后按门店生成账单时回写) */
bill_id?: number;
store?: { store?: {
id: number; id: number;
name: string; name: string;
@@ -56,18 +58,6 @@ export default interface IStoreOrder {
total_quantity?: number; total_quantity?: number;
total_weight?: string; total_weight?: string;
total_amount?: string; total_amount?: string;
product_amount: string;
added_amount: string;
box_num: number;
tray_num: number;
/** 周转框单价(列表接口附加) */
box_price?: string;
/** 周转托盘单价(列表接口附加) */
tray_price?: string;
/** 周转框金额(列表接口附加) */
box_amount?: string;
/** 周转托盘金额(列表接口附加) */
tray_amount?: string;
/** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */ /** 0待接单 1已接单 2采购中 3配送中 4已完成 9已取消 */
status?: number; status?: number;
remark?: string; remark?: string;
+16 -53
View File
@@ -261,12 +261,8 @@ const StoreOrderPage: React.FC = () => {
{record.total_quantity} {record.total_quantity}
</div> </div>
<div> <div>
<Text type={'secondary'}></Text> <Text type={'secondary'}></Text>
{record.box_num} {record.total_weight}
</div>
<div>
<Text type={'secondary'}></Text>
{record.tray_num}
</div> </div>
</Space> </Space>
) )
@@ -274,24 +270,11 @@ const StoreOrderPage: React.FC = () => {
{ {
title: '订单金额', title: '订单金额',
hideInForm: true, hideInForm: true,
dataIndex: 'box_num', dataIndex: 'total_amount',
hideInSearch: true, hideInSearch: true,
width: 200, width: 200,
render: (_, record) => ( render: (text) => (
<Space orientation={'vertical'}> <span className={'text-[red] text-[16px]'}>{text} </span>
<div>
<Text type={'secondary'}></Text>
<span className={'text-[red]'}>{record.product_amount} </span>
</div>
<div>
<Text type={'secondary'}></Text>
<span className={'text-[red]'}>{record.added_amount} </span>
</div>
<div>
<Text type={'secondary'}></Text>
<span className={'text-[red] text-[16px]'}>{record.total_amount} </span>
</div>
</Space>
) )
}, },
{ {
@@ -334,7 +317,7 @@ const StoreOrderPage: React.FC = () => {
}, },
{ {
title: '账单ID', title: '账单ID',
dataIndex: 'statement_id', dataIndex: 'bill_id',
valueType: 'digit', valueType: 'digit',
hideInForm: true, hideInForm: true,
}, },
@@ -507,8 +490,8 @@ const StoreOrderPage: React.FC = () => {
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item> <Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
<Descriptions.Item label="周转框数量">{detail.box_num}</Descriptions.Item> <Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
<Descriptions.Item label="周转托盘数量">{detail.tray_num}</Descriptions.Item> <Descriptions.Item label="账单ID">{detail.bill_id ?? '-'}</Descriptions.Item>
{detail.remark ? ( {detail.remark ? (
<Descriptions.Item label="备注" span={3}> <Descriptions.Item label="备注" span={3}>
{detail.remark} {detail.remark}
@@ -568,6 +551,14 @@ const StoreOrderPage: React.FC = () => {
</div> </div>
</div> </div>
))} ))}
<div className="flex bg-gray-50 px-4 py-3 text-sm">
<div className="flex-1"></div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-26 shrink-0 text-center">{detail.total_quantity}</div>
<div className="w-33 shrink-0 text-center text-[red] text-[16px]">{detail.total_amount}</div>
<div className="w-33 shrink-0 text-center">{detail.total_weight}</div>
</div>
{(detail.items ?? []).length === 0 && ( {(detail.items ?? []).length === 0 && (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -576,34 +567,6 @@ const StoreOrderPage: React.FC = () => {
/> />
)} )}
</div> </div>
{/* 附加信息 */}
<div className="mt-3! flex justify-end">
<div className="w-full rounded bg-gray-50 p-4 text-sm">
<div className="flex justify-between py-1">
<Text type="secondary">
{detail.box_num} × ¥{detail.box_price ?? '0.00'}
</Text>
<span>¥{detail.box_amount ?? '0.00'}</span>
</div>
<div className="flex justify-between py-1">
<Text type="secondary">
{detail.tray_num} × ¥{detail.tray_price ?? '0.00'}
</Text>
<span>¥{detail.tray_amount ?? '0.00'}</span>
</div>
<div className="flex justify-between py-1">
<Text type="secondary"></Text>
<span>¥{detail.product_amount}</span>
</div>
<div className="mt-1! flex justify-between border-t border-gray-200 pt-2">
<Text strong></Text>
<Text strong type="danger">
¥{detail.total_amount}
</Text>
</div>
</div>
</div>
</> </>
) : null} ) : null}
</Drawer> </Drawer>
+211 -129
View File
@@ -20,7 +20,7 @@ import {
Tag, Tag,
Typography, Typography,
} from 'antd'; } from 'antd';
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons'; import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable'; import XinTable from '@/components/XinTable';
import type { import type {
@@ -30,9 +30,10 @@ import type {
} from '@/components/XinTable/typings.ts'; } from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts'; import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type { import type {
IBill,
IBillPrepare,
IPurchaseCell, IPurchaseCell,
IPurchaseCellItem, IPurchaseCellItem,
IPurchaseContainerStore,
IPurchaseDetail, IPurchaseDetail,
IPurchaseDetailRow, IPurchaseDetailRow,
IPurchaseStoreItem, IPurchaseStoreItem,
@@ -42,12 +43,15 @@ import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { import {
exportPurchase, exportPurchase,
generateBill,
getBillPrepare,
getPurchaseCell, getPurchaseCell,
getPurchaseDetail, getPurchaseDetail,
getPurchaseStoreSummary, getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams, type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem, updatePurchaseCellItem,
updatePurchaseContainer,
updatePurchaseRow, updatePurchaseRow,
} from '@/api/purchase/order.ts'; } from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts'; import { Update } from '@/api/common/table.ts';
@@ -100,25 +104,28 @@ const PurchaseOrderPage: React.FC = () => {
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null); const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false); const [storeLoading, setStoreLoading] = useState(false);
// 周转/托盘合并记录修改(按门店覆盖全部订单逐笔修改 // 生成账单(已完成采购单按门店生成:填写配送费/周转/托盘数量,金额只读
const [containerOpen, setContainerOpen] = useState(false); const [billOpen, setBillOpen] = useState(false);
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null); const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
const [containerSaving, setContainerSaving] = useState(false); const [billLoading, setBillLoading] = useState(false);
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>(); const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价 // 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? []; const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const previewContainerBox = watchContainerOrders.reduce( const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
(sum, order) => sum + Number(order?.box_num ?? 0), const input = watchBillStores[index] ?? {};
0, const deliveryFee = Number(input.delivery_fee ?? 0);
); const added =
const previewContainerTray = watchContainerOrders.reduce( Number(input.box_num ?? 0) * Number(row.box_price) +
(sum, order) => sum + Number(order?.tray_num ?? 0), Number(input.tray_num ?? 0) * Number(row.tray_price);
0, return {
); added,
const previewContainerAdded = total: Number(row.product_amount) + deliveryFee + added,
previewContainerBox * Number(containerStore?.box_price ?? 0) + };
previewContainerTray * Number(containerStore?.tray_price ?? 0); });
const billAllGenerated =
(billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null);
useEffect(() => { useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
@@ -264,34 +271,45 @@ const PurchaseOrderPage: React.FC = () => {
} }
}; };
/** 打开周转/托盘合并记录修改(初始化该门店全部订单的当前数量 */ /** 打开生成账单弹窗:拉取按门店汇总的商品金额(只读),初始化配送费/周转/托盘数量 */
const openContainerEdit = (store: IPurchaseContainerStore) => { const openBillGenerate = async (id: number) => {
setContainerStore(store); setBillOpen(true);
containerForm.setFieldsValue({ setBillLoading(true);
orders: store.orders.map((order) => ({ setBillPrepare(null);
order_id: order.order_id, try {
box_num: order.box_num, const res = await getBillPrepare(id);
tray_num: order.tray_num, const data = res.data.data ?? null;
setBillPrepare(data);
billForm.setFieldsValue({
stores: (data?.stores ?? []).map((row) => ({
store_id: row.store_id,
delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0,
box_num: row.bill ? row.bill.box_num : 0,
tray_num: row.bill ? row.bill.tray_num : 0,
})), })),
}); });
setContainerOpen(true); } finally {
setBillLoading(false);
}
}; };
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */ /** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => { const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => {
if (!detail || !containerStore) { if (!billPrepare) {
return; return;
} }
setContainerSaving(true); setBillSaving(true);
try { try {
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders); const res = await generateBill(billPrepare.purchase.id, values.stores);
message.success('周转框/托盘已更新,订单金额已重算'); message.success(`已生成 ${res.data.data?.count ?? 0} 张门店账单,采购单订单已关联`);
setContainerOpen(false); setBillOpen(false);
setContainerStore(null); setBillPrepare(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload(); await tableRef.current?.reload();
if (detail && detail.purchase.id === billPrepare.purchase.id) {
await loadDetail(detail.purchase.id!);
}
} finally { } finally {
setContainerSaving(false); setBillSaving(false);
} }
}; };
@@ -473,56 +491,87 @@ const PurchaseOrderPage: React.FC = () => {
); );
}; };
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */ /** 门店账单列:门店/账单号/账单日期/商品金额/配送费/周转筐/托盘/附加金额/总金额 */
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [ const billColumns: TableProps<IBill>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' }, { title: '门店', dataIndex: 'store_name', width: 140, align: 'center' },
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
{ {
title: '周转框合计', title: '账单号',
dataIndex: 'box_num', dataIndex: 'bill_no',
width: 180,
align: 'center',
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
},
{ title: '账单日期', dataIndex: 'bill_date', width: 110, align: 'center' },
{
title: '商品金额',
dataIndex: 'product_amount',
width: 110, width: 110,
align: 'center', align: 'center',
render: (_, row) => ( render: (v) => `¥${Number(v).toFixed(2)}`,
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
),
}, },
{ {
title: '周转托盘合计', title: '配送费',
dataIndex: 'tray_num', dataIndex: 'delivery_fee',
width: 120, width: 100,
align: 'center', align: 'center',
render: (_, row) => ( render: (v) => `¥${Number(v).toFixed(2)}`,
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link> },
), {
title: '周转筐',
key: 'box',
width: 110,
align: 'center',
render: (_, row) => `${row.box_num} × ¥${Number(row.box_price).toFixed(2)}`,
},
{
title: '周转托盘',
key: 'tray',
width: 110,
align: 'center',
render: (_, row) => `${row.tray_num} × ¥${Number(row.tray_price).toFixed(2)}`,
}, },
{ {
title: '附加金额', title: '附加金额',
dataIndex: 'added_amount', dataIndex: 'added_amount',
width: 110, width: 100,
align: 'center', align: 'center',
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>, render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单总金额',
dataIndex: 'total_amount',
width: 120,
align: 'center',
render: (v) => <Text strong type="danger">¥{Number(v).toFixed(2)}</Text>,
}, },
]; ];
/** 周转框/托盘合并记录合计行 */ /** 门店账单合计行 */
const renderContainerSummary = () => { const renderBillSummary = () => {
const containers = detail?.containers ?? []; const bills = detail?.bills ?? [];
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0); const totalProduct = bills.reduce((sum, row) => sum + Number(row.product_amount), 0);
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0); const totalDelivery = bills.reduce((sum, row) => sum + Number(row.delivery_fee), 0);
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0); const totalAdded = bills.reduce((sum, row) => sum + Number(row.added_amount), 0);
const totalAmount = bills.reduce((sum, row) => sum + Number(row.total_amount), 0);
return ( return (
<Table.Summary.Row> <Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2} align="center"> <Table.Summary.Cell index={0} colSpan={3} align="center">
<Text strong></Text> <Text strong></Text>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell index={2} align="center">
<Text strong>{totalBox}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center"> <Table.Summary.Cell index={3} align="center">
<Text strong>{totalTray}</Text> <Text strong>¥{totalProduct.toFixed(2)}</Text>
</Table.Summary.Cell> </Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center"> <Table.Summary.Cell index={4} align="center">
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text> <Text strong>¥{totalDelivery.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} colSpan={2} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong>¥{totalAdded.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell> </Table.Summary.Cell>
</Table.Summary.Row> </Table.Summary.Row>
); );
@@ -632,7 +681,20 @@ const PurchaseOrderPage: React.FC = () => {
</Button> </Button>
</Popconfirm> </Popconfirm>
</AuthButton> </AuthButton>
) : null ) : (
<AuthButton auth="purchase.order.bill">
<Button
type={'primary'}
size="small"
variant={'solid'}
color={'orange'}
icon={<AccountBookOutlined />}
onClick={() => openBillGenerate(record.id!)}
>
</Button>
</AuthButton>
)
]; ];
const tableProps: XinTableProps<IPurchaseOrder> = { const tableProps: XinTableProps<IPurchaseOrder> = {
@@ -696,7 +758,7 @@ const PurchaseOrderPage: React.FC = () => {
items={[ items={[
{ key: 'items', label: '商品明细' }, { key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' }, { key: 'stores', label: '门店购买详情' },
{ key: 'containers', label: '周转框/托盘' }, { key: 'bills', label: '门店账单' },
]} ]}
/> />
@@ -736,21 +798,21 @@ const PurchaseOrderPage: React.FC = () => {
)} )}
</Spin> </Spin>
</> </>
) : detailTab === 'containers' ? ( ) : detailTab === 'bills' ? (
detail.containers.length > 0 ? ( detail.bills.length > 0 ? (
<Table<IPurchaseContainerStore> <Table<IBill>
rowKey="store_id" rowKey="id"
size="small" size="small"
bordered bordered
columns={containerColumns} columns={billColumns}
dataSource={detail.containers} dataSource={detail.bills}
pagination={false} pagination={false}
summary={renderContainerSummary} summary={renderBillSummary}
/> />
) : ( ) : (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该采购单暂无门店订单" description={detail.purchase.status === 0 ? '采购单完成后可在列表操作列生成账单' : '该采购单暂未生成账单'}
className="py-8!" className="py-8!"
/> />
) )
@@ -1005,69 +1067,87 @@ const PurchaseOrderPage: React.FC = () => {
</Form> </Form>
</Modal> </Modal>
{/* 周转/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读 */} {/* 生成账单:按门店填写配送费/周转/托盘数量(商品金额只读,由订单汇总 */}
<Modal <Modal
title={ title={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
containerStore open={billOpen}
? `${containerStore.store_name} · 周转框/托盘`
: '周转框/托盘'
}
open={containerOpen}
onCancel={() => { onCancel={() => {
setContainerOpen(false); setBillOpen(false);
setContainerStore(null); setBillPrepare(null);
}} }}
onOk={() => containerForm.submit()} onOk={() => billForm.submit()}
confirmLoading={containerSaving} confirmLoading={billSaving}
okText="保存" okText="确认生成"
okButtonProps={{ disabled: detail?.purchase.status !== 0 }} okButtonProps={{ disabled: billAllGenerated }}
width={760} width={960}
destroyOnHidden destroyOnHidden
> >
<Spin spinning={billLoading}>
{billPrepare && (
<>
<div className="py-2 text-gray-500"> <div className="py-2 text-gray-500">
{containerStore?.order_count ?? 0} /
{detail?.purchase.status === 0 {billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
: '采购单已完成,仅可查看。'}
</div> </div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}> <Form form={billForm} layout="vertical" onFinish={handleBillSave}>
<Form.List name="orders"> <Form.List name="stores">
{(fields) => ( {(fields) => (
<div className="overflow-hidden rounded border border-gray-200"> <div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500"> <div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1"></div> <div className="w-40 shrink-0"></div>
<div className="w-36 shrink-0 text-center"> <div className="w-28 shrink-0 text-center"></div>
¥{containerStore?.box_price ?? '0.00'} <div className="w-32 shrink-0 text-center"></div>
</div> <div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.box_price ?? '0.00'}</div>
<div className="w-36 shrink-0 text-center"> <div className="w-32 shrink-0 text-center">¥{billPrepare.stores[0]?.tray_price ?? '0.00'}</div>
¥{containerStore?.tray_price ?? '0.00'} <div className="w-28 shrink-0 text-center"></div>
</div> <div className="flex-1 text-center"></div>
</div> </div>
{fields.map((field) => { {fields.map((field) => {
const order = containerStore?.orders[field.name]; const row = billPrepare.stores[field.name];
const billed = row?.bill != null;
return ( return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2"> <div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="min-w-0 flex-1 pr-2 text-sm"> <div className="w-40 shrink-0 pr-2 text-sm">
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text> <div>{row?.store_name ?? '-'}</div>
<div className="text-xs text-gray-400">
{row?.order_count ?? 0}
{billed && <Tag className="ml-1!" color="success"></Tag>}
</div> </div>
<Form.Item name={[field.name, 'order_id']} hidden> </div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
</div>
<Form.Item name={[field.name, 'store_id']} hidden>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
className="m-0! w-36 shrink-0" className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]}
>
<InputNumber
className="w-full"
min={0}
precision={2}
disabled={billed}
placeholder="配送费"
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'box_num']} name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转数量' }]} rules={[{ required: true, message: '请输入周转数量' }]}
> >
<InputNumber <InputNumber
className="w-full" className="w-full"
min={0} min={0}
precision={0} precision={0}
disabled={detail?.purchase.status !== 0} disabled={billed}
placeholder="周转数量" placeholder="周转数量"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
className="m-0! w-36 shrink-0" className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'tray_num']} name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]} rules={[{ required: true, message: '请输入周转托盘数量' }]}
> >
@@ -1075,10 +1155,21 @@ const PurchaseOrderPage: React.FC = () => {
className="w-full" className="w-full"
min={0} min={0}
precision={0} precision={0}
disabled={detail?.purchase.status !== 0} disabled={billed}
placeholder="周转托盘数量" placeholder="周转托盘数量"
/> />
</Form.Item> </Form.Item>
<div className="w-28 shrink-0 text-center">
¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)}
</div>
<div className="flex-1 text-center">
<Text strong type="danger">
¥{(billed ? Number(row.bill!.total_amount) : billPreview[field.name]?.total ?? 0).toFixed(2)}
</Text>
{billed && (
<div className="text-xs text-gray-400">{row.bill!.bill_no}</div>
)}
</div>
</div> </div>
); );
})} })}
@@ -1086,18 +1177,9 @@ const PurchaseOrderPage: React.FC = () => {
)} )}
</Form.List> </Form.List>
</Form> </Form>
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3"> </>
<div> )}
<Text type="secondary"></Text> </Spin>
<Text strong>{previewContainerBox}</Text>
<Text type="secondary" className="ml-6!"></Text>
<Text strong>{previewContainerTray}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
</div>
</Space>
</Modal> </Modal>
</> </>
); );