fix(发货数据): 重跑时清除被取代的兜底包裹,保留包裹也刷新省市区/快递/仓库

- 凌晨同步时发货明细常未到齐,包裹先按自动发货仓/出库单兜底入库(无省份、无商品);之后发货明细到齐换成发货明细来源键,
  旧兜底包裹因"上游未再出现"被保留,造成重复和空白省份(10 月重跑 2,226 个)。现在:旧包裹的快递单号已由本次其它包裹吐出时
  视为被取代并删除;无对应的仍保留待核对。
- 商品金额无法安全匹配(或新商品明细缺失)而保留的旧包裹(10 月 28,201 个),此前整包不更新;现在商品与金额不动,
  省市区、快递名称、仓库、店铺、平台、交易单号、付款时间按本次上游刷新。

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-10-14 08:54:45 +08:00
parent b4f8416794
commit f6497f5afc
2 changed files with 109 additions and 5 deletions

View File

@ -43,6 +43,10 @@ class ShippingReplicaSynchronizer
'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no',
]; ];
private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone']; private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone'];
private const REFRESHED_PACKAGE_FIELDS = [
'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time',
'receiver_state', 'receiver_city', 'receiver_district',
];
private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier']; private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier'];
private const VIEW = 'rpt.Jst_SendData_View_extension'; private const VIEW = 'rpt.Jst_SendData_View_extension';
@ -68,6 +72,7 @@ class ShippingReplicaSynchronizer
private int $packagesRetainedForMissingItems = 0; private int $packagesRetainedForMissingItems = 0;
private int $packagesRetainedForUnmatchedAmount = 0; private int $packagesRetainedForUnmatchedAmount = 0;
private int $staleMirrorPackages = 0; private int $staleMirrorPackages = 0;
private int $supersededRemoved = 0;
/** @var array<int,true> */ /** @var array<int,true> */
private array $seenSourceIds = []; private array $seenSourceIds = [];
@ -174,6 +179,7 @@ class ShippingReplicaSynchronizer
$this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForMissingItems = 0;
$this->packagesRetainedForUnmatchedAmount = 0; $this->packagesRetainedForUnmatchedAmount = 0;
$this->staleMirrorPackages = 0; $this->staleMirrorPackages = 0;
$this->supersededRemoved = 0;
$this->seenSourceIds = []; $this->seenSourceIds = [];
$this->expressOrderIds = []; $this->expressOrderIds = [];
$this->dwsFailedDays = []; $this->dwsFailedDays = [];
@ -261,6 +267,7 @@ class ShippingReplicaSynchronizer
$result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount; $result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount;
$result['dws_failed_days'] = $this->dwsFailedDays; $result['dws_failed_days'] = $this->dwsFailedDays;
$result['stale_mirror_packages'] = $this->staleMirrorPackages; $result['stale_mirror_packages'] = $this->staleMirrorPackages;
$result['superseded_packages_removed'] = $this->supersededRemoved;
$result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped;
} }
if ($skipFreight) { if ($skipFreight) {
@ -2177,9 +2184,11 @@ class ShippingReplicaSynchronizer
$rows = []; $rows = [];
$replaceIds = []; $replaceIds = [];
$refresh = [];
foreach ($packages as $id => $packageRows) { foreach ($packages as $id => $packageRows) {
if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) { if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) {
$this->packagesRetainedForMissingItems++; $this->packagesRetainedForMissingItems++;
$refresh[$id] = $packageRows[0] ?? [];
continue; continue;
} }
$unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? []; $unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? [];
@ -2216,6 +2225,7 @@ class ShippingReplicaSynchronizer
} }
if ($unsafeAmount) { if ($unsafeAmount) {
$this->packagesRetainedForUnmatchedAmount++; $this->packagesRetainedForUnmatchedAmount++;
$refresh[$id] = $packageRows[0] ?? [];
continue; continue;
} }
$replaceIds[] = $id; $replaceIds[] = $id;
@ -2245,6 +2255,7 @@ class ShippingReplicaSynchronizer
} }
array_push($rows, ...$packageRows); array_push($rows, ...$packageRows);
} }
$this->refreshRetainedPackages($refresh);
if ($replaceIds === []) { if ($replaceIds === []) {
return; return;
} }
@ -2347,21 +2358,55 @@ class ShippingReplicaSynchronizer
return $out; return $out;
} }
/**
* 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录
* (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白;
* 其余仍保留并计数,需按源键核对。
*/
private function countUnseenMirrorPackages(string $fromS, string $toS): int private function countUnseenMirrorPackages(string $fromS, string $toS): int
{ {
$unseen = 0; $unseen = 0;
$ids = $this->db->table('jst_oms_shipping') $superseded = [];
$rows = $this->db->table('jst_oms_shipping')
->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS)
->distinct()->pluck('source_id'); ->selectRaw('source_id, MAX(express_no) AS express_no')->groupBy('source_id')->get();
foreach ($ids as $id) { foreach ($rows as $row) {
if (! isset($this->seenSourceIds[(int) $id])) { $id = (int) $row->source_id;
$unseen++; if (isset($this->seenSourceIds[$id])) {
continue;
} }
$tk = ltrim(trim((string) ($row->express_no ?? '')), '@');
if ($tk !== '' && isset($this->emittedTrack[$tk])) {
$superseded[] = $id;
continue;
}
$unseen++;
} }
foreach (array_chunk($superseded, 500) as $chunk) {
$this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete();
}
$this->supersededRemoved = count($superseded);
return $unseen; return $unseen;
} }
/** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */
private function refreshRetainedPackages(array $refresh): void
{
foreach ($refresh as $id => $new) {
$set = [];
foreach (self::REFRESHED_PACKAGE_FIELDS as $field) {
$v = $new[$field] ?? null;
if ($v !== null && trim((string) $v) !== '') {
$set[$field] = $v;
}
}
if ($set !== []) {
$this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set);
}
}
}
private function withPreservedFreight(array $payload, array $previous): array private function withPreservedFreight(array $payload, array $previous): array
{ {
$payload = $this->withPreservedCharges($payload, $previous); $payload = $this->withPreservedCharges($payload, $previous);

View File

@ -1315,6 +1315,65 @@ class ShippingReplicaSynchronizerTest extends TestCase
$this->assertSame('15170678', $pending->getValue($sync)[2]); $this->assertSame('15170678', $pending->getValue($sync)[2]);
} }
public function test_retained_package_refreshes_address_and_superseded_fallback_package_is_removed(): void
{
$db = DB::connection('business_mirror');
$db->table('jst_oms_shipping')->insert([
['source_id' => 5000, 'sku_source_id' => 500000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'YT5',
'barcode' => null, 'product_name' => '旧商品甲', 'amount' => 10.50, 'receiver_state' => null],
// 凌晨先按自动发货仓兜底入库的旧包裹,之后发货明细到齐由 6000 吐出同一单号
['source_id' => 1000000777, 'sku_source_id' => 1000000777, 'ship_time' => '2026-09-02 13:00:00', 'express_no' => 'YT6',
'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null],
['source_id' => 1000000888, 'sku_source_id' => 1000000888, 'ship_time' => '2026-09-02 14:00:00', 'express_no' => 'ZZ9',
'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null],
]);
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturnCallback(function (string $sql): array {
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
return [
['id' => 5000, 'inner_order_id' => 'order-1', 'logistic_id' => 'YT5', 'send_time' => '2026-09-02 12:00:00',
'receiver_address' => '河南省-郑州市-金水区'],
['id' => 6000, 'inner_order_id' => 'order-2', 'logistic_id' => 'YT6', 'send_time' => '2026-09-02 13:00:00',
'receiver_address' => '江苏省-常州市-武进区'],
];
}
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) {
return [
['o_id' => 'order-1', 'io_id' => 'outbound-1', 'l_id' => 'YT5'],
['o_id' => 'order-2', 'io_id' => 'outbound-2', 'l_id' => 'YT6'],
];
}
if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) {
return [
['io_id' => 'outbound-1', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品甲', 'volume' => null],
['io_id' => 'outbound-2', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品乙', 'volume' => null],
];
}
return [];
});
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
$result = (new ShippingReplicaSynchronizer($factory))->sync(
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'),
);
$this->assertSame(1, $result['packages_retained_for_unmatched_amount']);
$this->assertSame(1, $result['superseded_packages_removed']);
$this->assertSame(1, $result['stale_mirror_packages']);
// 保留的旧包裹:商品金额不动,省市区按上游刷新
$kept = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole();
$this->assertSame('旧商品甲', $kept->product_name);
$this->assertEquals(10.50, $kept->amount);
$this->assertSame('河南省', $kept->receiver_state);
$this->assertSame('郑州市', $kept->receiver_city);
// 被发货明细取代的兜底包裹删除;无对应的仍保留待核对
$this->assertSame(0, $db->table('jst_oms_shipping')->where('source_id', 1000000777)->count());
$this->assertSame(1, $db->table('jst_oms_shipping')->where('source_id', 1000000888)->count());
$this->assertSame('江苏省', $db->table('jst_oms_shipping')->where('source_id', 6000)->value('receiver_state'));
}
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
{ {
$reflection = new \ReflectionProperty($synchronizer, $property); $reflection = new \ReflectionProperty($synchronizer, $property);