采购单优化

This commit is contained in:
liu
2026-08-12 14:38:20 +08:00
parent ce1f36b5b4
commit dadfdc1511
30 changed files with 1059 additions and 1441 deletions
+102 -20
View File
@@ -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;
@@ -20,7 +22,8 @@ use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 采购单管理(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 +48,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 +163,7 @@ class PurchaseOrderController extends BaseController
/**
* C1 生成采购单:合并全部「已接单」门店订单(或指定的 order_ids),
* 生成后源订单转为「采购中」并回写 purchase_id
* 生成后源订单转为「采购中」并回写 purchase_id(订单与订货明细同步归集)
*/
#[PostRoute('/generate', 'generate')]
public function generate(Request $request): JsonResponse
@@ -130,128 +213,49 @@ class PurchaseOrderController extends BaseController
}
/**
* C4 采购明细修改:amount 后端重算(weight>0 ? weight×price : quantity×price),
* 同步回写采购单头汇总(Σ total_weight / actual_amount
* C4 门店单元格修改:数量/称重回写订货明细,金额重算并同步订单/采购单汇总
*/
#[PutRoute(route: '/item/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function updateItem(int $id, PurchaseItemUpdateRequest $request): JsonResponse
#[PutRoute(route: '/cell/{id}', authorize: 'update', where: ['id' => '[0-9]+'])]
public function updateCell(int $id, PurchaseCellUpdateRequest $request): JsonResponse
{
$item = PurchaseOrderItemModel::find($id);
$item = StoreOrderItemModel::find($id);
if (empty($item)) {
throw new RepositoryException('采购明细不存在');
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('采购明细不存在');
}
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('采购单不存在');
}
$allocations = PurchaseAllocationModel::query()
->whereIn('purchase_item_id', $purchase->items()->pluck('id'))
->with(['store:id,name', 'product:id,name,unit'])
->get();
$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();
$item = app(PurchaseEditService::class)->updateCell(
$item,
(int) $validated['quantity'],
isset($validated['weight']) ? (float) $validated['weight'] : null,
);
return $this->success([
'by_store' => $byStore->toArray(),
'by_product' => $byProduct->toArray(),
'total_amount' => (float) $allocations->sum('amount'),
'quantity' => (int) $item->quantity,
'weight' => (float) $item->weight,
'amount' => (float) $item->amount,
]);
}
/**
* C4 商品行修改:采购成本同步该商品全部订货明细;实际称重按数量比例分摊(尾差修正守恒)
*/
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
public function updateRow(int $id, int $productId, PurchaseRowUpdateRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$validated = $request->validated();
$costPrice = isset($validated['cost_price']) ? (float) $validated['cost_price'] : null;
$weight = isset($validated['weight']) ? (float) $validated['weight'] : null;
if ($costPrice === null && $weight === null) {
throw new RepositoryException('采购成本与实际称重至少填写一项');
}
$count = app(PurchaseEditService::class)->updateRow($purchase, $productId, $costPrice, $weight);
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;
/**
* 采购明细修改 验证(C4amount 由后端按 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,31 @@
<?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 [
'cost_price' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'cost_price.numeric' => '采购成本必须为数字',
'cost_price.min' => '采购成本不能小于 0',
'weight.numeric' => '实际称重必须为数字',
'weight.min' => '实际称重不能小于 0',
];
}
}
-67
View File
@@ -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');
}
}
-87
View File
@@ -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');
}
}
+13 -9
View File
@@ -9,18 +9,14 @@ 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 STATUS_COMPLETED = 3;
@@ -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');
}
}
-10
View File
@@ -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');
}
/**
* 溯源订货明细
*/
+3 -1
View File
@@ -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
{
-8
View File
@@ -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');
}
/**
* 绑定本供应商的小程序用户
*/
-113
View File
@@ -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;
});
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
/**
* C4 采购单数据修改(明细矩阵:门店单元格 / 商品行)
*
* 采购单无独立明细表,修改直接回写订货明细(store_order_item),并重算:
* 1. 受影响订单汇总(recalculateOrderTotals 同口径:商品金额 + 附加金额)
* 2. 采购单头汇总:estimate_amount = Σ订货金额;actual_amount = Σ(称重>0 ? 称重×成本 : 数量×成本)
*/
class PurchaseEditService
{
/**
* 门店单元格修改:数量(必填)/ 称重(可空),金额按 数量×单价 重算
*/
public function updateCell(StoreOrderItemModel $item, int $quantity, ?float $weight): StoreOrderItemModel
{
return DB::transaction(function () use ($item, $quantity, $weight) {
$item->quantity = $quantity;
if ($weight !== null) {
$item->weight = bcadd((string) $weight, '0', 3);
}
$item->amount = bcmul((string) $quantity, (string) $item->price, 2);
$item->save();
$order = StoreOrderModel::find($item->order_id);
if ($order !== null) {
$this->recalculateOrderTotals($order);
}
if ($item->purchase_id > 0) {
$purchase = PurchaseOrderModel::find($item->purchase_id);
if ($purchase !== null) {
$this->recalculatePurchaseTotals($purchase);
}
}
return $item->refresh();
});
}
/**
* 商品行修改:采购成本(写入该商品全部订货明细)/ 实际称重(按数量比例分摊,尾差修正守恒)
*
* @param float|null $costPrice 采购成本(每包规),NULL 不修改
* @param float|null $weight 行实际称重合计,NULL 不修改
*/
public function updateRow(PurchaseOrderModel $purchase, int $productId, ?float $costPrice, ?float $weight): int
{
return DB::transaction(function () use ($purchase, $productId, $costPrice, $weight) {
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
if ($costPrice !== null) {
$cost = bcadd((string) $costPrice, '0', 2);
foreach ($items as $item) {
$item->cost_price = $cost;
$item->save();
}
}
if ($weight !== null) {
$totalQty = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
'0'
);
$target = bcadd((string) $weight, '0', 3);
$allocated = '0';
$lastIndex = $items->count() - 1;
foreach ($items->values() as $index => $item) {
if ($index === $lastIndex) {
// 尾差修正:最后一行 = 总称重 − 已分摊,保证守恒
$rowWeight = bcsub($target, $allocated, 3);
} elseif ((float) $totalQty > 0) {
$ratio = bcdiv((string) $item->quantity, $totalQty, 6);
$rowWeight = bcmul($target, $ratio, 3);
$allocated = bcadd($allocated, $rowWeight, 3);
} else {
$rowWeight = '0';
}
$item->weight = $rowWeight;
$item->save();
}
}
$orderIds = $items->pluck('order_id')->unique()->all();
StoreOrderModel::whereIn('id', $orderIds)->get()
->each(fn (StoreOrderModel $order) => $this->recalculateOrderTotals($order));
$this->recalculatePurchaseTotals($purchase);
return $items->count();
});
}
/**
* 重算订单汇总(与 StoreOrderController::recalculateOrderTotals 同口径)
*/
public function recalculateOrderTotals(StoreOrderModel $order): void
{
$totals = StoreOrderItemModel::query()
->where('order_id', $order->id)
->selectRaw('COALESCE(SUM(quantity), 0) as total_quantity')
->selectRaw('COALESCE(SUM(weight), 0) as total_weight')
->selectRaw('COALESCE(SUM(amount), 0) as product_amount')
->first();
$productAmount = bcadd((string) $totals->product_amount, '0', 2);
$order->total_quantity = (int) $totals->total_quantity;
$order->total_weight = bcadd((string) $totals->total_weight, '0', 3);
$order->product_amount = $productAmount;
$order->total_amount = bcadd($productAmount, (string) $order->added_amount, 2);
$order->save();
}
/**
* 重算采购单头汇总:estimate_amount = Σ订货金额(销售口径);
* actual_amount = Σ(称重>0 ? 称重×成本 : 数量×成本)(采购成本口径)
*/
public function recalculatePurchaseTotals(PurchaseOrderModel $purchase): void
{
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->get(['quantity', 'weight', 'amount', 'cost_price', 'product_spec']);
$totalQuantity = '0';
$totalWeight = '0';
$estimate = '0';
$actual = '0';
foreach ($items as $item) {
$unitCost = self::unitCost((string) ($item->cost_price ?? '0'), (string) $item->product_spec);
$totalQuantity = bcadd($totalQuantity, (string) $item->quantity, 2);
$totalWeight = bcadd($totalWeight, (string) $item->weight, 3);
$estimate = bcadd($estimate, (string) $item->amount, 2);
$actual = bcadd($actual, self::costAmount((string) $item->quantity, (string) $item->weight, $unitCost), 2);
}
$purchase->total_quantity = $totalQuantity;
$purchase->total_weight = $totalWeight;
$purchase->estimate_amount = $estimate;
$purchase->actual_amount = $actual;
$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);
}
}
+15 -108
View File
@@ -3,10 +3,8 @@
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;
@@ -15,11 +13,9 @@ use Illuminate\Support\Facades\DB;
*
* 流程(事务内):
* 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 溯源)
* 2. 创建采购单头(PO 单号;total_quantity / estimate_amount 取订货明细汇总
* 3. 源订单批量回写 status = 采购中、purchase_id = 采购单ID
* 订货明细同步回写 purchase_id(采购明细直接溯源订货明细,无独立采购明细表
*/
class PurchaseGenerateService
{
@@ -53,96 +49,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, (string) $item->amount, 2),
'0'
);
// 4. 采购单头
$purchase = PurchaseOrderModel::create([
'purchase_no' => $this->billNumberService->make('PO'),
'purchase_date' => $date,
@@ -154,30 +77,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;
});
+54 -71
View File
@@ -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_amountstatus → 对账中
* 3. 汇总写回头的 publish/actual/diff_amountstatus → 对账中
*/
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);