Compare commits

...

5 Commits

Author SHA1 Message Date
xinadmin 61a70319a0 优化采购单 2026-08-13 12:34:32 +08:00
xinadmin 099e92b1ff 优化采购单 2026-08-13 10:53:11 +08:00
xinadmin 79e3ed4a8a 导出 2026-08-13 00:35:09 +08:00
xinadmin 91ea91784d 移除旧版导出 2026-08-13 00:13:13 +08:00
xinadmin 6c794d0732 采购单优化 2026-08-13 00:11:44 +08:00
22 changed files with 1201 additions and 524 deletions
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -16,8 +16,7 @@ use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单导出(C2 全品类按分类 sort 排序 / C3 仅蔬果分类)
* 数据源为订货明细按商品聚合(无独立采购明细表),每门店一列显示数量
* 采购单导出
*/
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
@@ -135,7 +134,7 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
return array_merge(
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
array_map(static fn (string $name): string => $name . '(数量)', array_values($this->storeNames)),
array_values($this->storeNames),
);
}
@@ -83,23 +83,4 @@ class StatementController extends BaseMiniController
return $this->success($statement->toArray());
}
/** 导出对账单:?format=xlsx|pdf */
#[GetRoute('/statement/{id}/export', authorize: true, where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$user = $this->currentUser($request);
$store = $this->ensureStoreBound($user);
$statement = StatementModel::where('store_id', $store->id)->find($id);
if ($statement === null) {
throw new RepositoryException('对账单不存在');
}
return app(ExportService::class)->download(
'statement',
$statement,
(string) $request->query('format', ExportService::FORMAT_XLSX),
);
}
}
@@ -38,6 +38,7 @@ class StoreOrderController extends BaseController
$query = StoreOrderModel::query()->with([
'store:id,name,address,contact,phone',
'items:id,order_id,product_id,product_name,product_spec,unit,price,quantity,amount,image_ids',
'purchase:id,purchase_no,purchase_date,status'
]);
// 按包含的商品名称搜索:任一明细品名包含关键字即命中
@@ -206,60 +207,6 @@ class StoreOrderController extends BaseController
return $this->success();
}
/**
* 修改周转框/周转托盘数量
*/
#[PutRoute(route: '/{id}/container', authorize: 'update', where: ['id' => '[0-9]+'])]
public function container(int $id, Request $request): JsonResponse
{
$data = $request->validate([
'box_num' => 'required|integer|min:0',
'tray_num' => 'required|integer|min:0',
], [
'box_num.required' => '周转框数量不能为空',
'box_num.integer' => '周转框数量必须为整数',
'box_num.min' => '周转框数量不能小于 0',
'tray_num.required' => '周转托盘数量不能为空',
'tray_num.integer' => '周转托盘数量必须为整数',
'tray_num.min' => '周转托盘数量不能小于 0',
]);
$order = StoreOrderModel::find($id);
if (empty($order)) {
throw new RepositoryException('订单不存在');
}
$editable = [
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
];
if (! in_array($order->status, $editable, true)) {
throw new RepositoryException(
'订单当前状态为「' . (StoreOrderModel::STATUS_NAMES[$order->status] ?? $order->status) . '」,不允许修改周转框/托盘数量'
);
}
$boxPrice = (float) site_config('services.box_amount', 0);
$trayPrice = (float) site_config('services.tray_amount', 0);
$addedAmount = round($data['box_num'] * $boxPrice + $data['tray_num'] * $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 = (int) $data['box_num'];
$order->tray_num = (int) $data['tray_num'];
$order->product_amount = $productAmount;
$order->added_amount = $addedAmount;
$order->total_amount = round($productAmount + $addedAmount, 2);
$order->save();
return $this->success();
}
/**
* 状态流转合法路径
*
@@ -3,7 +3,9 @@
namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
@@ -12,19 +14,21 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
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;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* 采购单管理C1 汇总生成 / C2-C3 导出 / C4 修改)
* 采购单无独立明细表,明细直接溯源门店订货明细(store_order_item.purchase_id
* 采购单管理
*/
#[RequestAttribute('/purchase/order', 'purchase.order')]
class PurchaseOrderController extends BaseController
@@ -131,6 +135,45 @@ 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()
->where('purchase_id', $purchase->id)
->whereNull('deleted_at')
->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);
return $this->success([
'purchase' => $purchase->toArray(),
'stores' => $stores->toArray(),
@@ -138,29 +181,65 @@ class PurchaseOrderController extends BaseController
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows),
'containers' => $containers,
]);
}
/** 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,
'actual_amount' => 'required|numeric|min:0',
'remark' => 'nullable|string|max:255',
], [
'purchase_date.date_format' => '采购日期格式为 Y-m-d',
'status.in' => '采购单状态不正确',
'actual_amount.required' => '实际金额不能为空',
'actual_amount.numeric' => '实际金额格式错误',
'actual_amount.min' => '实际金额不能小于0',
'remark.max' => '备注超过最大长度',
]);
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改信息');
}
$purchase->update(array_filter($data, static fn ($v) => $v !== null));
return $this->success();
}
/**
* 完成采购单
* @throws
*/
#[PutRoute(route: '/{id}/finish', authorize: 'update', where: ['id' => '[0-9]+'])]
public function finish(int $id): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改信息');
}
return DB::transaction(function () use ($purchase) {
// 修改采购单状态
$purchase->status = PurchaseOrderModel::STATUS_COMPLETED;
$purchase->save();
// 修改所有订单状态为配送中
StoreOrderModel::where('purchase_id', $purchase->id)->update([
'status' => StoreOrderModel::STATUS_DISTRIBUTION
]);
// 生成并发送账单
return $this->success();
});
}
/**
* 生成采购单
*/
@@ -190,6 +269,29 @@ class PurchaseOrderController extends BaseController
);
}
/**
* 导出采购单 Excel 表格
*
* @throws
*/
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$type = (string) $request->query('type', 'all');
if (! in_array($type, ['all', 'category'], true)) {
throw new RepositoryException('导出类型参数不正确(all 全品类 / category 蔬果分类)');
}
return Excel::download(
new PurchaseOrderExport($purchase, $type),
$purchase->purchase_no . '_采购单.xlsx',
);
}
/**
* 单元格下钻:采购单中某门店某商品的全部订货明细
*/
@@ -243,8 +345,6 @@ class PurchaseOrderController extends BaseController
'amount' => (string) $item->amount,
'remark' => (string) $item->remark,
'image_ids' => $item->image_ids,
'editable' => $purchase->status === PurchaseOrderModel::STATUS_PENDING
&& in_array((int) $item->order_status, StoreOrderItemModel::ITEM_EDITABLE_STATUS, true),
];
}
app(ItemImageResolver::class)->resolve($rows);
@@ -259,6 +359,152 @@ class PurchaseOrderController extends BaseController
]);
}
/**
* 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
*/
#[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])]
public function storeSummary(int $id, Request $request): JsonResponse
{
$storeId = (int) $request->query('store_id', 0);
if ($storeId <= 0) {
throw new RepositoryException('缺少门店参数');
}
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$items = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $purchase->id)
->where('store_order_item.store_id', $storeId)
->whereNull('store_order.deleted_at')
->select('store_order_item.*')
->orderBy('store_order_item.id')
->get();
// 排序键:分类 sort → 商品 sort(与明细矩阵同序)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', $items->pluck('product_id')->unique())
->get()
->keyBy('id');
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$product = $products->get((int) $productId);
$first = $group->first();
// 采购总数量(包数)
$quantity = 0;
// 预计金额 = Σ 明细 amount
$amount = '0';
// 总重量
$weight = '0';
foreach ($group as $item) {
$weight = bcadd($weight, (string) $item->weight, 3);
$quantity += (int) $item->quantity;
$amount = bcadd($amount, (string) $item->amount, 2);
}
$rows[] = [
'product_id' => (int) $productId,
'product_name' => $first->product_name,
'product_spec' => $first->product_spec, // 包规
'unit' => $first->unit, // 单位
'price' => $quantity > 0 // 加权平均单价
? bcdiv($amount, (string) $quantity, 2)
: (string) $first->price,
'quantity' => $quantity,
'amount' => $amount,
'weight' => $weight,
'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']]);
$store = StoreModel::withTrashed()->find($storeId);
return $this->success([
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
'items' => array_map(static function (array $row): array {
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows),
]);
}
/**
* 周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(仅采购单进行中可改)
* @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
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改周转框/托盘');
}
$submitted = $request->validated()['orders'];
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();
if ($orders->isEmpty()) {
throw new RepositoryException('该采购单下无此门店的订单');
}
// 合并记录修改必须覆盖该门店全部订单,避免只改部分造成汇总偏差
$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();
});
}
/**
* 商品行修改:品名/供应商/包规/单位/成本
* @throws Throwable
@@ -4,13 +4,11 @@ namespace App\Http\Controllers\Recon;
use App\Exceptions\RepositoryException;
use App\Models\SettlementModel;
use App\Services\ExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\RequestAttribute;
use Modules\Common\Http\Controllers\BaseController;
use Symfony\Component\HttpFoundation\Response;
/**
* 结算表管理(D9 生成于对账结算,D10 导出下载存档)
@@ -52,26 +50,4 @@ class SettlementController extends BaseController
}
return $this->success($settlement->toArray());
}
/**
* D10 导出下载:?format=xlsx|pdf,成功后回写 file_path 存档标记
*/
#[GetRoute(route: '/{id}/download', authorize: 'download', where: ['id' => '[0-9]+'])]
public function download(int $id, Request $request): Response
{
$settlement = SettlementModel::find($id);
if (empty($settlement)) {
throw new RepositoryException('结算表不存在');
}
$format = (string) $request->query('format', ExportService::FORMAT_XLSX);
$response = app(ExportService::class)->download('settlement', $settlement, $format);
// 同步流式下载不落盘,file_path 仅作存档标记(后续切队列导出时替换为真实文件路径)
$extension = $format === ExportService::FORMAT_PDF ? 'pdf' : 'xlsx';
$settlement->file_path = 'exports/settlement/' . $settlement->settlement_no . '.' . $extension;
$settlement->save();
return $response;
}
}
@@ -0,0 +1,41 @@
<?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',
];
}
}
+1
View File
@@ -43,6 +43,7 @@ class PurchaseOrderModel extends Model
'estimate_amount' => 'decimal:2',
'actual_amount' => 'decimal:2',
'operator_id' => 'integer',
'created_at' => 'datetime:Y-m-d H:i:s',
];
/**
+8
View File
@@ -88,4 +88,12 @@ class StoreOrderModel extends Model
{
return $this->hasMany(StoreOrderItemModel::class, 'order_id', 'id');
}
/**
* 采购单
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(PurchaseOrderModel::class, 'purchase_id', 'id');
}
}
-111
View File
@@ -1,111 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Exports\SettlementExport;
use App\Exports\StatementExport;
use App\Models\PurchaseOrderModel;
use App\Models\SettlementModel;
use App\Models\StatementModel;
use Barryvdh\DomPDF\Facade\Pdf;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\Response;
/**
* 导出统一入口:按业务类型 + format 分发到 app/Exports 导出类 / PDF 模板
*
* - Excel 分支:Excel::download(new XxxExport(...))
* - PDF 分支:Pdf::loadView('exports.xxx', ...)->setPaper('a4')->download(...),模板统一 font-family: SimHei
* - 文件名规范:{单号}_{业务名}.{ext},中文文件名由响应自动做 RFC 5987 编码
*/
class ExportService
{
/** 导出格式:Excel */
public const FORMAT_XLSX = 'xlsx';
/** 导出格式:PDF */
public const FORMAT_PDF = 'pdf';
/**
* 导出下载
*
* @param string $business 业务类型:purchase 采购单 / statement 门店对账单 / settlement 结算表
* @param mixed $subject 业务主体(如 PurchaseOrderModel / StatementModel / SettlementModel 实例)
* @param string $format 导出格式 xlsx|pdf,默认 xlsx,非法值报错
* @param string|null $type 业务子类型(purchase 专用:all 全品类 / category 仅蔬果分类)
* @return Response 文件流响应(blob
*/
public function download(
string $business,
mixed $subject,
string $format = self::FORMAT_XLSX,
?string $type = null,
): Response {
if (! in_array($format, [self::FORMAT_XLSX, self::FORMAT_PDF], true)) {
throw new RepositoryException('导出格式参数不正确(仅支持 xlsx / pdf');
}
return match ($business) {
'purchase' => $this->exportPurchase($subject, $format, $type),
'statement' => $this->exportStatement($subject, $format),
'settlement' => $this->exportSettlement($subject, $format),
default => throw new RepositoryException('不支持的导出业务类型:' . $business),
};
}
/**
* C2/C3 采购单导出
*/
private function exportPurchase(PurchaseOrderModel $purchase, string $format, ?string $type): Response
{
$type = in_array($type, ['all', 'category'], true) ? $type : 'all';
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $purchase->purchase_no . '_采购单.' . $extension;
$export = new PurchaseOrderExport($purchase, $type);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.purchase', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* 门店对账单导出
*/
private function exportStatement(StatementModel $statement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $statement->statement_no . '_对账单.' . $extension;
$export = new StatementExport($statement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.statement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
/**
* D10 结算表导出
*/
private function exportSettlement(SettlementModel $settlement, string $format): Response
{
$extension = $format === self::FORMAT_PDF ? 'pdf' : 'xlsx';
$filename = $settlement->settlement_no . '_结算表.' . $extension;
$export = new SettlementExport($settlement);
if ($format === self::FORMAT_PDF) {
return Pdf::loadView('exports.settlement', $export->viewData())
->setPaper('a4')
->download($filename);
}
return Excel::download($export, $filename);
}
}
+2 -2
View File
@@ -58,9 +58,9 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
'0'
);
// 预估金额 = Σ订货金额(明细金额 price×quantity),与采购单修改重算口径一致
// 预估成本
$estimateAmount = $items->reduce(
static fn (string $carry, $item): string => bcadd($carry, (string) $item->amount, 2),
static fn (string $carry, $item): string => bcadd($carry, bcmul($item->quantity, $item->cost_price, 2), 2),
'0'
);
@@ -0,0 +1,34 @@
<?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();
}
}
+12 -15
View File
@@ -14,8 +14,8 @@ use App\Models\UserModel;
use Maatwebsite\Excel\Facades\Excel;
/**
* 采购单导出:xlsx Content-Type / 蔬果分类过滤 / PDF / format 非法拒绝 / 权限拦截 /
* 中文文件名 RFC 5987 编码
* 采购单导出(仅 Excel 表格)xlsx Content-Type / 蔬果分类过滤 / type 非法拒绝 /
* 权限拦截 / 中文文件名 RFC 5987 编码
*/
class ExportTest extends ProcurementTestCase
{
@@ -56,7 +56,7 @@ class ExportTest extends ProcurementTestCase
$purchase = $this->buildPurchaseWithItems();
$this->actingAsSysUser();
$response = $this->get("/purchase/order/{$purchase->id}/export?type=all&format=xlsx");
$response = $this->get("/purchase/order/{$purchase->id}/export");
$response->assertOk();
$this->assertStringContainsString(
@@ -78,7 +78,7 @@ class ExportTest extends ProcurementTestCase
$this->actingAsSysUser();
Excel::fake();
$this->get("/purchase/order/{$purchase->id}/export?type=category&format=xlsx")->assertOk();
$this->get("/purchase/order/{$purchase->id}/export?type=category")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_采购单.xlsx',
@@ -90,25 +90,22 @@ class ExportTest extends ProcurementTestCase
);
}
/** PDF 导出:application/pdf */
public function test_export_pdf(): void
/** type 参数非法 → 拒绝 */
public function test_export_invalid_type_rejected(): void
{
$purchase = $this->buildPurchaseWithItems();
$this->actingAsSysUser();
$response = $this->get("/purchase/order/{$purchase->id}/export?format=pdf");
$response->assertOk();
$this->assertStringContainsString('application/pdf', (string) $response->headers->get('Content-Type'));
$this->assertStringStartsWith('%PDF', (string) $response->getContent());
$this->get("/purchase/order/{$purchase->id}/export?type=doc")
->assertJsonPath('success', false);
}
/** format 参数非法 → 拒绝 */
public function test_export_invalid_format_rejected(): void
/** 采购单不存在 → 拒绝 */
public function test_export_missing_purchase_rejected(): void
{
$purchase = $this->buildPurchaseWithItems();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/export?format=doc")
$this->get('/purchase/order/99999/export')
->assertJsonPath('success', false);
}
@@ -119,7 +116,7 @@ class ExportTest extends ProcurementTestCase
// 仅持有查询权限的用户
$this->actingAsSysUser(['purchase.order.query']);
$response = $this->get("/purchase/order/{$purchase->id}/export?format=xlsx");
$response = $this->get("/purchase/order/{$purchase->id}/export");
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
}
}
+245
View File
@@ -11,6 +11,9 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Illuminate\Support\Facades\Cache;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
/**
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
@@ -52,6 +55,23 @@ class PurchaseEditTest extends ProcurementTestCase
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
}
/** 播种站点配置:周转框单价 2.00、周转托盘单价 10.00 */
private function seedContainerConfig(): void
{
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => $key,
'title' => $title,
'type' => 'InputNumber',
'values' => $value,
'sort' => 0,
]);
}
Cache::forget('site_config');
}
/** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
public function test_detail_returns_store_matrix(): void
{
@@ -335,6 +355,87 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
}
/** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */
public function test_store_summary_returns_aggregated_items(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$response = $this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[0]->id}")
->assertOk()
->assertJsonPath('success', true);
$data = $response->json('data');
$this->assertSame($stores[0]->name, $data['store']['name']);
$this->assertCount(1, $data['items']);
$row = $data['items'][0];
$this->assertSame($product->id, $row['product_id']);
$this->assertSame('10斤/箱', $row['product_spec']);
$this->assertSame('斤', $row['unit']);
$this->assertSame('10.00', (string) $row['price']);
$this->assertSame(2, $row['quantity']);
$this->assertSame('20.00', (string) $row['amount']);
// 另一门店只见自身明细
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[1]->id}")
->assertOk()
->assertJsonPath('data.items.0.quantity', 3)
->assertJsonPath('data.items.0.amount', '30.00');
}
/** 门店购买详情:同一商品多笔订单不同单价 → 数量合计、金额求和、单价为加权平均 */
public function test_store_summary_aggregates_multiple_orders_with_weighted_price(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 20,
'spec' => '10斤/箱',
'unit' => '斤',
]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
foreach ([2, 3] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
// 第二笔明细单价改为 20.00,构造同商品双单价:2×10 + 3×20
$secondItem = StoreOrderItemModel::orderBy('id')->skip(1)->first();
$this->putJson("/purchase/order/cell/{$secondItem->id}", ['quantity' => 3, 'price' => 20])
->assertJsonPath('success', true);
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$store->id}")
->assertOk()
->assertJsonPath('data.items.0.quantity', 5)
->assertJsonPath('data.items.0.price', '16.00', '(20+60) ÷ 5 加权平均')
->assertJsonPath('data.items.0.amount', '80.00');
}
/** 门店购买详情参数校验:缺少门店参数 / 采购单不存在均拒绝 */
public function test_store_summary_rejects_missing_or_invalid_params(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->getJson("/purchase/order/{$purchase->id}/store")
->assertJsonPath('success', false)
->assertJsonPath('msg', '缺少门店参数');
$this->getJson('/purchase/order/99999/store?store_id=1')
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
}
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
public function test_edits_rejected_when_purchase_completed(): void
{
@@ -359,4 +460,148 @@ class PurchaseEditTest extends ProcurementTestCase
->assertOk()
->assertJsonPath('data.items.0.editable', false);
}
/** 详情返回周转框/托盘合并记录:按门店聚合全部订单(含底层订单明细与单价) */
public function test_detail_returns_container_summary(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
StoreOrderModel::where('store_id', $stores[0]->id)->update(['box_num' => 2, 'tray_num' => 1]);
StoreOrderModel::where('store_id', $stores[1]->id)->update(['box_num' => 3, 'tray_num' => 0]);
$this->actingAsSysUser();
$containers = $this->getJson("/purchase/order/{$purchase->id}")
->assertOk()
->assertJsonPath('success', true)
->json('data.containers');
$this->assertCount(2, $containers);
$this->assertSame($stores[0]->id, $containers[0]['store_id'], '与门店列同序');
$row = $containers[0];
$this->assertSame($stores[0]->name, $row['store_name']);
$this->assertSame(2, $row['box_num']);
$this->assertSame(1, $row['tray_num']);
$this->assertSame('2.00', $row['box_price']);
$this->assertSame('10.00', $row['tray_price']);
$this->assertSame('14.00', $row['added_amount'], '2×2.00 + 1×10.00');
$this->assertSame(1, $row['order_count']);
$this->assertCount(1, $row['orders']);
$order = $row['orders'][0];
$this->assertStringStartsWith('SO', $order['order_no']);
$this->assertSame(2, $order['box_num']);
$this->assertSame(1, $order['tray_num']);
$this->assertSame(3, $containers[1]['box_num']);
$this->assertSame('6.00', $containers[1]['added_amount']);
}
/** 合并记录修改:逐笔更新订单数量并重算附加金额与订单总金额,其他门店不受影响 */
public function test_update_container_syncs_order_amounts(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$this->assertSame('20.00', (string) $order->product_amount, '下单时应写入商品金额');
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => 3, 'tray_num' => 1]],
])->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertSame(3, $order->box_num);
$this->assertSame(1, $order->tray_num);
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
$this->assertSame('20.00', (string) $order->product_amount);
$this->assertSame('36.00', (string) $order->total_amount, '20.00 + 16.00');
$other = StoreOrderModel::where('store_id', $stores[1]->id)->first();
$this->assertSame(0, $other->box_num, '其他门店订单不受影响');
$this->assertSame('30.00', (string) $other->total_amount);
}
/** 采购单已完成:合并记录修改拒绝,数量与金额不变 */
public function test_update_container_rejected_when_purchase_completed(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => 5, 'tray_num' => 5]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改周转框/托盘');
$this->assertSame(0, $order->fresh()->box_num, '被拒绝后数量不变');
}
/** 提交必须覆盖该门店全部订单;跨门店/跨采购单订单拒绝 */
public function test_update_container_rejects_incomplete_or_foreign_orders(): void
{
$this->seedContainerConfig();
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
foreach ([1, 2] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
$orders = StoreOrderModel::where('purchase_id', $purchase->id)->orderBy('id')->get();
$this->assertCount(2, $orders, '同一门店两笔订单');
// 只提交一笔 → 缺少另一笔,整批拒绝
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
'orders' => [['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '提交不完整,缺少订单:' . $orders[1]->id);
// 混入其他门店订单(同一采购单另一门店)→ 拒绝
$otherStore = StoreModel::factory()->create(['level_id' => $level->id]);
$this->actingAsMiniUser(UserModel::factory()->forStore($otherStore->id)->create());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]])
->assertJsonPath('success', true);
$foreign = StoreOrderModel::where('store_id', $otherStore->id)->first();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/container/{$store->id}", [
'orders' => [
['order_id' => $orders[0]->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $orders[1]->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $foreign->id, 'box_num' => 1, 'tray_num' => 0],
],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '包含不属于该采购单该门店的订单');
$this->assertSame(0, $orders[0]->fresh()->box_num, '被拒绝后数量不变');
}
/** 数量为负/重复订单时校验失败 */
public function test_update_container_validates_negative_and_duplicate_orders(): void
{
$this->seedContainerConfig();
[$purchase, , $stores] = $this->buildPurchase();
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [['order_id' => $order->id, 'box_num' => -1, 'tray_num' => 0]],
])->assertJsonPath('success', false)
->assertJsonPath('msg', '周转框数量不能小于 0');
$this->putJson("/purchase/order/{$purchase->id}/container/{$stores[0]->id}", [
'orders' => [
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
['order_id' => $order->id, 'box_num' => 1, 'tray_num' => 0],
],
])->assertJsonPath('success', false);
}
}
+4 -4
View File
@@ -19,7 +19,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
{
/**
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价)
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00
*/
private function seedAcceptedOrders(): ProductModel
{
@@ -27,7 +27,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
$levelLow = CustomerLevelModel::factory()->create();
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '6.00']);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 5.00]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.00]);
@@ -43,7 +43,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
return $product;
}
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ数量×成本价(预估成本) */
public function test_generate_aggregates_order_items_into_header(): void
{
$this->seedAcceptedOrders();
@@ -61,7 +61,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
$this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00');
$this->assertSame('60.00', (string) $purchase->estimate_amount, '10 件 × 每包成本 6.00');
$this->assertSame('0.00', (string) $purchase->actual_amount);
}
-108
View File
@@ -8,9 +8,6 @@ use App\Models\ProductPriceModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\UserModel;
use Illuminate\Support\Facades\Cache;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
/**
* 小程序下单:等级价快照、服务端重算总价、取消限制、门店数据隔离
@@ -133,23 +130,6 @@ class StoreOrderTest extends ProcurementTestCase
$this->getJson('/mini/order')->assertJsonPath('data.total', 0);
}
/** 种子化周转框/托盘单价配置(框 2.00、托盘 10.00 */
private function seedContainerConfig(): void
{
$group = SysSiteConfigGroupModel::create(['title' => '业务配置', 'key' => 'services']);
foreach ([['box_amount', '周转框单价', '2.00'], ['tray_amount', '周转托盘单价', '10.00']] as [$key, $title, $value]) {
SysSiteConfigItemsModel::create([
'group_id' => $group->id,
'key' => $key,
'title' => $title,
'type' => 'InputNumber',
'values' => $value,
'sort' => 0,
]);
}
Cache::forget('site_config');
}
/** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00 */
private function makeOrderWithStatus(int $status): StoreOrderModel
{
@@ -163,94 +143,6 @@ class StoreOrderTest extends ProcurementTestCase
return $order;
}
/** 修改周转框/托盘:自动重算附加金额与订单总金额 */
public function test_container_update_recalculates_amounts(): void
{
$this->seedContainerConfig();
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
$this->assertSame('10.00', (string) $order->product_amount, '下单时应写入商品金额');
$this->actingAsSysUser();
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 3, 'tray_num' => 1])
->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertSame(3, $order->box_num);
$this->assertSame(1, $order->tray_num);
$this->assertSame('16.00', (string) $order->added_amount, '3×2.00 + 1×10.00');
$this->assertSame('10.00', (string) $order->product_amount);
$this->assertSame('26.00', (string) $order->total_amount, '10.00 + 16.00');
}
/** 已接单、采购中、配送中均可修改 */
public function test_container_update_allowed_in_editable_statuses(): void
{
$this->seedContainerConfig();
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
$this->actingAsSysUser();
foreach ([
StoreOrderModel::STATUS_SUMMARIZED,
StoreOrderModel::STATUS_DELIVERING,
StoreOrderModel::STATUS_DISTRIBUTION,
] as $status) {
$order->update(['status' => $status]);
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 1, 'tray_num' => 0])
->assertOk()->assertJsonPath('success', true);
$this->assertSame('2.00', (string) $order->fresh()->added_amount);
}
}
/** 待接单、已完成、已取消不允许修改 */
public function test_container_update_rejected_in_other_statuses(): void
{
$this->seedContainerConfig();
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_PENDING);
$this->actingAsSysUser();
foreach ([
StoreOrderModel::STATUS_PENDING,
StoreOrderModel::STATUS_COMPLETED,
StoreOrderModel::STATUS_CANCELLED,
] as $status) {
$order->update(['status' => $status]);
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 5, 'tray_num' => 5])
->assertOk()->assertJsonPath('success', false);
}
$order->refresh();
$this->assertSame(0, $order->box_num, '被拒绝后数量不变');
$this->assertSame('10.00', (string) $order->total_amount, '被拒绝后总金额不变');
}
/** 历史订单未写商品金额时按「总额 - 附加」反推回写 */
public function test_container_update_heals_legacy_product_amount(): void
{
$this->seedContainerConfig();
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
$order->update(['product_amount' => 0]); // 模拟历史数据
$this->actingAsSysUser();
$this->putJson("/order/store/{$order->id}/container", ['box_num' => 2, 'tray_num' => 0])
->assertOk()->assertJsonPath('success', true);
$order->refresh();
$this->assertSame('10.00', (string) $order->product_amount, '反推回写商品金额');
$this->assertSame('4.00', (string) $order->added_amount, '2×2.00');
$this->assertSame('14.00', (string) $order->total_amount);
}
/** 数量为负时校验失败 */
public function test_container_update_validates_negative_numbers(): void
{
$this->seedContainerConfig();
$order = $this->makeOrderWithStatus(StoreOrderModel::STATUS_SUMMARIZED);
$this->actingAsSysUser();
$this->putJson("/order/store/{$order->id}/container", ['box_num' => -1, 'tray_num' => 0])
->assertJsonPath('success', false)
->assertJsonPath('msg', '周转框数量不能小于 0');
}
/** 软删除:仅已取消订单可由后台删除,删除后后台/小程序端均不可见 */
public function test_soft_delete_only_cancelled_orders(): void
{
-9
View File
@@ -27,15 +27,6 @@ export async function batchUpdateOrderStatus(ids: number[], status: number) {
});
}
/** 修改周转框/周转托盘数量(自动重算附加金额与订单总金额,仅已接单/采购中/配送中可改) */
export async function updateOrderContainer(id: number, data: { box_num: number; tray_num: number }) {
return createAxios({
url: `/order/store/${id}/container`,
method: 'put',
data,
});
}
/** 删除订单 */
export async function deleteStoreOrder(id: number) {
return createAxios({
+41
View File
@@ -1,7 +1,10 @@
import createAxios from '@/utils/request';
import { downloadBlob } from '@/api/common/download.ts';
import type {
IPurchaseCell,
IPurchaseDetail,
IPurchaseStoreSummary,
PurchaseExportType,
} from '@/domain/iPurchaseOrder.ts';
/** 订单商品参数修改 */
@@ -20,6 +23,13 @@ export interface PurchaseCellUpdateParams {
weight?: number;
}
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */
export interface PurchaseContainerOrderParams {
order_id: number;
box_num: number;
tray_num: number;
}
/** 生成采购单 */
export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
@@ -64,3 +74,34 @@ export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellU
data,
});
}
/** 门店购买详情:采购单内某门店的采购汇总(按商品聚合) */
export async function getPurchaseStoreSummary(purchaseId: number, storeId: number) {
return createAxios<IPurchaseStoreSummary>({
url: `/purchase/order/${purchaseId}/store`,
method: 'get',
params: { store_id: storeId },
});
}
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */
export async function updatePurchaseContainer(
purchaseId: number,
storeId: number,
orders: PurchaseContainerOrderParams[],
) {
return createAxios({
url: `/purchase/order/${purchaseId}/container/${storeId}`,
method: 'put',
data: { orders },
});
}
/** C2/C3 导出采购单(Excel 表格):type=all 全品类 / category 仅蔬果分类 */
export async function exportPurchase(id: number, type: PurchaseExportType = 'all') {
return downloadBlob(
`/purchase/order/${id}/export`,
{ type },
`采购单_${id}.xlsx`,
);
}
+50 -2
View File
@@ -48,6 +48,34 @@ export interface IPurchaseDetail {
purchase: IPurchaseOrder;
stores: { id: number; name: string }[];
items: IPurchaseDetailRow[];
/** 周转框/托盘合并记录(按门店聚合) */
containers: IPurchaseContainerStore[];
}
/** 合并记录中的底层门店订单(订单号 + 框/托盘数量) */
export interface IPurchaseContainerOrder {
order_id: number;
order_no: string;
box_num: number;
tray_num: number;
}
/** 门店周转框/托盘合并记录(该门店在本采购单下全部订单的合计) */
export interface IPurchaseContainerStore {
store_id: number;
store_name: string;
/** 周转框合计 */
box_num: number;
/** 周转托盘合计 */
tray_num: number;
/** 周转框单价(站点配置) */
box_price: string;
/** 周转托盘单价(站点配置) */
tray_price: string;
/** 附加金额合计 = 框合计×框单价 + 托盘合计×托盘单价 */
added_amount: string;
order_count: number;
orders: IPurchaseContainerOrder[];
}
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
@@ -73,8 +101,6 @@ export interface IPurchaseCellItem {
remark: string;
/** 首图 */
image?: string;
/** 是否可编辑/同步(采购单进行中且订货单未锁定) */
editable: boolean;
}
/** 单元格下钻数据(门店 + 商品 + 全部订货明细) */
@@ -84,6 +110,28 @@ export interface IPurchaseCell {
items: IPurchaseCellItem[];
}
/** 门店购买详情行(门店采购汇总,按商品聚合) */
export interface IPurchaseStoreItem {
product_id: number;
product_name: string;
/** 规格/包规 */
product_spec: string;
unit: string;
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
price: string;
/** 数量(包数) */
quantity: number;
/** 预计金额 = Σ 明细 amount */
amount: string;
weight: string;
}
/** 门店购买详情(采购单内某门店的采购汇总) */
export interface IPurchaseStoreSummary {
store: { id: number; name: string } | null;
items: IPurchaseStoreItem[];
}
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '进行中', color: 'processing' },
3: { text: '已完成', color: 'success' },
+6 -10
View File
@@ -73,6 +73,12 @@ export default interface IStoreOrder {
remark?: string;
items?: IStoreOrderItem[];
created_at?: string;
purchase?: {
id: number;
purchase_no: string;
purchase_date: string;
status: number;
}
}
export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: string }> = {
@@ -83,13 +89,3 @@ export const STORE_ORDER_STATUS_MAP: Record<number, { text: string; color: strin
4: { text: '已完成', color: 'success' },
9: { text: '已取消', color: 'error' },
};
/** 待汇总预览行 */
export interface IOrderSummaryRow {
product_id: number;
product_name: string;
product_spec: string;
unit: string;
total_quantity: string;
store_count: number;
}
+21 -98
View File
@@ -6,7 +6,6 @@ import {
Drawer,
Empty,
Form,
InputNumber,
message,
Modal,
Popconfirm,
@@ -27,7 +26,6 @@ import type IStoreOrder from '@/domain/iStoreOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
getStoreOrder,
updateOrderContainer,
updateOrderStatus,
batchUpdateOrderStatus,
deleteStoreOrder,
@@ -36,13 +34,11 @@ import { generatePurchase } from '@/api/purchase/order.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import AuthButton from '@/components/AuthButton';
import { DeleteOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { DeleteOutlined, UnorderedListOutlined } from '@ant-design/icons';
import {PURCHASE_STATUS_MAP} from "@/domain/iPurchaseOrder.ts";
const { Title, Text } = Typography;
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
/**
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
@@ -73,11 +69,6 @@ const StoreOrderPage: React.FC = () => {
const [detail, setDetail] = useState<IStoreOrder | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [containerOpen, setContainerOpen] = useState(false);
const [containerOrder, setContainerOrder] = useState<IStoreOrder | null>(null);
const [containerSaving, setContainerSaving] = useState(false);
const [containerForm] = Form.useForm<{ box_num: number; tray_num: number }>();
/** 勾选行(批量流转/生成采购单用) */
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchOpen, setBatchOpen] = useState(false);
@@ -146,25 +137,6 @@ const StoreOrderPage: React.FC = () => {
}
};
const openContainer = (record: IStoreOrder) => {
setContainerOrder(record);
containerForm.setFieldsValue({ box_num: record.box_num, tray_num: record.tray_num });
setContainerOpen(true);
};
const handleContainerSave = async (values: { box_num: number; tray_num: number }) => {
if (!containerOrder?.id) return;
setContainerSaving(true);
try {
await updateOrderContainer(containerOrder.id, values);
message.success('周转框/托盘数量已更新');
setContainerOpen(false);
await tableRef.current?.reload();
} finally {
setContainerSaving(false);
}
};
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
const handleDelete = async (id: number) => {
await deleteStoreOrder(id);
@@ -173,13 +145,6 @@ const StoreOrderPage: React.FC = () => {
await tableRef.current?.reload();
};
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
const previewAdded = watchBoxNum * Number(containerOrder?.box_price ?? 0)
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
const columns: XinTableColumn<IStoreOrder>[] = [
{
title: '订单号',
@@ -344,10 +309,28 @@ const StoreOrderPage: React.FC = () => {
}
},
{
title: '采购单ID',
title: '采购单',
dataIndex: 'purchase_id',
valueType: 'digit',
hideInForm: true,
render: (_, record) => record.purchase ? (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}></Text>
{record.purchase.purchase_no}
</div>
<div>
<Text type={'secondary'}></Text>
{record.purchase.purchase_date}
</div>
<div>
<Text type={'secondary'}></Text>
<Tag color={PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.color}>
{PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.text}
</Tag>
</div>
</Space>
) : '-'
},
{
title: '账单ID',
@@ -368,15 +351,6 @@ const StoreOrderPage: React.FC = () => {
icon={<UnorderedListOutlined />}
onClick={() => openDetail(record.id!)}
/>
{ CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) && (
<AuthButton key="container" auth="order.store.update">
<Button
icon={<SettingOutlined />}
type={'primary'}
onClick={() => openContainer(record)}
/>
</AuthButton>
)}
{ NEXT_STATUS[record.status!] && (
<AuthButton auth="order.store.update">
<Popconfirm
@@ -468,13 +442,6 @@ const StoreOrderPage: React.FC = () => {
footer={
detail ? (
<Space className="flex justify-end" wrap>
{CONTAINER_EDITABLE_STATUS.includes(detail.status ?? -1) && (
<AuthButton auth="order.store.update">
<Button icon={<SettingOutlined />} onClick={() => openContainer(detail)}>
</Button>
</AuthButton>
)}
{ NEXT_STATUS[detail.status!] && (
<AuthButton auth="order.store.update">
<Popconfirm
@@ -696,50 +663,6 @@ const StoreOrderPage: React.FC = () => {
</Space>
</Radio.Group>
</Modal>
{/* 修改周转框/托盘数量 */}
<Modal
title="修改周转框/托盘"
open={containerOpen}
onCancel={() => setContainerOpen(false)}
onOk={() => containerForm.submit()}
confirmLoading={containerSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
{containerOrder?.order_no}
</div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
<Form.Item
label={`周转框数量(单价 ¥${containerOrder?.box_price ?? '0.00'}`}
name="box_num"
rules={[{ required: true, message: '请输入周转框数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转框数量" />
</Form.Item>
<Form.Item
label={`周转托盘数量(单价 ¥${containerOrder?.tray_price ?? '0.00'}`}
name="tray_num"
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转托盘数量" />
</Form.Item>
</Form>
<Space orientation="vertical" className="w-full rounded bg-gray-50 p-3">
<div>
<Text type="secondary"></Text>¥{containerOrder?.product_amount ?? '0.00'}
</div>
<div>
<Text type="secondary"></Text>
<Text strong>¥{previewAdded.toFixed(2)}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong type="danger">¥{previewTotal.toFixed(2)}</Text>
</div>
</Space>
</Modal>
</>
);
};
+477 -55
View File
@@ -3,6 +3,7 @@ import {
Button,
Descriptions,
Drawer,
Dropdown,
Empty,
Form,
Image,
@@ -15,10 +16,11 @@ import {
Space,
Spin,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
@@ -30,15 +32,22 @@ import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IPurchaseCell,
IPurchaseCellItem,
IPurchaseContainerStore,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
exportPurchase,
getPurchaseCell,
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
getPurchaseDetail,
getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseContainer,
updatePurchaseRow,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
@@ -48,10 +57,10 @@ import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 参考价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitCost = (cost: number, spec: string): number => {
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitRefPrice = (total: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
};
/**
@@ -85,6 +94,32 @@ const PurchaseOrderPage: React.FC = () => {
const [cellItemSaving, setCellItemSaving] = useState(false);
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
// 门店购买详情(按商品聚合的门店采购汇总)
const [detailTab, setDetailTab] = useState('items');
const [storeId, setStoreId] = useState<number>(0);
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改)
const [containerOpen, setContainerOpen] = useState(false);
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
const [containerSaving, setContainerSaving] = useState(false);
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
const previewContainerBox = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.box_num ?? 0),
0,
);
const previewContainerTray = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.tray_num ?? 0),
0,
);
const previewContainerAdded =
previewContainerBox * Number(containerStore?.box_price ?? 0) +
previewContainerTray * Number(containerStore?.tray_price ?? 0);
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
@@ -96,6 +131,20 @@ const PurchaseOrderPage: React.FC = () => {
}
}, [cellOpen, cellQuery]);
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
useEffect(() => {
if (detail && detail.stores.length > 0) {
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
}
}, [detail]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
void loadStoreSummary();
}
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
@@ -107,10 +156,26 @@ const PurchaseOrderPage: React.FC = () => {
};
const openDetail = async (id: number) => {
setDetailTab('items');
setStoreSummary(null);
setDetailOpen(true);
await loadDetail(id);
};
/** 加载门店购买详情(门店采购汇总) */
const loadStoreSummary = async () => {
if (!detail || storeId <= 0) {
return;
}
setStoreLoading(true);
try {
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
setStoreSummary(res.data.data ?? null);
} finally {
setStoreLoading(false);
}
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
@@ -199,15 +264,42 @@ const PurchaseOrderPage: React.FC = () => {
}
};
const handleComplete = async () => {
if (!detail) {
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
const openContainerEdit = (store: IPurchaseContainerStore) => {
setContainerStore(store);
containerForm.setFieldsValue({
orders: store.orders.map((order) => ({
order_id: order.order_id,
box_num: order.box_num,
tray_num: order.tray_num,
})),
});
setContainerOpen(true);
};
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
if (!detail || !containerStore) {
return;
}
setContainerSaving(true);
try {
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
message.success('周转框/托盘已更新,订单金额已重算');
setContainerOpen(false);
setContainerStore(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setContainerSaving(false);
}
};
const handleComplete = async (id: number) => {
setCompleting(true);
try {
await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 });
await Update(`/purchase/order/${id}/finish`, { status: 3 });
message.success('采购单已标记完成');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setCompleting(false);
@@ -226,11 +318,11 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '参考单价',
title: '参考成本单价',
key: 'unit_cost',
width: 100,
align: 'center',
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
@@ -267,16 +359,20 @@ const PurchaseOrderPage: React.FC = () => {
fixed: 'right',
align: 'center',
render: (_, row) => (
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(row)}
>
</Button>
</AuthButton>
<>
{ detail?.purchase.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(row)}
>
</Button>
</AuthButton>
) : '-' }
</>
),
},
];
@@ -317,20 +413,137 @@ const PurchaseOrderPage: React.FC = () => {
);
};
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{
title: '参考零售价',
key: 'retail_price',
width: 130,
align: 'center',
render: (_, row) => `¥${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '单价',
dataIndex: 'price',
width: 100,
align: 'center',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 90,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}` },
{
title: '预计金额',
dataIndex: 'amount',
width: 110,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
const renderStoreTotal = () => {
const items = storeSummary?.items ?? [];
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={5} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
{
title: '周转框合计',
dataIndex: 'box_num',
width: 110,
align: 'center',
render: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
),
},
{
title: '周转托盘合计',
dataIndex: 'tray_num',
width: 120,
align: 'center',
render: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
),
},
{
title: '附加金额',
dataIndex: 'added_amount',
width: 110,
align: 'center',
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
},
];
/** 周转框/托盘合并记录合计行 */
const renderContainerSummary = () => {
const containers = detail?.containers ?? [];
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="center">
<Text strong>{totalBox}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center">
<Text strong>{totalTray}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
const columns: XinTableColumn<IPurchaseOrder>[] = [
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
align: 'center',
width: 260,
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
},
{
title: '采购日期',
dataIndex: 'purchase_date',
valueType: 'dateRange',
hideInForm: true,
valueType: 'date',
align: 'center',
hideInForm: true,
render: (_, record) => record.purchase_date,
},
{
@@ -338,15 +551,20 @@ const PurchaseOrderPage: React.FC = () => {
dataIndex: 'estimate_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
align: 'center',
render: (_, record) => `¥${record.estimate_amount}`,
},
{
title: '实际成本',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
valueType: "digit",
fieldProps: {
min: 0,
precision: 3,
placeholder: "请输入单价"
},
align: 'center',
render: (_, record) =>
Number(record.actual_amount) > 0 ? (
<Text strong>¥{record.actual_amount}</Text>
@@ -354,6 +572,28 @@ const PurchaseOrderPage: React.FC = () => {
<Text type="secondary"></Text>
),
},
{
title: '总件数',
dataIndex: 'total_quantity',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: "textarea",
hideInTable: true,
hideInSearch: true
},
{
title: '总重量',
dataIndex: 'total_weight',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.total_weight}`,
},
{
title: '状态',
dataIndex: 'status',
@@ -381,10 +621,18 @@ const PurchaseOrderPage: React.FC = () => {
},
];
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record, dom) => [
record.status === 0 ? dom.edit : null,
<Button key="detail" size="small" icon={<UnorderedListOutlined />} type={'primary'} onClick={() => openDetail(record.id!)} />,
record.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认该采购单已完成?" description="已完成采购单将锁定,不能再修改订单信息" onConfirm={() => handleComplete(record.id!)}>
<Button type={'primary'} size="small" variant={'solid'} color={'green'} loading={completing}>
</Button>
</Popconfirm>
</AuthButton>
) : null
];
const tableProps: XinTableProps<IPurchaseOrder> = {
@@ -394,6 +642,9 @@ const PurchaseOrderPage: React.FC = () => {
accessName: 'purchase.order',
tableRef,
operateRender,
operateProps: {
width: 300
},
formProps: false,
};
@@ -414,19 +665,10 @@ const PurchaseOrderPage: React.FC = () => {
onClose={() => setDetailOpen(false)}
size={1200}
loading={detailLoading}
extra={detail?.purchase.status === 0 && (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
<Button size="small" icon={<CheckOutlined />} loading={completing}>
</Button>
</Popconfirm>
</AuthButton>
)}
>
{detail && (
<>
<Title level={5} className="mt-5! mb-3!">
<Title level={5} className="mb-3!">
</Title>
<Descriptions column={3} size="small" bordered>
@@ -439,24 +681,109 @@ const PurchaseOrderPage: React.FC = () => {
<Descriptions.Item label="制单人">
{detail.purchase.operator?.nickname ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="预估金额">¥{detail.purchase.estimate_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.purchase.actual_amount}</Descriptions.Item>
<Descriptions.Item label="预估成本">¥{detail.purchase.estimate_amount}</Descriptions.Item>
<Descriptions.Item label="实际成本">¥{detail.purchase.actual_amount}</Descriptions.Item>
<Descriptions.Item label="总件数">{detail.purchase.total_quantity}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.purchase.created_at}</Descriptions.Item>
<Descriptions.Item label="备注" span={3}>{detail.purchase.remark}</Descriptions.Item>
</Descriptions>
<Title level={5} className="mt-5! mb-3!">
</Title>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSummary}
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
className="mt-3!"
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'containers', label: '周转框/托盘' },
]}
/>
{ detailTab === 'stores' ? (
<>
<div className="mb-3 flex items-center gap-2">
<Text></Text>
<Select
value={storeId || undefined}
onChange={(value) => setStoreId(value)}
placeholder="选择门店"
className="w-60!"
showSearch
optionFilterProp="label"
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
<Table<IPurchaseStoreItem>
rowKey="product_id"
size="small"
bordered
columns={storeColumns}
dataSource={storeSummary.items}
pagination={false}
summary={renderStoreTotal}
/>
) : (
!storeLoading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店在此采购单中无采购商品"
className="py-8!"
/>
)
)}
</Spin>
</>
) : detailTab === 'containers' ? (
detail.containers.length > 0 ? (
<Table<IPurchaseContainerStore>
rowKey="store_id"
size="small"
bordered
columns={containerColumns}
dataSource={detail.containers}
pagination={false}
summary={renderContainerSummary}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该采购单暂无门店订单"
className="py-8!"
/>
)
) : (
<>
<Space style={{ marginBottom: 20 }}>
<AuthButton key="export" auth="purchase.order.export">
<Dropdown
menu={{
items: [
{ key: 'all', label: '导出全品类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'all') },
{ key: 'category', label: '仅导出蔬果分类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'category') },
],
}}
>
<Button type="primary" ghost icon={<DownloadOutlined />}>
</Button>
</Dropdown>
</AuthButton>
</Space>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSummary}
/>
</>
) }
</>
)}
</Drawer>
@@ -598,7 +925,7 @@ const PurchaseOrderPage: React.FC = () => {
{item.weight ?? '-'}
</div>
<div className="w-40 shrink-0 text-center">
{item.editable ? (
{detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
@@ -677,6 +1004,101 @@ const PurchaseOrderPage: React.FC = () => {
</Form.Item>
</Form>
</Modal>
{/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */}
<Modal
title={
containerStore
? `${containerStore.store_name} · 周转框/托盘`
: '周转框/托盘'
}
open={containerOpen}
onCancel={() => {
setContainerOpen(false);
setContainerStore(null);
}}
onOk={() => containerForm.submit()}
confirmLoading={containerSaving}
okText="保存"
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
width={760}
destroyOnHidden
>
<div className="py-2 text-gray-500">
{containerStore?.order_count ?? 0}
{detail?.purchase.status === 0
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
: '采购单已完成,仅可查看。'}
</div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
<Form.List name="orders">
{(fields) => (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1"></div>
<div className="w-36 shrink-0 text-center">
¥{containerStore?.box_price ?? '0.00'}
</div>
<div className="w-36 shrink-0 text-center">
¥{containerStore?.tray_price ?? '0.00'}
</div>
</div>
{fields.map((field) => {
const order = containerStore?.orders[field.name];
return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="min-w-0 flex-1 pr-2 text-sm">
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
</div>
<Form.Item name={[field.name, 'order_id']} hidden>
<Input />
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转框数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转框数量"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转托盘数量"
/>
</Form.Item>
</div>
);
})}
</div>
)}
</Form.List>
</Form>
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
<div>
<Text type="secondary"></Text>
<Text strong>{previewContainerBox}</Text>
<Text type="secondary" className="ml-6!"></Text>
<Text strong>{previewContainerTray}</Text>
</div>
<div>
<Text type="secondary"></Text>
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
</div>
</Space>
</Modal>
</>
);
};