Files
xin-procurement/app/Services/BillGenerateService.php
T
2026-08-14 01:19:47 +08:00

146 lines
6.5 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\ContainerReturnModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* 门店账单生成(采购单完成后,按门店各生成一张账单)
*
* 流程(事务内):
* 1. 锁定采购单全部有效订单,按门店分组,校验提交门店完整覆盖
* 2. 商品金额 = 门店订单商品金额汇总(快照,生成后不可修改)
* 3. 附加金额 = 周转筐数量×筐单价 + 托盘数量×托盘单价(单价取站点配置快照)
* 4. 总金额 = 商品金额 + 配送费 + 附加金额;回写门店订单 bill_id 完成关联
*/
readonly class BillGenerateService
{
public function __construct(private BillNumberService $billNumberService)
{
}
/**
* @param PurchaseOrderModel $purchase 已完成采购单
* @param array<int, array{store_id: int, delivery_fee: string, box_num: int, tray_num: int}> $stores 按门店提交的配送费/周转筐/托盘数量
* @param int $operatorId 生成人(后台系统用户ID)
* @return BillModel[] 生成的账单列表
* @throws Throwable
*/
public function generate(PurchaseOrderModel $purchase, array $stores, int $operatorId): array
{
return DB::transaction(function () use ($purchase, $stores, $operatorId) {
// 1. 行锁采购单全部有效订单(并发防护),按门店分组
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->lockForUpdate()
->get();
if ($orders->isEmpty()) {
throw new RepositoryException('采购单下无门店订单,无法生成账单');
}
$ordersByStore = $orders->groupBy('store_id');
// 提交的门店必须完整覆盖采购单门店,避免漏门店造成订单未入账
$submitted = [];
foreach ($stores as $row) {
$submitted[(int) $row['store_id']] = $row;
}
$missing = array_diff(array_map('intval', $ordersByStore->keys()->all()), array_keys($submitted));
if ($missing !== []) {
throw new RepositoryException('提交不完整,缺少门店账单信息:' . implode('、', $missing));
}
if (array_diff(array_keys($submitted), array_map('intval', $ordersByStore->keys()->all())) !== []) {
throw new RepositoryException('包含不属于该采购单的门店');
}
// 2. 防重复生成:任一门店已出账则整批拒绝
$billedStoreIds = BillModel::query()
->where('purchase_id', $purchase->id)
->pluck('store_id')
->map(static fn ($id) => (int) $id)
->all();
if ($billedStoreIds !== []) {
$names = StoreModel::withTrashed()
->whereIn('id', $billedStoreIds)
->pluck('name')
->implode('、');
throw new RepositoryException('以下门店已生成账单,不允许重复生成:' . $names);
}
// 3. 逐门店生成账单并关联订单
$boxPrice = (string) site_config('services.box_amount', 0);
$trayPrice = (string) site_config('services.tray_amount', 0);
$billDate = now()->toDateString();
$bills = [];
foreach ($ordersByStore as $storeId => $storeOrders) {
$row = $submitted[(int) $storeId];
$productAmount = $storeOrders->reduce(
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'0'
);
$deliveryFee = bcadd((string) $row['delivery_fee'], '0', 2);
$addedAmount = bcadd(
bcmul((string) (int) $row['box_num'], $boxPrice, 2),
bcmul((string) (int) $row['tray_num'], $trayPrice, 2),
2
);
$totalAmount = bcadd(bcadd($productAmount, $deliveryFee, 2), $addedAmount, 2);
$bill = BillModel::create([
'bill_no' => $this->billNumberService->make('ZD'),
'purchase_id' => $purchase->id,
'store_id' => (int) $storeId,
'bill_date' => $billDate,
'product_amount' => $productAmount,
'delivery_fee' => $deliveryFee,
'box_num' => (int) $row['box_num'],
'tray_num' => (int) $row['tray_num'],
'box_price' => bcadd($boxPrice, '0', 2),
'tray_price' => bcadd($trayPrice, '0', 2),
'added_amount' => $addedAmount,
'total_amount' => $totalAmount,
'operator_id' => $operatorId,
]);
// 关联该门店在采购单中的全部订单与订单明细到账单
StoreOrderModel::query()
->whereIn('id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->id]);
StoreOrderItemModel::query()
->whereIn('order_id', $storeOrders->pluck('id'))
->update(['bill_id' => $bill->id]);
// 压筐:累加门店待回筐/托盘(行锁防并发),并写入压筐记录
$store = StoreModel::query()->lockForUpdate()->find((int) $storeId);
if ($store !== null) {
$store->pending_box_num = (int) $store->pending_box_num + (int) $row['box_num'];
$store->pending_tray_num = (int) $store->pending_tray_num + (int) $row['tray_num'];
$store->save();
}
ContainerReturnModel::create([
'store_id' => (int) $storeId,
'bill_id' => $bill->id,
'type' => ContainerReturnModel::TYPE_PRESS,
'box_num' => (int) $row['box_num'],
'tray_num' => (int) $row['tray_num'],
'return_date' => $billDate,
'operator_id' => $operatorId,
]);
$bills[] = $bill;
}
return $bills;
});
}
}