186 lines
8.0 KiB
PHP
186 lines
8.0 KiB
PHP
<?php
|
||
|
||
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\StoreOrderModel;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
public function __construct(private readonly BillNumberService $billNumberService)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* @param string $date 采购日期(Y-m-d)
|
||
* @param int $operatorId 制单人(后台系统用户ID)
|
||
* @param int[] $orderIds 指定合并的门店订单ID(空 = 全部已接单订单)
|
||
*/
|
||
public function generate(string $date, int $operatorId, array $orderIds = []): PurchaseOrderModel
|
||
{
|
||
return DB::transaction(function () use ($date, $operatorId, $orderIds) {
|
||
// 1. 行锁已接单订单(并发防护);指定订单时要求全部处于已接单,否则整批拒绝
|
||
$query = StoreOrderModel::query()->lockForUpdate();
|
||
if ($orderIds !== []) {
|
||
$orders = $query->whereIn('id', $orderIds)->get();
|
||
$invalid = $orders->where('status', '<>', StoreOrderModel::STATUS_SUMMARIZED);
|
||
if ($orders->isEmpty() || $invalid->isNotEmpty()) {
|
||
throw new RepositoryException(
|
||
'所选订单包含非「已接单」状态,无法生成采购单:' . $invalid->pluck('order_no')->implode('、')
|
||
);
|
||
}
|
||
} else {
|
||
$orders = $query->where('status', StoreOrderModel::STATUS_SUMMARIZED)->get();
|
||
}
|
||
|
||
if ($orders->isEmpty()) {
|
||
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
|
||
);
|
||
}
|
||
}
|
||
|
||
if ($aggregated === []) {
|
||
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),
|
||
'0'
|
||
);
|
||
$totalQuantity = array_reduce(
|
||
$rows,
|
||
static fn (string $carry, array $row): string => bcadd($carry, $row['quantity'], 2),
|
||
'0'
|
||
);
|
||
|
||
// 4. 采购单头
|
||
$purchase = PurchaseOrderModel::create([
|
||
'purchase_no' => $this->billNumberService->make('PO'),
|
||
'purchase_date' => $date,
|
||
'status' => PurchaseOrderModel::STATUS_PENDING,
|
||
'total_quantity' => $totalQuantity,
|
||
'total_weight' => 0,
|
||
'estimate_amount' => $estimateAmount,
|
||
'actual_amount' => 0,
|
||
'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 溯源)
|
||
StoreOrderModel::whereIn('id', $sourceOrderIds)
|
||
->update([
|
||
'status' => StoreOrderModel::STATUS_DELIVERING,
|
||
'purchase_id' => $purchase->id,
|
||
]);
|
||
|
||
return $purchase;
|
||
});
|
||
}
|
||
}
|