CaiWuXiTong/tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php
limeng 4e174963e8 fix(种菜损耗): 透明挂钩/虚拟条码整单不扣款;拍A发B 从种菜报销直接挑出发运营
财务确认(2026-10-20):种菜损耗是查种菜报销的单有没有真实发出有价值的货——
- 发货数据中交易单号下出现信誉订单虚拟条码或透明挂钩(含条形码 GG 开头)即整单不扣款;
- 任务备注/卖家备注含拍A发B/拍A发A 的不扣款,且无论是否匹配到发货都从种菜报销中挑出发运营;
- “导出拍A发B(发运营)”直接按「种菜报销」页字段输出;其余真实发货的单按成本价×数量+运费扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-20 10:10:29 +08:00

240 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Unit\EcommerceFinance;
use Carbon\CarbonImmutable;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
use Tests\TestCase;
class PlantingLossCalculatorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'database.connections.planting_shipping' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'jushuitan.connection' => 'planting_shipping',
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
Repository::DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo',
'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime',
'indexes' => [['woTypeId', 'woNo', 'Id']],
],
Calculator::SKU_DATASET => [
'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku',
'target' => 'fixture_sku', 'keys' => ['autoid'], 'watermark' => 'modified',
],
],
]);
Cache::setDefaultDriver('array');
Cache::flush();
DB::purge('business_mirror');
DB::purge('planting_shipping');
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('fixture_work_orders', function (Blueprint $table): void {
$table->integer('Id');
foreach (['woNo', 'woTypeId', 'status', 'createby', 'executors', 'completer', 'completeTime', 'created', 'updateTime', 'businessFieldList'] as $column) {
$table->text($column)->nullable();
}
});
$schema->create('fixture_sku', function (Blueprint $table): void {
$table->integer('autoid');
$table->string('sku_id');
$table->string('sku_type');
$table->decimal('cost_price', 18, 4)->nullable();
});
DB::connection('business_mirror')->table('fixture_sku')->insert([
['autoid' => 1, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 90],
['autoid' => 2, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 100.5],
['autoid' => 3, 'sku_id' => 'DNZ-02', 'sku_type' => 'combine', 'cost_price' => 50],
['autoid' => 4, 'sku_id' => 'ZERO', 'sku_type' => 'normal', 'cost_price' => 0],
]);
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => Repository::DATASET, 'status' => 'succeeded',
'initial_completed_at' => '2026-09-08 16:00:00', 'last_succeeded_at' => '2026-10-19 13:00:00',
]);
$shipping = Schema::connection('planting_shipping');
$shipping->create('jst_oms_shipping', function (Blueprint $table): void {
$table->increments('id');
foreach (['express_no', 'trade_no', 'barcode', 'product_name', 'warehouse_name'] as $column) {
$table->string($column)->nullable();
}
$table->integer('qty')->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->decimal('freight', 18, 2)->nullable();
$table->dateTime('ship_time')->nullable();
});
$shipping->create('jst_sync_state', function (Blueprint $table): void {
$table->string('dataset_key');
$table->string('status')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
DB::connection('planting_shipping')->table('jst_sync_state')->insert(['dataset_key' => 'jst.sctrade', 'status' => 'succeeded', 'last_succeeded_at' => '2026-10-19 12:00:00']);
}
private function workOrder(int $id, string $woNo, string $orderNo, string $completeTime, array $patch = [], string $taskRemark = '', string $sellerRemark = ''): void
{
$fields = [
['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => $orderNo],
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18501511', 'valueLabel' => '天猫-亿家达旗舰店'],
['fieldId' => 'platform', 'type' => 'SELECT', 'value' => '淘宝天猫'],
['fieldId' => 'actualPaidAmountField', 'type' => 'MONEY', 'value' => '37.79'],
['fieldId' => 'NUMBERField1413856620250924131615', 'type' => 'NUMBER', 'value' => '37.79'],
['fieldId' => 'NUMBERField1413856620251230154457', 'type' => 'NUMBER', 'value' => '2'],
['fieldId' => 'NUMBERField1413856620251117101250', 'type' => 'NUMBER', 'value' => '39.79'],
['fieldId' => 'TEXTField1413856620251117180931', 'type' => 'TEXT', 'value' => '黄文梳'],
['fieldId' => 'TEXTField1413856620251117112729', 'type' => 'TEXT', 'value' => '招商银行'],
['fieldId' => 'TEXTField1413856620251113150546', 'type' => 'TEXT', 'value' => $taskRemark],
['fieldId' => 'businessRemarkField', 'type' => 'TEXTAREA', 'value' => $sellerRemark],
['fieldId' => 'erpOrderStatus', 'type' => 'SELECT', 'value' => 'Sent'],
['fieldId' => 'itemInfo', 'type' => 'FORM', 'childFieldList' => [[
['fieldId' => 'skuId', 'type' => 'TEXT', 'value' => 'GG003-SZ'],
['fieldId' => 'name', 'type' => 'TEXT', 'value' => '透明挂钩3个'],
['fieldId' => 'qty', 'type' => 'NUMBER', 'value' => '1'],
]]],
];
DB::connection('business_mirror')->table('fixture_work_orders')->insert(array_replace([
'Id' => $id, 'woNo' => $woNo, 'woTypeId' => Repository::WORK_TYPE_ID, 'status' => 'CONFIRMED',
'createby' => '怀德', 'executors' => '李斯', 'completer' => '李斯', 'completeTime' => $completeTime,
'created' => '2026-09-01 09:00:00', 'updateTime' => $completeTime,
'businessFieldList' => json_encode($fields, JSON_UNESCAPED_UNICODE),
], $patch));
}
private function ship(string $tradeNo, string $shipTime, string $expressNo = 'YT001', float $amount = 40.6, string $barcode = 'DNZ-01', string $name = '儿童学习椅', int $qty = 1): void
{
DB::connection('planting_shipping')->table('jst_oms_shipping')->insert([
'trade_no' => $tradeNo, 'express_no' => $expressNo, 'barcode' => $barcode, 'product_name' => $name,
'warehouse_name' => '常州仓', 'qty' => $qty, 'amount' => $amount, 'freight' => 3.2, 'ship_time' => $shipTime,
]);
}
public function test_windows_follow_completion_and_two_month_shipping_rules(): void
{
$this->assertSame(['from' => '2026-09-02 00:00:00', 'to' => '2026-10-02 00:00:00'], Repository::window('2026-09'));
$this->assertSame(['from' => '2026-12-02 00:00:00', 'to' => '2027-01-02 00:00:00'], Repository::window('2026-12'));
$this->assertSame(['from' => '2026-08-01 00:00:00', 'to' => '2026-10-01 00:00:00'], Calculator::shippingWindow('2026-09'));
$this->assertSame('2026-09', Repository::defaultMonth(CarbonImmutable::parse('2026-10-01 18:00:00')));
$this->assertSame('2026-10', Repository::defaultMonth(CarbonImmutable::parse('2026-10-19 10:00:00')));
$this->assertSame(['2026-10', '2026-09'], Repository::months(CarbonImmutable::parse('2026-10-19')));
$this->expectException(\InvalidArgumentException::class);
Repository::window('2026-08');
}
public function test_exemption_keyword_tolerates_width_case_and_spaces(): void
{
$this->assertSame('拍A发B', Calculator::exemptionKeyword('拍A发B+GG003-SZ'));
$this->assertSame('拍A发A', Calculator::exemptionKeyword('', '客户要求 拍a 发A'));
$this->assertSame('拍A发B', Calculator::exemptionKeyword('CCDD', '拍a发b,供货价一致'));
$this->assertNull(Calculator::exemptionKeyword('AABB', '拍B发A'));
$this->assertNull(Calculator::exemptionKeyword('转挂钩发货', ''));
$this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码'));
$this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个'));
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
$this->assertTrue(Calculator::isFreeGoods('tcxj', '书柜 晒图有奖励(无实物)'));
$this->assertTrue(Calculator::isVirtualOrderItem('X00006', ''));
$this->assertTrue(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
$this->assertTrue(Calculator::isVirtualOrderItem('GG-005', '挂钩'));
$this->assertFalse(Calculator::isVirtualOrderItem('DNZ-01', '儿童学习椅'));
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
}
public function test_loss_rows_match_shipments_and_split_exempt_from_deduct(): void
{
$this->workOrder(1, 'GD-DEDUCT', '3316392626048007769', '2026-09-02 00:00:00', [], 'CCDD', 'AABB');
$this->workOrder(2, 'GD-EXEMPT', '3604408018648198', '2026-09-15 11:47:53', [], '拍A发B+GG003-SZ');
$this->workOrder(3, 'GD-SELLER', '260901-000000000000001', '2026-10-01 23:59:59', [], '', '拍A发A');
$this->workOrder(4, 'GD-UNMATCHED', '9999', '2026-09-20 10:00:00');
$this->workOrder(5, 'GD-OUTSIDE', '3316392626048007769', '2026-10-02 00:00:00');
$this->workOrder(6, 'GD-AUG', '1111', '2026-09-01 23:59:59');
$this->workOrder(7, 'GD-OTHER-TYPE', '2222', '2026-09-10 10:00:00', ['woTypeId' => 'OTHER']);
// 同一工单两版:取最新一版;最新一版作废则不计入。
$this->workOrder(8, 'GD-DISABLED', '3333', '2026-09-10 10:00:00');
$this->workOrder(9, 'GD-DISABLED', '3333', '2026-09-10 10:00:00', ['status' => 'DISABLED']);
$this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00');
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
$this->workOrder(12, 'GD-MERGED', '7777', '2026-09-22 10:00:00');
$this->workOrder(13, 'GD-HOOK', '8888', '2026-09-23 10:00:00');
// 未匹配到发货但备注拍A发B:同样列入不扣款(发运营)。
$this->workOrder(14, 'GD-PAB-NOSHIP', '9090', '2026-09-24 10:00:00', [], '拍A发B');
// 一单两个包裹:学习椅 ×2 + 无实物商品(不计货值)。
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'XS-01', '晒图有奖励(无实物)');
$this->ship('3604408018648198', '2026-09-30 23:59:59');
$this->ship('260901-000000000000001', '2026-09-01 08:00:00');
$this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日
$this->ship('9999', '2026-10-01 00:00:00'); // 晚于本月末
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'XN-001', '信誉订单虚拟条码');
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'ZERO', '无成本商品');
// 合并发货:同一交易单号下有信誉订单虚拟条码 + 别的订单的实物,整单不扣。
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'X00006', '信誉订单虚拟条码');
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'DNZ-01', '儿童学习椅');
// 实物 + 透明挂钩:整单不扣。
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'DNZ-01', '儿童学习椅');
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品');
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-PAB-NOSHIP', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
$this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']);
// 取最新一条普通商品成本价 100.5:100.5 × 2 = 201.00,无实物不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
$this->assertSame('207.40', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame("DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", $rows['GD-DEDUCT']['deduction_detail']);
$this->assertFalse($rows['GD-DEDUCT']['cost_missing']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']);
$this->assertNull($rows['GD-VIRTUAL']['deduction_amount']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-MERGED']['loss_result']);
$this->assertNull($rows['GD-MERGED']['deduction_amount']);
$this->assertStringContainsString('信誉订单虚拟条码', $rows['GD-MERGED']['loss_basis']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-HOOK']['loss_result']);
$this->assertNull($rows['GD-HOOK']['deduction_amount']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-PAB-NOSHIP']['loss_result']);
$this->assertSame('备注含“拍A发B”(未匹配到发货)', $rows['GD-PAB-NOSHIP']['loss_basis']);
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
$this->assertSame('6.40', $rows['GD-NOCOST']['deduction_amount']);
$this->assertTrue($rows['GD-NOCOST']['cost_missing']);
$this->assertStringContainsString('ZERO、DNZ-02', $rows['GD-NOCOST']['loss_basis']);
$this->assertNull($rows['GD-EXEMPT']['deduction_amount']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-EXEMPT']['loss_result']);
$this->assertSame('备注含“拍A发B”', $rows['GD-EXEMPT']['loss_basis']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-SELLER']['loss_result']);
$this->assertSame(Calculator::RESULT_UNMATCHED, $rows['GD-UNMATCHED']['loss_result']);
$row = $rows['GD-DEDUCT'];
$this->assertSame('天猫-亿家达旗舰店', $row['shop_name']);
$this->assertSame('37.79', $row['task_payment']);
$this->assertSame('39.79', $row['reimbursement_amount']);
$this->assertSame('已发货', $row['erp_order_status']);
$this->assertSame('GG003-SZ', $row['product_code']);
$this->assertSame('2026-09-02 00:00:00', $row['completed_at']);
}
}