优化采购单

This commit is contained in:
liu
2026-08-13 12:34:32 +08:00
parent 099e92b1ff
commit 61a70319a0
14 changed files with 628 additions and 276 deletions
File diff suppressed because one or more lines are too long
@@ -207,60 +207,6 @@ class StoreOrderController extends BaseController
return $this->success(); 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();
}
/** /**
* 状态流转合法路径 * 状态流转合法路径
* *
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException; use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport; use App\Exports\PurchaseOrderExport;
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest; use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
use App\Http\Requests\Purchase\PurchaseContainerUpdateRequest;
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest; use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\PurchaseOrderModel; use App\Models\PurchaseOrderModel;
@@ -13,6 +14,7 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Services\ItemImageResolver; use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService; use App\Services\PurchaseGenerateService;
use App\Services\StoreOrderContainerService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -133,6 +135,45 @@ class PurchaseOrderController extends BaseController
[$a['category_sort'], $a['product_sort'], $a['product_id']] [$a['category_sort'], $a['product_sort'], $a['product_id']]
<=> [$b['category_sort'], $b['product_sort'], $b['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([ return $this->success([
'purchase' => $purchase->toArray(), 'purchase' => $purchase->toArray(),
'stores' => $stores->toArray(), 'stores' => $stores->toArray(),
@@ -140,6 +181,7 @@ class PurchaseOrderController extends BaseController
unset($row['category_sort'], $row['product_sort']); unset($row['category_sort'], $row['product_sort']);
return $row; return $row;
}, $rows), }, $rows),
'containers' => $containers,
]); ]);
} }
@@ -395,6 +437,74 @@ 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
{
$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 * @throws Throwable
@@ -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',
];
}
}
+2 -2
View File
@@ -58,9 +58,9 @@ readonly class PurchaseGenerateService
static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2), static fn (string $carry, $item): string => bcadd($carry, (string) $item->quantity, 2),
'0' '0'
); );
// 预估金额 = Σ订货金额(明细金额 price×quantity),与采购单修改重算口径一致 // 预估成本
$estimateAmount = $items->reduce( $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,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();
}
}
+164
View File
@@ -11,6 +11,9 @@ use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use App\Models\UserModel; use App\Models\UserModel;
use Illuminate\Support\Facades\Cache;
use Modules\SystemTool\Models\SysSiteConfigGroupModel;
use Modules\SystemTool\Models\SysSiteConfigItemsModel;
/** /**
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本, * C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
@@ -52,6 +55,23 @@ class PurchaseEditTest extends ProcurementTestCase
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]]; 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 按门店聚合数量 */ /** 详情返回「商品行 × 门店列」矩阵:cells 按门店聚合数量 */
public function test_detail_returns_store_matrix(): void public function test_detail_returns_store_matrix(): void
{ {
@@ -440,4 +460,148 @@ class PurchaseEditTest extends ProcurementTestCase
->assertOk() ->assertOk()
->assertJsonPath('data.items.0.editable', false); ->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 件(同一商品),下单后统一接单 * 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价) * 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价),每包成本 6.00
*/ */
private function seedAcceptedOrders(): ProductModel private function seedAcceptedOrders(): ProductModel
{ {
@@ -27,7 +27,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
$levelLow = CustomerLevelModel::factory()->create(); $levelLow = CustomerLevelModel::factory()->create();
$storeA = StoreModel::factory()->create(['level_id' => $level->id]); $storeA = StoreModel::factory()->create(['level_id' => $level->id]);
$storeB = 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' => $level->id, 'price' => 5.00]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $levelLow->id, 'price' => 4.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; return $product;
} }
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */ /** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ数量×成本价(预估成本) */
public function test_generate_aggregates_order_items_into_header(): void public function test_generate_aggregates_order_items_into_header(): void
{ {
$this->seedAcceptedOrders(); $this->seedAcceptedOrders();
@@ -61,7 +61,7 @@ class PurchaseGenerateTest extends ProcurementTestCase
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status); $this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4'); $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); $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\StoreModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Models\UserModel; 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); $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 */ /** 造一笔指定状态的订单(商品 5.00 × 2 = 10.00 */
private function makeOrderWithStatus(int $status): StoreOrderModel private function makeOrderWithStatus(int $status): StoreOrderModel
{ {
@@ -163,94 +143,6 @@ class StoreOrderTest extends ProcurementTestCase
return $order; 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 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) { export async function deleteStoreOrder(id: number) {
return createAxios({ return createAxios({
+20
View File
@@ -23,6 +23,13 @@ export interface PurchaseCellUpdateParams {
weight?: number; weight?: number;
} }
/** 采购单周转框/托盘合并记录修改:单笔订单的框/托盘数量 */
export interface PurchaseContainerOrderParams {
order_id: number;
box_num: number;
tray_num: number;
}
/** 生成采购单 */ /** 生成采购单 */
export async function generatePurchase(purchase_date: string, order_ids?: number[]) { export async function generatePurchase(purchase_date: string, order_ids?: number[]) {
@@ -77,6 +84,19 @@ export async function getPurchaseStoreSummary(purchaseId: number, storeId: numbe
}); });
} }
/** 采购单周转框/托盘合并记录修改:按门店覆盖全部订单逐笔更新(自动重算附加金额与订单总金额) */
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 仅蔬果分类 */ /** C2/C3 导出采购单(Excel 表格):type=all 全品类 / category 仅蔬果分类 */
export async function exportPurchase(id: number, type: PurchaseExportType = 'all') { export async function exportPurchase(id: number, type: PurchaseExportType = 'all') {
return downloadBlob( return downloadBlob(
+28
View File
@@ -48,6 +48,34 @@ export interface IPurchaseDetail {
purchase: IPurchaseOrder; purchase: IPurchaseOrder;
stores: { id: number; name: string }[]; stores: { id: number; name: string }[];
items: IPurchaseDetailRow[]; 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[];
} }
/** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */ /** 单元格下钻明细行(溯源订货单明细,附订单号/状态/可编辑标记) */
+1 -97
View File
@@ -6,7 +6,6 @@ import {
Drawer, Drawer,
Empty, Empty,
Form, Form,
InputNumber,
message, message,
Modal, Modal,
Popconfirm, Popconfirm,
@@ -27,7 +26,6 @@ import type IStoreOrder from '@/domain/iStoreOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { import {
getStoreOrder, getStoreOrder,
updateOrderContainer,
updateOrderStatus, updateOrderStatus,
batchUpdateOrderStatus, batchUpdateOrderStatus,
deleteStoreOrder, deleteStoreOrder,
@@ -36,14 +34,11 @@ import { generatePurchase } from '@/api/purchase/order.ts';
import { getStoreOptions } from '@/api/customer/store.ts'; import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts'; import type IStore from '@/domain/iStore.ts';
import AuthButton from '@/components/AuthButton'; 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"; import {PURCHASE_STATUS_MAP} from "@/domain/iPurchaseOrder.ts";
const { Title, Text } = Typography; const { Title, Text } = Typography;
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
/** /**
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成 * 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
@@ -74,11 +69,6 @@ const StoreOrderPage: React.FC = () => {
const [detail, setDetail] = useState<IStoreOrder | null>(null); const [detail, setDetail] = useState<IStoreOrder | null>(null);
const [detailLoading, setDetailLoading] = useState(false); 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 [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchOpen, setBatchOpen] = useState(false); const [batchOpen, setBatchOpen] = useState(false);
@@ -147,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) => { const handleDelete = async (id: number) => {
await deleteStoreOrder(id); await deleteStoreOrder(id);
@@ -174,13 +145,6 @@ const StoreOrderPage: React.FC = () => {
await tableRef.current?.reload(); 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>[] = [ const columns: XinTableColumn<IStoreOrder>[] = [
{ {
title: '订单号', title: '订单号',
@@ -387,15 +351,6 @@ const StoreOrderPage: React.FC = () => {
icon={<UnorderedListOutlined />} icon={<UnorderedListOutlined />}
onClick={() => openDetail(record.id!)} 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!] && ( { NEXT_STATUS[record.status!] && (
<AuthButton auth="order.store.update"> <AuthButton auth="order.store.update">
<Popconfirm <Popconfirm
@@ -487,13 +442,6 @@ const StoreOrderPage: React.FC = () => {
footer={ footer={
detail ? ( detail ? (
<Space className="flex justify-end" wrap> <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!] && ( { NEXT_STATUS[detail.status!] && (
<AuthButton auth="order.store.update"> <AuthButton auth="order.store.update">
<Popconfirm <Popconfirm
@@ -715,50 +663,6 @@ const StoreOrderPage: React.FC = () => {
</Space> </Space>
</Radio.Group> </Radio.Group>
</Modal> </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>
</> </>
); );
}; };
+223 -1
View File
@@ -32,6 +32,7 @@ import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type { import type {
IPurchaseCell, IPurchaseCell,
IPurchaseCellItem, IPurchaseCellItem,
IPurchaseContainerStore,
IPurchaseDetail, IPurchaseDetail,
IPurchaseDetailRow, IPurchaseDetailRow,
IPurchaseStoreItem, IPurchaseStoreItem,
@@ -44,8 +45,9 @@ import {
getPurchaseCell, getPurchaseCell,
getPurchaseDetail, getPurchaseDetail,
getPurchaseStoreSummary, getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseRowUpdateParams, type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem, updatePurchaseCellItem,
updatePurchaseContainer,
updatePurchaseRow, updatePurchaseRow,
} from '@/api/purchase/order.ts'; } from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts'; import { Update } from '@/api/common/table.ts';
@@ -98,6 +100,26 @@ const PurchaseOrderPage: React.FC = () => {
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null); const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false); 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(() => { useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []); }, []);
@@ -242,6 +264,37 @@ const PurchaseOrderPage: React.FC = () => {
} }
}; };
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
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) => { const handleComplete = async (id: number) => {
setCompleting(true); setCompleting(true);
try { try {
@@ -420,6 +473,61 @@ const PurchaseOrderPage: React.FC = () => {
); );
}; };
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
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>[] = [ const columns: XinTableColumn<IPurchaseOrder>[] = [
{ {
title: '采购单号', title: '采购单号',
@@ -588,6 +696,7 @@ const PurchaseOrderPage: React.FC = () => {
items={[ items={[
{ key: 'items', label: '商品明细' }, { key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' }, { key: 'stores', label: '门店购买详情' },
{ key: 'containers', label: '周转框/托盘' },
]} ]}
/> />
@@ -627,6 +736,24 @@ const PurchaseOrderPage: React.FC = () => {
)} )}
</Spin> </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 }}> <Space style={{ marginBottom: 20 }}>
@@ -877,6 +1004,101 @@ const PurchaseOrderPage: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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>
</> </>
); );
}; };