整合发货补漏与导出口径修复,保留主线同步保护 (#282)

This commit is contained in:
WuYongJie 2026-10-07 13:52:18 +08:00
parent 4b97b30a3a
commit 6c054c7081
6 changed files with 982 additions and 48 deletions

View File

@ -27,7 +27,7 @@ class JstDump extends Command
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
['package_no', '配货单号'], ['amount', '金额'],
['package_no', '配货单号'], ['amount', '金额'], ['express_name1', '快递名称'],
];
$fh = fopen('php://output', 'w');
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));

View File

@ -38,6 +38,21 @@ return [
'retry_sleep_ms' => (int) env('JST_RETRY_SLEEP_MS', 1000),
],
// 发货数据「导出明细」体积口径(对齐工作平台 OmsDataDay:明细体积默认 = 数量 × 长×宽×高)。
// 依据:工作平台 9.1-9.16 导出明细,非代发仓 8421/8421 行乘数量;代发仓走简尚线路快递的不乘数量。
// 合并口径体积不受此影响(单独存 m_volume_cm3)。
'detail_volume' => [
// 这些发货仓的明细体积不乘数量(工厂代发虚拟仓)
'no_qty_warehouses' => ['常州简尚家居有限公司'],
// 上述仓库中,用这些标准承运商发货时仍乘数量
'qty_carriers_in_no_qty_warehouses' => [
'中通快运', '韵达快递', '申通E物流', '安能物流', '邮政电商标快', '邮政快递包裹POSTB',
'壹米滴答', '百世物流', '德邦快递', '中通速递', '圆通速递',
],
// 这些快递的明细体积一律为 0
'zero_volume_expresses' => ['白云韵达-天猫亿家达', '中通礼品单'],
],
// 接口方法名(对应工作平台 ErpTradeData 中的调用)。
'methods' => [
'sctrade_list' => 'erp.sctrade.query', // 出库单列表(按时间窗分页)

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 组合件(视图一行多条码)拆件后:明细行存部件自己的名称/尺寸/体积,
* 合并导出改用以下"合并用"列(整行值),对齐工作平台两种口径。普通行这些列为空。
*/
return new class extends Migration
{
private array $cols = ['m_name', 'm_volume_cm3', 'm_length_cm', 'm_width_cm', 'm_height_cm'];
private function conn(): \Illuminate\Database\Schema\Builder
{
return Schema::connection((string) config('jushuitan.connection', 'jushuitan_local'));
}
public function up(): void
{
$schema = $this->conn();
if (! $schema->hasTable('jst_oms_shipping')) {
return;
}
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($schema): void {
if (! $schema->hasColumn('jst_oms_shipping', 'm_name')) {
$table->string('m_name', 500)->nullable()->comment('合并用商品名称(组合件整行)');
}
if (! $schema->hasColumn('jst_oms_shipping', 'm_volume_cm3')) {
$table->decimal('m_volume_cm3', 18, 4)->nullable()->comment('合并用体积(组合件整行均摊)');
}
foreach (['m_length_cm' => '长', 'm_width_cm' => '宽', 'm_height_cm' => '高'] as $c => $label) {
if (! $schema->hasColumn('jst_oms_shipping', $c)) {
$table->decimal($c, 12, 2)->nullable()->comment('合并用' . $label . '(组合件整行)');
}
}
});
}
public function down(): void
{
$schema = $this->conn();
if (! $schema->hasTable('jst_oms_shipping')) {
return;
}
$drop = array_values(array_filter($this->cols, fn ($c) => $schema->hasColumn('jst_oms_shipping', $c)));
if ($drop) {
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($drop): void {
$table->dropColumn($drop);
});
}
}
};

View File

