财务确认(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
240 lines
15 KiB
PHP
240 lines
15 KiB
PHP
<?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']);
|
||
}
|
||
}
|