采购单优化
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseAllocationModel;
|
||||
use App\Models\PurchaseOrderItemModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* D3 采购金额分摊:金额守恒(含尾差修正)、按订货比例、幂等重跑
|
||||
*/
|
||||
class AllocationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造可分摊的采购单:各门店下单 → 生成采购单 → 录入实际金额
|
||||
*
|
||||
* @param array<int, string> $quantities 各门店订货量
|
||||
* @return array{0: PurchaseOrderModel, 1: PurchaseOrderItemModel}
|
||||
*/
|
||||
private function buildAllocatablePurchase(array $quantities, string $actualPrice = '10.00'): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
foreach ($quantities as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$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();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
// 录入实际单价(weight=0 → amount = quantity × price)
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => $actualPrice,
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $item->fresh()];
|
||||
}
|
||||
|
||||
/** 金额守恒:Σallocation.amount === item.amount,尾差由最后一行承担 */
|
||||
public function test_allocation_conserves_amount_with_tail_correction(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '1', '1']);
|
||||
|
||||
// 把实际金额调成不可被 3 整除的 100.00(quantity=10 × price=10)
|
||||
$this->actingAsSysUser();
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => '10.00',
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$item = $item->fresh();
|
||||
$this->assertSame('100.00', (string) $item->amount);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$allocations = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$this->assertCount(3, $allocations);
|
||||
|
||||
$sum = $allocations->reduce(
|
||||
static fn (string $carry, $a): string => bcadd($carry, (string) $a->amount, 2),
|
||||
'0'
|
||||
);
|
||||
$this->assertSame('100.00', $sum, '分摊总额必须守恒');
|
||||
|
||||
// 三等分尾差修正:33.33 / 33.33 / 33.34
|
||||
$this->assertSame('33.33', (string) $allocations[0]->amount);
|
||||
$this->assertSame('33.33', (string) $allocations[1]->amount);
|
||||
$this->assertSame('33.34', (string) $allocations[2]->amount);
|
||||
}
|
||||
|
||||
/** 按订货数量比例分摊 */
|
||||
public function test_allocation_follows_order_ratio(): void
|
||||
{
|
||||
[$purchase, $item] = $this->buildAllocatablePurchase(['1', '3']);
|
||||
// quantity=4,实际金额 4×10=40 → 1:3 → 10.00 / 30.00
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
$amounts = PurchaseAllocationModel::where('purchase_item_id', $item->id)
|
||||
->pluck('amount')
|
||||
->map(static fn ($v) => (string) $v)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
$this->assertSame(['10.00', '30.00'], $amounts);
|
||||
}
|
||||
|
||||
/** 幂等:重复分摊先删旧记录再重建,数量不变 */
|
||||
public function test_allocation_is_idempotent(): void
|
||||
{
|
||||
[$purchase] = $this->buildAllocatablePurchase(['2', '3']);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$first = PurchaseAllocationModel::count();
|
||||
$this->assertGreaterThan(0, $first);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
$this->assertSame($first, PurchaseAllocationModel::count(), '重复分摊不应产生重复记录');
|
||||
}
|
||||
|
||||
/** 未录入实际金额时拒绝分摊 */
|
||||
public function test_allocation_rejected_without_actual_amount(): void
|
||||
{
|
||||
$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());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]);
|
||||
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
|
||||
// 模拟未录入实际金额
|
||||
$purchase->items->first()->update(['amount' => 0, 'price' => 0]);
|
||||
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', false);
|
||||
$this->assertSame(0, PurchaseAllocationModel::count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格数量、行级成本/称重,
|
||||
* 修改同步订货明细并重算订单/采购单汇总
|
||||
*/
|
||||
class PurchaseEditTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造采购链路:门店A 2 件 + 门店B 3 件(同一商品,等级价 10.00,成本 20,包规 10斤/箱 → 单价 2.00),
|
||||
* 接单后生成采购单
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: ProductModel, 2: array<int, StoreModel>}
|
||||
*/
|
||||
private function buildPurchase(): array
|
||||
{
|
||||
$level = 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,
|
||||
'cost_price' => 20,
|
||||
'spec' => '10斤/箱',
|
||||
'unit' => '斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
|
||||
|
||||
foreach ([[$storeA, 2], [$storeB, 3]] as [$store, $qty]) {
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$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);
|
||||
|
||||
return [PurchaseOrderModel::first(), $product, [$storeA, $storeB]];
|
||||
}
|
||||
|
||||
/** 详情返回「商品行 × 门店列」矩阵:单价 = 成本/包规,单元格金额 = 数量×单价 */
|
||||
public function test_detail_returns_store_matrix(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$response = $this->getJson("/purchase/order/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertCount(2, $data['stores']);
|
||||
$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->assertEquals(20.0, $row['cost_price']);
|
||||
$this->assertEquals(2.0, $row['unit_cost'], '单价 = 20 ÷ 10');
|
||||
$this->assertEquals(5.0, $row['quantity'], '2+3');
|
||||
$this->assertEquals(10.0, $row['amount'], '5 × 2.00');
|
||||
|
||||
$cells = collect($row['cells'])->keyBy('store_id');
|
||||
$this->assertSame(2, $cells[$stores[0]->id]['quantity']);
|
||||
$this->assertEquals(4.0, $cells[$stores[0]->id]['amount'], '2 × 2.00');
|
||||
$this->assertSame(3, $cells[$stores[1]->id]['quantity']);
|
||||
$this->assertEquals(6.0, $cells[$stores[1]->id]['amount'], '3 × 2.00');
|
||||
}
|
||||
|
||||
/** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
|
||||
public function test_update_cell_syncs_order_item_and_totals(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.amount', 50);
|
||||
|
||||
$item = $item->fresh();
|
||||
$this->assertSame(5, $item->quantity);
|
||||
$this->assertSame('50.00', (string) $item->amount, '5 × 订货单价 10.00');
|
||||
|
||||
$order = StoreOrderModel::find($item->order_id);
|
||||
$this->assertSame(5, $order->total_quantity);
|
||||
$this->assertSame('50.00', (string) $order->product_amount);
|
||||
$this->assertSame('50.00', (string) $order->total_amount);
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('8.00', (string) $purchase->total_quantity, '5+3');
|
||||
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30');
|
||||
}
|
||||
|
||||
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×单价 重算 */
|
||||
public function test_update_row_cost_syncs_all_order_items(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.count', 2);
|
||||
|
||||
$this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count());
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
// 单价 = 30 ÷ 10 = 3.00,实际金额 = 5 × 3.00
|
||||
$this->assertSame('15.00', (string) $purchase->actual_amount);
|
||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响');
|
||||
}
|
||||
|
||||
/** 行级称重修改 → 按各店数量比例分摊写入明细(尾差修正守恒),实际金额按 称重×单价 计 */
|
||||
public function test_update_row_weight_distributed_by_quantity(): void
|
||||
{
|
||||
[$purchase, $product, $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['weight' => 10])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$items = StoreOrderItemModel::query()->orderBy('store_id')->get()->keyBy('store_id');
|
||||
$this->assertSame('4.000', (string) $items[$stores[0]->id]->weight, '10 × 2/5');
|
||||
$this->assertSame('6.000', (string) $items[$stores[1]->id]->weight, '10 × 3/5');
|
||||
$this->assertSame(
|
||||
'10.000',
|
||||
bcadd((string) $items[$stores[0]->id]->weight, (string) $items[$stores[1]->id]->weight, 3),
|
||||
'分摊守恒'
|
||||
);
|
||||
|
||||
$purchase = $purchase->fresh();
|
||||
$this->assertSame('10.000', (string) $purchase->total_weight);
|
||||
// 称重>0 → 金额按 称重×单价:10 × 2.00
|
||||
$this->assertSame('20.00', (string) $purchase->actual_amount);
|
||||
}
|
||||
|
||||
/** 行级修改:成本与称重至少一项 */
|
||||
public function test_update_row_requires_at_least_one_field(): void
|
||||
{
|
||||
[$purchase, $product] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [])
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
|
||||
/** 单元格数量校验:负数拒绝 */
|
||||
public function test_update_cell_rejects_negative_quantity(): void
|
||||
{
|
||||
[$purchase, , $stores] = $this->buildPurchase();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
|
||||
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1])
|
||||
->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,19 @@ use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* C1 订单汇总生成采购单:多门店聚合、订单状态回写、无订单/重复生成防护
|
||||
* C1 订单汇总生成采购单:订单/明细归集回写、头部汇总、无订单/重复生成防护
|
||||
* (采购单无独立明细表,明细直接溯源订货明细 store_order_item.purchase_id)
|
||||
*/
|
||||
class PurchaseGenerateTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 造当日已接单订单:门店A 两单各 3 件 + 门店B 一单 4 件(同一商品),下单后统一接单
|
||||
* 商品设两个等级价 5.00 / 4.00,估算单价应取最低 4.00
|
||||
* 门店等级价 5.00(另设低等级价 4.00 不影响本店下单价)
|
||||
*/
|
||||
private function seedAcceptedOrders(): ProductModel
|
||||
{
|
||||
@@ -41,8 +43,8 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
return $product;
|
||||
}
|
||||
|
||||
/** 多门店多订单按商品聚合,估算单价取最低等级价 */
|
||||
public function test_generate_aggregates_orders_by_product(): void
|
||||
/** 头部汇总取订货明细合计:total_quantity = Σ数量,estimate_amount = Σ订货金额 */
|
||||
public function test_generate_aggregates_order_items_into_header(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
$admin = $this->actingAsSysUser();
|
||||
@@ -58,15 +60,12 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame($admin->id, $purchase->operator_id);
|
||||
$this->assertSame(PurchaseOrderModel::STATUS_PENDING, $purchase->status);
|
||||
|
||||
$this->assertCount(1, $purchase->items, '单一商品应聚合为一行');
|
||||
$item = $purchase->items->first();
|
||||
$this->assertSame('10.00', (string) $item->quantity, '3+3+4');
|
||||
$this->assertSame('4.00', (string) $item->price, '估算单价取最低等级价');
|
||||
$this->assertSame('40.00', (string) $item->amount, '10 × 4.00');
|
||||
$this->assertSame('40.00', (string) $purchase->estimate_amount);
|
||||
$this->assertSame('10.00', (string) $purchase->total_quantity, '3+3+4');
|
||||
$this->assertSame('50.00', (string) $purchase->estimate_amount, '10 件 × 等级价 5.00');
|
||||
$this->assertSame('0.00', (string) $purchase->actual_amount);
|
||||
}
|
||||
|
||||
/** 源订单状态回写为采购中,并关联 purchase_id */
|
||||
/** 源订单状态回写为采购中,订单与订货明细均回写 purchase_id */
|
||||
public function test_generate_writes_back_order_status(): void
|
||||
{
|
||||
$this->seedAcceptedOrders();
|
||||
@@ -79,6 +78,12 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
$this->assertSame(0, StoreOrderModel::where('status', StoreOrderModel::STATUS_SUMMARIZED)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('status', StoreOrderModel::STATUS_DELIVERING)->count());
|
||||
$this->assertSame(3, StoreOrderModel::where('purchase_id', $purchase->id)->count());
|
||||
$this->assertSame(
|
||||
3,
|
||||
StoreOrderItemModel::where('purchase_id', $purchase->id)->count(),
|
||||
'订货明细应全部回写 purchase_id(采购明细溯源依据)'
|
||||
);
|
||||
$this->assertSame(0, StoreOrderItemModel::where('purchase_id', 0)->count());
|
||||
}
|
||||
|
||||
/** 仅有待接单订单时不可生成(待接单不再被归集)→ 报错 */
|
||||
@@ -140,44 +145,4 @@ class PurchaseGenerateTest extends ProcurementTestCase
|
||||
|
||||
$this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单');
|
||||
}
|
||||
|
||||
/** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */
|
||||
public function test_generate_uses_min_actual_price_across_types(): void
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$levelFixed = CustomerLevelModel::factory()->create();
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'cost_price' => 10,
|
||||
]);
|
||||
// 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $level->id,
|
||||
'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT,
|
||||
'percent' => 40,
|
||||
'price' => 14.00,
|
||||
]);
|
||||
ProductPriceModel::factory()->create([
|
||||
'product_id' => $product->id,
|
||||
'level_id' => $levelFixed->id,
|
||||
'price' => 5.00,
|
||||
]);
|
||||
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
// 接单后生成
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
$item = PurchaseOrderModel::first()->items->first();
|
||||
$this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价');
|
||||
$this->assertSame('15.00', (string) $item->amount, '3 × 5.00');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
||||
use App\Models\CustomerLevelModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\ProductPriceModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\ReconciliationItemModel;
|
||||
use App\Models\ReconciliationModel;
|
||||
use App\Models\SettlementModel;
|
||||
@@ -15,27 +14,31 @@ use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 财务对账:明细构建(品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
* 财务对账:明细构建(已完成订单直读,品类/供应商筛选)、D4 修改后差额与头汇总重算、D8 标记、D9 结算
|
||||
* publish = 订货金额;actual = 采购成本(称重>0 ? 称重×单价 : 数量×单价,单价 = 成本/包规)
|
||||
*/
|
||||
class ReconciliationTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 构造已分摊的完整链路:2 门店下单(2/3 件)→ 生成采购单 → 录入实际金额 → 分摊
|
||||
* 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成
|
||||
* 预期:publish 20/30,actual 16/24,diff 4/6
|
||||
*
|
||||
* @return array{0: PurchaseOrderModel, 1: array<int, StoreModel>, 2: SupplierModel}
|
||||
* @return array{0: array<int, StoreModel>, 1: SupplierModel}
|
||||
*/
|
||||
private function buildAllocatedChain(): array
|
||||
private function buildCompletedOrders(): array
|
||||
{
|
||||
$level = CustomerLevelModel::factory()->create();
|
||||
$supplier = SupplierModel::factory()->create();
|
||||
$product = ProductModel::factory()->create([
|
||||
'status' => ProductModel::STATUS_ON,
|
||||
'supplier_id' => $supplier->id,
|
||||
'cost_price' => 8,
|
||||
'spec' => '1斤',
|
||||
]);
|
||||
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']);
|
||||
|
||||
$stores = [];
|
||||
foreach (['2.00', '3.00'] as $qty) {
|
||||
foreach ([2, 3] as $qty) {
|
||||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||||
$stores[] = $store;
|
||||
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
|
||||
@@ -43,23 +46,10 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
->assertJsonPath('success', true);
|
||||
}
|
||||
|
||||
// 接单后生成采购单
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
|
||||
// 订单完成(对账数据源为已完成订单的订货明细)
|
||||
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_COMPLETED]);
|
||||
|
||||
$this->actingAsSysUser();
|
||||
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
|
||||
->assertJsonPath('success', true);
|
||||
$purchase = PurchaseOrderModel::first();
|
||||
$item = $purchase->items->first();
|
||||
|
||||
$this->putJson("/purchase/order/item/{$item->id}", [
|
||||
'price' => '10.00',
|
||||
'quantity' => $item->quantity,
|
||||
'weight' => 0,
|
||||
])->assertJsonPath('success', true);
|
||||
$this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true);
|
||||
|
||||
return [$purchase->fresh(), $stores, $supplier];
|
||||
return [$stores, $supplier];
|
||||
}
|
||||
|
||||
private function createRecon(array $extra = []): int
|
||||
@@ -74,34 +64,53 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
return (int) $response->json('data.id');
|
||||
}
|
||||
|
||||
/** 构建明细:publish=订货金额,actual=分摊金额,diff=publish-actual,头汇总回写 */
|
||||
/** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */
|
||||
public function test_build_creates_reconciliation_items(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true);
|
||||
|
||||
$items = ReconciliationItemModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(2, $items, '两门店分摊 → 两条对账明细');
|
||||
$this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细');
|
||||
|
||||
$byStore = $items->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount, '分摊金额');
|
||||
$this->assertSame('0.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount, '采购成本 2×8');
|
||||
$this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$this->assertSame('30.00', (string) $byStore[$stores[1]->id]->publish_amount);
|
||||
$this->assertSame('24.00', (string) $byStore[$stores[1]->id]->actual_amount);
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('50.00', (string) $recon->publish_amount);
|
||||
$this->assertSame('50.00', (string) $recon->actual_amount);
|
||||
$this->assertSame('0.00', (string) $recon->diff_amount);
|
||||
$this->assertSame('40.00', (string) $recon->actual_amount);
|
||||
$this->assertSame('10.00', (string) $recon->diff_amount);
|
||||
$this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status);
|
||||
}
|
||||
|
||||
/** 供应商筛选:仅拉取该供应商的采购数据 */
|
||||
/** 未完成订单不计入对账 */
|
||||
public function test_build_excludes_unfinished_orders(): void
|
||||
{
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
// 第二家门店订单回退为配送中 → 仅第一家进入对账
|
||||
StoreOrderModel::where('store_id', $stores[1]->id)
|
||||
->update(['status' => StoreOrderModel::STATUS_DISTRIBUTION]);
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
$this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true);
|
||||
|
||||
$items = ReconciliationItemModel::where('recon_id', $reconId)->get();
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame($stores[0]->id, $items->first()->store_id);
|
||||
}
|
||||
|
||||
/** 供应商筛选:仅拉取该供应商的订单数据 */
|
||||
public function test_build_filters_by_supplier(): void
|
||||
{
|
||||
[, , $supplier] = $this->buildAllocatedChain();
|
||||
[, $supplier] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
// 无关供应商 → 无数据报错
|
||||
@@ -118,7 +127,7 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
/** D4 修改明细:自动重算本行 diff 与对账单头汇总 */
|
||||
public function test_update_item_recalculates_diff_and_header(): void
|
||||
{
|
||||
$this->buildAllocatedChain();
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -132,14 +141,14 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
$this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00');
|
||||
|
||||
$recon = ReconciliationModel::find($reconId);
|
||||
$this->assertSame('55.00', (string) $recon->actual_amount, '25 + 30');
|
||||
$this->assertSame('-5.00', (string) $recon->diff_amount, '50 - 55');
|
||||
$this->assertSame('49.00', (string) $recon->actual_amount, '25 + 24');
|
||||
$this->assertSame('1.00', (string) $recon->diff_amount, '50 - 49');
|
||||
}
|
||||
|
||||
/** D8 对账状态标记翻转 */
|
||||
public function test_toggle_reconciled_flag(): void
|
||||
{
|
||||
$this->buildAllocatedChain();
|
||||
$this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -157,7 +166,7 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
/** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */
|
||||
public function test_settle_creates_settlements_per_store(): void
|
||||
{
|
||||
[, $stores] = $this->buildAllocatedChain();
|
||||
[$stores] = $this->buildCompletedOrders();
|
||||
$this->actingAsSysUser();
|
||||
|
||||
$reconId = $this->createRecon();
|
||||
@@ -170,7 +179,8 @@ class ReconciliationTest extends ProcurementTestCase
|
||||
|
||||
$byStore = $settlements->keyBy('store_id');
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount);
|
||||
$this->assertSame('20.00', (string) $byStore[$stores[0]->id]->actual_amount);
|
||||
$this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount);
|
||||
$this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount);
|
||||
$this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no);
|
||||
|
||||
$this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status);
|
||||
|
||||
Reference in New Issue
Block a user