账单生成

This commit is contained in:
liu
2026-08-14 00:15:28 +08:00
parent 61a70319a0
commit e35c951a59
19 changed files with 728 additions and 631 deletions
@@ -68,14 +68,6 @@ class StoreOrderController extends BaseController
}
unset($order);
$box_amount = site_config('services.box_amount');
$tray_amount = site_config('services.tray_amount');
foreach ($data['data'] as &$item) {
$item['box_price'] = number_format($box_amount, 2);
$item['tray_price'] = number_format($tray_amount, 2);
$item['box_amount'] = number_format($box_amount * $item['box_num'], 2);
$item['tray_amount'] = number_format($tray_amount * $item['tray_num'], 2);
}
return $this->success($data);
}
@@ -96,14 +88,8 @@ class StoreOrderController extends BaseController
$data = $order->toArray();
// 明细首图 + 附加金额(与列表接口一致)
// 明细首图
app(ItemImageResolver::class)->resolve($data['items']);
$boxAmount = site_config('services.box_amount');
$trayAmount = site_config('services.tray_amount');
$data['box_price'] = number_format($boxAmount, 2);
$data['tray_price'] = number_format($trayAmount, 2);
$data['box_amount'] = number_format($boxAmount * $data['box_num'], 2);
$data['tray_amount'] = number_format($trayAmount * $data['tray_num'], 2);
return $this->success($data);
}
@@ -4,17 +4,18 @@ namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
use App\Models\BillModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Services\BillGenerateService;
use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService;
use App\Services\StoreOrderContainerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -135,44 +136,21 @@ class PurchaseOrderController extends BaseController
[$a['category_sort'], $a['product_sort'], $a['product_id']]
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
// 周转框/托盘合并记录:按门店聚合全部订单(附底层订单明细,供采购单完成前修改
$storeOrders = StoreOrderModel::query()
// 门店账单:采购单完成后按门店生成(含软删除门店,保证历史单据可见
$bills = BillModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->with('store:id,name')
->orderBy('id')
->get(['id', 'order_no', 'store_id', 'box_num', 'tray_num']);
$boxPrice = (float) site_config('services.box_amount', 0);
$trayPrice = (float) site_config('services.tray_amount', 0);
$storeSort = $stores->pluck('id')->flip();
$containers = [];
foreach ($storeOrders->groupBy('store_id') as $storeId => $orders) {
$boxNum = (int) $orders->sum('box_num');
$trayNum = (int) $orders->sum('tray_num');
$containers[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->firstWhere('id', (int) $storeId)['name'] ?? '门店#' . $storeId,
'box_num' => $boxNum,
'tray_num' => $trayNum,
'box_price' => number_format($boxPrice, 2),
'tray_price' => number_format($trayPrice, 2),
'added_amount' => number_format($boxNum * $boxPrice + $trayNum * $trayPrice, 2),
'order_count' => $orders->count(),
'orders' => $orders->map(static fn (StoreOrderModel $order) => [
'order_id' => $order->id,
'order_no' => $order->order_no,
'box_num' => (int) $order->box_num,
'tray_num' => (int) $order->tray_num,
])->values()->toArray(),
'store_sort' => (int) ($storeSort[$storeId] ?? 9999),
];
}
usort($containers, static fn (array $a, array $b): int =>
[$a['store_sort'], $a['store_id']] <=> [$b['store_sort'], $b['store_id']]);
$containers = array_map(static function (array $row): array {
unset($row['store_sort']);
return $row;
}, $containers);
->get()
->map(static function (BillModel $bill): array {
$row = $bill->toArray();
$row['store_name'] = $bill->store->name ?? ('门店#' . $bill->store_id);
$row['order_count'] = $bill->orders()->count();
unset($row['store']);
return $row;
})
->values()
->toArray();
return $this->success([
'purchase' => $purchase->toArray(),
@@ -181,7 +159,7 @@ class PurchaseOrderController extends BaseController
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows),
'containers' => $containers,
'bills' => $bills,
]);
}
@@ -232,7 +210,6 @@ class PurchaseOrderController extends BaseController
StoreOrderModel::where('purchase_id', $purchase->id)->update([
'status' => StoreOrderModel::STATUS_DISTRIBUTION
]);
// 生成并发送账单
return $this->success();
});
@@ -438,71 +415,81 @@ class PurchaseOrderController extends BaseController
}
/**
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改)
* @throws Throwable
* 账单生成预览
*/
#[PutRoute(route: '/{id}/container/{storeId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
public function updateContainer(int $id, int $storeId, PurchaseContainerUpdateRequest $request): JsonResponse
#[GetRoute(route: '/{id}/bill/prepare', authorize: 'query', where: ['id' => '[0-9]+'])]
public function billPrepare(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->orderBy('id')
->get(['id', 'store_id', 'total_amount']);
$stores = StoreModel::withTrashed()
->whereIn('id', $orders->pluck('store_id')->unique())
->get(['id', 'name'])
->keyBy('id');
$bills = BillModel::query()
->where('purchase_id', $purchase->id)
->get()
->keyBy('store_id');
$boxPrice = number_format((float) site_config('services.box_amount', 0), 2);
$trayPrice = number_format((float) site_config('services.tray_amount', 0), 2);
$rows = [];
foreach ($orders->groupBy('store_id') as $storeId => $storeOrders) {
$productAmount = $storeOrders->reduce(
static fn (string $carry, StoreOrderModel $order): string => bcadd($carry, (string) $order->total_amount, 2),
'0'
);
$bill = $bills->get((int) $storeId);
$rows[] = [
'store_id' => (int) $storeId,
'store_name' => $stores->get((int) $storeId)->name ?? ('门店#' . $storeId),
'order_count' => $storeOrders->count(),
'product_amount' => $productAmount,
'box_price' => $boxPrice,
'tray_price' => $trayPrice,
'bill' => $bill?->toArray(),
];
}
$submitted = $request->validated()['orders'];
return $this->success([
'purchase' => $purchase->only(['id', 'purchase_no', 'status']),
'stores' => $rows,
]);
}
return DB::transaction(function () use ($purchase, $storeId, $submitted) {
$orders = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->whereNull('deleted_at')
->lockForUpdate()
->get();
/**
* 生成账单
* @throws Throwable
*/
#[PostRoute(route: '/{id}/bill', authorize: 'bill', where: ['id' => '[0-9]+'])]
public function generateBill(int $id, PurchaseBillGenerateRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_COMPLETED) {
throw new RepositoryException('采购单未完成,不允许生成账单');
}
if ($orders->isEmpty()) {
throw new RepositoryException('该采购单下无此门店的订单');
}
$bills = app(BillGenerateService::class)->generate(
$purchase,
$request->validated()['stores'],
(int) $request->user()->id,
);
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差
$actualIds = $orders->pluck('id')->map(static fn ($orderId) => (int) $orderId)->all();
$submittedIds = array_map(static fn (array $row) => (int) $row['order_id'], $submitted);
$missing = array_diff($actualIds, $submittedIds);
if ($missing !== []) {
throw new RepositoryException('提交不完整,缺少订单:' . implode('、', $missing));
}
if (array_diff($submittedIds, $actualIds) !== []) {
throw new RepositoryException('包含不属于该采购单该门店的订单');
}
foreach ($orders as $order) {
if (! in_array($order->status, [
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
], true)) {
throw new RepositoryException(
'订单 ' . $order->order_no . ' 当前状态为「'
. (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status)
. '」,不允许修改周转框/托盘数量'
);
}
}
$ordersById = $orders->keyBy('id');
$containerService = app(StoreOrderContainerService::class);
foreach ($submitted as $row) {
$containerService->update(
$ordersById->get($row['order_id']),
(int) $row['box_num'],
(int) $row['tray_num'],
);
}
return $this->success();
});
return $this->success(['count' => count($bills)], '已生成 ' . count($bills) . ' 张门店账单');
}
/**
@@ -575,9 +562,8 @@ class PurchaseOrderController extends BaseController
// 重算订单金额
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
$order->product_amount = $order->items()->sum('amount');
$order->total_weight = $order->items()->sum('weight');
$order->total_amount = bcadd($order->product_amount, $order->added_amount, 2);
$order->total_amount = $order->items()->sum('amount');
$order->save();
// 重算采购单重量
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单生成账单 验证(按门店提交配送费/周转筐/托盘数量,金额由系统汇总不可修改)
*/
class PurchaseBillGenerateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'stores' => 'required|array|min:1',
'stores.*.store_id' => 'required|integer|distinct',
'stores.*.delivery_fee' => 'required|numeric|min:0',
'stores.*.box_num' => 'required|integer|min:0',
'stores.*.tray_num' => 'required|integer|min:0',
];
}
public function messages(): array
{
return [
'stores.required' => '请提交门店账单信息',
'stores.array' => '门店账单数据格式错误',
'stores.min' => '请提交门店账单信息',
'stores.*.store_id.required' => '门店ID不能为空',
'stores.*.store_id.integer' => '门店ID格式错误',
'stores.*.store_id.distinct' => '存在重复门店',
'stores.*.delivery_fee.required' => '配送费不能为空',
'stores.*.delivery_fee.numeric' => '配送费格式错误',
'stores.*.delivery_fee.min' => '配送费不能小于 0',
'stores.*.box_num.required' => '周转筐数量不能为空',
'stores.*.box_num.integer' => '周转筐数量必须为整数',
'stores.*.box_num.min' => '周转筐数量不能小于 0',
'stores.*.tray_num.required' => '周转托盘数量不能为空',
'stores.*.tray_num.integer' => '周转托盘数量必须为整数',
'stores.*.tray_num.min' => '周转托盘数量不能小于 0',
];
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单周转框/托盘合并记录修改 验证(按门店覆盖全部订单,逐笔重算附加金额)
*/
class PurchaseContainerUpdateRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
return [
'orders' => 'required|array|min:1',
'orders.*.order_id' => 'required|integer|distinct',
'orders.*.box_num' => 'required|integer|min:0',
'orders.*.tray_num' => 'required|integer|min:0',
];
}
public function messages(): array
{
return [
'orders.required' => '请提交门店订单周转框/托盘数量',
'orders.array' => '订单数据格式错误',
'orders.min' => '请提交门店订单周转框/托盘数量',
'orders.*.order_id.required' => '订单ID不能为空',
'orders.*.order_id.integer' => '订单ID格式错误',
'orders.*.order_id.distinct' => '存在重复订单',
'orders.*.box_num.required' => '周转框数量不能为空',
'orders.*.box_num.integer' => '周转框数量必须为整数',
'orders.*.box_num.min' => '周转框数量不能小于 0',
'orders.*.tray_num.required' => '周转托盘数量不能为空',
'orders.*.tray_num.integer' => '周转托盘数量必须为整数',
'orders.*.tray_num.min' => '周转托盘数量不能小于 0',
];
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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;
use Modules\SystemUser\Models\SysUserModel;
/**
* 门店账单模型(采购单完成后按门店生成;商品金额由订单汇总快照,生成后不可修改)
*/
class BillModel extends Model
{
use HasFactory;
protected $table = 'bill';
protected $primaryKey = 'id';
protected $fillable = [
'bill_no',
'purchase_id',
'store_id',
'bill_date',
'product_amount',
'delivery_fee',
'box_num',
'tray_num',
'box_price',
'tray_price',
'added_amount',
'total_amount',
'operator_id',
'remark',
];
protected $casts = [
'purchase_id' => 'integer',
'store_id' => 'integer',
'bill_date' => 'date:Y-m-d',
'product_amount' => 'decimal:2',
'delivery_fee' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'box_price' => 'decimal:2',
'tray_price' => 'decimal:2',
'added_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
* 所属门店(含软删除门店,保证历史账单可见)
*/
public function store(): BelongsTo
{
return $this->belongsTo(StoreModel::class, 'store_id', 'id')->withTrashed();
}
/**
* 关联采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 生成人(后台系统用户)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SysUserModel::class, 'operator_id', 'id');
}
/**
* 本账单关联的门店订单
*/
public function orders(): HasMany
{
return $this->hasMany(StoreOrderModel::class, 'bill_id', 'id');
}
}
+10 -8
View File
@@ -48,27 +48,21 @@ class StoreOrderModel extends Model
'total_quantity',
'total_weight',
'total_amount',
'product_amount',
'added_amount',
'box_num',
'tray_num',
'status',
'remark',
'purchase_id',
'bill_id',
];
protected $casts = [
'store_id' => 'integer',
'purchase_id' => 'integer',
'statement_id' => 'integer',
'bill_id' => 'integer',
'order_date' => 'date:Y-m-d',
'total_quantity' => 'integer',
'total_weight' => 'decimal:3',
'total_amount' => 'decimal:2',
'product_amount' => 'decimal:2',
'added_amount' => 'decimal:2',
'box_num' => 'integer',
'tray_num' => 'integer',
'status' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
@@ -96,4 +90,12 @@ class StoreOrderModel extends Model
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
/**
* 关联账单(采购单完成后按门店生成)
*/
public function bill(): BelongsTo
{
return $this->belongsTo(BillModel::class, 'bill_id', 'id');
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Models\BillModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
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]);
$bills[] = $bill;
}
return $bills;
});
}
}
+2 -1
View File
@@ -27,12 +27,13 @@ class BillNumberService
'RC' => ['reconciliation', 'recon_no'],
'ST' => ['statement', 'statement_no'],
'JS' => ['settlement', 'settlement_no'],
'ZD' => ['bill', 'bill_no'],
];
/**
* 生成业务单号
*
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算
* @param string $prefix 业务前缀:PO 采购单 / SO 订货单 / RC 对账 / ST 对账单 / JS 结算 / ZD 账单
* @return string 如 PO202607230001
*/
public function make(string $prefix): string
@@ -1,34 +0,0 @@
<?php
namespace App\Services;
use App\Models\StoreOrderModel;
/**
* 周转框/周转托盘数量修改(订单附加金额重算)
*/
readonly class StoreOrderContainerService
{
/**
* 更新订单周转框/托盘数量并重算附加金额与订单总金额。
* 历史订单未写商品金额:按「总额 - 附加」反推并回写,保证 总额 = 商品 + 附加 恒成立。
*/
public function update(StoreOrderModel $order, int $boxNum, int $trayNum): void
{
$boxPrice = (float) site_config('services.box_amount', 0);
$trayPrice = (float) site_config('services.tray_amount', 0);
$addedAmount = round($boxNum * $boxPrice + $trayNum * $trayPrice, 2);
$productAmount = (float) $order->product_amount;
if ($productAmount <= 0 && (float) $order->total_amount > 0) {
$productAmount = round((float) $order->total_amount - (float) $order->added_amount, 2);
}
$order->box_num = $boxNum;
$order->tray_num = $trayNum;
$order->product_amount = $productAmount;
$order->added_amount = $addedAmount;
$order->total_amount = round($productAmount + $addedAmount, 2);
$order->save();
}
}