Compare commits
4 Commits
ce1f36b5b4
...
c2c56408a0
| Author | SHA1 | Date | |
|---|---|---|---|
| c2c56408a0 | |||
| d4bdabb866 | |||
| ef0d394f4f | |||
| dadfdc1511 |
File diff suppressed because one or more lines are too long
@@ -5,7 +5,10 @@ namespace App\Exports;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Services\PurchaseEditService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
@@ -15,6 +18,7 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
/**
|
||||
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类)
|
||||
* 数据源为订货明细按商品聚合(无独立采购明细表),每门店一列显示数量
|
||||
*/
|
||||
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
|
||||
{
|
||||
@@ -24,6 +28,9 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
/** @var array<int, string> 供应商ID => 名称 */
|
||||
private array $supplierNames = [];
|
||||
|
||||
/** @var array<int, string> 门店ID => 名称(导出列) */
|
||||
private array $storeNames = [];
|
||||
|
||||
private ?Collection $items = null;
|
||||
|
||||
/**
|
||||
@@ -37,7 +44,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出行(按 sort 排序;蔬果分类时按顶级分类名过滤)
|
||||
* 导出行(商品聚合行,按 分类sort → 商品sort 排序;蔬果分类时按顶级分类名过滤)
|
||||
*/
|
||||
public function collection(): Collection
|
||||
{
|
||||
@@ -45,38 +52,112 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
$items = $this->purchase->items()->orderBy('sort')->get();
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $this->purchase->id)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->get()
|
||||
->makeVisible('cost_price');
|
||||
|
||||
$this->storeNames = StoreModel::withTrashed()
|
||||
->whereIn('id', $orderItems->pluck('store_id')->unique())
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', $orderItems->pluck('product_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
||||
$first = $group->first();
|
||||
$product = $products->get((int) $productId);
|
||||
$unitCost = PurchaseEditService::unitCost((string) ($first->cost_price ?? '0'), (string) $first->product_spec);
|
||||
|
||||
$quantity = '0';
|
||||
$weight = '0';
|
||||
$amount = '0';
|
||||
$storeQuantities = array_fill_keys(array_keys($this->storeNames), '0');
|
||||
foreach ($group as $item) {
|
||||
$quantity = bcadd($quantity, (string) $item->quantity, 2);
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
$amount = bcadd($amount, PurchaseEditService::costAmount(
|
||||
(string) $item->quantity,
|
||||
(string) $item->weight,
|
||||
$unitCost
|
||||
), 2);
|
||||
$storeQuantities[(int) $item->store_id] = bcadd(
|
||||
$storeQuantities[(int) $item->store_id] ?? '0',
|
||||
(string) $item->quantity,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'product_id' => (int) $productId,
|
||||
'supplier_id' => (int) $first->supplier_id,
|
||||
'product_name' => $first->product_name,
|
||||
'product_spec' => $first->product_spec,
|
||||
'unit' => $first->unit,
|
||||
'cost_price' => (float) ($first->cost_price ?? 0),
|
||||
'unit_cost' => (float) $unitCost,
|
||||
'quantity' => (float) $quantity,
|
||||
'weight' => (float) $weight,
|
||||
'amount' => (float) $amount,
|
||||
'store_quantities' => array_map('floatval', $storeQuantities),
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
$items = collect($rows);
|
||||
$this->loadLookups($items);
|
||||
|
||||
if ($this->type === 'category') {
|
||||
$items = $items->filter(function ($item): bool {
|
||||
$rootName = $this->categoryNames[(int) $item->product_id] ?? '';
|
||||
$items = $items->filter(function (array $row): bool {
|
||||
$rootName = $this->categoryNames[$row['product_id']] ?? '';
|
||||
return str_contains($rootName, '蔬菜') || str_contains($rootName, '水果');
|
||||
})->values();
|
||||
}
|
||||
|
||||
return $this->items = $items;
|
||||
$sort = 1;
|
||||
return $this->items = $items->map(static function (array $row) use (&$sort): array {
|
||||
$row['sort'] = $sort++;
|
||||
return $row;
|
||||
})->values();
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return ['序号', '分类', '品名', '规格/包规', '单价', '数量', '实际称重', '金额', '供应商', '备注'];
|
||||
$this->collection();
|
||||
|
||||
return array_merge(
|
||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)),
|
||||
);
|
||||
}
|
||||
|
||||
public function map($item): array
|
||||
public function map($row): array
|
||||
{
|
||||
return [
|
||||
$item->sort,
|
||||
$this->categoryNames[(int) $item->product_id] ?? '',
|
||||
$item->product_name,
|
||||
$item->product_spec,
|
||||
(float) $item->price,
|
||||
(float) $item->quantity,
|
||||
(float) $item->weight,
|
||||
(float) $item->amount,
|
||||
$this->supplierNames[(int) $item->supplier_id] ?? '',
|
||||
$item->remark,
|
||||
];
|
||||
return array_merge([
|
||||
$row['sort'],
|
||||
$this->categoryNames[$row['product_id']] ?? '',
|
||||
$row['product_name'],
|
||||
$this->supplierNames[$row['supplier_id']] ?? '',
|
||||
$row['product_spec'],
|
||||
$row['unit'],
|
||||
$row['cost_price'],
|
||||
$row['unit_cost'],
|
||||
$row['quantity'],
|
||||
$row['weight'],
|
||||
$row['amount'],
|
||||
], array_values($row['store_quantities']));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,13 +175,14 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
|
||||
/**
|
||||
* PDF 模板视图数据
|
||||
*
|
||||
* @return array{purchase: PurchaseOrderModel, items: Collection, categoryNames: array<int, string>, supplierNames: array<int, string>}
|
||||
* @return array{purchase: PurchaseOrderModel, items: Collection, storeNames: array<int, string>, categoryNames: array<int, string>, supplierNames: array<int, string>}
|
||||
*/
|
||||
public function viewData(): array
|
||||
{
|
||||
return [
|
||||
'purchase' => $this->purchase,
|
||||
'items' => $this->collection(),
|
||||
'storeNames' => $this->storeNames,
|
||||
'categoryNames' => $this->categoryNames,
|
||||
'supplierNames' => $this->supplierNames,
|
||||
];
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Http\Requests\Purchase\PurchaseItemUpdateRequest;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
|
||||
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Services\ExportService;
|
||||
use App\Services\PurchaseAllocateService;
|
||||
use App\Services\PurchaseEditService;
|
||||
use App\Services\PurchaseGenerateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,9 +20,11 @@ use Modules\AnnoRoute\Attribute\PutRoute;
|
||||
use Modules\AnnoRoute\Attribute\RequestAttribute;
|
||||
use Modules\Common\Http\Controllers\BaseController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改 / C5-C6 发送供应商 / D3 金额分摊)
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 修改)
|
||||
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id)
|
||||
*/
|
||||
#[RequestAttribute('/purchase/order', 'purchase.order')]
|
||||
class PurchaseOrderController extends BaseController
|
||||
@@ -45,30 +49,110 @@ class PurchaseOrderController extends BaseController
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/** 采购单详情:头 + 明细(含供应商)+ 分摊记录 */
|
||||
/**
|
||||
* 采购单详情:头 + 明细矩阵(商品行 × 门店列)
|
||||
* 行 = 商品(按分类 sort → 商品 sort 排序),列 = 商品信息 + 每个门店一格(数量/金额)
|
||||
*/
|
||||
#[GetRoute(route: '/{id}', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function detail(int $id): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::with([
|
||||
'operator:id,nickname',
|
||||
'items.supplier:id,name',
|
||||
'items.allocations.store:id,name',
|
||||
])->find($id);
|
||||
$purchase = PurchaseOrderModel::with('operator:id,nickname')->find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
return $this->error('采购单不存在');
|
||||
}
|
||||
return $this->success($purchase->toArray());
|
||||
|
||||
// 订货明细
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order_item.purchase_id', $purchase->id)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->with('supplier:id,name')
|
||||
->select('store_order_item.*')
|
||||
->get()
|
||||
->makeVisible('cost_price');
|
||||
|
||||
// 门店列(含软删除门店,保证历史单据可见)
|
||||
$stores = StoreModel::withTrashed()
|
||||
->whereIn('id', $orderItems->pluck('store_id')->unique())
|
||||
->get(['id', 'name'])
|
||||
->map(static fn (StoreModel $store) => ['id' => $store->id, 'name' => $store->name])
|
||||
->values();
|
||||
|
||||
// 排序键:分类 sort → 商品 sort
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', $orderItems->pluck('product_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
|
||||
$first = $group->first();
|
||||
// 商品
|
||||
$product = $products->get((int) $productId);
|
||||
// 采购总数
|
||||
$quantity = 0;
|
||||
// 采购总重量
|
||||
$weight = '0';
|
||||
// 门店明细
|
||||
$cellsMap = [];
|
||||
|
||||
foreach ($group as $item) {
|
||||
$storeId = $item->store_id;
|
||||
// 累加总量
|
||||
$quantity += (int) $item->quantity;
|
||||
$weight = bcadd($weight, (string) $item->weight, 3);
|
||||
|
||||
// 按门店合并
|
||||
if (!isset($cellsMap[$storeId])) {
|
||||
$cellsMap[$storeId] = 0;
|
||||
}
|
||||
|
||||
$cellsMap[$storeId] += (int) $item->quantity;
|
||||
}
|
||||
|
||||
// 供应商
|
||||
$supplier = $first->supplier ? ['id' => $first->supplier->id, 'name' => $first->supplier->name] : null;
|
||||
$rows[] = [
|
||||
'product_id' => (int) $productId,
|
||||
'product_name' => $first->product_name, // 品名
|
||||
'product_spec' => $first->product_spec, // 包规
|
||||
'unit' => $first->unit, // 单位
|
||||
'supplier_id' => (int) $first->supplier_id, // 供应商id
|
||||
'supplier' => $supplier, // 供应商
|
||||
'cost_price' => $first->cost_price, // 成本价
|
||||
'quantity' => $quantity,
|
||||
'weight' => (float) $weight,
|
||||
'cells' => $cellsMap,
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
return $this->success([
|
||||
'purchase' => $purchase->toArray(),
|
||||
'stores' => $stores->toArray(),
|
||||
'items' => array_map(static function (array $row): array {
|
||||
unset($row['category_sort'], $row['product_sort']);
|
||||
return $row;
|
||||
}, $rows),
|
||||
]);
|
||||
}
|
||||
|
||||
/** C4 修改采购单头信息(采购日期、备注) */
|
||||
/** C4 修改采购单头信息(采购日期、状态、备注) */
|
||||
#[PutRoute(route: '/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function update(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchase_date' => 'nullable|date_format:Y-m-d',
|
||||
'status' => 'nullable|integer|in:' . PurchaseOrderModel::STATUS_PENDING . ',' . PurchaseOrderModel::STATUS_COMPLETED,
|
||||
'remark' => 'nullable|string|max:255',
|
||||
], [
|
||||
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
|
||||
'status.in' => '采购单状态不正确',
|
||||
]);
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
@@ -80,7 +164,7 @@ class PurchaseOrderController extends BaseController
|
||||
|
||||
/**
|
||||
* C1 生成采购单:合并全部「已接单」门店订单(或指定的 order_ids),
|
||||
* 生成后源订单转为「采购中」并回写 purchase_id
|
||||
* 生成后源订单转为「采购中」并回写 purchase_id(订单与订货明细同步归集)
|
||||
*/
|
||||
#[PostRoute('/generate', 'generate')]
|
||||
public function generate(Request $request): JsonResponse
|
||||
@@ -130,128 +214,35 @@ class PurchaseOrderController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* C4 采购明细修改:amount 后端重算(weight>0 ? weight×price : quantity×price),
|
||||
* 同步回写采购单头汇总(Σ total_weight / actual_amount)
|
||||
* 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
|
||||
public function updateItem(int $id, PurchaseItemUpdateRequest $request): JsonResponse
|
||||
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
|
||||
public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse
|
||||
{
|
||||
$item = PurchaseOrderItemModel::find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('采购明细不存在');
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
$validated = $request->validated();
|
||||
|
||||
$price = (string) $validated['price'];
|
||||
$quantity = (string) $validated['quantity'];
|
||||
$weight = (string) ($validated['weight'] ?? 0);
|
||||
$amount = (float) $weight > 0
|
||||
? bcmul($weight, $price, 2)
|
||||
: bcmul($quantity, $price, 2);
|
||||
|
||||
$item->update([
|
||||
'product_name' => $validated['product_name'] ?? $item->product_name,
|
||||
'product_spec' => $validated['product_spec'] ?? $item->product_spec,
|
||||
'price' => $price,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'remark' => $validated['remark'] ?? $item->remark,
|
||||
]);
|
||||
|
||||
// 回写采购单头汇总
|
||||
$sums = PurchaseOrderItemModel::query()
|
||||
->where('purchase_id', $item->purchase_id)
|
||||
->selectRaw('COALESCE(SUM(weight), 0) as total_weight, COALESCE(SUM(amount), 0) as actual_amount')
|
||||
->first();
|
||||
PurchaseOrderModel::whereKey($item->purchase_id)->update([
|
||||
'total_weight' => $sums->total_weight,
|
||||
'actual_amount' => $sums->actual_amount,
|
||||
]);
|
||||
|
||||
return $this->success(['amount' => $amount]);
|
||||
}
|
||||
|
||||
/** C5/C6 明细发送供应商:is_sent=1 + sent_at;联动采购单状态(全发送→ALL_SENT,否则 PART_SENT) */
|
||||
#[PutRoute(route: '/item/{id}/send', authorize: 'send', where: ['id' => '[0-9]+'])]
|
||||
public function sendItem(int $id): JsonResponse
|
||||
{
|
||||
$item = PurchaseOrderItemModel::find($id);
|
||||
if (empty($item)) {
|
||||
throw new RepositoryException('采购明细不存在');
|
||||
$attrs = [];
|
||||
foreach (['product_name', 'supplier_id', 'product_spec', 'unit', 'cost_price'] as $field) {
|
||||
if (isset($validated[$field])) {
|
||||
$attrs[$field] = $validated[$field];
|
||||
}
|
||||
}
|
||||
if ($item->is_sent === PurchaseOrderItemModel::SENT) {
|
||||
throw new RepositoryException('该明细已发送,请勿重复操作');
|
||||
}
|
||||
$item->is_sent = PurchaseOrderItemModel::SENT;
|
||||
$item->sent_at = now();
|
||||
$item->save();
|
||||
|
||||
$purchase = $item->purchase;
|
||||
$hasUnsent = $purchase->items()
|
||||
->where('is_sent', PurchaseOrderItemModel::NOT_SENT)
|
||||
->exists();
|
||||
$purchase->status = $hasUnsent
|
||||
? PurchaseOrderModel::STATUS_PART_SENT
|
||||
: PurchaseOrderModel::STATUS_ALL_SENT;
|
||||
$purchase->save();
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** D3 执行金额分摊(按订货比例摊到门店/单品,尾差修正守恒;可重复执行) */
|
||||
#[PostRoute(route: '/{id}/allocate', authorize: 'allocate', where: ['id' => '[0-9]+'])]
|
||||
public function allocate(int $id): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
}
|
||||
$count = app(PurchaseAllocateService::class)->allocate($purchase);
|
||||
return $this->success(['count' => $count], '分摊完成');
|
||||
}
|
||||
|
||||
/** 分摊结果:按门店、按商品两个聚合维度 */
|
||||
#[GetRoute(route: '/{id}/allocation', authorize: 'query', where: ['id' => '[0-9]+'])]
|
||||
public function allocation(int $id): JsonResponse
|
||||
{
|
||||
$purchase = PurchaseOrderModel::find($id);
|
||||
if (empty($purchase)) {
|
||||
throw new RepositoryException('采购单不存在');
|
||||
if ($attrs === []) {
|
||||
throw new RepositoryException('品名/供应商/包规/单位/成本至少填写一项');
|
||||
}
|
||||
|
||||
$allocations = PurchaseAllocationModel::query()
|
||||
->whereIn('purchase_item_id', $purchase->items()->pluck('id'))
|
||||
->with(['store:id,name', 'product:id,name,unit'])
|
||||
->get();
|
||||
$count = app(PurchaseEditService::class)->updateRow(
|
||||
$purchase,
|
||||
$productId,
|
||||
$attrs,
|
||||
(bool) ($validated['sync_product'] ?? false),
|
||||
);
|
||||
|
||||
$byStore = $allocations->groupBy('store_id')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
return [
|
||||
'store_id' => $first->store_id,
|
||||
'store_name' => $first->store?->name ?? '',
|
||||
'quantity' => (float) $group->sum('quantity'),
|
||||
'weight' => (float) $group->sum('weight'),
|
||||
'amount' => (float) $group->sum('amount'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$byProduct = $allocations->groupBy('product_id')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
return [
|
||||
'product_id' => $first->product_id,
|
||||
'product_name' => $first->product?->name ?? '',
|
||||
'unit' => $first->product?->unit ?? '',
|
||||
'quantity' => (float) $group->sum('quantity'),
|
||||
'weight' => (float) $group->sum('weight'),
|
||||
'amount' => (float) $group->sum('amount'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->success([
|
||||
'by_store' => $byStore->toArray(),
|
||||
'by_product' => $byProduct->toArray(),
|
||||
'total_amount' => (float) $allocations->sum('amount'),
|
||||
]);
|
||||
return $this->success(['count' => $count], '已同步 ' . $count . ' 条订货明细');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ class ReconciliationController extends BaseController
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** 生成对账明细(按周期 + 品类 + 供应商拉取采购分摊数据;可重复生成) */
|
||||
/** 生成对账明细(按周期 + 品类 + 供应商拉取已完成订单明细;可重复生成) */
|
||||
#[PostRoute(route: '/{id}/build', authorize: 'build', where: ['id' => '[0-9]+'])]
|
||||
public function build(int $id): JsonResponse
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购明细门店单元格修改 验证(C4;amount = 数量×单价 由后端重算)
|
||||
*/
|
||||
class PurchaseCellUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => 'required|integer|min:0',
|
||||
'weight' => 'nullable|numeric|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'quantity.required' => '数量不能为空',
|
||||
'quantity.integer' => '数量必须为整数',
|
||||
'quantity.min' => '数量不能小于 0',
|
||||
'weight.numeric' => '实际称重必须为数字',
|
||||
'weight.min' => '实际称重不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购明细修改 验证(C4;amount 由后端按 weight>0 ? weight×price : quantity×price 重算)
|
||||
*/
|
||||
class PurchaseItemUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_name' => 'nullable|string|max:100',
|
||||
'product_spec' => 'nullable|string|max:100',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'quantity' => 'required|numeric|min:0',
|
||||
'weight' => 'nullable|numeric|min:0',
|
||||
'remark' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'price.required' => '采购单价不能为空',
|
||||
'price.numeric' => '采购单价必须为数字',
|
||||
'price.min' => '采购单价不能小于 0',
|
||||
'quantity.required' => '采购量不能为空',
|
||||
'quantity.numeric' => '采购量必须为数字',
|
||||
'quantity.min' => '采购量不能小于 0',
|
||||
'weight.numeric' => '实际称重必须为数字',
|
||||
'weight.min' => '实际称重不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Purchase;
|
||||
|
||||
use Modules\Common\Http\Requests\BaseFormRequest;
|
||||
|
||||
/**
|
||||
* 采购明细商品行修改 验证(C4;品名/供应商/包规/单位/成本/称重至少一项,
|
||||
* 提交后一键同步该商品在本采购单下的全部订货明细)
|
||||
*/
|
||||
class PurchaseRowUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected $stopOnFirstFailure = true;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_name' => 'nullable|string|max:100',
|
||||
'supplier_id' => 'nullable|integer|exists:supplier,id',
|
||||
'product_spec' => 'nullable|string|max:100',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'sync_product' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'product_name.max' => '品名不能超过 100 字',
|
||||
'supplier_id.exists' => '供应商不存在',
|
||||
'product_spec.max' => '包规不能超过 100 字',
|
||||
'unit.max' => '单位不能超过 20 字',
|
||||
'cost_price.numeric' => '采购成本必须为数字',
|
||||
'cost_price.min' => '采购成本不能小于 0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 采购金额分摊模型(实际采购金额按订货比例分摊到门店/单品,尾差修正保证金额守恒)
|
||||
*/
|
||||
class PurchaseAllocationModel extends Model
|
||||
{
|
||||
protected $table = 'purchase_allocation';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_item_id',
|
||||
'order_item_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'weight',
|
||||
'amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchase_item_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'quantity' => 'decimal:2',
|
||||
'weight' => 'decimal:3',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 来源采购明细
|
||||
*/
|
||||
public function purchaseItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 溯源订货明细
|
||||
*/
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreOrderItemModel::class, 'order_item_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');
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 采购单明细模型(按商品+供应商聚合,快照品名/规格;实际金额录入后用于分摊)
|
||||
*/
|
||||
class PurchaseOrderItemModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 未发送供应商 */
|
||||
public const NOT_SENT = 0;
|
||||
/** 已发送供应商 */
|
||||
public const SENT = 1;
|
||||
|
||||
protected $table = 'purchase_order_item';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_id',
|
||||
'product_id',
|
||||
'supplier_id',
|
||||
'product_name',
|
||||
'product_spec',
|
||||
'price',
|
||||
'quantity',
|
||||
'weight',
|
||||
'amount',
|
||||
'sort',
|
||||
'is_sent',
|
||||
'sent_at',
|
||||
'supplier_confirmed_at',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchase_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'supplier_id' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'quantity' => 'decimal:2',
|
||||
'weight' => 'decimal:3',
|
||||
'amount' => 'decimal:2',
|
||||
'sort' => 'integer',
|
||||
'is_sent' => 'integer',
|
||||
'sent_at' => 'datetime',
|
||||
'supplier_confirmed_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所属采购单
|
||||
*/
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 供货供应商
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SupplierModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额分摊记录(按订货比例摊到门店/单品)
|
||||
*/
|
||||
public function allocations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseAllocationModel::class, 'purchase_item_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\SystemUser\Models\SysUserModel;
|
||||
|
||||
/**
|
||||
* 采购单模型(按门店订单汇总生成,按商品+供应商聚合)
|
||||
* 采购单模型(按门店订单汇总生成;无独立明细表,明细直接溯源订货明细 store_order_item.purchase_id)
|
||||
*/
|
||||
class PurchaseOrderModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** 状态:待发送 */
|
||||
public const STATUS_PENDING = 0;
|
||||
/** 状态:部分发送 */
|
||||
public const STATUS_PART_SENT = 1;
|
||||
/** 状态:全部发送 */
|
||||
public const STATUS_ALL_SENT = 2;
|
||||
/** 状态:进行中(已生成,待完成) */
|
||||
public const int STATUS_PENDING = 0;
|
||||
/** 状态:已完成 */
|
||||
public const STATUS_COMPLETED = 3;
|
||||
public const int STATUS_COMPLETED = 3;
|
||||
|
||||
protected $table = 'purchase_order';
|
||||
protected $primaryKey = 'id';
|
||||
@@ -58,10 +54,18 @@ class PurchaseOrderModel extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购明细
|
||||
* 本采购单归集的门店订货明细
|
||||
*/
|
||||
public function items(): HasMany
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItemModel::class, 'purchase_id', 'id')->orderBy('sort');
|
||||
return $this->hasMany(StoreOrderItemModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 本采购单合并的门店订单
|
||||
*/
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(StoreOrderModel::class, 'purchase_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class ReconciliationItemModel extends Model
|
||||
protected $fillable = [
|
||||
'recon_id',
|
||||
'store_id',
|
||||
'purchase_item_id',
|
||||
'order_item_id',
|
||||
'product_id',
|
||||
'product_name',
|
||||
@@ -38,7 +37,6 @@ class ReconciliationItemModel extends Model
|
||||
protected $casts = [
|
||||
'recon_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'purchase_item_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'quantity' => 'decimal:2',
|
||||
@@ -74,14 +72,6 @@ class ReconciliationItemModel extends Model
|
||||
return $this->belongsTo(ProductModel::class, 'product_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源采购明细
|
||||
*/
|
||||
public function purchaseItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderItemModel::class, 'purchase_item_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 溯源订货明细
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ class StoreOrderItemModel extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'purchase_id',
|
||||
'store_id',
|
||||
'product_id',
|
||||
'category_id',
|
||||
@@ -40,6 +41,7 @@ class StoreOrderItemModel extends Model
|
||||
|
||||
protected $casts = [
|
||||
'order_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'store_id' => 'integer',
|
||||
'product_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
@@ -94,7 +96,7 @@ class StoreOrderItemModel extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照供应商
|
||||
* 供应商
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ class StoreOrderModel extends Model
|
||||
'tray_num',
|
||||
'status',
|
||||
'remark',
|
||||
'purchase_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -44,14 +44,6 @@ class SupplierModel extends Model
|
||||
return $this->hasMany(ProductModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商的采购明细行
|
||||
*/
|
||||
public function purchaseItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItemModel::class, 'supplier_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定本供应商的小程序用户
|
||||
*/
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* D3 采购金额分摊
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 采购单须已录入实际金额(存在 amount>0 的明细),否则拒绝
|
||||
* 2. 每个采购明细按 purchase_id 溯源该采购单合并的门店订单明细(生成采购单时回写)
|
||||
* 3. 按订货数量比例分摊实际金额/数量/重量:bcmul(item.amount, bcdiv(item_qty, total_qty, 6), 2)
|
||||
* 尾差修正——最后一行承担舍入差额,保证 Σallocation.amount === item.amount(金额守恒)
|
||||
* 4. 重复分摊先删旧记录再重建(幂等)
|
||||
*/
|
||||
class PurchaseAllocateService
|
||||
{
|
||||
/**
|
||||
* @return int 生成的分摊记录数
|
||||
*/
|
||||
public function allocate(PurchaseOrderModel $purchase): int
|
||||
{
|
||||
return DB::transaction(function () use ($purchase) {
|
||||
$items = PurchaseOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
// 1. 须已录入实际金额
|
||||
if (! $items->contains(static fn ($item) => (float) $item->amount > 0)) {
|
||||
throw new RepositoryException('采购单尚未录入实际金额,无法分摊');
|
||||
}
|
||||
|
||||
// 2. 按 purchase_id 溯源本采购单合并的门店订单明细,按商品分组
|
||||
$orderItems = StoreOrderItemModel::query()
|
||||
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
|
||||
->where('store_order.purchase_id', $purchase->id)
|
||||
->whereNull('store_order.deleted_at')
|
||||
->select('store_order_item.*')
|
||||
->get()
|
||||
->groupBy('product_id');
|
||||
|
||||
// 4. 幂等:删除旧分摊记录
|
||||
PurchaseAllocationModel::query()
|
||||
->whereIn('purchase_item_id', $items->pluck('id'))
|
||||
->delete();
|
||||
|
||||
$count = 0;
|
||||
$now = now();
|
||||
foreach ($items as $item) {
|
||||
$sources = $orderItems->get((int) $item->product_id);
|
||||
if ($sources === null || $sources->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalQty = $sources->reduce(
|
||||
static fn (string $carry, $orderItem): string => bcadd($carry, (string) $orderItem->quantity, 2),
|
||||
'0'
|
||||
);
|
||||
if ((float) $totalQty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceValues = $sources->values();
|
||||
$lastIndex = $sourceValues->count() - 1;
|
||||
$allocatedAmount = '0';
|
||||
$allocatedQuantity = '0';
|
||||
$allocatedWeight = '0';
|
||||
$records = [];
|
||||
|
||||
foreach ($sourceValues as $index => $orderItem) {
|
||||
if ($index === $lastIndex) {
|
||||
// 3. 尾差修正:最后一行 = 总额 − 已分摊,保证守恒
|
||||
$amount = bcsub((string) $item->amount, $allocatedAmount, 2);
|
||||
$quantity = bcsub((string) $item->quantity, $allocatedQuantity, 2);
|
||||
$weight = bcsub((string) $item->weight, $allocatedWeight, 3);
|
||||
} else {
|
||||
$ratio = bcdiv((string) $orderItem->quantity, $totalQty, 6);
|
||||
$amount = bcmul((string) $item->amount, $ratio, 2);
|
||||
$quantity = bcmul((string) $item->quantity, $ratio, 2);
|
||||
$weight = bcmul((string) $item->weight, $ratio, 3);
|
||||
$allocatedAmount = bcadd($allocatedAmount, $amount, 2);
|
||||
$allocatedQuantity = bcadd($allocatedQuantity, $quantity, 2);
|
||||
$allocatedWeight = bcadd($allocatedWeight, $weight, 3);
|
||||
}
|
||||
|
||||
$records[] = [
|
||||
'purchase_item_id' => $item->id,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'store_id' => $orderItem->store_id,
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $quantity,
|
||||
'weight' => $weight,
|
||||
'amount' => $amount,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
PurchaseAllocationModel::insert($records);
|
||||
$count += count($records);
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 采购单数据修改
|
||||
*/
|
||||
class PurchaseEditService
|
||||
{
|
||||
|
||||
/**
|
||||
* 商品行修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细;
|
||||
* syncProduct = true 时同步更新商品档案(product 表)
|
||||
*
|
||||
* @param PurchaseOrderModel $purchase
|
||||
* @param int $productId
|
||||
* @param array{product_name?: string, supplier_id?: int, product_spec?: string, unit?: string, cost_price?: float} $attrs 行属性(仅同步传入键)
|
||||
* @param bool $syncProduct
|
||||
* @return int
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function updateRow(PurchaseOrderModel $purchase, int $productId, array $attrs, bool $syncProduct = false): int
|
||||
{
|
||||
return DB::transaction(function () use ($purchase, $productId, $attrs, $syncProduct) {
|
||||
$items = StoreOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->where('product_id', $productId)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
if ($items->isEmpty()) {
|
||||
throw new RepositoryException('该采购单下无此商品的订货明细');
|
||||
}
|
||||
|
||||
$sync = [];
|
||||
if (isset($attrs['product_name'])) {
|
||||
$sync['product_name'] = $attrs['product_name'];
|
||||
}
|
||||
if (isset($attrs['supplier_id'])) {
|
||||
$sync['supplier_id'] = (int) $attrs['supplier_id'];
|
||||
}
|
||||
if (isset($attrs['product_spec'])) {
|
||||
$sync['product_spec'] = $attrs['product_spec'];
|
||||
}
|
||||
if (isset($attrs['unit'])) {
|
||||
$sync['unit'] = $attrs['unit'];
|
||||
}
|
||||
if (isset($attrs['cost_price'])) {
|
||||
$sync['cost_price'] = bcadd((string) $attrs['cost_price'], '0', 2);
|
||||
}
|
||||
if ($sync !== []) {
|
||||
foreach ($items as $item) {
|
||||
$item->fill($sync)->save();
|
||||
}
|
||||
|
||||
// 同步至商品档案
|
||||
if ($syncProduct) {
|
||||
$product = ProductModel::withTrashed()->find($productId);
|
||||
if ($product === null || $product->trashed()) {
|
||||
throw new RepositoryException('商品档案不存在或已删除,无法同步至商品');
|
||||
}
|
||||
$productAttrs = [];
|
||||
if (isset($sync['product_name'])) {
|
||||
$productAttrs['name'] = $sync['product_name'];
|
||||
}
|
||||
if (isset($sync['supplier_id'])) {
|
||||
$productAttrs['supplier_id'] = $sync['supplier_id'];
|
||||
}
|
||||
if (isset($sync['product_spec'])) {
|
||||
$productAttrs['spec'] = $sync['product_spec'];
|
||||
}
|
||||
if (isset($sync['unit'])) {
|
||||
$productAttrs['unit'] = $sync['unit'];
|
||||
}
|
||||
if (isset($sync['cost_price'])) {
|
||||
$productAttrs['cost_price'] = $sync['cost_price'];
|
||||
}
|
||||
$product->fill($productAttrs)->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->recalculatePurchaseTotals($purchase);
|
||||
|
||||
return $items->count();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算采购金额汇总
|
||||
*/
|
||||
public function recalculatePurchaseTotals(PurchaseOrderModel $purchase): void
|
||||
{
|
||||
$items = StoreOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->get(['quantity', 'cost_price']);
|
||||
|
||||
$totalQuantity = 0;
|
||||
$estimate = '0';
|
||||
foreach ($items as $item) {
|
||||
$totalQuantity += $item->quantity;
|
||||
$item_estimate = bcmul($item->cost_price, $item->quantity, 2);
|
||||
$estimate = bcadd($estimate, $item_estimate, 2);
|
||||
}
|
||||
|
||||
$purchase->total_quantity = $totalQuantity;
|
||||
$purchase->estimate_amount = $estimate;
|
||||
$purchase->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购成本金额:称重>0 按 称重×单价,否则按 数量×单价
|
||||
*/
|
||||
public static function costAmount(string $quantity, string $weight, string $unitCost): string
|
||||
{
|
||||
return (float) $weight > 0
|
||||
? bcmul($weight, $unitCost, 2)
|
||||
: bcmul($quantity, $unitCost, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单价 = 成本 / 包规数值(spec 解析不出正数时按 1 处理,即单价=成本)
|
||||
*/
|
||||
public static function unitCost(string $costPrice, string $spec): string
|
||||
{
|
||||
if (preg_match('/\d+(?:\.\d+)?/', $spec, $matches) === 1 && (float) $matches[0] > 0) {
|
||||
return bcdiv($costPrice, $matches[0], 2);
|
||||
}
|
||||
|
||||
return bcadd($costPrice, '0', 2);
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,18 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* C1 订单汇总生成采购单
|
||||
*
|
||||
* 流程(事务内):
|
||||
* 1. 行锁「已接单」订单(可传 orderIds 只合并指定订单;状态条件天然排除已归集订单,幂等)
|
||||
* 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商)
|
||||
* 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 min,PHP 侧兼容 MySQL/SQLite),amount = quantity × 估算单价
|
||||
* 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount)
|
||||
* 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号
|
||||
* 6. 源订单批量回写 status = 采购中、purchase_id = 采购单ID(分摊按 purchase_id 溯源)
|
||||
* 订单汇总生成采购单
|
||||
*/
|
||||
class PurchaseGenerateService
|
||||
readonly class PurchaseGenerateService
|
||||
{
|
||||
public function __construct(private readonly BillNumberService $billNumberService)
|
||||
public function __construct(private BillNumberService $billNumberService)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -31,6 +22,8 @@ class PurchaseGenerateService
|
||||
* @param string $date 采购日期(Y-m-d)
|
||||
* @param int $operatorId 制单人(后台系统用户ID)
|
||||
* @param int[] $orderIds 指定合并的门店订单ID(空 = 全部已接单订单)
|
||||
* @return PurchaseOrderModel
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function generate(string $date, int $operatorId, array $orderIds = []): PurchaseOrderModel
|
||||
{
|
||||
@@ -53,96 +46,23 @@ class PurchaseGenerateService
|
||||
throw new RepositoryException('无已接单订单,无法生成采购单');
|
||||
}
|
||||
|
||||
// 2. 展开明细按商品聚合
|
||||
$aggregated = [];
|
||||
$sourceOrderIds = [];
|
||||
foreach ($orders as $order) {
|
||||
$sourceOrderIds[] = $order->id;
|
||||
foreach ($order->items as $item) {
|
||||
$productId = (int) $item->product_id;
|
||||
if (! isset($aggregated[$productId])) {
|
||||
$aggregated[$productId] = [
|
||||
'product_id' => $productId,
|
||||
'product_name' => $item->product_name,
|
||||
'product_spec' => $item->product_spec,
|
||||
'quantity' => '0',
|
||||
];
|
||||
}
|
||||
$aggregated[$productId]['quantity'] = bcadd(
|
||||
$aggregated[$productId]['quantity'],
|
||||
(string) $item->quantity,
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
$sourceOrderIds = $orders->pluck('id')->all();
|
||||
|
||||
if ($aggregated === []) {
|
||||
// 2. 订货明细汇总(采购单头数据)
|
||||
$items = StoreOrderItemModel::query()->whereIn('order_id', $sourceOrderIds)->get();
|
||||
if ($items->isEmpty()) {
|
||||
throw new RepositoryException('已接单订单均无明细,无法生成采购单');
|
||||
}
|
||||
|
||||
$products = ProductModel::withTrashed()
|
||||
->with('category:id,sort')
|
||||
->whereIn('id', array_keys($aggregated))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 3. 估算单价 = 最低实际等级价(逐行按计价类型换算后取 min;数量级 = 当日 SKU × 等级,可控)
|
||||
$priceRows = ProductPriceModel::query()
|
||||
->whereIn('product_id', array_keys($aggregated))
|
||||
->get(['product_id', 'price', 'price_type', 'percent'])
|
||||
->groupBy('product_id');
|
||||
|
||||
$minPrices = [];
|
||||
foreach ($aggregated as $productId => $item) {
|
||||
$product = $products->get($productId);
|
||||
$minPrice = null;
|
||||
foreach ($priceRows->get($productId, collect()) as $priceRow) {
|
||||
$actual = ProductPriceModel::calcActualPrice(
|
||||
(int) $priceRow->price_type,
|
||||
$priceRow->price,
|
||||
$priceRow->percent,
|
||||
(float) ($product->cost_price ?? 0),
|
||||
);
|
||||
if ($minPrice === null || bccomp($actual, $minPrice, 2) < 0) {
|
||||
$minPrice = $actual;
|
||||
}
|
||||
}
|
||||
$minPrices[$productId] = $minPrice ?? '0';
|
||||
}
|
||||
|
||||
// 组装明细行并按「分类 sort → 商品 sort」排序
|
||||
$rows = [];
|
||||
foreach ($aggregated as $productId => $item) {
|
||||
$product = $products->get($productId);
|
||||
$price = (string) ($minPrices[$productId] ?? '0');
|
||||
$rows[] = [
|
||||
'product_id' => $productId,
|
||||
'supplier_id' => (int) ($product->supplier_id ?? 0),
|
||||
'product_name' => $item['product_name'],
|
||||
'product_spec' => $item['product_spec'],
|
||||
'price' => $price,
|
||||
'quantity' => $item['quantity'],
|
||||
'amount' => bcmul($item['quantity'], $price, 2),
|
||||
'category_sort' => (int) ($product->category->sort ?? 9999),
|
||||
'product_sort' => (int) ($product->sort ?? 9999),
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int =>
|
||||
[$a['category_sort'], $a['product_sort'], $a['product_id']]
|
||||
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
|
||||
|
||||
$estimateAmount = array_reduce(
|
||||
$rows,
|
||||
static fn (string $carry, array $row): string => bcadd($carry, $row['amount'], 2),
|
||||
$totalQuantity = $items->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
|
||||
'0'
|
||||
);
|
||||
$totalQuantity = array_reduce(
|
||||
$rows,
|
||||
static fn (string $carry, array $row): string => bcadd($carry, $row['quantity'], 2),
|
||||
$estimateAmount = $items->reduce(
|
||||
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, (string) $item->cost_price, 2), 2),
|
||||
'0'
|
||||
);
|
||||
|
||||
// 4. 采购单头
|
||||
$purchase = PurchaseOrderModel::create([
|
||||
'purchase_no' => $this->billNumberService->make('PO'),
|
||||
'purchase_date' => $date,
|
||||
@@ -154,30 +74,14 @@ class PurchaseGenerateService
|
||||
'operator_id' => $operatorId,
|
||||
]);
|
||||
|
||||
// 5. 采购明细(sort 行号)
|
||||
$sort = 1;
|
||||
foreach ($rows as $row) {
|
||||
PurchaseOrderItemModel::create([
|
||||
'purchase_id' => $purchase->id,
|
||||
'product_id' => $row['product_id'],
|
||||
'supplier_id' => $row['supplier_id'],
|
||||
'product_name' => $row['product_name'],
|
||||
'product_spec' => $row['product_spec'],
|
||||
'price' => $row['price'],
|
||||
'quantity' => $row['quantity'],
|
||||
'weight' => 0,
|
||||
'amount' => $row['amount'],
|
||||
'sort' => $sort++,
|
||||
'is_sent' => PurchaseOrderItemModel::NOT_SENT,
|
||||
]);
|
||||
}
|
||||
|
||||
// 6. 源订单回写「采购中」并关联采购单(分摊按 purchase_id 溯源)
|
||||
// 3. 源订单回写「采购中」并关联采购单;订货明细同步回写 purchase_id
|
||||
StoreOrderModel::whereIn('id', $sourceOrderIds)
|
||||
->update([
|
||||
'status' => StoreOrderModel::STATUS_DELIVERING,
|
||||
'purchase_id' => $purchase->id,
|
||||
]);
|
||||
StoreOrderItemModel::whereIn('order_id', $sourceOrderIds)
|
||||
->update(['purchase_id' => $purchase->id]);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
|
||||
@@ -5,25 +5,22 @@ namespace App\Services;
|
||||
use App\Exceptions\RepositoryException;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
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. 按周期 + 品类(含子孙分类)+ 供应商拉取 purchase_order_item
|
||||
* 2. 校验命中的采购明细均已完成 D3 分摊(分摊是门店/单品粒度的对账数据源)
|
||||
* 3. 每条分摊记录 → 一条对账明细:
|
||||
* publish_amount = 订货金额(溯源 order_item.amount)
|
||||
* actual_amount = 分摊金额
|
||||
* 1. 按周期 + 品类(含子孙分类)+ 供应商拉取「已完成」门店订单的订货明细
|
||||
* 2. 每条订货明细 → 一条对账明细:
|
||||
* publish_amount = 订货金额(order_item.amount)
|
||||
* actual_amount = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规)
|
||||
* diff = publish − actual,冗余 product_name / store_id
|
||||
* 4. 汇总写回头的 publish/actual/diff_amount,status → 对账中
|
||||
* 3. 汇总写回头的 publish/actual/diff_amount,status → 对账中
|
||||
*/
|
||||
class ReconciliationBuildService
|
||||
{
|
||||
@@ -33,49 +30,31 @@ class ReconciliationBuildService
|
||||
public function build(ReconciliationModel $recon): int
|
||||
{
|
||||
return DB::transaction(function () use ($recon) {
|
||||
// 1. 按周期 + 品类 + 供应商拉取采购明细
|
||||
$itemQuery = PurchaseOrderItemModel::query()
|
||||
->join('purchase_order', 'purchase_order.id', '=', 'purchase_order_item.purchase_id')
|
||||
->whereDate('purchase_order.purchase_date', '>=', $recon->period_start)
|
||||
->whereDate('purchase_order.purchase_date', '<=', $recon->period_end)
|
||||
->select('purchase_order_item.*');
|
||||
// 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('purchase_order_item.supplier_id', $recon->supplier_id);
|
||||
$itemQuery->where('store_order_item.supplier_id', $recon->supplier_id);
|
||||
}
|
||||
if ((int) $recon->category_id > 0) {
|
||||
$productIds = ProductModel::withTrashed()
|
||||
->whereIn('category_id', $this->descendantCategoryIds((int) $recon->category_id))
|
||||
->pluck('id');
|
||||
$itemQuery->whereIn('purchase_order_item.product_id', $productIds);
|
||||
$itemQuery->whereIn(
|
||||
'store_order_item.category_id',
|
||||
$this->descendantCategoryIds((int) $recon->category_id)
|
||||
);
|
||||
}
|
||||
|
||||
$purchaseItems = $itemQuery->get();
|
||||
if ($purchaseItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内无符合筛选条件的采购数据,无法生成对账明细');
|
||||
$orderItems = $itemQuery->get()->makeVisible('cost_price');
|
||||
if ($orderItems->isEmpty()) {
|
||||
throw new RepositoryException('周期内无符合筛选条件的已完成订单数据,无法生成对账明细');
|
||||
}
|
||||
|
||||
// 2. 分摊记录(未完成分摊的采购单拒绝,保证对账数据到门店/单品粒度)
|
||||
$allocations = PurchaseAllocationModel::query()
|
||||
->whereIn('purchase_item_id', $purchaseItems->pluck('id'))
|
||||
->get()
|
||||
->groupBy('purchase_item_id');
|
||||
|
||||
$missing = $purchaseItems->filter(fn ($item) => ! $allocations->has($item->id));
|
||||
if ($missing->isNotEmpty()) {
|
||||
$purchaseNos = PurchaseOrderModel::query()
|
||||
->whereIn('id', $missing->pluck('purchase_id')->unique())
|
||||
->pluck('purchase_no')
|
||||
->implode('、');
|
||||
throw new RepositoryException('采购单 ' . $purchaseNos . ' 尚未完成金额分摊,请先执行分摊再生成对账明细');
|
||||
}
|
||||
|
||||
// 3. 溯源订货金额(公布金额)
|
||||
$orderAmounts = StoreOrderItemModel::query()
|
||||
->whereIn('id', $allocations->flatten()->pluck('order_item_id')->unique())
|
||||
->pluck('amount', 'id');
|
||||
|
||||
// 4. 先清后建(幂等)
|
||||
// 2. 先清后建(幂等)
|
||||
ReconciliationItemModel::where('recon_id', $recon->id)->delete();
|
||||
|
||||
$publishTotal = '0';
|
||||
@@ -83,37 +62,41 @@ class ReconciliationBuildService
|
||||
$rows = [];
|
||||
$sort = 1;
|
||||
$now = now();
|
||||
foreach ($allocations as $purchaseItemId => $group) {
|
||||
$purchaseItem = $purchaseItems->firstWhere('id', $purchaseItemId);
|
||||
foreach ($group as $allocation) {
|
||||
$publish = (string) ($orderAmounts[$allocation->order_item_id] ?? '0');
|
||||
$actual = (string) $allocation->amount;
|
||||
$publishTotal = bcadd($publishTotal, $publish, 2);
|
||||
$actualTotal = bcadd($actualTotal, $actual, 2);
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$publish = (string) $orderItem->amount;
|
||||
$unitCost = PurchaseEditService::unitCost(
|
||||
(string) ($orderItem->cost_price ?? '0'),
|
||||
(string) $orderItem->product_spec
|
||||
);
|
||||
$actual = PurchaseEditService::costAmount(
|
||||
(string) $orderItem->quantity,
|
||||
(string) $orderItem->weight,
|
||||
$unitCost
|
||||
);
|
||||
$publishTotal = bcadd($publishTotal, $publish, 2);
|
||||
$actualTotal = bcadd($actualTotal, $actual, 2);
|
||||
|
||||
$rows[] = [
|
||||
'recon_id' => $recon->id,
|
||||
'store_id' => $allocation->store_id,
|
||||
'purchase_item_id' => $allocation->purchase_item_id,
|
||||
'order_item_id' => $allocation->order_item_id,
|
||||
'product_id' => $allocation->product_id,
|
||||
'product_name' => $purchaseItem->product_name,
|
||||
'quantity' => $allocation->quantity,
|
||||
'weight' => $allocation->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,
|
||||
];
|
||||
}
|
||||
$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);
|
||||
|
||||
// 5. 汇总写回头 + 状态流转
|
||||
// 3. 汇总写回头 + 状态流转
|
||||
$recon->publish_amount = $publishTotal;
|
||||
$recon->actual_amount = $actualTotal;
|
||||
$recon->diff_amount = bcsub($publishTotal, $actualTotal, 2);
|
||||
|
||||
@@ -61,7 +61,7 @@ return new class extends Migration
|
||||
$table->string('address', 255)->default('')->comment('门店地址');
|
||||
$table->integer('payment_cycle_days')->default(0)->comment('回款周期(天)');
|
||||
$table->integer('status')->default(1)->comment('状态(1正常 0停用)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->string('remark', 255)->nullable()->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->index(['level_id', 'status'], 'store_level_status_index');
|
||||
|
||||
@@ -44,6 +44,7 @@ return new class extends Migration
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('order_id')->comment('订单ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('purchase_id')->default(0)->comment('归属采购单ID(0=未归集)');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->integer('category_id')->default(0)->comment('分类ID');
|
||||
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||
@@ -61,6 +62,7 @@ return new class extends Migration
|
||||
$table->string('remark', 255)->default('')->comment('门店下单备注');
|
||||
$table->timestamps();
|
||||
$table->index(['order_id'], 'store_order_item_order_index');
|
||||
$table->index(['purchase_id'], 'store_order_item_purchase_index');
|
||||
$table->index(['store_id', 'product_id'], 'store_order_item_store_product_index');
|
||||
$table->comment('门店订货明细表');
|
||||
});
|
||||
|
||||
@@ -30,50 +30,6 @@ return new class extends Migration
|
||||
$table->comment('采购单表');
|
||||
});
|
||||
}
|
||||
|
||||
// 采购单明细表(C2/C3 按分类排序导出,C4 可修改,C5/C6 发送供应商及状态标记)
|
||||
if (! Schema::hasTable('purchase_order_item')) {
|
||||
Schema::create('purchase_order_item', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('purchase_id')->comment('采购单ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->integer('supplier_id')->default(0)->comment('供应商ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
$table->string('product_spec', 100)->default('')->comment('规格/包规(快照)');
|
||||
$table->decimal('price', 10, 2)->default(0)->comment('采购单价');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('采购量');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('实际称重');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('采购金额');
|
||||
$table->integer('sort')->default(0)->comment('排序(导出用)');
|
||||
$table->integer('is_sent')->default(0)->comment('是否已发送供应商(1已发送 0未发送)');
|
||||
$table->timestamp('sent_at')->nullable()->comment('发送时间');
|
||||
$table->timestamp('supplier_confirmed_at')->nullable()->comment('供应商确认接单时间(NULL未确认)');
|
||||
$table->string('remark', 255)->default('')->comment('备注');
|
||||
$table->timestamps();
|
||||
$table->index(['purchase_id'], 'purchase_order_item_purchase_index');
|
||||
$table->index(['supplier_id', 'is_sent'], 'purchase_order_item_supplier_sent_index');
|
||||
$table->comment('采购单明细表');
|
||||
});
|
||||
}
|
||||
|
||||
// 采购分摊表(D3 采购金额自动分配到各门店/各单品)
|
||||
if (! Schema::hasTable('purchase_allocation')) {
|
||||
Schema::create('purchase_allocation', function (Blueprint $table) {
|
||||
$table->increments('id')->comment('分摊ID');
|
||||
$table->integer('purchase_item_id')->comment('采购单明细ID');
|
||||
$table->integer('order_item_id')->comment('门店订货明细ID');
|
||||
$table->integer('store_id')->comment('分摊到的门店ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->decimal('quantity', 10, 2)->default(0)->comment('分摊数量');
|
||||
$table->decimal('weight', 10, 3)->default(0)->comment('分摊重量');
|
||||
$table->decimal('amount', 10, 2)->default(0)->comment('分摊金额');
|
||||
$table->timestamps();
|
||||
$table->index(['purchase_item_id'], 'purchase_allocation_item_index');
|
||||
$table->index(['order_item_id'], 'purchase_allocation_order_item_index');
|
||||
$table->index(['store_id'], 'purchase_allocation_store_index');
|
||||
$table->comment('采购分摊表');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +38,5 @@ return new class extends Migration
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('purchase_order');
|
||||
Schema::dropIfExists('purchase_order_item');
|
||||
Schema::dropIfExists('purchase_allocation');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,7 +40,6 @@ return new class extends Migration
|
||||
$table->increments('id')->comment('明细ID');
|
||||
$table->integer('recon_id')->comment('财务对账单ID');
|
||||
$table->integer('store_id')->comment('门店ID');
|
||||
$table->integer('purchase_item_id')->default(0)->comment('采购单明细ID');
|
||||
$table->integer('order_item_id')->default(0)->comment('门店订货明细ID');
|
||||
$table->integer('product_id')->comment('商品ID');
|
||||
$table->string('product_name', 100)->comment('品名(快照)');
|
||||
|
||||
@@ -194,8 +194,6 @@ class PermissionSeeder extends Seeder
|
||||
['type' => 'rule', 'key' => 'purchase.order.update', 'name' => '修改'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.generate', 'name' => '生成采购单'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.export', 'name' => '导出'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.send', 'name' => '发送供应商'],
|
||||
['type' => 'rule', 'key' => 'purchase.order.allocate', 'name' => '金额分摊'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -26,34 +26,44 @@
|
||||
<th>序号</th>
|
||||
<th>分类</th>
|
||||
<th>品名</th>
|
||||
<th>规格/包规</th>
|
||||
<th>供应商</th>
|
||||
<th>包规</th>
|
||||
<th>单位</th>
|
||||
<th>成本</th>
|
||||
<th>单价</th>
|
||||
<th>数量</th>
|
||||
<th>实际称重</th>
|
||||
<th>金额</th>
|
||||
<th>供应商</th>
|
||||
@foreach ($storeNames as $storeName)
|
||||
<th>{{ $storeName }}(数量)</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($items as $item)
|
||||
<tr>
|
||||
<td>{{ $item->sort }}</td>
|
||||
<td>{{ $categoryNames[$item->product_id] ?? '' }}</td>
|
||||
<td>{{ $item->product_name }}</td>
|
||||
<td>{{ $item->product_spec }}</td>
|
||||
<td class="text-right">{{ $item->price }}</td>
|
||||
<td class="text-right">{{ $item->quantity }}</td>
|
||||
<td class="text-right">{{ $item->weight }}</td>
|
||||
<td class="text-right">{{ $item->amount }}</td>
|
||||
<td>{{ $supplierNames[$item->supplier_id] ?? '' }}</td>
|
||||
<td>{{ $item['sort'] }}</td>
|
||||
<td>{{ $categoryNames[$item['product_id']] ?? '' }}</td>
|
||||
<td>{{ $item['product_name'] }}</td>
|
||||
<td>{{ $supplierNames[$item['supplier_id']] ?? '' }}</td>
|
||||
<td>{{ $item['product_spec'] }}</td>
|
||||
<td>{{ $item['unit'] }}</td>
|
||||
<td class="text-right">{{ $item['cost_price'] }}</td>
|
||||
<td class="text-right">{{ $item['unit_cost'] }}</td>
|
||||
<td class="text-right">{{ $item['quantity'] }}</td>
|
||||
<td class="text-right">{{ $item['weight'] }}</td>
|
||||
<td class="text-right">{{ $item['amount'] }}</td>
|
||||
@foreach ($storeNames as $storeId => $storeName)
|
||||
<td class="text-right">{{ $item['store_quantities'][$storeId] ?? 0 }}</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="7" class="text-right">合计金额</td>
|
||||
<td colspan="10" class="text-right">合计金额</td>
|
||||
<td class="text-right">¥{{ $items->sum('amount') }}</td>
|
||||
<td></td>
|
||||
<td colspan="{{ count($storeNames) }}"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* D3 采购金额分摊:金额守恒(含尾差修正)、按订货比例、幂等重跑
|
||||
*/
|
||||
class AllocationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造可分摊的采购单:各门店下单 → 生成采购单 → 录入实际金额
|
||||
*
|
||||
* @param array<int, string> $quantities 各门店订货量
|
||||
* @return array{0: PurchaseOrderModel, 1: PurchaseOrderItemModel}
|
||||
*/
|
||||
private function buildAllocatablePurchase(array $quantities, string $actualPrice = '10.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
foreach ($quantities as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$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_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
// 录入实际单价(weight=0 → amount = quantity × price)
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => $actualPrice,
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $item->fresh()];
|
||||
}
|
||||
|
||||
/** 金额守恒:Σallocation.amount === item.amount,尾差由最后一行承担 */
|
||||
public function test_allocation_conserves_amount_with_tail_correction(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '1', '1']);
|
||||
|
||||
// 把实际金额调成不可被 3 整除的 100.00(quantity=10 × price=10)
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => '10.00',
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$item = $item->fresh();
|
||||
$this->assertSame('100.00', (string) $item->amount);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$allocations = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$this->assertCount(3, $allocations);
|
||||
|
||||
$sum = $allocations->reduce(
|
||||
static fn (string $carry, $a): string => bcadd($carry, (string) $a->amount, 2),
|
||||
'0'
|
||||
);
|
||||
$this->assertSame('100.00', $sum, '分摊总额必须守恒');
|
||||
|
||||
// 三等分尾差修正:33.33 / 33.33 / 33.34
|
||||
$this->assertSame('33.33', (string) $allocations[0]->amount);
|
||||
$this->assertSame('33.33', (string) $allocations[1]->amount);
|
||||
$this->assertSame('33.34', (string) $allocations[2]->amount);
|
||||
}
|
||||
|
||||
/** 按订货数量比例分摊 */
|
||||
public function test_allocation_follows_order_ratio(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '3']);
|
||||
// quantity=4,实际金额 4×10=40 → 1:3 → 10.00 / 30.00
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
$amounts = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->pluck('amount')
|
||||
->map(static fn ($v) => (string) $v)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
$this->assertSame(['10.00', '30.00'], $amounts);
|
||||
}
|
||||
|
||||
/** 幂等:重复分摊先删旧记录再重建,数量不变 */
|
||||
public function test_allocation_is_idempotent(): void
|
||||
{
|
||||
[$purchase] = $this->buildAllocatablePurchase(['2', '3']);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$first = PurchaseAllocationModel::count();
|
||||
$this->assertGreaterThan(0, $first);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$this->assertSame($first, PurchaseAllocationModel::count(), '重复分摊不应产生重复记录');
|
||||
}
|
||||
|
||||
/** 未录入实际金额时拒绝分摊 */
|
||||
public function test_allocation_rejected_without_actual_amount(): void
|
||||
{
|
||||
$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());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
|
||||
// 模拟未录入实际金额
|
||||
$purchase->items->first()->update(['amount' => 0, 'price' => 0]);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', false);
|
||||
$this->assertSame(0, PurchaseAllocationModel::count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格数量、行级成本/称重,
|
||||
* 修改同步订货明细并重算订单/采购单汇总
|
||||
*/
|
||||
class PurchaseEditTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级价 10.00,成本 20,包规 10斤/箱 → 单价 2.00),
|
||||
* 接单后生成采购单
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: ProductModel, 2: array<int, StoreModel>}
|
||||
*/
|
||||
private function buildPurchase(): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 20,
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
foreach ([[$storeA, 2], [$storeB, 3]] as [$store, $qty]) {
|
||||
$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_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||
}
|
||||
|
||||
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
|
||||
public function test_detail_returns_store_matrix(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertCount(2, $data['stores']);
|
||||
$this->assertCount(1, $data['items']);
|
||||
|
||||
$row = $data['items'][0];
|
||||
$this->assertSame($product->id, $row['product_id']);
|
||||
$this->assertSame('10斤/箱', $row['product_spec']);
|
||||
$this->assertSame('斤', $row['unit']);
|
||||
$this->assertSame('20.00', (string) $row['cost_price']);
|
||||
$this->assertSame(5, $row['quantity'], '2+3');
|
||||
|
||||
$cells = $row['cells'];
|
||||
$this->assertSame(2, $cells[$stores[0]->id]);
|
||||
$this->assertSame(3, $cells[$stores[1]->id]);
|
||||
}
|
||||
|
||||
/** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
|
||||
public function test_update_cell_syncs_order_item_and_totals(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.amount', 50);
|
||||
|
||||
$item = $item->fresh();
|
||||
$this->assertSame(5, $item->quantity);
|
||||
$this->assertSame('50.00', (string) $item->amount, '5 × 订货单价 10.00');
|
||||
|
||||
$order = StoreOrderModel::find($item->order_id);
|
||||
$this->assertSame(5, $order->total_quantity);
|
||||
$this->assertSame('50.00', (string) $order->product_amount);
|
||||
$this->assertSame('50.00', (string) $order->total_amount);
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('8.00', (string) $purchase->total_quantity, '5+3');
|
||||
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30');
|
||||
}
|
||||
|
||||
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×单价 重算 */
|
||||
public function test_update_row_cost_syncs_all_order_items(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.count', 2);
|
||||
|
||||
$this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count());
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
// 单价 = 30 ÷ 10 = 3.00,实际金额 = 5 × 3.00
|
||||
$this->assertSame('15.00', (string) $purchase->actual_amount);
|
||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响');
|
||||
}
|
||||
|
||||
/** 行级称重修改 → 按各店数量比例分摊写入明细(尾差修正守恒),实际金额按 称重×单价 计 */
|
||||
public function test_update_row_weight_distributed_by_quantity(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['weight' => 10])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$items = StoreOrderItemModel::query()->orderBy('store_id')->get()->keyBy('store_id');
|
||||
$this->assertSame('4.000', (string) $items[$stores[0]->id]->weight, '10 × 2/5');
|
||||
$this->assertSame('6.000', (string) $items[$stores[1]->id]->weight, '10 × 3/5');
|
||||
$this->assertSame(
|
||||
'10.000',
|
||||
bcadd((string) $items[$stores[0]->id]->weight, (string) $items[$stores[1]->id]->weight, 3),
|
||||
'分摊守恒'
|
||||
);
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('10.000', (string) $purchase->total_weight);
|
||||
// 称重>0 → 金额按 称重×单价:10 × 2.00
|
||||
$this->assertSame('20.00', (string) $purchase->actual_amount);
|
||||
}
|
||||
|
||||
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细 */
|
||||
public function test_update_row_syncs_product_attributes(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||
'product_name' => '优选土豆',
|
||||
'supplier_id' => $supplier->id,
|
||||
'product_spec' => '5斤/袋',
|
||||
'unit' => '袋',
|
||||
'cost_price' => 25,
|
||||
])->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.count', 2);
|
||||
|
||||
$items = StoreOrderItemModel::all();
|
||||
$this->assertCount(2, $items);
|
||||
foreach ($items as $item) {
|
||||
$this->assertSame('优选土豆', $item->product_name);
|
||||
$this->assertSame($supplier->id, $item->supplier_id);
|
||||
$this->assertSame('5斤/袋', $item->product_spec);
|
||||
$this->assertSame('袋', $item->unit);
|
||||
$this->assertSame('25.00', (string) $item->cost_price);
|
||||
}
|
||||
|
||||
// 未传 sync_product:商品档案保持原值
|
||||
$product = $product->fresh();
|
||||
$this->assertNotSame('优选土豆', $product->name);
|
||||
|
||||
// 新单价 = 25 ÷ 5 = 5.00,实际金额 = 5 件 × 5.00
|
||||
$this->assertSame('25.00', (string) $purchase->fresh()->actual_amount);
|
||||
}
|
||||
|
||||
/** sync_product=true:订货明细与商品档案同步更新;软删除商品拒绝 */
|
||||
public function test_update_row_syncs_to_product_catalog(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||
'product_name' => '优选土豆',
|
||||
'supplier_id' => $supplier->id,
|
||||
'product_spec' => '5斤/袋',
|
||||
'unit' => '袋',
|
||||
'cost_price' => 25,
|
||||
'sync_product' => true,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
$product = $product->fresh();
|
||||
$this->assertSame('优选土豆', $product->name);
|
||||
$this->assertSame($supplier->id, $product->supplier_id);
|
||||
$this->assertSame('5斤/袋', $product->spec);
|
||||
$this->assertSame('袋', $product->unit);
|
||||
$this->assertSame('25.00', (string) $product->cost_price);
|
||||
|
||||
// 软删除商品 → 拒绝并回滚(订货明细不同步)
|
||||
$product->delete();
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
|
||||
'product_name' => '再次改名',
|
||||
'sync_product' => true,
|
||||
])->assertJsonPath('success', false);
|
||||
$this->assertSame('优选土豆', StoreOrderItemModel::first()->product_name, '回滚后明细保持原值');
|
||||
}
|
||||
|
||||
/** 行级修改:属性与称重至少一项 */
|
||||
public function test_update_row_requires_at_least_one_field(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [])
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 单元格数量校验:负数拒绝 */
|
||||
public function test_update_cell_rejects_negative_quantity(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1])
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,19 @@ use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* C1 订单汇总生成采购单:多门店聚合、订单状态回写、无订单/重复生成防护
|
||||
* C1 订单汇总生成采购单:订单/明细归集回写、头部汇总、无订单/重复生成防护
|
||||
* (采购单无独立明细表,明细直接溯源订货明细 store_order_item.purchase_id)
|
||||
*/
|
||||
class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||
* 商品设两个等级价 5.00 / 4.00,估算单价应取最低 4.00
|
||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价)
|
||||
*/
|
||||
private function seedAcceptedOrders(): ProductModel
|
||||
{
|
||||
@@ -41,8 +43,8 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
return $product;
|
||||
}
|
||||
|
||||
/** 多门店多订单按商品聚合,估算单价取最低等级价 */
|
||||
public function test_generate_aggregates_orders_by_product(): void
|
||||
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */
|
||||
public function test_generate_aggregates_order_items_into_header(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
$admin = $this->actingAsSysUser();
|
||||
@@ -58,15 +60,12 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame($admin->id, $purchase->operator_id);
|
||||
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
||||
|
||||
$this->assertCount(1, $purchase->items, '单一商品应聚合为一行');
|
||||
$item = $purchase->items->first();
|
||||
$this->assertSame('10.00', (string) $item->quantity, '3+3+4');
|
||||
$this->assertSame('4.00', (string) $item->price, '估算单价取最低等级价');
|
||||
$this->assertSame('40.00', (string) $item->amount, '10 × 4.00');
|
||||
$this->assertSame('40.00', (string) $purchase->estimate_amount);
|
||||
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
|
||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00');
|
||||
$this->assertSame('0.00', (string) $purchase->actual_amount);
|
||||
}
|
||||
|
||||
/** 源订单状态回写为采购中,并关联 purchase_id */
|
||||
/** 源订单状态回写为采购中,订单与订货明细均回写 purchase_id */
|
||||
public function test_generate_writes_back_order_status(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
@@ -79,6 +78,12 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_DELIVERING)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('purchase_id', $purchase->id)->count());
|
||||
$this->assertSame(
|
||||
3,
|
||||
StoreOrderItemModel::where('purchase_id', $purchase->id)->count(),
|
||||
'订货明细应全部回写 purchase_id(采购明细溯源依据)'
|
||||
);
|
||||
$this->assertSame(0, StoreOrderItemModel::where('purchase_id', 0)->count());
|
||||
}
|
||||
|
||||
/** 仅有待接单订单时不可生成(待接单不再被归集)→ 报错 */
|
||||
@@ -140,44 +145,4 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||
}
|
||||
|
||||
/** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */
|
||||
public function test_generate_uses_min_actual_price_across_types(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelFixed = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 10,
|
||||
]);
|
||||
// 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 40,
|
||||
'price' => 14.00,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $levelFixed->id,
|
||||
'price' => 5.00,
|
||||
]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 接单后生成
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$item = PurchaseOrderModel::first()->items->first();
|
||||
$this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价');
|
||||
$this->assertSame('15.00', (string) $item->amount, '3 × 5.00');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
@@ -15,27 +14,31 @@ use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 财务对账:明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
* 财务对账:明细构建(已完成订单直读,品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
* publish = 订货金额;actual = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规)
|
||||
*/
|
||||
class ReconciliationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造已分摊的完整链路:2 门店下单(2/3 件)→ 生成采购单 → 录入实际金额 → 分摊
|
||||
* 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成
|
||||
* 预期:publish 20/30,actual 16/24,diff 4/6
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: array<int, StoreModel>, 2: SupplierModel}
|
||||
* @return array{0: array<int, StoreModel>, 1: SupplierModel}
|
||||
*/
|
||||
private function buildAllocatedChain(): array
|
||||
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.00', '3.00'] as $qty) {
|
||||
foreach ([2, 3] as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$stores[] = $store;
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
@@ -43,23 +46,10 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 接单后生成采购单
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
// 订单完成(对账数据源为已完成订单的订货明细)
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_COMPLETED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $stores, $supplier];
|
||||
return [$stores, $supplier];
|
||||
}
|
||||
|
||||
private function createRecon(array $extra = []): int
|
||||
@@ -74,34 +64,53 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
return (int) $response->json('data.id');
|
||||
}
|
||||
|
||||
/** 构建明细:publish=订货金额,actual=分摊金额,diff=publish-actual,头汇总回写 */
|
||||
/** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */
|
||||
public function test_build_creates_reconciliation_items(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
[$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, '两门店分摊 → 两条对账明细');
|
||||
$this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细');
|
||||
|
||||
$byStore = $items->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount, '分摊金额');
|
||||
$this->assertSame('0.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$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('50.00', (string) $recon->actual_amount);
|
||||
$this->assertSame('0.00', (string) $recon->diff_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->buildAllocatedChain();
|
||||
[, $supplier] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 无关供应商 → 无数据报错
|
||||
@@ -118,7 +127,7 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
/** D4 修改明细:自动重算本行 diff 与对账单头汇总 */
|
||||
public function test_update_item_recalculates_diff_and_header(): void
|
||||
{
|
||||
$this->buildAllocatedChain();
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -132,14 +141,14 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
$this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00');
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('55.00', (string) $recon->actual_amount, '25 + 30');
|
||||
$this->assertSame('-5.00', (string) $recon->diff_amount, '50 - 55');
|
||||
$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->buildAllocatedChain();
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -157,7 +166,7 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
/** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */
|
||||
public function test_settle_creates_settlements_per_store(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -170,7 +179,8 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
|
||||
$byStore = $settlements->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount);
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_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);
|
||||
|
||||
+29
-41
@@ -1,21 +1,11 @@
|
||||
import createAxios from '@/utils/request';
|
||||
import type {
|
||||
ExportFormat,
|
||||
IAllocationResult,
|
||||
IPurchaseOrderItem,
|
||||
IPurchaseDetail,
|
||||
PurchaseExportType,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { downloadBlob } from '@/api/common/download.ts';
|
||||
|
||||
export interface PurchaseItemUpdateParams {
|
||||
product_name?: string;
|
||||
product_spec?: string;
|
||||
price: number | string;
|
||||
quantity: number | string;
|
||||
weight?: number | string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** C1 合并「已接单」门店订单生成采购单(order_ids 为空 = 全部已接单;生成后源订单转采购中) */
|
||||
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
|
||||
return createAxios<{ id: number; purchase_no: string }>({
|
||||
@@ -25,6 +15,14 @@ export async function generatePurchase(purchase_date: string, order_ids?: number
|
||||
});
|
||||
}
|
||||
|
||||
/** 采购单详情(商品行 × 门店列矩阵) */
|
||||
export async function getPurchaseDetail(id: number) {
|
||||
return createAxios<IPurchaseDetail>({
|
||||
url: `/purchase/order/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** C2/C3 导出采购单(blob 下载) */
|
||||
export async function exportPurchase(id: number, type: PurchaseExportType, format: ExportFormat) {
|
||||
return downloadBlob(
|
||||
@@ -34,37 +32,27 @@ export async function exportPurchase(id: number, type: PurchaseExportType, forma
|
||||
);
|
||||
}
|
||||
|
||||
/** C4 修改采购明细(amount 由后端重算) */
|
||||
export async function updatePurchaseItem(id: number, data: PurchaseItemUpdateParams) {
|
||||
return createAxios<{ amount: string }>({
|
||||
url: `/purchase/order/item/${id}`,
|
||||
/** C4 商品行修改参数(品名/供应商/包规/单位/成本/称重,一键同步该商品全部订货明细;sync_product 追加同步商品档案) */
|
||||
export interface PurchaseRowUpdateParams {
|
||||
product_name?: string;
|
||||
supplier_id?: number;
|
||||
product_spec?: string;
|
||||
unit?: string;
|
||||
cost_price?: number;
|
||||
weight?: number;
|
||||
/** true = 同时同步至商品档案(product 表) */
|
||||
sync_product?: boolean;
|
||||
}
|
||||
|
||||
/** C4 商品行修改 */
|
||||
export async function updatePurchaseRow(
|
||||
purchaseId: number,
|
||||
productId: number,
|
||||
data: PurchaseRowUpdateParams,
|
||||
) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/purchase/order/${purchaseId}/row/${productId}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** C5/C6 明细发送供应商 */
|
||||
export async function sendPurchaseItem(id: number) {
|
||||
return createAxios({
|
||||
url: `/purchase/order/item/${id}/send`,
|
||||
method: 'put',
|
||||
});
|
||||
}
|
||||
|
||||
/** D3 执行金额分摊 */
|
||||
export async function allocatePurchase(id: number) {
|
||||
return createAxios<{ count: number }>({
|
||||
url: `/purchase/order/${id}/allocate`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
/** 分摊结果(按门店 / 按商品聚合) */
|
||||
export async function getAllocation(id: number) {
|
||||
return createAxios<IAllocationResult>({
|
||||
url: `/purchase/order/${id}/allocation`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export type { IPurchaseOrderItem };
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
/** 采购分摊记录 */
|
||||
export interface IPurchaseAllocation {
|
||||
id?: number;
|
||||
purchase_item_id?: number;
|
||||
order_item_id?: number;
|
||||
store_id?: number;
|
||||
product_id?: number;
|
||||
quantity?: string;
|
||||
weight?: string;
|
||||
amount?: string;
|
||||
store?: { id: number; name: string };
|
||||
product?: { id: number; name: string; unit: string };
|
||||
/** 采购明细门店单元格(溯源订货明细) */
|
||||
export interface IPurchaseStoreCell {
|
||||
order_item_id: number;
|
||||
store_id: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
/** 金额 = 称重>0 ? 称重×单价 : 数量×单价(单价 = 成本/包规) */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** 采购明细 */
|
||||
export interface IPurchaseOrderItem {
|
||||
id?: number;
|
||||
purchase_id?: number;
|
||||
product_id?: number;
|
||||
supplier_id?: number;
|
||||
product_name?: string;
|
||||
product_spec?: string;
|
||||
price?: string;
|
||||
quantity?: string;
|
||||
weight?: string;
|
||||
amount?: string;
|
||||
sort?: number;
|
||||
is_sent?: number;
|
||||
sent_at?: string;
|
||||
supplier_confirmed_at?: string;
|
||||
remark?: string;
|
||||
supplier?: { id: number; name: string };
|
||||
allocations?: IPurchaseAllocation[];
|
||||
/** 采购明细行(商品维度聚合,行 × 门店列矩阵) */
|
||||
export interface IPurchaseDetailRow {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
/** 规格/包规 */
|
||||
product_spec: string;
|
||||
unit: string;
|
||||
supplier_id: number;
|
||||
supplier?: { id: number; name: string } | null;
|
||||
/** 成本 */
|
||||
cost_price: number;
|
||||
/** 合计数量 */
|
||||
quantity: number;
|
||||
/** 合计实际称重 */
|
||||
weight: number;
|
||||
cells: Record<number, number>;
|
||||
}
|
||||
|
||||
/** 采购单 */
|
||||
@@ -38,7 +31,7 @@ export default interface IPurchaseOrder {
|
||||
id?: number;
|
||||
purchase_no?: string;
|
||||
purchase_date?: string;
|
||||
/** 0待发送 1部分发送 2全部发送 3已完成 */
|
||||
/** 0进行中 3已完成 */
|
||||
status?: number;
|
||||
total_quantity?: string;
|
||||
total_weight?: string;
|
||||
@@ -47,34 +40,20 @@ export default interface IPurchaseOrder {
|
||||
operator_id?: number;
|
||||
operator?: { id: number; nickname: string };
|
||||
remark?: string;
|
||||
items?: IPurchaseOrderItem[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 采购单详情(矩阵数据) */
|
||||
export interface IPurchaseDetail {
|
||||
purchase: IPurchaseOrder;
|
||||
stores: { id: number; name: string }[];
|
||||
items: IPurchaseDetailRow[];
|
||||
}
|
||||
|
||||
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待发送', color: 'default' },
|
||||
1: { text: '部分发送', color: 'processing' },
|
||||
2: { text: '全部发送', color: 'cyan' },
|
||||
0: { text: '进行中', color: 'processing' },
|
||||
3: { text: '已完成', color: 'success' },
|
||||
};
|
||||
|
||||
/** 分摊结果聚合行 */
|
||||
export interface IAllocationAggRow {
|
||||
store_id?: number;
|
||||
store_name?: string;
|
||||
product_id?: number;
|
||||
product_name?: string;
|
||||
unit?: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface IAllocationResult {
|
||||
by_store: IAllocationAggRow[];
|
||||
by_product: IAllocationAggRow[];
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
export type PurchaseExportType = 'all' | 'category';
|
||||
export type ExportFormat = 'xlsx' | 'pdf';
|
||||
|
||||
@@ -3,7 +3,6 @@ export interface IReconciliationItem {
|
||||
id?: number;
|
||||
recon_id?: number;
|
||||
store_id?: number;
|
||||
purchase_item_id?: number;
|
||||
order_item_id?: number;
|
||||
product_id?: number;
|
||||
product_name?: string;
|
||||
@@ -11,7 +10,7 @@ export interface IReconciliationItem {
|
||||
weight?: string;
|
||||
/** 公布金额(订货金额) */
|
||||
publish_amount?: string;
|
||||
/** 实际金额(分摊金额) */
|
||||
/** 实际金额(采购成本) */
|
||||
actual_amount?: string;
|
||||
/** 差额 = publish − actual */
|
||||
diff_amount?: string;
|
||||
|
||||
+273
-402
@@ -1,30 +1,21 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
PlusOutlined,
|
||||
SendOutlined,
|
||||
SplitCellsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
@@ -33,305 +24,224 @@ import type {
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
||||
import type {
|
||||
IAllocationAggRow,
|
||||
IPurchaseOrderItem,
|
||||
IPurchaseDetail,
|
||||
IPurchaseDetailRow,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import {
|
||||
allocatePurchase,
|
||||
exportPurchase,
|
||||
generatePurchase,
|
||||
getAllocation,
|
||||
sendPurchaseItem,
|
||||
updatePurchaseItem,
|
||||
getPurchaseDetail,
|
||||
updatePurchaseRow,
|
||||
} from '@/api/purchase/order.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import { Update } from '@/api/common/table.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 行内编辑中的明细值 */
|
||||
interface EditingItem {
|
||||
price: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
/** 行修改表单:品名/供应商/包规/单位/成本 */
|
||||
interface RowEditForm {
|
||||
product_name: string;
|
||||
supplier_id?: number;
|
||||
product_spec: string;
|
||||
unit: string;
|
||||
cost_price: number;
|
||||
}
|
||||
|
||||
/** 单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径) */
|
||||
const calcUnitCost = (cost: number, spec: string): number => {
|
||||
const pack = parseFloat(spec);
|
||||
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
|
||||
};
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 明细修改 / C5-C6 发送 / D3 分摊)
|
||||
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
|
||||
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
|
||||
*/
|
||||
const PurchaseOrderPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
||||
|
||||
// 生成采购单
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [generateLoading, setGenerateLoading] = useState(false);
|
||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
||||
|
||||
// 详情抽屉
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IPurchaseOrder | null>(null);
|
||||
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
|
||||
const [savingItemId, setSavingItemId] = useState<number | null>(null);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
|
||||
// 分摊
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocation, setAllocation] = useState<{
|
||||
byStore: IAllocationAggRow[];
|
||||
byProduct: IAllocationAggRow[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
// 行修改弹窗
|
||||
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
|
||||
const [rowSaving, setRowSaving] = useState(false);
|
||||
const [syncTarget, setSyncTarget] = useState<'purchase' | 'product'>('purchase');
|
||||
const [editForm] = Form.useForm<RowEditForm>();
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<IPurchaseOrder>('/purchase/order', id);
|
||||
const purchase = res.data.data ?? null;
|
||||
setDetail(purchase);
|
||||
const editingMap: Record<number, EditingItem> = {};
|
||||
purchase?.items?.forEach((item) => {
|
||||
if (item.id !== undefined) {
|
||||
editingMap[item.id] = {
|
||||
price: Number(item.price ?? 0),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
};
|
||||
}
|
||||
});
|
||||
setEditing(editingMap);
|
||||
const res = await getPurchaseDetail(id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setAllocation(null);
|
||||
setDetailOpen(true);
|
||||
await loadDetail(id);
|
||||
await loadAllocation(id);
|
||||
};
|
||||
|
||||
const loadAllocation = async (id: number) => {
|
||||
try {
|
||||
const res = await getAllocation(id);
|
||||
const data = res.data.data;
|
||||
if (data) {
|
||||
setAllocation({
|
||||
byStore: data.by_store ?? [],
|
||||
byProduct: data.by_product ?? [],
|
||||
total: data.total_amount ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 未分摊时忽略
|
||||
}
|
||||
const openEdit = (row: IPurchaseDetailRow) => {
|
||||
setEditingRow(row);
|
||||
editForm.setFieldsValue({
|
||||
product_name: row.product_name,
|
||||
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
|
||||
product_spec: row.product_spec,
|
||||
unit: row.unit,
|
||||
cost_price: Number(row.cost_price),
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { purchase_date: dayjs.Dayjs }) => {
|
||||
setGenerateLoading(true);
|
||||
try {
|
||||
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'));
|
||||
message.success(`采购单 ${res.data.data?.purchase_no} 已生成`);
|
||||
setGenerateOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
await openDetail(res.data.data!.id);
|
||||
} finally {
|
||||
setGenerateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isItemDirty = (item: IPurchaseOrderItem): boolean => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
edit.price !== Number(item.price ?? 0) ||
|
||||
edit.quantity !== Number(item.quantity ?? 0) ||
|
||||
edit.weight !== Number(item.weight ?? 0)
|
||||
);
|
||||
};
|
||||
|
||||
const saveItem = async (item: IPurchaseOrderItem) => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit || !isItemDirty(item)) {
|
||||
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
|
||||
const handleEditSave = async (values: RowEditForm) => {
|
||||
if (!detail || !editingRow) {
|
||||
return;
|
||||
}
|
||||
setSavingItemId(item.id!);
|
||||
setRowSaving(true);
|
||||
try {
|
||||
const res = await updatePurchaseItem(item.id!, {
|
||||
price: edit.price,
|
||||
quantity: edit.quantity,
|
||||
weight: edit.weight,
|
||||
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
|
||||
...values,
|
||||
supplier_id: values.supplier_id ?? 0,
|
||||
sync_product: syncTarget === 'product',
|
||||
});
|
||||
message.success(`金额已重算:¥${res.data.data?.amount}`);
|
||||
await loadDetail(detail!.id!);
|
||||
message.success(
|
||||
syncTarget === 'product'
|
||||
? `已同步 ${res.data.data?.count ?? 0} 条订货明细,并更新商品档案`
|
||||
: `已同步 ${res.data.data?.count ?? 0} 条订货明细`,
|
||||
);
|
||||
setEditingRow(null);
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setSavingItemId(null);
|
||||
setRowSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (item: IPurchaseOrderItem) => {
|
||||
await sendPurchaseItem(item.id!);
|
||||
message.success('已发送给供应商');
|
||||
await loadDetail(detail!.id!);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleAllocate = async () => {
|
||||
setAllocating(true);
|
||||
const handleComplete = async () => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
setCompleting(true);
|
||||
try {
|
||||
const res = await allocatePurchase(detail!.id!);
|
||||
message.success(`分摊完成,共 ${res.data.data?.count} 条记录`);
|
||||
await loadAllocation(detail!.id!);
|
||||
await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 });
|
||||
message.success('采购单已标记完成');
|
||||
await loadDetail(detail.purchase.id!);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
setCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IPurchaseOrderItem>['columns'] = [
|
||||
{ title: '序号', dataIndex: 'sort', width: 60, align: 'center' },
|
||||
{ title: '品名', dataIndex: 'product_name', width: 130 },
|
||||
{ title: '规格', dataIndex: 'product_spec', width: 110, render: (v) => v || '-' },
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
width: 130,
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={editing[record.id!]?.price}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], price: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<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: 130,
|
||||
render: (_, record) => (
|
||||
<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-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
|
||||
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
|
||||
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (_, row) => row.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
key: 'unit_cost',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
|
||||
},
|
||||
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
|
||||
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
|
||||
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => `¥${Number(v).toFixed(2)}` },
|
||||
];
|
||||
|
||||
const storeColumns = (detail?.stores ?? []).map((store) => ({
|
||||
title: <span className={'text-[red]'}>{store.name}</span>,
|
||||
key: `store-${store.id}`,
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v) => <Text strong>¥{String(v)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '发送状态',
|
||||
dataIndex: 'is_sent',
|
||||
width: 150,
|
||||
render: (_, record) =>
|
||||
record.is_sent === 1 ? (
|
||||
<Tag color="success">
|
||||
已发送
|
||||
{record.sent_at ? ` ${record.sent_at}` : ''}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未发送</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IPurchaseDetailRow) => {
|
||||
const quantity = row.cells[store.id];
|
||||
return quantity !== undefined ? <Text>{quantity}</Text> : <Text type="secondary">-</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
|
||||
{
|
||||
title: '合计数量',
|
||||
key: 'total_quantity',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, row) => <Text strong>{row.quantity}</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!isItemDirty(record)}
|
||||
loading={savingItemId === record.id}
|
||||
onClick={() => saveItem(record)}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
保存
|
||||
修改
|
||||
</Button>
|
||||
</AuthButton>
|
||||
{record.is_sent !== 1 ? (
|
||||
<AuthButton auth="purchase.order.send">
|
||||
<Popconfirm
|
||||
title="确认发送该明细给供应商?"
|
||||
onConfirm={() => handleSend(record)}
|
||||
>
|
||||
<Button size="small" type="link" icon={<SendOutlined />}>
|
||||
发送
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const aggColumns = (nameTitle: string): TableProps<IAllocationAggRow>['columns'] => [
|
||||
{
|
||||
title: nameTitle,
|
||||
key: 'name',
|
||||
render: (_, row) => row.store_name ?? row.product_name ?? '-',
|
||||
},
|
||||
{ title: '数量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '重量', dataIndex: 'weight', align: 'right' },
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
align: 'right',
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
];
|
||||
return [...fixed, ...storeColumns, ...tail];
|
||||
};
|
||||
|
||||
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
|
||||
const renderSummary = () => {
|
||||
const stores = detail?.stores ?? [];
|
||||
const items = detail?.items ?? [];
|
||||
const storeTotals = stores.map((store) =>
|
||||
items.reduce(
|
||||
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
|
||||
const totalAmount = storeTotals.reduce((sum, amount) => sum + amount, 0);
|
||||
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={6} align="center">
|
||||
<Text strong>成本统计(按门店)</Text>
|
||||
</Table.Summary.Cell>
|
||||
{storeTotals.map((amount, index) => (
|
||||
<Table.Summary.Cell key={stores[index].id} index={6 + index} align="center">
|
||||
<Text strong>¥{amount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
))}
|
||||
<Table.Summary.Cell index={6 + stores.length} align="center">
|
||||
<Text strong>{totalQuantity}</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={7 + stores.length} align="center">
|
||||
<Text strong>¥{totalAmount.toFixed(2)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||
{
|
||||
@@ -350,7 +260,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
render: (_, record) => record.purchase_date,
|
||||
},
|
||||
{
|
||||
title: '预估金额',
|
||||
title: '预估成本',
|
||||
dataIndex: 'estimate_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
@@ -358,7 +268,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
render: (_, record) => `¥${record.estimate_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
title: '实际成本',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
@@ -400,21 +310,7 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
<AuthButton key="export" auth="purchase.order.export">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'all-xlsx', label: '全品类 Excel', onClick: () => exportPurchase(record.id!, 'all', 'xlsx') },
|
||||
{ key: 'all-pdf', label: '全品类 PDF', onClick: () => exportPurchase(record.id!, 'all', 'pdf') },
|
||||
{ key: 'category-xlsx', label: '蔬果分类 Excel', onClick: () => exportPurchase(record.id!, 'category', 'xlsx') },
|
||||
{ key: 'category-pdf', label: '蔬果分类 PDF', onClick: () => exportPurchase(record.id!, 'category', 'pdf') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />} />
|
||||
</Dropdown>
|
||||
</AuthButton>,
|
||||
</Button>
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||
@@ -425,15 +321,6 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [
|
||||
<AuthButton key="generate" auth="purchase.order.generate">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateOpen(true)}>
|
||||
生成采购单
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
dom.search,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -441,148 +328,132 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
<div className="mb-5">
|
||||
<Title level={3}>采购单</Title>
|
||||
<Text type="secondary">
|
||||
按门店订单汇总生成;录入实际称重/单价后执行金额分摊(按订货比例摊到各门店,尾差修正确保金额守恒)。
|
||||
在「门店订单」页勾选已接单订单合并生成采购单;采购单支持修改订单信息。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IPurchaseOrder> {...tableProps} />
|
||||
|
||||
{/* 生成采购单 */}
|
||||
<Modal
|
||||
title="生成采购单"
|
||||
open={generateOpen}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
onOk={() => generateForm.submit()}
|
||||
confirmLoading={generateLoading}
|
||||
okText="确认生成"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
将合并全部「已接单」门店订单,按商品聚合生成采购单(估算单价取最低等级价),生成后源订单转为「采购中」。
|
||||
</div>
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
onFinish={handleGenerate}
|
||||
initialValues={{ purchase_date: dayjs() }}
|
||||
>
|
||||
<Form.Item
|
||||
label="采购日期"
|
||||
name="purchase_date"
|
||||
rules={[{ required: true, message: '请选择采购日期' }]}
|
||||
>
|
||||
<DatePicker className="w-full" allowClear={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 采购单详情 */}
|
||||
{/* 采购单详情:商品行 × 门店列矩阵 */}
|
||||
<Drawer
|
||||
title={detail ? `采购单 ${detail.purchase_no}` : '采购单详情'}
|
||||
title={detail ? `采购单 ${detail.purchase.purchase_no}` : '采购单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
size={1080}
|
||||
size={1200}
|
||||
loading={detailLoading}
|
||||
extra={detail?.purchase.status === 0 && (
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
|
||||
<Button size="small" icon={<CheckOutlined />} loading={completing}>
|
||||
标记完成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
)}
|
||||
>
|
||||
{detail ? (
|
||||
{detail && (
|
||||
<>
|
||||
<Title level={5} className="mt-5! mb-3!">
|
||||
采购单信息
|
||||
</Title>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="采购日期">{detail.purchase_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="采购日期">{detail.purchase.purchase_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={PURCHASE_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{PURCHASE_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
<Tag color={PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.color}>
|
||||
{PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="制单人">
|
||||
{detail.operator?.nickname ?? '-'}
|
||||
{detail.purchase.operator?.nickname ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估金额">¥{detail.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
|
||||
<Descriptions.Item label="预估金额">¥{detail.purchase.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.purchase.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
className="mt-4"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: `采购明细(${detail.items?.length ?? 0})`,
|
||||
children: (
|
||||
<>
|
||||
<div className="mb-2 text-gray-500">
|
||||
录入实际称重与单价后点击行内「保存」,金额由后端重算(称重>0 按 称重×单价,否则按 数量×单价)。
|
||||
</div>
|
||||
<Table<IPurchaseOrderItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'allocation',
|
||||
label: '金额分摊',
|
||||
children: (
|
||||
<>
|
||||
<Space className="mb-3">
|
||||
<AuthButton auth="purchase.order.allocate">
|
||||
<Popconfirm
|
||||
title="执行金额分摊?"
|
||||
description="按订货比例将实际金额摊到各门店单品,重复执行会先清空旧分摊记录。"
|
||||
onConfirm={handleAllocate}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SplitCellsOutlined />}
|
||||
loading={allocating}
|
||||
>
|
||||
执行分摊
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
{allocation ? (
|
||||
<Text type="secondary">
|
||||
分摊总额:¥{allocation.total}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Title level={5}>按门店</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.store_id)}
|
||||
size="small"
|
||||
columns={aggColumns('门店')}
|
||||
dataSource={allocation.byStore}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>按商品</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.product_id)}
|
||||
size="small"
|
||||
columns={aggColumns('商品')}
|
||||
dataSource={allocation.byProduct}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无分摊记录,请先录入实际金额后执行分摊" />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
<Title level={5} className="mt-5! mb-3!">
|
||||
商品明细
|
||||
</Title>
|
||||
<Table<IPurchaseDetailRow>
|
||||
rowKey="product_id"
|
||||
size="small"
|
||||
bordered
|
||||
columns={buildItemColumns()}
|
||||
dataSource={detail.items}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
summary={renderSummary}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 行修改:品名/供应商/包规/单位/成本,同步采购单明细 / 追加同步商品档案 */}
|
||||
<Modal
|
||||
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
|
||||
open={editingRow !== null}
|
||||
onCancel={() => setEditingRow(null)}
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setEditingRow(null)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="product"
|
||||
loading={rowSaving && syncTarget === 'product'}
|
||||
onClick={() => {
|
||||
setSyncTarget('product');
|
||||
editForm.submit();
|
||||
}}
|
||||
>
|
||||
保存并同步
|
||||
</Button>,
|
||||
<Button
|
||||
key="purchase"
|
||||
type="primary"
|
||||
loading={rowSaving && syncTarget === 'purchase'}
|
||||
onClick={() => {
|
||||
setSyncTarget('purchase');
|
||||
editForm.submit();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
「保存」仅修改本采购单中该商品的所有订单项;「保存并同步」在此基础上同时更新商品档案(后续新订单生效)。
|
||||
</div>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
|
||||
<Form.Item
|
||||
label="品名"
|
||||
name="product_name"
|
||||
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
|
||||
>
|
||||
<Input maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item label="供应商" name="supplier_id">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
placeholder="选择供应商"
|
||||
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="包规" name="product_spec" rules={[{ max: 100 }]}>
|
||||
<Input maxLength={100} placeholder="如:10斤/箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="单位" name="unit" rules={[{ max: 20 }]}>
|
||||
<Input maxLength={20} placeholder="如:斤" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="成本"
|
||||
name="cost_price"
|
||||
rules={[{ required: true, message: '请输入成本' }]}
|
||||
>
|
||||
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user