CaiWuXiTong/tests/Unit/EcommerceFinance/PlantingLossCalculatorTest.php
limeng ea479733ab feat(种菜数据汇总): 接入聚工单种菜报销,新增种菜损耗扣款页面
- 种菜报销:复用已登记镜像 jiansun.dbo.jst_workOrderInfo,工单类型“种菜报销”、状态已完成,
  按完成时间归属报销月份(M 月 = [M-02 00:00, M+1-02 00:00)),自 2026-09-02 起;同一工单只取最新一版
- 种菜损耗:当月种菜报销的线上订单号匹配上月+本月发货数据(jst_oms_shipping)交易单号;
  任务备注/卖家备注含“拍A发B/拍A发A”的不扣款(单独导出发运营),只发信誉订单虚拟条码/透明挂钩的不扣款,
  其余按发货条码在普通商品资料(ops_jst_sku)的成本价 × 数量 × 1.2 扣款,成本价缺失单独提示
- 两页均支持筛选、列设置、CSV 导出;按月缓存 10 分钟
- 测试:窗口口径、免扣字样、虚拟条码/挂钩、成本价取数与缺失、页面筛选和导出

涉及:金额计算展示(只读);不涉及数据库迁移、权限模型和上游写入。

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

218 lines
13 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->assertSame(12060, Calculator::deductionCents('100.5', 1));
$this->assertSame(24120, 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');
// 一单两个包裹:学习椅 ×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, 'GG003-SZ', '透明挂钩3个');
$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('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-NOCOST', '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 × 1.2 = 241.20,挂钩不计。
$this->assertSame('241.20', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame('DNZ-01 × 2 × 100.5 × 1.2 = 241.20', $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']);
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
$this->assertSame('0.00', $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']);
}
}