整合发货补漏与导出口径修复,保留主线同步保护 (#282)
This commit is contained in:
parent
4b97b30a3a
commit
6c054c7081
@ -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));
|
||||
|
||||
@ -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', // 出库单列表(按时间窗分页)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user