fix(发货数据): 清理包裹里残留的订单占位商品行(数量0),件数随之更正

- 订单里被替换的原商品、组合装原套装等(数量 0、金额 0/空、item_status=Replaced),
  聚水潭销售出库单不显示;占位过滤上线前入库的包裹,之后因"金额对不上保留旧包裹"一直保留着这些行,
  导致商品编码多出、件数(1/N)偏大,按件数加收的面单费跟着多算。
- 每次窗口同步后,包裹已有实发商品时删除这些占位行,件数改为实发行数;
  首行若是占位行,其上的重量/运费/结算等包裹级字段先挪到保留的首行。
- 同步修正单测 test_view_item_with_unmatched_synthetic_sku_retains_old_package_and_marks_items_pending 的预期:
  main 上已上线"新旧条码完全不同视为商品已更正、覆盖旧记录",原用例仍按旧行为断言 2 个保留包裹,与现行逻辑不符。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
This commit is contained in:
limeng 2026-11-02 11:56:52 +08:00
parent 867ff805a4
commit b5cab149e1
2 changed files with 116 additions and 3 deletions

View File

@ -59,6 +59,7 @@ class ShippingReplicaSynchronizer
private bool $replaceWindow = false;
private int $shipTimeFixed = 0;
private int $weightFixed = 0;
private int $placeholderPurged = 0;
/** @var array<int,list<array<string,mixed>>> */
private array $replacementPackages = [];
@ -202,6 +203,7 @@ class ShippingReplicaSynchronizer
$this->staleMirrorPackages = 0;
$this->shipTimeFixed = 0;
$this->weightFixed = 0;
$this->placeholderPurged = 0;
$this->supersededRemoved = 0;
$this->outboundScanned = 0;
$this->purchaseScanned = 0;
@ -267,12 +269,16 @@ class ShippingReplicaSynchronizer
}
}
try {
$this->placeholderPurged = $this->purgeZeroQtyPlaceholders($fromS, $toS);
$al = $this->alignWithOutbound($fromS, $toS);
$this->shipTimeFixed = $al['ship_time'];
$this->weightFixed = $al['weight'];
if ($progress && ($this->shipTimeFixed > 0 || $this->weightFixed > 0)) {
$progress('按销售出库单校正:发货时间 ' . $this->shipTimeFixed . ' 个包裹,线上/实称重量 ' . $this->weightFixed . ' 个包裹');
}
if ($progress && $this->placeholderPurged > 0) {
$progress('清理订单占位商品行(数量0):' . $this->placeholderPurged . ' 个包裹');
}
} catch (Throwable $e) {
if ($progress) {
$progress('发货时间校正跳过:' . $e->getMessage());
@ -311,6 +317,7 @@ class ShippingReplicaSynchronizer
$result['tracking_pairing_skipped_pages'] = $this->pairingSkipped;
$result['ship_time_fixed'] = $this->shipTimeFixed;
$result['weight_fixed'] = $this->weightFixed;
$result['placeholder_packages_cleaned'] = $this->placeholderPurged;
}
if ($skipFreight) {
$result['freight_incomplete'] = true;
@ -2909,6 +2916,64 @@ class ShippingReplicaSynchronizer
return ['ship_time' => $nTime, 'weight' => $nWeight];
}
/**
* 清理历史残留的订单占位行:包裹已有实发商品(数量>0)时,删除数量 0、金额 0/空 的订单商品行
* (被替换的原商品、组合装原套装等,聚水潭销售出库单不显示),商品行数(件数 1/N)随之更正。
* 这类行多半是占位过滤上线前入库、之后因"金额对不上保留旧包裹"一直没被替换下来的。
* 包裹级字段(重量/运费/结算等)若记在被删的首行上,先挪到保留下来的首行。
*/
public function purgeZeroQtyPlaceholders(string $fromS, string $toS): int
{
$pkgFields = array_merge(
['online_weight', 'actual_weight', 'weight_kg', 'settle_weight_kg', 'freight', 'settle_freight'],
self::PRESERVED_PACKAGE_FIELDS,
);
$sids = $this->db->table('jst_oms_shipping')
->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS)
->where('qty', 0)
->where(fn ($q) => $q->whereNull('amount')->orWhere('amount', 0))
->distinct()->pluck('source_id')->map(fn ($v) => (int) $v)->all();
$packages = 0;
foreach (array_chunk($sids, 500) as $chunk) {
$rows = $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)
->orderBy('id')->get(array_merge(['id', 'source_id', 'qty', 'amount'], $pkgFields));
$by = [];
foreach ($rows as $r) {
$by[(int) $r->source_id][] = (array) $r;
}
foreach ($by as $list) {
$isPh = static fn (array $r): bool => is_numeric($r['qty']) && (float) $r['qty'] == 0.0
&& ($r['amount'] === null || (is_numeric($r['amount']) && (float) $r['amount'] == 0.0));
$keep = array_values(array_filter($list, fn ($r) => ! $isPh($r)));
$drop = array_values(array_filter($list, $isPh));
$shipped = array_filter($keep, static fn ($r) => is_numeric($r['qty']) && (float) $r['qty'] > 0);
if ($drop === [] || $shipped === []) {
continue;
}
$this->db->transaction(function () use ($list, $keep, $drop, $isPh, $pkgFields): void {
if ($isPh($list[0])) {
$set = [];
foreach ($pkgFields as $f) {
$old = $list[0][$f] ?? null;
$cur = $keep[0][$f] ?? null;
if (($cur === null || $cur === '') && $old !== null && $old !== '') {
$set[$f] = $old;
}
}
if ($set !== []) {
$this->db->table('jst_oms_shipping')->where('id', $keep[0]['id'])->update($set);
}
}
$this->db->table('jst_oms_shipping')->whereIn('id', array_column($drop, 'id'))->delete();
$this->db->table('jst_oms_shipping')->whereIn('id', array_column($keep, 'id'))->update(['pkg_count' => '1/' . count($keep)]);
});
$packages++;
}
}
return $packages;
}
private function ts(mixed $v): ?string
{
if ($v === null || $v === '') {

View File

@ -782,14 +782,15 @@ class ShippingReplicaSynchronizerTest extends TestCase
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'),
);
$this->assertSame(2, $result['packages_retained_for_unmatched_amount']);
// 旧条码为空的包裹无法判断是否错配:保留旧记录;旧条码 old-sku 与实发 new-sku 完全不同:视为商品已更正,覆盖旧记录
$this->assertSame(1, $result['packages_retained_for_unmatched_amount']);
$this->assertSame(1, $result['packages_items_corrected']);
$this->assertSame('pending', $db->table('jst_sync_state')
->where('dataset_key', 'jst.sctrade.items.20260901000000-20260905000000')->value('status'));
$this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']);
$rows = $db->table('jst_oms_shipping')->orderBy('source_id')->get();
$this->assertSame(['旧商品甲', '旧商品乙'], $rows->pluck('product_name')->all());
$this->assertSame(['旧商品甲', '新商品乙'], $rows->pluck('product_name')->all());
$this->assertEquals(10.50, $rows[0]->amount);
$this->assertEquals(20.75, $rows[1]->amount);
}
public function test_explicit_zero_item_amount_replaces_old_amount(): void
@ -1539,6 +1540,53 @@ class ShippingReplicaSynchronizerTest extends TestCase
$this->assertEquals(3.0, $w(8005, 1)->actual_weight);
}
public function test_stale_zero_quantity_order_rows_are_purged_and_package_fields_move_to_first_kept_row(): void
{
$db = DB::connection('business_mirror');
foreach ([
// 组合套装原行(数量0、金额0)排在首行,且带着包裹级运费/重量;实发只有挂钩一行
['source_id' => 9001, 'sku_source_id' => 1, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => '运营组合套装13462',
'qty' => 0, 'amount' => 0, 'pkg_count' => '1/3', 'freight' => 2.5, 'weight_kg' => 0.01, 'online_weight' => 0.01],
['source_id' => 9001, 'sku_source_id' => 2, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => 'SJ038059N1410V01',
'qty' => 0, 'amount' => 0, 'pkg_count' => '1/3'],
['source_id' => 9001, 'sku_source_id' => 3, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => 'GG003-SZ',
'qty' => 1, 'amount' => 568, 'pkg_count' => '1/3'],
// 被替换的原商品(数量0、金额空)在后面
['source_id' => 9002, 'sku_source_id' => 1, 'ship_time' => '2026-10-22 11:02:57', 'express_no' => 'JT3181752322961', 'barcode' => 'HCZDYMJ-50-KFSBD-292',
'qty' => 1, 'amount' => 73, 'pkg_count' => '1/2', 'freight' => 4.1],
['source_id' => 9002, 'sku_source_id' => 2, 'ship_time' => '2026-10-22 11:02:57', 'express_no' => 'JT3181752322961', 'barcode' => 'HCZDYMJ-50-KFSBD-TJ',
'qty' => 0, 'amount' => null, 'pkg_count' => '1/2'],
// 只有数量0的行:不动(不把包裹删成无商品)
['source_id' => 9003, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => 'ZERO3', 'barcode' => 'X',
'qty' => 0, 'amount' => 0, 'pkg_count' => '1/1'],
// 窗口外:不动
['source_id' => 9004, 'sku_source_id' => 1, 'ship_time' => '2026-09-10 10:00:00', 'express_no' => 'OLD4', 'barcode' => 'A',
'qty' => 0, 'amount' => 0, 'pkg_count' => '1/2'],
['source_id' => 9004, 'sku_source_id' => 2, 'ship_time' => '2026-09-10 10:00:00', 'express_no' => 'OLD4', 'barcode' => 'B',
'qty' => 1, 'amount' => 5, 'pkg_count' => '1/2'],
] as $row) {
$db->table('jst_oms_shipping')->insert($row);
}
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
$n = (new ShippingReplicaSynchronizer($factory))->purgeZeroQtyPlaceholders('2026-10-01 00:00:00', '2026-10-26 00:00:00');
$this->assertSame(2, $n);
$kept = $db->table('jst_oms_shipping')->where('source_id', 9001)->get();
$this->assertCount(1, $kept);
$this->assertSame('GG003-SZ', $kept[0]->barcode);
$this->assertSame('1/1', $kept[0]->pkg_count);
$this->assertEquals(2.5, $kept[0]->freight);
$this->assertEquals(0.01, $kept[0]->weight_kg);
$this->assertEquals(0.01, $kept[0]->online_weight);
$this->assertSame(['HCZDYMJ-50-KFSBD-292'], $db->table('jst_oms_shipping')->where('source_id', 9002)->pluck('barcode')->all());
$this->assertSame('1/1', $db->table('jst_oms_shipping')->where('source_id', 9002)->value('pkg_count'));
$this->assertEquals(4.1, $db->table('jst_oms_shipping')->where('source_id', 9002)->value('freight'));
$this->assertSame(1, $db->table('jst_oms_shipping')->where('source_id', 9003)->count());
$this->assertSame(2, $db->table('jst_oms_shipping')->where('source_id', 9004)->count());
}
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
{
$reflection = new \ReflectionProperty($synchronizer, $property);