['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']); } }