@ -17,6 +17,11 @@ class ShippingOrderRepository
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
try {
// 合并取"最后一行"值用 GROUP_CONCAT,放宽默认 1024 长度避免截断
$this->db->statement('SET SESSION group_concat_max_len = 4194304');
} catch (\Throwable) {
}
}
/**
@ -36,7 +41,7 @@ class ShippingOrderRepository
->orderByDesc('source_id')
->orderBy('sku_source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => (array) $r)->all();
->get()->map(fn ($r) => $this->detailRow((array) $r))->all();
return ['data' => $rows, 'total' => $total];
}
@ -55,6 +60,28 @@ class ShippingOrderRepository
return ['data' => $rows, 'total' => $total];
}
/** 明细口径:无尺寸(0)的长宽高/体积显示为空白,对齐工作平台导出明细(合并保持 0)。 */
private function detailRow(array $r): array
{
foreach (['length_cm', 'width_cm', 'height_cm', 'volume_cm3'] as $c) {
if (array_key_exists($c, $r) && $r[$c] !== null && $r[$c] !== '' && (float) $r[$c] == 0.0) {
$r[$c] = null;
}
}
return $r;
}
/**
* 包裹内"最后一行"(按明细 id 顺序)的值。工作平台合并的商品名称/供应商取的是
* 条码串最后一个商品那行(样本 6213/6216、575/575),不是 MIN。
*/
private function lastOf(string $expr): string
{
// SEPARATOR 只能是字符串常量;用名称里不会出现的 '#~#' 作分隔
return "NULLIF(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE({$expr}, '') ORDER BY `id` SEPARATOR '#~#'), '#~#', -1), '')";
}
/** 合并聚合 SELECT:条码/商品ID 逗号拼接、数量/重量/体积/金额/运费求和、长宽高取最大、其余取值。 */
private function mergedSelectRaw(): string
{
@ -71,14 +98,22 @@ class ShippingOrderRepository
'weight_kg', 'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3',
'finished_barcode', 'finished_name', 'finished_supplier', 'settle_freight', 'front_no'];
// 组合件拆件后明细存部件值,合并取整行值(m_*),普通行 m_* 为空则回落原列
$mcol = ['product_name' => 'm_name', 'volume_cm3' => 'm_volume_cm3',
'length_cm' => 'm_length_cm', 'width_cm' => 'm_width_cm', 'height_cm' => 'm_height_cm'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if (in_array($c, $sum, true)) {
if ($c === 'product_name') {
$parts[] = $this->lastOf("COALESCE(`m_name`, `product_name`)") . " as `{$c}`";
} elseif (isset($mcol[$c])) {
$fn = $c === 'volume_cm3' ? 'SUM' : 'MAX';
$parts[] = "{$fn}(COALESCE(`{$mcol[$c]}`, `{$c}`)) as `{$c}`";
} elseif (in_array($c, $sum, true)) {
$parts[] = "SUM(`{$c}`) as `{$c}`";
} elseif (in_array($c, $concat, true)) {
$parts[] = "GROUP_CONCAT(DISTINCT `{$c}` SEPARATOR ',') as `{$c}`";
} elseif (in_array($c, $min, true)) {
$parts[] = "MIN(`{$c}`) as `{$c}`";
$parts[] = $this->lastOf("`{$c}`") . " as `{$c}`";
} else {
$parts[] = "MAX(`{$c}`) as `{$c}`";
}
@ -107,7 +142,7 @@ class ShippingOrderRepository
return;
}
foreach ($rows as $row) {
yield (array) $row;
yield $this->detailRow((array) $row);
}
$last = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {

View File

@ -1093,6 +1093,87 @@ class ShippingReplicaSynchronizerTest extends TestCase
);
}
public function test_outbound_fallback_flushes_replacements_and_preserves_freight_when_skipped(): void
{
$db = DB::connection('business_mirror');
$db->table('jst_oms_shipping')->insert([
'source_id' => 2000000420, 'sku_source_id' => 2000000420,
'express_no' => 'outbound-420', 'trade_no' => 'trade-420',
'ship_time' => '2026-09-02 12:00:00', 'freight' => 18.50, 'settle_weight_kg' => 7,
]);
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturnCallback(function (string $sql): array {
if (str_contains($sql, 'MIN(Id) AS lo')) {
return [['lo' => 1, 'hi' => 1]];
}
if (str_contains($sql, 'WHERE Id >= ?')) {
return [['io_id' => '420', 'so_id' => 'trade-420', 'l_id' => 'outbound-420', 'io_date' => '2026-09-02 12:00:00']];
}
if (str_contains($sql, 'SELECT io_id, sku_ids')) {
return [['io_id' => '420', 'sku_ids' => 'sku-420', 'qty' => 2, 'type' => '0', 'volume' => 100, 'l' => 10, 'w' => 5, 'h' => 2]];
}
$this->assertStringNotContainsString('fys_jstFreightCost', $sql);
return [];
});
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$result = (new ShippingReplicaSynchronizer($factory))->sync(
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true,
);
$this->assertSame(1, $result['outbound']);
$row = $db->table('jst_oms_shipping')->where('source_id', 2000000420)->sole();
$this->assertSame('sku-420', $row->barcode);
$this->assertEquals(2, $row->qty);
$this->assertEquals(18.50, $row->freight);
$this->assertEquals(7, $row->settle_weight_kg);
}
public function test_outbound_mapping_keeps_shared_tracking_orders_distinct_and_ignores_cancelled(): void
{
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$sync = new ShippingReplicaSynchronizer($factory);
$map = (new \ReflectionMethod($sync, 'mapOutboundRows'))->invoke($sync, [
['io_id' => '10', 'o_id' => 'A', 'l_id' => '@shared', 'io_date' => '2026-09-02'],
['io_id' => '20', 'o_id' => 'B', 'l_id' => 'shared', 'io_date' => '2026-09-03'],
['io_id' => '30', 'o_id' => 'A', 'l_id' => 'shared', 'io_date' => '2026-09-04', 'status' => 'Cancelled'],
]);
$method = new \ReflectionMethod($sync, 'ioFor');
$this->assertSame('10', $method->invoke($sync, $map, 'shared', 'A'));
$this->assertSame('20', $method->invoke($sync, $map, '@shared', 'B'));
$this->assertArrayNotHasKey(30, $map['ioL']);
}
public function test_tracking_replacement_requires_one_unique_pair(): void
{
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturn([]);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$sync = new ShippingReplicaSynchronizer($factory);
$method = new \ReflectionMethod($sync, 'pairChangedTracking');
$rows = [['id' => 1, 'inner_order_id' => 'A', 'logistic_id' => 'old']];
$this->assertSame([], $method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a', '20' => 'new-b']]]));
$this->assertSame(['1' => ['io' => '10', 'l_id' => 'new-a', 'old' => 'old']],
$method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a']]]));
}
public function test_cloud_order_label_is_included_and_volume_rules_are_bounded(): void
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
$sync = new ShippingReplicaSynchronizer($factory);
$label = new \ReflectionMethod($sync, 'excludedByLabel');
$this->assertTrue($label->invoke($sync, '线上发货'));
$this->assertFalse($label->invoke($sync, '线上发货,云仓订单'));
$volume = new \ReflectionMethod($sync, 'detailVolume');
$this->assertSame(120.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通速递'));
$this->assertSame(60.0, $volume->invoke($sync, 10, 3, 2, 2.0, '常州简尚家居有限公司', '自送'));
$this->assertNull($volume->invoke($sync, 0, 3, 2, 2.0, '普通仓', '中通速递'));
$this->assertSame(0.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通礼品单'));
}
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
{
$reflection = new \ReflectionProperty($synchronizer, $property);