fix(发货数据): 补拉改到每晚21:28并记录日志,过滤订单商品数量0占位行,超长文本按列宽截断 #707

Merged
limeng merged 1 commits from fix/shipping-evening-backfill-placeholders into main 2026-10-28 09:24:57 +08:00
3 changed files with 86 additions and 4 deletions

View File

@ -110,12 +110,15 @@ class Kernel extends ConsoleKernel
$schedule->command('jushuitan:sync-shipping --days=40')
->dailyAt('01:28')
->withoutOverlapping(300)
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
->runInBackground();
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
// 凌晨副本库最近一两天的商品明细常未到齐(出库单常在发货次日下午才确认,视图随后才有商品);
// 白天副本库负载高、查询常超时断开,补拉放到晚上:每天 21:28 再拉最近 3 天,
// 补齐凌晨只写入单号、缺商品明细的包裹。
$schedule->command('jushuitan:sync-shipping --days=3')
->dailyAt('07:28')
->dailyAt('21:28')
->withoutOverlapping(120)
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
->runInBackground();
}

View File

@ -1115,6 +1115,7 @@ class ShippingReplicaSynchronizer
private function emitPackage(int $pkgId, array $pkg, array $items, array $skus, array $freight, string $so, ?string $tracking, $now, bool $auto = false): int
{
$items = array_values(array_filter($items, fn ($it) => ! $this->isPricePatch($it['name'] ?? null)));
$items = $this->dropZeroQtyPlaceholders($items);
$n = count($items);
$tk = ltrim(trim((string) ($tracking ?? '')), '@');
if ($tk !== '') {
@ -2458,17 +2459,49 @@ class ShippingReplicaSynchronizer
return $payload;
}
/** jst_oms_shipping 文本列长度上限:上游偶有超长值(如组合装商品ID),超长截断而不是整批写入失败。 */
private const COLUMN_LIMITS = [
'express_no' => 64, 'trade_no' => 64, 'item_id' => 64, 'barcode' => 128, 'product_name' => 512,
'receiver_state' => 64, 'receiver_city' => 64, 'receiver_district' => 64, 'receiver_name' => 128,
'receiver_mobile' => 64, 'receiver_phone' => 64, 'shop_name' => 255, 'express_name1' => 128,
'express_name2' => 128, 'warehouse_name' => 128, 'supplier_name' => 128, 'platform' => 64,
'product_code' => 128, 'pkg_count' => 32, 'm_name' => 500,
];
private function cleanPayload(array $data): array
{
foreach ($data as $k => $v) {
if (is_string($v)) {
$data[$k] = $this->cleanStr($v);
$v = $this->cleanStr($v);
$limit = self::COLUMN_LIMITS[$k] ?? null;
if ($limit !== null && mb_strlen($v) > $limit) {
$v = mb_substr($v, 0, $limit);
}
$data[$k] = $v;
}
}
return $data;
}
/**
* 订单商品表里平台原始商品行(数量 0、金额 0)并没有实际出库,聚水潭工作平台也不显示;
* 包裹已有实发商品时不写入这类行,避免多出一个"条码"(其实是平台商品编号)。
*
* @param list<array<string,mixed>> $items
* @return list<array<string,mixed>>
*/
private function dropZeroQtyPlaceholders(array $items): array
{
$zero = static fn ($v): bool => $v !== null && $v !== '' && is_numeric($v) && (float) $v == 0.0;
$placeholder = static fn (array $it): bool => $zero($it['qty'] ?? null)
&& (($it['amount'] ?? null) === null || ($it['amount'] ?? null) === '' || $zero($it['amount']));
$keep = array_values(array_filter($items, static fn (array $it): bool => ! $placeholder($it)));
$hasShipped = array_filter($keep, static fn (array $it): bool => is_numeric($it['qty'] ?? null) && (float) $it['qty'] > 0);
return ($hasShipped !== [] && count($keep) < count($items)) ? $keep : $items;
}
private function cleanStr(string $v): string
{
$v = str_replace("\0", '', $v);

View File

@ -1388,6 +1388,52 @@ class ShippingReplicaSynchronizerTest extends TestCase
$this->assertSame('江苏省', $db->table('jst_oms_shipping')->where('source_id', 6000)->value('receiver_state'));
}
public function test_zero_quantity_order_item_placeholder_is_dropped_when_package_has_shipped_items(): void
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
$synchronizer = new ShippingReplicaSynchronizer($factory);
$method = new \ReflectionMethod($synchronizer, 'dropZeroQtyPlaceholders');
$method->setAccessible(true);
$items = [
['id' => 4343977, 'sku' => '923141324442', 'shopid' => '3780268746917151177', 'qty' => 0, 'amount' => '0.00'],
['id' => 4344020, 'sku' => 'CBZ000216N1071V01', 'shopid' => '3780268746917151177', 'qty' => 1, 'amount' => '81.66'],
];
$kept = $method->invoke($synchronizer, $items);
$this->assertCount(1, $kept);
$this->assertSame('CBZ000216N1071V01', $kept[0]['sku']);
// 只有占位行时保持原样(不把包裹变成无商品)
$onlyZero = [['id' => 1, 'sku' => 'X', 'qty' => 0, 'amount' => 0]];
$this->assertSame($onlyZero, $method->invoke($synchronizer, $onlyZero));
// 数量未知(null)的行不当作占位行
$unknownQty = [['id' => 2, 'sku' => 'A', 'qty' => null, 'amount' => null], ['id' => 3, 'sku' => 'B', 'qty' => 1, 'amount' => 5]];
$this->assertCount(2, $method->invoke($synchronizer, $unknownQty));
// 数量 0 但有金额的行保留
$paidZero = [['id' => 4, 'sku' => 'A', 'qty' => 0, 'amount' => 3.5], ['id' => 5, 'sku' => 'B', 'qty' => 1, 'amount' => 5]];
$this->assertCount(2, $method->invoke($synchronizer, $paidZero));
}
public function test_overlong_text_values_are_truncated_to_column_limits(): void
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
$synchronizer = new ShippingReplicaSynchronizer($factory);
$method = new \ReflectionMethod($synchronizer, 'cleanPayload');
$method->setAccessible(true);
$itemId = 'JST15438196_14138566_ZH-GBSFY-YMJ011901AN1403V01-1-GBSFY-YMJ011901BN1403V01-1';
$this->assertGreaterThan(64, mb_strlen($itemId));
$out = $method->invoke($synchronizer, ['item_id' => $itemId, 'barcode' => 'ABC', 'qty' => 2]);
$this->assertSame(64, mb_strlen($out['item_id']));
$this->assertSame(mb_substr($itemId, 0, 64), $out['item_id']);
$this->assertSame('ABC', $out['barcode']);
$this->assertSame(2, $out['qty']);
}
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
{
$reflection = new \ReflectionProperty($synchronizer, $property);