Files
xin-procurement/app/Services/PurchaseGenerateService.php
T
2026-08-11 21:08:01 +08:00

186 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
});
}
}