发货视图对采购退货单会重复出很多行(每行都写整单数量),拼接条码还会被截断出残码, 按视图累加把数量放大(如 1190330222661 聚水潭 93 件、财务 1083 件)。 现改为商品行以 rpt.jst_purchaseoutItem 为准:视图同条码沿用名称/长宽高,数量取明细; 视图没有的条码按明细补行;视图多出的残码/重复行丢弃;明细为空时仍按视图。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf > 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。 ## 任务占用与边界 - 任务名称: - 实际开发者: - 任务来源/团队分工: - 目标结果: - 预计修改路径: - 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件): - 仅复用、明确不修改的公共契约: - 数据源、表/视图和字段: - 上游 `source_key` / `scope`: - 权限名、菜单键或共享组件: - 明确不修改的范围: ## 开工碰撞检查 - [ ] Codex 已执行 `scripts/codex-task-preflight.ps1` - [ ] 已检查最新 `main` 和所有远程任务分支 - [ ] 已搜索主线既有页面、接口、Repository 和数据查询 - [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件 - [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论 相关分支、重叠项与协调结论: ## 实际改动 - 完成内容: - Codex 实际修改的文件: - 新增或变更的公共契约: - 与原计划的差异: ## 验证证据 - 正常流程: - 空数据/错误输入/无权限场景: - 受影响旧功能: - 自动测试及结果: - 实际页面、接口或只读业务查询结果: ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用” 高风险影响、回滚和授权状态: ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 `WIP:`,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: #865
1670 lines
94 KiB
PHP
1670 lines
94 KiB
PHP
<?php
|
||
|
||
namespace Tests\Unit\EcommerceFinance;
|
||
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
|
||
use PDOException;
|
||
use RuntimeException;
|
||
use Tests\TestCase;
|
||
|
||
class ShippingReplicaSynchronizerTest extends TestCase
|
||
{
|
||
public function test_windowed_express_scan_maps_order_metadata_without_joining_orders_per_page(): void
|
||
{
|
||
$detailQueries = [];
|
||
$orderMetadataQueries = [];
|
||
$detailCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$detailQueries, &$orderMetadataQueries, &$detailCalls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
$detailQueries[] = $sql;
|
||
|
||
return $detailCalls++ === 0
|
||
? [['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => null, 'logistic_id' => '']]
|
||
: [];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders om WHERE om.o_id IN')) {
|
||
$orderMetadataQueries[] = [$sql, $bindings];
|
||
|
||
return [['o_id' => 'order-1', 'l_id' => 'order-tracking', 'send_date' => '2026-09-02 12:00:00']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE o.status =')) {
|
||
return [];
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertNotEmpty($detailQueries);
|
||
$this->assertStringNotContainsString('LEFT JOIN rpt.jst_Orders', $detailQueries[0]);
|
||
$this->assertCount(1, $orderMetadataQueries);
|
||
$this->assertSame(['order-1'], $orderMetadataQueries[0][1]);
|
||
$this->assertSame(1, $result['processed']);
|
||
$row = DB::connection('business_mirror')->table('jst_oms_shipping')->where('source_id', 5000)->sole();
|
||
$this->assertSame('order-tracking', $row->express_no);
|
||
$this->assertSame('2026-09-02 12:00:00', $row->ship_time);
|
||
}
|
||
|
||
public function test_windowed_outbound_lookup_preloads_date_range_and_reuses_cache_without_in_queries(): void
|
||
{
|
||
$from = '2026-09-22 00:00:00';
|
||
$to = '2026-09-25 00:00:00';
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->expects($this->once())
|
||
->method('select')
|
||
->with(
|
||
$this->callback(fn (string $sql): bool => str_contains($sql, 'WHERE io_date >= ? AND io_date < ?')
|
||
&& ! str_contains($sql, 'l_id IN')
|
||
&& ! str_contains($sql, 'o_id IN')),
|
||
[$from, $to],
|
||
)
|
||
->willReturn([
|
||
['l_id' => 'logistic-1', 'o_id' => 'order-1', 'io_id' => 'outbound-1'],
|
||
['l_id' => 'logistic-2', 'o_id' => 'order-2', 'io_id' => 'outbound-2'],
|
||
]);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
$preload = new \ReflectionMethod($synchronizer, 'preloadOutboundMap');
|
||
$preload->setAccessible(true);
|
||
$preload->invoke($synchronizer, $from, $to);
|
||
|
||
$lookup = new \ReflectionMethod($synchronizer, 'ioMapFor');
|
||
$lookup->setAccessible(true);
|
||
$map = $lookup->invoke($synchronizer, true, ['logistic-1'], ['order-2']);
|
||
|
||
$this->assertSame('outbound-1', $map['byL']['logistic-1']);
|
||
$this->assertSame('outbound-2', $map['byO']['order-2']);
|
||
}
|
||
|
||
public function test_windowed_item_lookup_skips_unindexed_raw_so_id_fallback(): void
|
||
{
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->expects($this->once())
|
||
->method('select')
|
||
->with(
|
||
$this->stringContains('WHERE o_id IN'),
|
||
['order-1'],
|
||
)
|
||
->willReturn([
|
||
[
|
||
'o_id' => 'order-1',
|
||
'raw_so_id' => 'trade-1',
|
||
'sku_id' => 'sku-1',
|
||
'i_id' => 'item-1',
|
||
'shop_i_id' => 'shop-item-1',
|
||
'name' => '商品',
|
||
'qty' => 1,
|
||
'amount' => 10,
|
||
'send_warehouse' => '仓库',
|
||
'Id' => 1,
|
||
],
|
||
]);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
$method = new \ReflectionMethod($synchronizer, 'loadAllItems');
|
||
$method->setAccessible(true);
|
||
[$byOrder, $byTrade] = $method->invoke($synchronizer, ['order-1'], ['trade-1'], false);
|
||
|
||
$this->assertArrayHasKey('order-1', $byOrder);
|
||
$this->assertSame([], $byTrade);
|
||
}
|
||
|
||
public function test_windowed_scan_continues_past_an_old_page_to_find_later_shipments(): void
|
||
{
|
||
$pages = [
|
||
array_map(fn (int $id): array => ['id' => $id, 'send_time' => $id === 4500 ? '2026-09-02 12:00:00' : null], range(4500, 3001)),
|
||
array_map(fn (int $id): array => ['id' => $id, 'send_time' => '2026-08-31 12:00:00'], range(3000, 1501)),
|
||
[['id' => 1500, 'send_time' => '2026-09-03 12:00:00']],
|
||
];
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $pages[$scanCalls++] ?? [];
|
||
}
|
||
|
||
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(3, $scanCalls);
|
||
$this->assertSame(2, $result['processed']);
|
||
$this->assertSame(2, $result['unique_packages']);
|
||
$this->assertSame([1500, 4500], DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
}
|
||
|
||
public function test_express_page_reconnects_once_after_transient_dblib_cursor_failure(): void
|
||
{
|
||
$firstPage = array_map(fn (int $id): array => [
|
||
'id' => $id,
|
||
'send_time' => $id === 3000 ? '2026-09-02 12:00:00' : null,
|
||
], range(3000, 1501));
|
||
$lastPage = [['id' => 1500, 'send_time' => '2026-09-03 12:00:00']];
|
||
$scanCalls = 0;
|
||
$bindings = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bind) use (&$scanCalls, &$bindings, $firstPage, $lastPage): array {
|
||
if (! str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return [];
|
||
}
|
||
$scanCalls++;
|
||
$bindings[] = $bind;
|
||
if ($scanCalls === 2) {
|
||
throw new PDOException('SQLSTATE[HY000]: General error: PDO_DBLIB: dbnextrow() returned FAIL');
|
||
}
|
||
|
||
return $scanCalls === 1 ? $firstPage : $lastPage;
|
||
});
|
||
$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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(3, $scanCalls);
|
||
$this->assertSame([[], [1501], [1501]], $bindings);
|
||
$this->assertSame(2, $result['processed']);
|
||
$this->assertSame(2, $result['unique_packages']);
|
||
$this->assertSame([1500, 3000], DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
}
|
||
|
||
public function test_non_transient_upstream_failure_is_not_retried(): void
|
||
{
|
||
$calls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$calls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
$calls++;
|
||
throw new PDOException('SQLSTATE[HY000]: unrelated upstream error');
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
try {
|
||
(new ShippingReplicaSynchronizer($factory))->sync(
|
||
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true,
|
||
);
|
||
$this->fail('Expected the upstream error to be propagated.');
|
||
} catch (PDOException $e) {
|
||
$this->assertStringContainsString('unrelated upstream error', $e->getMessage());
|
||
}
|
||
|
||
$this->assertSame(1, $calls);
|
||
$this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
}
|
||
|
||
public function test_adaptive_server_timeout_reconnects_and_replays_the_same_page(): void
|
||
{
|
||
$page = array_map(fn (int $id): array => [
|
||
'id' => $id, 'send_time' => $id === 1500 ? '2026-09-02 12:00:00' : null,
|
||
], range(1500, 1));
|
||
$scanCalls = 0;
|
||
$bindings = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bind) use (&$scanCalls, &$bindings, $page): array {
|
||
if (! str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return [];
|
||
}
|
||
$scanCalls++;
|
||
$bindings[] = $bind;
|
||
if ($scanCalls <= 2) {
|
||
throw new PDOException('SQLSTATE[HY000]: General error: 20003 Adaptive Server connection timed out [20003] (severity 6)');
|
||
}
|
||
return $scanCalls === 3 ? $page : [];
|
||
});
|
||
$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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(4, $scanCalls);
|
||
$this->assertSame([[], [], [], [1]], $bindings);
|
||
$this->assertSame(1, $result['processed']);
|
||
$this->assertSame([1500], DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
}
|
||
|
||
public function test_window_result_distinguishes_duplicate_package_rows_from_unique_source_ids(): void
|
||
{
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
$row = ['id' => 5000, 'send_time' => '2026-09-02 12:00:00'];
|
||
return [$row, $row];
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(2, $result['packages']);
|
||
$this->assertSame(1, $result['unique_packages']);
|
||
$this->assertSame(1, DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->distinct()->count('source_id'));
|
||
}
|
||
|
||
public function test_express_scan_fails_when_more_rows_remain_after_batch_limit(): void
|
||
{
|
||
DB::connection('business_mirror')->table('jst_oms_shipping')->insert([
|
||
'source_id' => 3000, 'sku_source_id' => 3000, 'ship_time' => '2026-09-02 12:00:00',
|
||
'express_no' => 'old-tracking', 'freight' => 18.75,
|
||
]);
|
||
$pages = [
|
||
array_map(fn (int $id): array => ['id' => $id, 'send_time' => $id === 3000 ? '2026-09-02 12:00:00' : null], range(3000, 1501)),
|
||
[['id' => 1500]],
|
||
];
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $pages[$scanCalls++] ?? [];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
$this->setScanBatchLimit($synchronizer, 'maxExpressBatches', 1);
|
||
|
||
try {
|
||
$synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true);
|
||
$this->fail('Expected an incomplete express scan to fail.');
|
||
} catch (RuntimeException $e) {
|
||
$this->assertStringContainsString('发货扫描达到批次上限', $e->getMessage());
|
||
}
|
||
|
||
$this->assertSame(2, $scanCalls);
|
||
$this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
$this->assertSame('pending', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status'));
|
||
$original = DB::connection('business_mirror')->table('jst_oms_shipping')->where('source_id', 3000)->first();
|
||
$this->assertSame('old-tracking', $original->express_no);
|
||
$this->assertEquals(18.75, $original->freight);
|
||
}
|
||
|
||
public function test_full_last_batch_succeeds_when_no_more_express_rows_exist(): void
|
||
{
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $scanCalls++ === 0
|
||
? array_map(fn (int $id): array => ['id' => $id], range(1500, 1))
|
||
: [];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
$this->setScanBatchLimit($synchronizer, 'maxExpressBatches', 1);
|
||
|
||
$result = $synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'));
|
||
|
||
$this->assertSame(2, $scanCalls);
|
||
$this->assertSame(0, $result['processed']);
|
||
$this->assertSame('succeeded', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
}
|
||
|
||
public function test_auto_ship_scan_fails_when_more_rows_remain_after_batch_limit(): void
|
||
{
|
||
$pages = [
|
||
array_map(fn (int $id): array => ['rid' => $id, 'o_id' => ''], range(1000, 601)),
|
||
[['rid' => 600, 'o_id' => '']],
|
||
];
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) {
|
||
return $pages[$scanCalls++] ?? [];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
$this->setScanBatchLimit($synchronizer, 'maxAutoShipBatches', 1);
|
||
|
||
try {
|
||
$synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'));
|
||
$this->fail('Expected an incomplete auto-ship scan to fail.');
|
||
} catch (RuntimeException $e) {
|
||
$this->assertStringContainsString('代发仓扫描达到批次上限', $e->getMessage());
|
||
}
|
||
|
||
$this->assertSame(2, $scanCalls);
|
||
$this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
}
|
||
|
||
public function test_auto_ship_excludes_every_order_with_express_detail_even_when_detail_is_outside_window_or_label_excluded(): void
|
||
{
|
||
$orderQueries = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$orderQueries): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return [
|
||
['id' => 1001, 'inner_order_id' => '123', 'jo_send' => '2026-09-02 13:00:00',
|
||
'send_time' => '2026-08-31 12:00:00'],
|
||
['id' => 1002, 'inner_order_id' => '456', 'jo_send' => '2026-09-02 13:00:00',
|
||
'send_time' => '2026-09-02 12:00:00', 'order_labels' => '线上发货'],
|
||
['id' => 1003, 'inner_order_id' => '789', 'jo_send' => '2026-09-02 13:00:00',
|
||
'send_time' => '2026-09-02 12:00:00'],
|
||
];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) {
|
||
$orderQueries[] = [$sql, $bindings];
|
||
return array_map(fn (int $oid): array => [
|
||
'rid' => $oid, 'o_id' => (string) $oid, 'send_date' => '2026-09-02 13:00:00',
|
||
], [789, 456, 321, 123]);
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertCount(1, $orderQueries);
|
||
$this->assertStringNotContainsString('NOT EXISTS', $orderQueries[0][0]);
|
||
$this->assertSame(['2026-09-01 00:00:00', '2026-09-05 00:00:00'], $orderQueries[0][1]);
|
||
$this->assertSame(1, $result['processed']);
|
||
$this->assertSame(1, $result['autoship']);
|
||
$this->assertSame([1003, 1000000321], DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
}
|
||
|
||
public function test_auto_ship_advances_raw_page_cursor_when_all_400_orders_have_express_details(): void
|
||
{
|
||
$orderBindings = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$orderBindings): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return array_map(fn (int $oid): array => [
|
||
'id' => $oid, 'inner_order_id' => (string) $oid,
|
||
'jo_send' => '2026-09-02 13:00:00', 'send_time' => '2026-08-31 12:00:00',
|
||
], range(1000, 601));
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) {
|
||
$orderBindings[] = $bindings;
|
||
if (count($orderBindings) === 1) {
|
||
return array_map(fn (int $oid): array => [
|
||
'rid' => $oid, 'o_id' => (string) $oid, 'send_date' => '2026-09-02 13:00:00',
|
||
], range(1000, 601));
|
||
}
|
||
return [['rid' => 600, 'o_id' => '500', 'send_date' => '2026-09-02 13:00:00']];
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame([
|
||
['2026-09-01 00:00:00', '2026-09-05 00:00:00'],
|
||
['2026-09-01 00:00:00', '2026-09-05 00:00:00', 601],
|
||
], $orderBindings);
|
||
$this->assertSame(0, $result['processed']);
|
||
$this->assertSame(1, $result['autoship']);
|
||
$this->assertSame([1000000500], DB::connection('business_mirror')->table('jst_oms_shipping')
|
||
->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
}
|
||
|
||
public function test_skip_freight_preserves_existing_amounts_and_marks_new_amounts_incomplete(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00',
|
||
'express_no' => 'existing-tracking', 'express_name2' => 'existing-express',
|
||
'freight' => 12.34, 'settle_freight' => 13.45, 'weight_kg' => 1.2, 'settle_weight_kg' => 1.3],
|
||
['source_id' => 6000, 'sku_source_id' => 6000, 'ship_time' => '2026-09-03 12:00:00',
|
||
'express_no' => 'weight-only-tracking', 'express_name2' => null,
|
||
'freight' => null, 'settle_freight' => null, 'weight_kg' => null, 'settle_weight_kg' => 4.5],
|
||
]);
|
||
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array {
|
||
if (str_contains($sql, 'fys_jstFreightCostDetail')) {
|
||
$this->fail('Skip-freight mode must not query the freight view.');
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $scanCalls++ === 0 ? [
|
||
['id' => 6000, 'send_time' => '2026-09-03 12:00:00', 'weight' => 2, 'logistic_id' => 'new-weight-tracking'],
|
||
['id' => 5000, 'send_time' => '2026-09-02 12:00:00', 'weight' => 2, 'logistic_id' => 'new-tracking'],
|
||
['id' => 4000, 'send_time' => '2026-09-03 12:00:00', 'weight' => 2],
|
||
] : [];
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(3, $result['processed']);
|
||
$this->assertSame(3, $result['packages']);
|
||
$this->assertSame(3, $result['packages_without_items']);
|
||
$this->assertTrue($result['freight_incomplete']);
|
||
$this->assertSame('succeeded', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
$this->assertSame('pending', $db->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status'));
|
||
$this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']);
|
||
$existing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first();
|
||
$this->assertSame('existing-tracking', $existing->express_no);
|
||
$this->assertSame('existing-express', $existing->express_name2);
|
||
$this->assertEquals(12.34, $existing->freight);
|
||
$this->assertEquals(13.45, $existing->settle_freight);
|
||
$this->assertEquals(1.2, $existing->weight_kg);
|
||
$this->assertEquals(1.3, $existing->settle_weight_kg);
|
||
$weightOnly = $db->table('jst_oms_shipping')->where('source_id', 6000)->first();
|
||
$this->assertSame('weight-only-tracking', $weightOnly->express_no);
|
||
$this->assertNull($weightOnly->freight);
|
||
$this->assertEquals(4.5, $weightOnly->settle_weight_kg);
|
||
$new = $db->table('jst_oms_shipping')->where('source_id', 4000)->first();
|
||
$this->assertNull($new->freight);
|
||
$this->assertNull($new->settle_freight);
|
||
$this->assertNull($new->weight_kg);
|
||
$this->assertNull($new->settle_weight_kg);
|
||
$this->assertEquals(2, $new->actual_weight);
|
||
|
||
(new ShippingReplicaSynchronizer($factory))->sync(
|
||
Carbon::parse('2026-09-04'), Carbon::parse('2026-09-05'),
|
||
);
|
||
$this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']);
|
||
|
||
(new ShippingReplicaSynchronizer($factory))->sync(
|
||
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'),
|
||
);
|
||
$this->assertSame('pending', $db->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status'));
|
||
$this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']);
|
||
}
|
||
|
||
public function test_normal_window_preserves_old_charges_when_freight_is_missing_but_uses_new_cost(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00',
|
||
'express_no' => 'old-5000', 'freight' => 12.34, 'settle_freight' => 13.45,
|
||
'weight_kg' => 1.2, 'settle_weight_kg' => 1.3],
|
||
['source_id' => 6000, 'sku_source_id' => 6000, 'ship_time' => '2026-09-03 12:00:00',
|
||
'express_no' => 'old-6000', 'freight' => 15.00, 'settle_freight' => 15.00,
|
||
'weight_kg' => 1.5, 'settle_weight_kg' => 1.5],
|
||
]);
|
||
$db->table('jst_sync_state')->insert([
|
||
'dataset_key' => 'jst.sctrade.freight.20260901000000-20260905000000',
|
||
'status' => 'pending',
|
||
]);
|
||
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $scanCalls++ === 0 ? [
|
||
['id' => 6000, 'so_id' => 'so-6000', 'logistic_id' => 'new-6000',
|
||
'send_time' => '2026-09-03 12:00:00', 'weight' => 2],
|
||
['id' => 5000, 'so_id' => 'so-5000', 'logistic_id' => 'new-5000',
|
||
'send_time' => '2026-09-02 12:00:00', 'weight' => 2],
|
||
] : [];
|
||
}
|
||
if (str_contains($sql, 'fys_jstFreightCostDetail')) {
|
||
return [['so_id' => 'so-6000', 'expressNo' => 'new-6000', 'cost' => 7.50,
|
||
'w' => 2.5, 'name' => '快递公司']];
|
||
}
|
||
|
||
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(0, $result['stale_mirror_packages']);
|
||
$missing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first();
|
||
$this->assertSame('new-5000', $missing->express_no);
|
||
$this->assertEquals(12.34, $missing->freight);
|
||
$this->assertEquals(13.45, $missing->settle_freight);
|
||
$this->assertEquals(1.2, $missing->weight_kg);
|
||
$this->assertEquals(1.3, $missing->settle_weight_kg);
|
||
$updated = $db->table('jst_oms_shipping')->where('source_id', 6000)->first();
|
||
$this->assertEquals(7.50, $updated->freight);
|
||
$this->assertEquals(7.50, $updated->settle_freight);
|
||
$this->assertEquals(2.5, $updated->settle_weight_kg);
|
||
$this->assertSame('succeeded', $db->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status'));
|
||
}
|
||
|
||
public function test_window_replacement_preserves_unreconstructed_package_and_matching_item_fields(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00',
|
||
'package_no' => 'old-package', 'download_time' => '2026-09-02 13:00:00',
|
||
'freight_rate' => 1.25, 'freight_formula' => 'legacy-formula',
|
||
'settle_volume_cm3' => 100, 'recon_weight_kg' => 1.5, 'recon_volume_cm3' => 105,
|
||
'finished_barcode' => 'finished-101', 'finished_name' => '成品甲',
|
||
'finished_supplier' => '供应商甲',
|
||
]);
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 5000, 'sku_source_id' => 102, 'ship_time' => '2026-09-02 12:00:00',
|
||
'buyer_nick' => 'old-buyer', 'seller_memo' => 'old-seller-memo', 'front_no' => 'old-front-no',
|
||
'receiver_name' => 'old-recipient', 'receiver_mobile' => 'old-mobile', 'receiver_phone' => 'old-phone',
|
||
'finished_barcode' => 'finished-102', 'finished_name' => '成品乙',
|
||
'finished_supplier' => '供应商乙',
|
||
]);
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return $scanCalls++ === 0 ? [[
|
||
'id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00',
|
||
]] : [];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) {
|
||
return [
|
||
['o_id' => 'order-1', 'Id' => 101, 'name' => '新商品甲', 'qty' => 1],
|
||
['o_id' => 'order-1', 'Id' => 103, 'name' => '新商品丙', 'qty' => 1],
|
||
];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
(new ShippingReplicaSynchronizer($factory))->sync(
|
||
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'),
|
||
);
|
||
|
||
$this->assertSame([101, 103], $db->table('jst_oms_shipping')->where('source_id', 5000)
|
||
->orderBy('sku_source_id')->pluck('sku_source_id')->map(fn ($id) => (int) $id)->all());
|
||
$first = $db->table('jst_oms_shipping')->where('source_id', 5000)->where('sku_source_id', 101)->first();
|
||
$this->assertSame('old-buyer', $first->buyer_nick);
|
||
$this->assertSame('old-seller-memo', $first->seller_memo);
|
||
$this->assertSame('old-recipient', $first->receiver_name);
|
||
$this->assertSame('old-mobile', $first->receiver_mobile);
|
||
$this->assertSame('old-phone', $first->receiver_phone);
|
||
$this->assertSame('old-package', $first->package_no);
|
||
$this->assertSame('2026-09-02 13:00:00', $first->download_time);
|
||
$this->assertEquals(1.25, $first->freight_rate);
|
||
$this->assertSame('legacy-formula', $first->freight_formula);
|
||
$this->assertEquals(100, $first->settle_volume_cm3);
|
||
$this->assertEquals(1.5, $first->recon_weight_kg);
|
||
$this->assertEquals(105, $first->recon_volume_cm3);
|
||
$this->assertSame('old-front-no', $first->front_no);
|
||
$this->assertSame('finished-101', $first->finished_barcode);
|
||
$this->assertSame('成品甲', $first->finished_name);
|
||
$this->assertSame('供应商甲', $first->finished_supplier);
|
||
$second = $db->table('jst_oms_shipping')->where('source_id', 5000)->where('sku_source_id', 103)->first();
|
||
$this->assertNull($second->seller_memo);
|
||
$this->assertNull($second->settle_volume_cm3);
|
||
$this->assertNull($second->finished_barcode);
|
||
$this->assertNull($second->finished_name);
|
||
$this->assertNull($second->finished_supplier);
|
||
}
|
||
|
||
public function test_missing_new_items_preserves_old_product_rows_and_marks_items_pending(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00',
|
||
'barcode' => 'sku-101', 'product_name' => '旧商品甲', 'qty' => 1, 'amount' => 10.50,
|
||
'freight' => 5.25],
|
||
['source_id' => 5000, 'sku_source_id' => 102, 'ship_time' => '2026-09-02 12:00:00',
|
||
'barcode' => 'sku-102', 'product_name' => '旧商品乙', 'qty' => 2, 'amount' => 20.75,
|
||
'freight' => null],
|
||
]);
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings): array {
|
||
if (str_contains($sql, 'rpt.dws_sn_sales_full_info_di') && $bindings === ['2026-09-02']) {
|
||
throw new RuntimeException('DWS read timed out');
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return [['id' => 5000, 'inner_order_id' => 'order-1',
|
||
'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
|
||
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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(1, $result['packages_without_items']);
|
||
$this->assertSame(1, $result['packages_retained_for_missing_items']);
|
||
$this->assertContains('2026-09-02', $result['dws_failed_days']);
|
||
$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')->where('source_id', 5000)->orderBy('sku_source_id')->get();
|
||
$this->assertCount(2, $rows);
|
||
$this->assertSame(['sku-101', 'sku-102'], $rows->pluck('barcode')->all());
|
||
$this->assertSame(['旧商品甲', '旧商品乙'], $rows->pluck('product_name')->all());
|
||
$this->assertEquals(10.50, $rows[0]->amount);
|
||
$this->assertEquals(20.75, $rows[1]->amount);
|
||
$this->assertEquals(5.25, $rows[0]->freight);
|
||
}
|
||
|
||
public function test_view_item_with_null_amount_preserves_matching_synthetic_sku_amount(): 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',
|
||
'barcode' => 'sku-101', 'product_name' => '旧商品', 'amount' => 10.50,
|
||
]);
|
||
$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',
|
||
'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) {
|
||
return [['o_id' => 'order-1', 'io_id' => 'outbound-1']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) {
|
||
return [['io_id' => 'outbound-1', 'sku_ids' => 'sku-101', 'qty' => 1,
|
||
'type' => 0, 'sku_name' => '实发商品', 'volume' => null]];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) {
|
||
return [['o_id' => 'order-1', 'Id' => 101, 'sku_id' => 'other-sku',
|
||
'name' => '订单商品', 'qty' => 1, 'amount' => 999]];
|
||
}
|
||
|
||
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(0, $result['packages_retained_for_unmatched_amount']);
|
||
$row = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole();
|
||
$this->assertSame(500000, (int) $row->sku_source_id);
|
||
$this->assertSame('实发商品', $row->product_name);
|
||
$this->assertEquals(10.50, $row->amount);
|
||
}
|
||
|
||
public function test_view_item_with_unmatched_synthetic_sku_retains_old_package_and_marks_items_pending(): 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',
|
||
'barcode' => null, 'product_name' => '旧商品甲', 'amount' => 10.50],
|
||
['source_id' => 6000, 'sku_source_id' => 600000, 'ship_time' => '2026-09-02 13:00:00',
|
||
'barcode' => 'old-sku', 'product_name' => '旧商品乙', 'amount' => 20.75],
|
||
]);
|
||
$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', 'send_time' => '2026-09-02 12:00:00'],
|
||
['id' => 6000, 'inner_order_id' => 'order-2', 'send_time' => '2026-09-02 13:00:00'],
|
||
];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) {
|
||
return [
|
||
['o_id' => 'order-1', 'io_id' => 'outbound-1'],
|
||
['o_id' => 'order-2', 'io_id' => 'outbound-2'],
|
||
];
|
||
}
|
||
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'),
|
||
);
|
||
|
||
// 旧条码为空的包裹无法判断是否错配:保留旧记录;旧条码 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->assertEquals(10.50, $rows[0]->amount);
|
||
}
|
||
|
||
public function test_explicit_zero_item_amount_replaces_old_amount(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00',
|
||
'barcode' => 'sku-101', 'amount' => 10.50,
|
||
]);
|
||
$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',
|
||
'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) {
|
||
return [['o_id' => 'order-1', 'Id' => 101, 'sku_id' => 'sku-101',
|
||
'name' => '新商品', 'qty' => 1, 'amount' => 0]];
|
||
}
|
||
|
||
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(0, $result['packages_retained_for_unmatched_amount']);
|
||
$row = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole();
|
||
$this->assertSame('新商品', $row->product_name);
|
||
$this->assertEquals(0, $row->amount);
|
||
}
|
||
|
||
public function test_shipping_command_reports_express_and_autoship_counts_separately(): void
|
||
{
|
||
$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, 'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) {
|
||
return [['rid' => 100, 'o_id' => '123', 'send_date' => '2026-09-02 13:00:00']];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
|
||
|
||
$this->artisan('jushuitan:sync-shipping', [
|
||
'--from' => '2026-09-01', '--to' => '2026-09-05', '--skip-freight' => true,
|
||
])->expectsOutputToContain('唯一包裹源键 2 个,包裹记录处理 2 次;发货明细 1 条,代发仓 1 条,销售出库补漏 0 条,采购出库补漏 0 条,合计处理 2 条明细')
|
||
->assertExitCode(0);
|
||
}
|
||
|
||
public function test_normal_window_failure_preserves_unprocessed_old_packages(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 3000, 'sku_source_id' => 3000, 'ship_time' => '2026-09-02 12:00:00',
|
||
'express_no' => 'processed-old', 'freight' => 11.11],
|
||
['source_id' => 1000, 'sku_source_id' => 1000, 'ship_time' => '2026-09-02 12:00:00',
|
||
'express_no' => 'unprocessed-old', 'freight' => 22.22],
|
||
]);
|
||
|
||
$scanCalls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
if ($scanCalls++ > 0) {
|
||
throw new RuntimeException('upstream page failed');
|
||
}
|
||
return array_map(fn (int $id): array => [
|
||
'id' => $id,
|
||
'send_time' => $id >= 2701 ? '2026-09-02 12:00:00' : '2026-08-31 12:00:00',
|
||
], range(3000, 1501));
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
try {
|
||
(new ShippingReplicaSynchronizer($factory))->sync(
|
||
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'),
|
||
);
|
||
$this->fail('Expected the second upstream page to fail.');
|
||
} catch (RuntimeException $e) {
|
||
$this->assertSame('upstream page failed', $e->getMessage());
|
||
}
|
||
|
||
$this->assertSame(2, $scanCalls);
|
||
$this->assertSame('failed', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
$processed = $db->table('jst_oms_shipping')->where('source_id', 3000)->first();
|
||
$this->assertEquals(11.11, $processed->freight);
|
||
$unprocessed = $db->table('jst_oms_shipping')->where('source_id', 1000)->first();
|
||
$this->assertSame('unprocessed-old', $unprocessed->express_no);
|
||
$this->assertEquals(22.22, $unprocessed->freight);
|
||
$this->assertSame(301, $db->table('jst_oms_shipping')->count());
|
||
}
|
||
|
||
public function test_at_prefix_dedup_requires_same_trade_and_same_window(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 1, 'sku_source_id' => 1, 'ship_time' => '2026-09-02 12:00:00',
|
||
'trade_no' => 'same-trade', 'express_no' => 'clean-1'],
|
||
['source_id' => 2, 'sku_source_id' => 2, 'ship_time' => '2026-09-02 12:00:00',
|
||
'trade_no' => 'same-trade', 'express_no' => '@clean-1'],
|
||
['source_id' => 3, 'sku_source_id' => 3, 'ship_time' => '2026-09-02 12:00:00',
|
||
'trade_no' => 'other-trade', 'express_no' => '@clean-1'],
|
||
['source_id' => 4, 'sku_source_id' => 4, 'ship_time' => '2026-09-08 12:00:00',
|
||
'trade_no' => 'outside-trade', 'express_no' => 'clean-2'],
|
||
['source_id' => 5, 'sku_source_id' => 5, 'ship_time' => '2026-09-02 12:00:00',
|
||
'trade_no' => 'outside-trade', 'express_no' => '@clean-2'],
|
||
['source_id' => 6, 'sku_source_id' => 6, 'ship_time' => '2026-09-03 12:00:00',
|
||
'trade_no' => 'same-trade', 'express_no' => '@clean-1'],
|
||
]);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
|
||
|
||
$removed = (new ShippingReplicaSynchronizer($factory))->dedupAtPrefix(
|
||
'2026-09-01 00:00:00', '2026-09-05 00:00:00',
|
||
);
|
||
|
||
$this->assertSame(1, $removed);
|
||
$this->assertSame([1, 3, 4, 5, 6], $db->table('jst_oms_shipping')
|
||
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
|
||
$this->assertSame(0, (new ShippingReplicaSynchronizer($factory))->dedupAtPrefix());
|
||
}
|
||
|
||
public function test_non_window_fast_sync_still_ignores_existing_package(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 5000, 'sku_source_id' => 5000,
|
||
'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'existing-tracking', 'freight' => 12.34,
|
||
]);
|
||
$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,
|
||
'send_time' => '2026-09-02 12:00:00',
|
||
'logistic_id' => 'new-tracking',
|
||
]];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
$result = (new ShippingReplicaSynchronizer($factory))->sync();
|
||
|
||
$this->assertSame(1, $result['processed']);
|
||
$this->assertSame(1, $db->table('jst_oms_shipping')->count());
|
||
$existing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first();
|
||
$this->assertSame('existing-tracking', $existing->express_no);
|
||
$this->assertEquals(12.34, $existing->freight);
|
||
}
|
||
|
||
public function test_failed_dws_preload_is_reported_separately_from_package_count(): void
|
||
{
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings): array {
|
||
if (str_contains($sql, 'rpt.dws_sn_sales_full_info_di') && $bindings === ['2026-08-31']) {
|
||
throw new RuntimeException('read timed out');
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) {
|
||
return [['id' => 1234, 'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
|
||
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']);
|
||
$this->assertSame(1, $result['packages_without_items']);
|
||
$this->assertSame(['2026-08-31'], $result['dws_failed_days']);
|
||
$this->assertSame('pending', DB::connection('business_mirror')->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade.items.20260901000000-20260905000000')->value('status'));
|
||
}
|
||
|
||
public function test_skip_freight_rolls_back_old_package_when_replacement_insert_conflicts(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 5000, 'sku_source_id' => 5000,
|
||
'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'old-tracking', 'freight' => 12.34,
|
||
]);
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($upstream);
|
||
$synchronizer = new ShippingReplicaSynchronizer($factory);
|
||
|
||
$skipFreight = new \ReflectionProperty($synchronizer, 'skipFreight');
|
||
$skipFreight->setAccessible(true);
|
||
$skipFreight->setValue($synchronizer, true);
|
||
$replacements = new \ReflectionProperty($synchronizer, 'replacementPackages');
|
||
$replacements->setAccessible(true);
|
||
$newRow = [
|
||
'source_id' => 5000, 'sku_source_id' => 123,
|
||
'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'new-tracking', 'express_name2' => null,
|
||
'freight' => null, 'settle_freight' => null, 'weight_kg' => null, 'settle_weight_kg' => null,
|
||
'amount' => null,
|
||
'receiver_name' => null, 'receiver_mobile' => null, 'receiver_phone' => null,
|
||
];
|
||
$replacements->setValue($synchronizer, [5000 => [$newRow, $newRow]]);
|
||
$flush = new \ReflectionMethod($synchronizer, 'flushReplacements');
|
||
$flush->setAccessible(true);
|
||
|
||
try {
|
||
$flush->invoke($synchronizer);
|
||
$this->fail('Expected a duplicate replacement row to roll back the package transaction.');
|
||
} catch (\Illuminate\Database\QueryException $e) {
|
||
$this->assertStringContainsString('UNIQUE constraint failed', $e->getMessage());
|
||
}
|
||
|
||
$rows = $db->table('jst_oms_shipping')->where('source_id', 5000)->get();
|
||
$this->assertCount(1, $rows);
|
||
$this->assertSame(5000, (int) $rows[0]->sku_source_id);
|
||
$this->assertSame('old-tracking', $rows[0]->express_no);
|
||
$this->assertEquals(12.34, $rows[0]->freight);
|
||
}
|
||
|
||
public function test_skip_freight_keeps_unseen_mirror_package_and_marks_window_partial(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
'source_id' => 7000, 'sku_source_id' => 7000,
|
||
'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'unseen-tracking', 'freight' => 22.50,
|
||
]);
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturn([]);
|
||
$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'), 2000, null, true,
|
||
);
|
||
|
||
$this->assertSame(1, $result['stale_mirror_packages']);
|
||
$this->assertSame('partial', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status'));
|
||
$this->assertStringContainsString('源键核对', $db->table('jst_sync_state')
|
||
->where('dataset_key', 'jst.sctrade')->value('last_error'));
|
||
$old = $db->table('jst_oms_shipping')->where('source_id', 7000)->first();
|
||
$this->assertSame('unseen-tracking', $old->express_no);
|
||
$this->assertEquals(22.50, $old->freight);
|
||
}
|
||
|
||
public function test_partial_sync_status_uses_business_warning_instead_of_raw_error(): void
|
||
{
|
||
DB::connection('business_mirror')->table('jst_sync_state')->insert([
|
||
'dataset_key' => 'jst.sctrade',
|
||
'status' => 'partial',
|
||
'last_error' => 'SQLSTATE[42S02]: SECRET_MIRROR_TABLE does not exist',
|
||
]);
|
||
|
||
$status = (new ShippingOrderRepository())->syncStatus();
|
||
|
||
$this->assertSame('partial', $status['code']);
|
||
$this->assertStringContainsString('源键核对', $status['warning']);
|
||
$this->assertStringNotContainsString('SQLSTATE', $status['warning']);
|
||
$this->assertStringNotContainsString('SECRET_MIRROR_TABLE', $status['warning']);
|
||
}
|
||
|
||
public function test_sync_removes_only_the_known_demo_source_ids(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
['source_id' => 9000001, 'sku_source_id' => 9000001],
|
||
['source_id' => 9000006, 'sku_source_id' => 9000006],
|
||
['source_id' => 9000007, 'sku_source_id' => 9000007],
|
||
['source_id' => 999999999, 'sku_source_id' => 999999999],
|
||
]);
|
||
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->expects($this->once())->method('select')->willReturn([]);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->expects($this->once())
|
||
->method('forSource')
|
||
->with('operations_replica')
|
||
->willReturn($upstream);
|
||
|
||
$result = (new ShippingReplicaSynchronizer($factory))->sync();
|
||
|
||
$this->assertSame(['processed' => 0, 'autoship' => 0], $result);
|
||
$this->assertSame(
|
||
[9000007, 999999999],
|
||
$db->table('jst_oms_shipping')->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all(),
|
||
);
|
||
}
|
||
|
||
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->assertSame('10', $map['byLO']['shared|A']);
|
||
$this->assertArrayNotHasKey('30', $map['byOAll']['A']);
|
||
}
|
||
|
||
public function test_tracking_replacement_pairs_one_candidate_per_old_tracking(): 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(['1' => ['io' => '10', 'l_id' => 'new-a', 'old' => 'old']],
|
||
$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, '普通仓', '中通礼品单'));
|
||
}
|
||
|
||
public function test_stale_express_detail_carrier_and_warehouse_follow_the_actual_outbound(): void
|
||
{
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturn([
|
||
// 先分给工厂代发仓后作废,再由总仓按外采出库(实际发货单)
|
||
['l_id' => 'JT1', 'o_id' => 'o1', 'io_id' => '11', 'io_date' => '2026-08-29 17:52:09', 'status' => 'Delete', 'logistics_company' => '浩浦极兔', 'wms_co_id' => 15170678],
|
||
['l_id' => 'JT1', 'o_id' => 'o1', 'io_id' => '12', 'io_date' => '2026-09-01 10:17:16', 'status' => 'Confirmed', 'logistics_company' => '外采快递', 'wms_co_id' => 14138566],
|
||
['l_id' => 'DP2', 'o_id' => 'o2', 'io_id' => '21', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '外采快递', 'wms_co_id' => 14138566],
|
||
['l_id' => 'YT3', 'o_id' => 'o3', 'io_id' => '31', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '线下快递', 'wms_co_id' => 15137729],
|
||
['l_id' => '', 'o_id' => 'o4', 'io_id' => '41', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '无需物流', 'wms_co_id' => 15170001],
|
||
]);
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($upstream);
|
||
$sync = new ShippingReplicaSynchronizer($factory);
|
||
$preload = new \ReflectionMethod($sync, 'preloadOutboundMap');
|
||
$preload->setAccessible(true);
|
||
$preload->invoke($sync, '2026-08-25 00:00:00', '2026-09-05 00:00:00');
|
||
$map = new \ReflectionProperty($sync, 'outboundMap');
|
||
$map->setAccessible(true);
|
||
$ioFor = new \ReflectionMethod($sync, 'ioFor');
|
||
$ioFor->setAccessible(true);
|
||
$this->assertSame('12', $ioFor->invoke($sync, $map->getValue($sync), 'JT1', 'o1'));
|
||
$carrier = new \ReflectionMethod($sync, 'withOutboundCarrier');
|
||
$carrier->setAccessible(true);
|
||
$wmsName = new \ReflectionMethod($sync, 'wmsName');
|
||
$wmsName->setAccessible(true);
|
||
$pending = new \ReflectionProperty($sync, 'pendingWarehouse');
|
||
$pending->setAccessible(true);
|
||
|
||
// 出库单快递公司不同:快递公司按出库单,仓库登记待回填
|
||
$early = $carrier->invoke($sync, '31', ['logistic_company_name' => '霸州百世快运-雅柏', 'warehouse_name' => '工厂-河北雅柏代发仓'],
|
||
['express_name1' => '霸州百世快运-雅柏', 'warehouse_name' => '工厂-河北雅柏代发仓'], 3);
|
||
$this->assertSame('线下快递', $early['express_name1']);
|
||
$this->assertSame('15137729', $pending->getValue($sync)[3]);
|
||
|
||
// 发货明细停在作废前的工厂代发分配:快递公司按出库单;仓库按出库单发货主体回填
|
||
$stale = $carrier->invoke($sync, '12', ['logistic_company_name' => '浩浦极兔', 'warehouse_name' => '工厂-河北浩浦代发仓'],
|
||
['express_name1' => '浩浦极兔', 'warehouse_name' => '工厂-河北浩浦代发仓'], 1);
|
||
$this->assertSame('外采快递', $stale['express_name1']);
|
||
$this->assertSame('14138566', $pending->getValue($sync)[1]);
|
||
$this->assertNull($wmsName->invoke($sync, '14138566')); // 样本不足不回填
|
||
foreach ([20, 22, 23] as $id) {
|
||
$carrier->invoke($sync, '21', ['logistic_company_name' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'],
|
||
['express_name1' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'], $id);
|
||
}
|
||
$this->assertSame('常州简尚家居有限公司', $wmsName->invoke($sync, '14138566'));
|
||
|
||
// 无需物流(补差/信誉/运费):仓库统一自动发货仓,不按发货主体回填
|
||
$none = $carrier->invoke($sync, '41', ['logistic_company_name' => '', 'warehouse_name' => '工厂-山东祥和代发仓'],
|
||
['express_name1' => '', 'warehouse_name' => '工厂-山东祥和代发仓'], 4);
|
||
$this->assertSame('无需物流', $none['express_name1']);
|
||
$this->assertSame('简尚-自动发货仓', $none['warehouse_name']);
|
||
$this->assertArrayNotHasKey(4, $pending->getValue($sync));
|
||
|
||
// 同一发货主体对应多个仓库名(无明显多数)时不回填
|
||
$names = new \ReflectionProperty($sync, 'wmsNames');
|
||
$names->setAccessible(true);
|
||
$names->setValue($sync, ['9' => ['仓A' => 5, '仓B' => 4]]);
|
||
$this->assertNull($wmsName->invoke($sync, '9'));
|
||
}
|
||
|
||
public function test_tracking_pairing_treats_deleted_only_outbound_as_changed_and_survives_lookup_timeout(): void
|
||
{
|
||
$calls = 0;
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function () use (&$calls): array {
|
||
$calls++;
|
||
if ($calls === 1) {
|
||
return [];
|
||
}
|
||
throw new PDOException('SQLSTATE[HY000]: General error: 20003 Adaptive Server connection timed out [20003] (severity 6)');
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($upstream);
|
||
$sync = new ShippingReplicaSynchronizer($factory);
|
||
$ok = new \ReflectionProperty($sync, 'ioOk');
|
||
$ok->setAccessible(true);
|
||
$ok->setValue($sync, ['11' => false, '12' => true]);
|
||
$pair = new \ReflectionMethod($sync, 'pairChangedTracking');
|
||
$pair->setAccessible(true);
|
||
$ioMap = ['byL' => ['DPK1' => '11', 'SF2' => '12'], 'byO' => ['o1' => '12'], 'byLO' => [], 'byOAll' => ['o1' => ['12' => 'SF2']], 'ioL' => ['11' => 'DPK1', '12' => 'SF2']];
|
||
$rows = [['id' => 7, 'logistic_id' => 'DPK1', 'inner_order_id' => 'o1']];
|
||
|
||
$swap = $pair->invoke($sync, $rows, $ioMap);
|
||
$this->assertSame(['io' => '12', 'l_id' => 'SF2', 'old' => 'DPK1'], $swap['7']);
|
||
|
||
// 副本库超时:本页跳过配对,不抛错
|
||
$this->assertSame([], $pair->invoke($sync, $rows, $ioMap));
|
||
$skipped = new \ReflectionProperty($sync, 'pairingSkipped');
|
||
$skipped->setAccessible(true);
|
||
$this->assertSame(1, $skipped->getValue($sync));
|
||
}
|
||
|
||
public function test_view_express_and_warehouse_override_inferred_carrier_like_the_work_platform(): void
|
||
{
|
||
$chunks = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$chunks): array {
|
||
$this->assertStringContainsString('expressName', $sql);
|
||
$chunks[] = count($bindings);
|
||
|
||
return [
|
||
['io_id' => '12', 'sku_ids' => 'a', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '常州德邦', 'wareHoursName' => '工厂-河南千优代发仓'],
|
||
['io_id' => '12', 'sku_ids' => 'b', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '', 'wareHoursName' => ''],
|
||
['io_id' => '41', 'sku_ids' => 'c', 'qty' => 1, 'type' => 0, 'volume' => null, 'expressName' => '无需物流', 'wareHoursName' => '简尚-自动发货仓'],
|
||
];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($upstream);
|
||
$sync = new ShippingReplicaSynchronizer($factory);
|
||
$load = new \ReflectionMethod($sync, 'loadViewItems');
|
||
$load->setAccessible(true);
|
||
$load->invoke($sync, array_merge(['12', '41', '99'], array_map('strval', range(1000, 1400))));
|
||
$this->assertSame([300, 104], $chunks); // 视图按 300 个出库单一批
|
||
|
||
$pending = new \ReflectionProperty($sync, 'pendingWarehouse');
|
||
$pending->setAccessible(true);
|
||
$pending->setValue($sync, [1 => '14138566', 2 => '15170678']);
|
||
$carrier = new \ReflectionMethod($sync, 'withViewCarrier');
|
||
$carrier->setAccessible(true);
|
||
|
||
// 视图有值:快递名称、仓库按视图(工作平台口径),不再按发货主体回填
|
||
// 快递名称:出库单/发货明细已有当前值(换单后)时不被视图旧值覆盖;没有时用视图兜底
|
||
$pkg = $carrier->invoke($sync, '12', ['express_name1' => '崔桥顺丰干配', 'warehouse_name' => '工厂-河北浩浦代发仓'], 1);
|
||
$this->assertSame('崔桥顺丰干配', $pkg['express_name1']);
|
||
$blank = $carrier->invoke($sync, '12', ['express_name1' => '', 'warehouse_name' => ''], 5);
|
||
$this->assertSame('常州德邦', $blank['express_name1']);
|
||
$this->assertSame('工厂-河南千优代发仓', $pkg['warehouse_name']);
|
||
$this->assertArrayNotHasKey(1, $pending->getValue($sync));
|
||
|
||
$none = $carrier->invoke($sync, '41', ['express_name1' => '无需物流', 'warehouse_name' => '工厂-山东恒思代发仓'], 4);
|
||
$this->assertSame('简尚-自动发货仓', $none['warehouse_name']);
|
||
|
||
// 视图没有该出库单:保留出库单/发货明细推断值和待回填仓库
|
||
$keep = $carrier->invoke($sync, '99', ['express_name1' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'], 2);
|
||
$this->assertSame('外采快递', $keep['express_name1']);
|
||
$this->assertSame('常州简尚家居有限公司', $keep['warehouse_name']);
|
||
$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',
|
||
'trade_no' => null, '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',
|
||
'trade_no' => null, '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',
|
||
'trade_no' => null, 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null],
|
||
// 采购退货出库:上游已作废,本次未再出现
|
||
['source_id' => 3000000555, 'sku_source_id' => 3000000555, 'ship_time' => '2026-09-02 15:00:00', 'express_no' => 'SF555',
|
||
'trade_no' => null, 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null],
|
||
// 凌晨入库时还没有快递单号的自动发货仓包裹,同一交易单号本次已由 6000 吐出
|
||
['source_id' => 1000000999, 'sku_source_id' => 1000000999, 'ship_time' => '2026-09-02 13:30:00', 'express_no' => null,
|
||
'trade_no' => 'so-2', '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', 'so_id' => 'so-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_purchaseout WHERE io_date')) {
|
||
return [['io_id' => '555', 'io_date' => '2026-09-02 15:00:00', 'status' => 'Cancelled', 'l_id' => 'SF555']];
|
||
}
|
||
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(3, $result['superseded_packages_removed']);
|
||
$this->assertSame(0, $db->table('jst_oms_shipping')->where('source_id', 3000000555)->count());
|
||
$this->assertSame(0, $db->table('jst_oms_shipping')->where('source_id', 1000000999)->count());
|
||
$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'));
|
||
}
|
||
|
||
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']);
|
||
}
|
||
|
||
public function test_ship_time_is_moved_to_outbound_io_date_when_warehouse_ships_next_day(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
// 晚上打单(发货明细时间)、次日早上出库:应改成出库时间(一个包裹两行商品都改)
|
||
['source_id' => 7001, 'sku_source_id' => 1, 'ship_time' => '2026-10-20 20:40:51', 'express_no' => '79041235735054'],
|
||
['source_id' => 7001, 'sku_source_id' => 2, 'ship_time' => '2026-10-20 20:40:51', 'express_no' => '79041235735054'],
|
||
// 已经是出库时间:不动
|
||
['source_id' => 7002, 'sku_source_id' => 1, 'ship_time' => '2026-10-21 09:00:00', 'express_no' => 'YT2'],
|
||
// 单号带 @ 前缀、出库单有作废+有效两张:取有效那张
|
||
['source_id' => 7003, 'sku_source_id' => 1, 'ship_time' => '2026-10-22 10:00:00', 'express_no' => '@JT3'],
|
||
// 出库时间比现有时间早:不往前改
|
||
['source_id' => 7004, 'sku_source_id' => 1, 'ship_time' => '2026-10-23 10:00:00', 'express_no' => 'SF4'],
|
||
// 相差超过 7 天:视为单号复用,不改
|
||
['source_id' => 7005, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => 'DPK5'],
|
||
]);
|
||
$queries = [];
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$queries): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo so WHERE so.io_date')) {
|
||
$queries[] = $bindings;
|
||
|
||
return [
|
||
['l_id' => '79041235735054', 'io_date' => '2026-10-21 08:46:09', 'status' => 'Confirmed'],
|
||
['l_id' => 'YT2', 'io_date' => '2026-10-21 09:00:00', 'status' => 'Confirmed'],
|
||
['l_id' => 'JT3', 'io_date' => '2026-10-23 07:00:00', 'status' => 'Confirmed'],
|
||
['l_id' => '@JT3', 'io_date' => '2026-10-24 07:00:00', 'status' => 'Cancelled'],
|
||
['l_id' => 'SF4', 'io_date' => '2026-10-22 10:00:00', 'status' => 'Confirmed'],
|
||
['l_id' => 'DPK5', 'io_date' => '2026-10-20 10:00:00', 'status' => 'Confirmed'],
|
||
];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
$n = (new ShippingReplicaSynchronizer($factory))->fixShipTimes('2026-10-20 00:00:00', '2026-10-26 00:00:00');
|
||
|
||
$this->assertSame(2, $n);
|
||
$this->assertSame([['2026-10-20 00:00:00', '2026-10-29 00:00:00']], $queries);
|
||
$this->assertSame(['2026-10-21 08:46:09'], $db->table('jst_oms_shipping')->where('source_id', 7001)->distinct()->pluck('ship_time')->all());
|
||
$this->assertSame(2, $db->table('jst_oms_shipping')->where('source_id', 7001)->where('ship_time', '2026-10-21 08:46:09')->count());
|
||
$this->assertSame('2026-10-21 09:00:00', $db->table('jst_oms_shipping')->where('source_id', 7002)->value('ship_time'));
|
||
$this->assertSame('2026-10-23 07:00:00', $db->table('jst_oms_shipping')->where('source_id', 7003)->value('ship_time'));
|
||
$this->assertSame('2026-10-23 10:00:00', $db->table('jst_oms_shipping')->where('source_id', 7004)->value('ship_time'));
|
||
$this->assertSame('2026-10-10 10:00:00', $db->table('jst_oms_shipping')->where('source_id', 7005)->value('ship_time'));
|
||
}
|
||
|
||
public function test_online_and_actual_weight_follow_sales_outbound_weight_and_f_weight(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
$db->table('jst_oms_shipping')->insert([
|
||
// 没称重:发货明细重量(=预估)被写进了实称、线上是商品单重×数量 -> 线上=出库单预估,实称置空
|
||
['source_id' => 8001, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => '302359667733',
|
||
'online_weight' => 7.0, 'actual_weight' => 29.9],
|
||
// 两行商品:第二行残留的单重清空,重量只记在第一行
|
||
['source_id' => 8002, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 11:00:00', 'express_no' => 'YT7650735469496',
|
||
'online_weight' => 47.6, 'actual_weight' => 7.08],
|
||
['source_id' => 8002, 'sku_source_id' => 2, 'ship_time' => '2026-10-10 11:00:00', 'express_no' => 'YT7650735469496',
|
||
'online_weight' => 47.6, 'actual_weight' => null],
|
||
// 称过重:线上=预估,实称=称重重量
|
||
['source_id' => 8003, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 15:46:24', 'express_no' => '428196459565777',
|
||
'online_weight' => 1.344, 'actual_weight' => 1.34],
|
||
// 已经一致:不更新
|
||
['source_id' => 8004, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 16:00:00', 'express_no' => 'OK4',
|
||
'online_weight' => 2.195, 'actual_weight' => 2.04],
|
||
// 出库单里没有的包裹:不动
|
||
['source_id' => 8005, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 16:00:00', 'express_no' => 'NOIO5',
|
||
'online_weight' => 3.0, 'actual_weight' => 3.0],
|
||
]);
|
||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||
$upstream->method('select')->willReturnCallback(function (string $sql): array {
|
||
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo so WHERE so.io_date')) {
|
||
return [
|
||
['l_id' => '302359667733', 'io_date' => '2026-10-10 10:00:00', 'status' => '已出库', 'weight' => 29.9, 'f_weight' => null],
|
||
['l_id' => 'YT7650735469496', 'io_date' => '2026-10-10 11:00:00', 'status' => '已出库', 'weight' => 7.08, 'f_weight' => null],
|
||
['l_id' => '428196459565777', 'io_date' => '2026-10-19 15:46:24', 'status' => '已出库', 'weight' => 1.3442, 'f_weight' => 2.31],
|
||
['l_id' => 'OK4', 'io_date' => '2026-10-19 16:00:00', 'status' => '已出库', 'weight' => 2.195, 'f_weight' => 2.04],
|
||
];
|
||
}
|
||
|
||
return [];
|
||
});
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
|
||
|
||
$res = (new ShippingReplicaSynchronizer($factory))->alignWithOutbound('2026-10-01 00:00:00', '2026-10-26 00:00:00');
|
||
|
||
$this->assertSame(['ship_time' => 0, 'weight' => 3], $res);
|
||
$w = fn (int $sid, int $sku) => $db->table('jst_oms_shipping')->where('source_id', $sid)->where('sku_source_id', $sku)->first(['online_weight', 'actual_weight']);
|
||
$this->assertEquals(29.9, $w(8001, 1)->online_weight);
|
||
$this->assertNull($w(8001, 1)->actual_weight);
|
||
$this->assertEquals(7.08, $w(8002, 1)->online_weight);
|
||
$this->assertNull($w(8002, 1)->actual_weight);
|
||
$this->assertNull($w(8002, 2)->online_weight);
|
||
$this->assertNull($w(8002, 2)->actual_weight);
|
||
$this->assertEquals(1.3442, $w(8003, 1)->online_weight);
|
||
$this->assertEquals(2.31, $w(8003, 1)->actual_weight);
|
||
$this->assertEquals(2.195, $w(8004, 1)->online_weight);
|
||
$this->assertEquals(2.04, $w(8004, 1)->actual_weight);
|
||
$this->assertEquals(3.0, $w(8005, 1)->online_weight);
|
||
$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());
|
||
}
|
||
|
||
public function test_purchase_out_items_follow_purchase_out_detail_instead_of_repeated_view_rows(): void
|
||
{
|
||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
|
||
$sync = new ShippingReplicaSynchronizer($factory);
|
||
$method = new \ReflectionMethod($sync, 'purchaseOutItems');
|
||
// 视图重复行把数量放大(每行都写整单93件),拼接条码被截断出残码 GBGJ-SZ0
|
||
$view = [
|
||
['id' => null, 'sku' => 'GBGJ-SZ020401N1280V01', 'iid' => null, 'shopid' => null, 'name' => '视图名称A', 'qty' => 93,
|
||
'amount' => null, 'warehouse' => '青岗集货退上架仓', 'l' => 180, 'w' => 70, 'h' => 75, 'vol' => null, 'weight' => 30, 'supplier' => 'S1'],
|
||
['id' => null, 'sku' => 'GBGJ-SZ0', 'iid' => null, 'shopid' => null, 'name' => null, 'qty' => 93,
|
||
'amount' => null, 'warehouse' => '青岗集货退上架仓', 'l' => null, 'w' => null, 'h' => null, 'vol' => null, 'weight' => null, 'supplier' => null],
|
||
];
|
||
$po = [
|
||
'gbgj-sz020401n1280v01' => ['sku' => 'GBGJ-SZ020401N1280V01', 'qty' => 4.0, 'name' => '明细名称A'],
|
||
'gbpm-sj035402n1280v01' => ['sku' => 'GBPM-SJ035402N1280V01', 'qty' => 2.0, 'name' => '明细名称B'],
|
||
'gbgj-zero' => ['sku' => 'GBGJ-ZERO', 'qty' => 0.0, 'name' => null],
|
||
];
|
||
$items = $method->invoke($sync, $view, $po, '青岗集货退上架仓');
|
||
$this->assertSame(['GBGJ-SZ020401N1280V01', 'GBPM-SJ035402N1280V01'], array_column($items, 'sku'));
|
||
$this->assertSame([4, 2], array_column($items, 'qty'));
|
||
// 视图有的条码沿用视图的名称/长宽高;视图没有的按明细补一行
|
||
$this->assertSame('视图名称A', $items[0]['name']);
|
||
$this->assertSame(180, $items[0]['l']);
|
||
$this->assertSame('明细名称B', $items[1]['name']);
|
||
$this->assertSame('青岗集货退上架仓', $items[1]['warehouse']);
|
||
// 明细为空时仍按视图
|
||
$this->assertSame($view, $method->invoke($sync, $view, [], null));
|
||
}
|
||
|
||
public function test_outbound_view_items_replace_stale_order_fallback_items_when_barcodes_differ(): void
|
||
{
|
||
$db = DB::connection('business_mirror');
|
||
// 旧记录是订单商品兜底(配件母件/原商品,数量0但带金额),与出库单实发明细部分重叠
|
||
foreach ([
|
||
['source_id' => 7000, 'sku_source_id' => 700001, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'DPK7',
|
||
'barcode' => 'XG017603N113V06-11', 'product_name' => '订单商品-11', 'qty' => 0, 'amount' => 12.50],
|
||
['source_id' => 7000, 'sku_source_id' => 700002, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'DPK7',
|
||
'barcode' => 'XG017603N113V06-32', 'product_name' => '订单商品-32', 'qty' => 0, 'amount' => 3.00],
|
||
] as $row) {
|
||
$db->table('jst_oms_shipping')->insert($row);
|
||
}
|
||
$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' => 7000, 'inner_order_id' => 'order-7', 'logistic_id' => 'DPK7', 'send_time' => '2026-09-02 12:00:00']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) {
|
||
return [['o_id' => 'order-7', 'io_id' => 'outbound-7', 'l_id' => 'DPK7']];
|
||
}
|
||
if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) {
|
||
return [
|
||
['io_id' => 'outbound-7', 'sku_ids' => 'XG017603N113V06-11', 'qty' => 1, 'type' => 0, 'sku_name' => '实发配件-11', 'volume' => null],
|
||
['io_id' => 'outbound-7', 'sku_ids' => 'XG017603N113V06-40', 'qty' => 1, 'type' => 0, 'sku_name' => '实发配件-40', '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(0, $result['packages_retained_for_unmatched_amount']);
|
||
$this->assertSame(1, $result['packages_items_corrected']);
|
||
$rows = $db->table('jst_oms_shipping')->where('source_id', 7000)->orderBy('barcode')->get();
|
||
$this->assertSame(['XG017603N113V06-11', 'XG017603N113V06-40'], $rows->pluck('barcode')->all());
|
||
$this->assertEquals(12.50, $rows[0]->amount);
|
||
$this->assertNull($rows[1]->amount);
|
||
$this->assertSame(['1/2'], $rows->pluck('pkg_count')->unique()->values()->all());
|
||
}
|
||
|
||
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
|
||
{
|
||
$reflection = new \ReflectionProperty($synchronizer, $property);
|
||
$reflection->setAccessible(true);
|
||
$reflection->setValue($synchronizer, $limit);
|
||
}
|
||
}
|