1355 lines
61 KiB
PHP
1355 lines
61 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\EcommerceFinance;
|
|
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
|
|
use RuntimeException;
|
|
use Tests\TestCase;
|
|
|
|
class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase
|
|
{
|
|
private const HEADER_GENERATION = '11111111-1111-1111-1111-111111111111';
|
|
|
|
private const ITEM_GENERATION = '22222222-2222-2222-2222-222222222222';
|
|
|
|
private const INVENTORY_GENERATION = '33333333-3333-3333-3333-333333333333';
|
|
|
|
private ThirdPartyWarehouseStorageMirrorRepository $repository;
|
|
|
|
private int $nextHeaderId = 1;
|
|
|
|
private int $nextItemId = 1;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'database.connections.business_mirror' => [
|
|
'driver' => 'sqlite',
|
|
'database' => ':memory:',
|
|
'prefix' => '',
|
|
],
|
|
'upstream_mirrors.connection' => 'business_mirror',
|
|
'upstream_mirrors.third_party_warehouse_storage.owner_name' => '常州简尚家居有限公司',
|
|
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes' => 1560,
|
|
]);
|
|
DB::purge('business_mirror');
|
|
|
|
$this->createMirrorTables();
|
|
$this->createProductTable();
|
|
$this->createWarehouseTable();
|
|
$this->createDependencyStateTable();
|
|
$this->repository = new ThirdPartyWarehouseStorageMirrorRepository(app('db'));
|
|
}
|
|
|
|
public function test_it_refuses_queries_until_all_active_generations_finish_initial_sync(): void
|
|
{
|
|
$this->insertState([
|
|
'status' => 'running',
|
|
'run_mode' => 'full',
|
|
'active_header_generation' => self::HEADER_GENERATION,
|
|
'active_item_generation' => self::ITEM_GENERATION,
|
|
'active_inventory_generation' => null,
|
|
'initial_completed_at' => null,
|
|
]);
|
|
$this->assertTrue($this->repository->available());
|
|
$this->assertFalse($this->repository->ready());
|
|
$this->assertSame('initializing', $this->repository->syncStatus()['code']);
|
|
|
|
$this->expectException(RuntimeException::class);
|
|
$this->expectExceptionMessage('third_party_warehouse_mirror_not_ready');
|
|
$this->repository->paginate($this->filters(), 1, 25);
|
|
}
|
|
|
|
public function test_failed_refresh_keeps_reading_only_active_generations(): void
|
|
{
|
|
$this->markReady(['status' => 'failed', 'last_error_code' => 'upstream_timeout']);
|
|
$this->insertProduct('SKU-A', ['autoid' => 10]);
|
|
$this->insertInventory('SKU-A', 6, '2026-09-01 00:00:00');
|
|
$this->insertInventory('SKU-OLD', 99, '2026-09-01 00:00:00', [
|
|
'generation' => '99999999-9999-9999-9999-999999999999',
|
|
]);
|
|
$this->insertMovement('SKU-A', '采购进仓', 1, 1, '2026-08-10 10:00:00');
|
|
$this->insertMovement('SKU-OLD', '采购进仓', 99, 1, '2026-08-10 10:00:00', [
|
|
'header_generation' => '99999999-9999-9999-9999-999999999999',
|
|
'item_generation' => '99999999-9999-9999-9999-999999999999',
|
|
]);
|
|
|
|
$result = $this->repository->paginate($this->filters(), 1, 25);
|
|
|
|
$this->assertTrue($this->repository->ready());
|
|
$this->assertSame('failed', $result['sync']['code']);
|
|
// 月份维度按“日期 + 商品”展开,因此 8 月对应 31 条日明细。
|
|
$this->assertSame(31, $result['total']);
|
|
$this->assertSame('SKU-A', $result['data'][0]['sku_code']);
|
|
}
|
|
|
|
public function test_month_filter_returns_daily_rows_using_signed_movements_and_each_sku_snapshot_anchor(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-MONTH', [
|
|
'name' => '月报商品',
|
|
'short_name' => null,
|
|
'supplier_name' => '供应商甲',
|
|
'category' => '桌类',
|
|
'purchase_price' => 30,
|
|
]);
|
|
$this->insertInventory('SKU-MONTH', 25, '2026-09-03 12:00:00');
|
|
$this->insertMovement('SKU-MONTH', '采购进仓', 10, 1, '2026-08-01 00:00:00');
|
|
$this->insertMovement('SKU-MONTH', '销售出仓', 8, -1, '2026-08-15 12:00:00');
|
|
$this->insertMovement('SKU-MONTH', '其它进仓', 2, 1, '2026-08-31 23:59:59');
|
|
$this->insertMovement('SKU-MONTH', '采购进仓', 5, 1, '2026-09-01 00:00:00');
|
|
$this->insertMovement('SKU-MONTH', '销售出仓', 4, -1, '2026-09-02 00:00:00');
|
|
// 在该 SKU 自己的快照时间之后,不能参与从快照回推历史余额。
|
|
$this->insertMovement('SKU-MONTH', '采购进仓', 100, 1, '2026-09-04 00:00:00');
|
|
|
|
// 不提供统一 period 时仍兼容旧页面的 month 参数。
|
|
$rows = $this->repository->paginate($this->filters(['period' => '']), 1, 100)['data'];
|
|
$first = collect($rows)->firstWhere('date', '2026-08-01');
|
|
$salesDay = collect($rows)->firstWhere('date', '2026-08-15');
|
|
$last = collect($rows)->firstWhere('date', '2026-08-31');
|
|
|
|
$this->assertCount(31, $rows);
|
|
$this->assertSame('常州简尚家居有限公司', $first['owner']);
|
|
$this->assertSame('月报商品', $first['short_name']);
|
|
$this->assertSame(10.0, $first['purchase_in']);
|
|
$this->assertSame(20.0, $first['opening_qty']);
|
|
$this->assertSame(30.0, $first['closing_qty']);
|
|
$this->assertSame(-8.0, $salesDay['sales_out']);
|
|
$this->assertSame(30.0, $salesDay['opening_qty']);
|
|
$this->assertSame(22.0, $salesDay['closing_qty']);
|
|
$this->assertSame(2.0, $last['other_in']);
|
|
$this->assertSame(22.0, $last['opening_qty']);
|
|
$this->assertSame(24.0, $last['closing_qty']);
|
|
$this->assertCount(28, $first);
|
|
|
|
$this->insertProduct('SKU-EARLY', ['autoid' => 20, 'purchase_price' => 2]);
|
|
$this->insertInventory('SKU-EARLY', 10, '2026-08-10 00:00:00');
|
|
$this->insertMovement('SKU-EARLY', '采购进仓', 5, 1, '2026-08-20 00:00:00');
|
|
$early = collect($this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-20',
|
|
]), 1, 25)['data'])->firstWhere('sku_code', 'SKU-EARLY');
|
|
|
|
$this->assertSame(10.0, $early['opening_qty']);
|
|
$this->assertSame(15.0, $early['closing_qty']);
|
|
}
|
|
|
|
public function test_inventory_report_aggregates_the_same_confirmed_movements_by_product_warehouse_and_month(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-MONTHLY', ['category' => '住宅家具 - 书桌']);
|
|
$this->insertInventory('SKU-MONTHLY', 12, '2026-09-01 00:00:00');
|
|
$this->insertMovement('SKU-MONTHLY', '采购进仓', 5, 1, '2026-08-02 10:00:00');
|
|
$this->insertMovement('SKU-MONTHLY', '销售出仓', 3, -1, '2026-08-15 10:00:00');
|
|
$this->insertMovement('SKU-MONTHLY', '销售退货', 2, 1, '2026-08-20 10:00:00');
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-MONTHLY',
|
|
'warehouse_provider' => '南昌樵舍',
|
|
'category' => '书桌',
|
|
], 1, 25);
|
|
$row = $result['data'][0];
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame('2026-08-01', $row->month);
|
|
$this->assertSame('三方-南昌樵舍仓', $row->warehouse_provider);
|
|
$this->assertSame('三方-南昌樵舍仓主仓', $row->warehouse);
|
|
$this->assertSame('SKU-MONTHLY', $row->product_code);
|
|
$this->assertSame('住宅家具 - 书桌', $row->category);
|
|
$this->assertSame(8.0, (float) $row->opening_quantity);
|
|
$this->assertSame(12.0, (float) $row->closing_quantity);
|
|
$this->assertSame(5.0, (float) $row->purchase_in);
|
|
$this->assertSame(-3.0, (float) $row->sales_out);
|
|
$this->assertSame(2.0, (float) $row->sales_return);
|
|
}
|
|
|
|
public function test_inventory_report_prefers_authoritative_month_rows_with_exact_opening_quantities(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-AUTH', ['category' => '权威分类']);
|
|
$this->insertInventory('SKU-AUTH', 999, '2026-09-01 00:00:00');
|
|
$this->insertMovement('SKU-AUTH', '采购进仓', 500, 1, '2026-08-20 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertMovement('SKU-AUTH', '销售出仓', 2, -1, '2026-08-21 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertMovement('SKU-AUTH', '采购进仓', 90, 1, '2026-09-01 00:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'warehouse_provider' => '简尚-崔桥工厂仓',
|
|
'warehouse' => '简尚-崔桥工厂仓进货仓',
|
|
'product_code' => 'SKU-AUTH',
|
|
'category' => '权威分类',
|
|
'opening_quantity' => 14,
|
|
'closing_quantity' => 20,
|
|
'purchase_in' => 6,
|
|
]);
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-AUTH',
|
|
'warehouse_provider' => '崔桥',
|
|
'category' => '权威',
|
|
], 1, 25);
|
|
$row = $result['data'][0];
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame('简尚-崔桥工厂仓进货仓', $row->warehouse);
|
|
$this->assertSame(14.0, (float) $row->opening_quantity);
|
|
$this->assertSame(20.0, (float) $row->closing_quantity);
|
|
$this->assertSame(6.0, (float) $row->purchase_in);
|
|
$this->assertSame(0.0, (float) $row->sales_out);
|
|
}
|
|
|
|
public function test_inventory_report_export_reads_current_authoritative_month_without_movement_scan(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-AUTH-EXPORT');
|
|
$this->insertMovement('SKU-AUTH-EXPORT', '采购进仓', 999, 1, '2026-08-20 10:00:00');
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'warehouse_provider' => '简尚-崔桥工厂仓',
|
|
'warehouse' => '简尚-崔桥工厂仓进货仓',
|
|
'product_code' => 'SKU-AUTH-EXPORT',
|
|
'opening_quantity' => 14,
|
|
'closing_quantity' => 20,
|
|
'purchase_in' => 6,
|
|
]);
|
|
|
|
$rows = iterator_to_array($this->repository->exportMonthlyInventoryReportRows([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-AUTH-EXPORT',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1), false);
|
|
|
|
$this->assertCount(1, $rows);
|
|
$this->assertSame(14.0, (float) $rows[0]->opening_quantity);
|
|
$this->assertSame(6.0, (float) $rows[0]->purchase_in);
|
|
$this->assertSame(20.0, (float) $rows[0]->closing_quantity);
|
|
}
|
|
|
|
public function test_inventory_report_rolls_previous_month_closing_into_next_month_opening(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-ROLL-FORWARD');
|
|
$this->insertInventory('SKU-ROLL-FORWARD', 999, '2026-09-11 03:21:00');
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'warehouse_provider' => '三方-南昌樵舍仓',
|
|
'warehouse' => '三方-南昌樵舍仓主仓',
|
|
'product_code' => 'SKU-ROLL-FORWARD',
|
|
'opening_quantity' => 14,
|
|
'closing_quantity' => 20,
|
|
'purchase_in' => 6,
|
|
]);
|
|
$this->insertMovement('SKU-ROLL-FORWARD', '采购进仓', 3, 1, '2026-09-02 10:00:00');
|
|
$this->insertInventory('SKU-ROLL-FORWARD', 5, '2026-09-11 03:21:00', [
|
|
'wms_co_id' => 15038426,
|
|
]);
|
|
$this->insertMovement('SKU-ROLL-FORWARD', '调拨入', 5, 1, '2026-09-03 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
]);
|
|
|
|
$db = DB::connection('business_mirror');
|
|
$db->flushQueryLog();
|
|
$db->enableQueryLog();
|
|
$filters = [
|
|
'month' => '2026-09',
|
|
'product_code' => 'SKU-ROLL-FORWARD',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
];
|
|
$result = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25);
|
|
$queries = $db->getQueryLog();
|
|
$db->disableQueryLog();
|
|
$rows = collect($result['data'])->keyBy('warehouse');
|
|
|
|
$this->assertSame(2, $result['total']);
|
|
$this->assertSame(20.0, (float) $rows['三方-南昌樵舍仓主仓']->opening_quantity);
|
|
$this->assertSame(23.0, (float) $rows['三方-南昌樵舍仓主仓']->closing_quantity);
|
|
$this->assertSame(3.0, (float) $rows['三方-南昌樵舍仓主仓']->purchase_in);
|
|
$this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓主仓']->opening_quantity);
|
|
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->closing_quantity);
|
|
$this->assertSame(5.0, (float) $rows['简尚-崔桥工厂仓主仓']->transfer_in);
|
|
|
|
$countQueries = array_values(array_filter(
|
|
$queries,
|
|
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)'),
|
|
));
|
|
$this->assertCount(3, $countQueries);
|
|
$this->assertFalse(collect($queries)->contains(
|
|
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*) over()'),
|
|
));
|
|
$candidatePage = collect($queries)
|
|
->first(static fn (array $query): bool => str_contains($query['query'], '"prior"."report_month"'));
|
|
$this->assertNotNull($candidatePage);
|
|
$this->assertStringNotContainsString('strftime', strtolower($candidatePage['query']));
|
|
|
|
$db->flushQueryLog();
|
|
$db->enableQueryLog();
|
|
$cached = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25);
|
|
$cachedQueries = $db->getQueryLog();
|
|
$db->disableQueryLog();
|
|
$this->assertSame($result['total'], $cached['total']);
|
|
$this->assertCount(0, array_filter(
|
|
$cachedQueries,
|
|
static fn (array $query): bool => str_contains(strtolower($query['query']), 'report_total'),
|
|
));
|
|
}
|
|
|
|
public function test_inventory_report_rolls_multiple_months_from_the_july_authoritative_baseline(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-ROLL-MULTI');
|
|
$this->insertInventory('SKU-ROLL-MULTI', 999, '2026-09-11 03:21:00');
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'source_month' => '2026-07-01',
|
|
'report_month' => '2026-07-01',
|
|
'warehouse_provider' => '三方-南昌樵舍仓',
|
|
'warehouse' => '三方-南昌樵舍仓主仓',
|
|
'product_code' => 'SKU-ROLL-MULTI',
|
|
'opening_quantity' => 10,
|
|
'closing_quantity' => 12,
|
|
]);
|
|
$this->insertMovement('SKU-ROLL-MULTI', '采购进仓', 3, 1, '2026-08-02 10:00:00');
|
|
$this->insertMovement('SKU-ROLL-MULTI', '销售出仓', 2, -1, '2026-09-03 10:00:00');
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-09',
|
|
'product_code' => 'SKU-ROLL-MULTI',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
$row = $result['data'][0];
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame(15.0, (float) $row->opening_quantity);
|
|
$this->assertSame(13.0, (float) $row->closing_quantity);
|
|
$this->assertSame(-2.0, (float) $row->sales_out);
|
|
}
|
|
|
|
public function test_inventory_report_includes_confirmed_movement_subwarehouse_without_current_stock(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('CTG008616N1152V01');
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'source_month' => '2026-07-01',
|
|
'report_month' => '2026-07-01',
|
|
'warehouse_provider' => '简尚-崔桥工厂仓',
|
|
'warehouse' => '简尚-崔桥工厂仓主仓',
|
|
'product_code' => 'OTHER-SKU',
|
|
]);
|
|
$this->insertInventory('CTG008616N1152V01', 39, '2026-09-11 03:21:00', [
|
|
'wms_co_id' => 15038426,
|
|
'in_qty' => 0,
|
|
]);
|
|
foreach ([30, 50, 30] as $quantity) {
|
|
$this->insertMovement('CTG008616N1152V01', '采购进仓', $quantity, 1, '2026-08-23 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
}
|
|
// 按确认的进出仓明细口径汇总日期、类型、仓储方、仓库、商品和带符号数量。
|
|
$this->insertMovement('CTG008616N1152V01', '销售出仓', 1, -1, '2026-08-24 09:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertMovement('CTG008616N1152V01', '销售出仓', 2, -1, '2026-08-24 09:30:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertMovement('CTG008616N1152V01', '销售出仓', 999, -1, '2026-08-24 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
]);
|
|
$this->insertMovement('CTG008616N1152V01', '采购进仓', 999, 1, '2026-08-25 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
'wh_id' => 3,
|
|
'status' => 'WaitConfirm',
|
|
]);
|
|
$filters = [
|
|
'month' => '2026-08',
|
|
'product_code' => 'CTG008616N1152V01',
|
|
'warehouse_provider' => '崔桥工厂仓',
|
|
'category' => '',
|
|
];
|
|
|
|
$august = $this->repository->paginateMonthlyInventoryReport($filters, 1, 25);
|
|
$rows = collect($august['data'])->keyBy('warehouse');
|
|
$this->assertSame(2, $august['total']);
|
|
$this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->opening_quantity);
|
|
$this->assertSame(110.0, (float) $rows['简尚-崔桥工厂仓进货仓']->purchase_in);
|
|
$this->assertSame(-1002.0, (float) $rows['简尚-崔桥工厂仓进货仓']->sales_out);
|
|
$this->assertSame(0.0, (float) $rows['简尚-崔桥工厂仓进货仓']->transfer_out);
|
|
$this->assertSame(-892.0, (float) $rows['简尚-崔桥工厂仓进货仓']->closing_quantity);
|
|
|
|
$secondPage = $this->repository->paginateMonthlyInventoryReport($filters, 2, 1);
|
|
$this->assertSame(2, $secondPage['total']);
|
|
$this->assertSame('简尚-崔桥工厂仓进货仓', $secondPage['data'][0]->warehouse);
|
|
|
|
$categorized = $this->repository->paginateMonthlyInventoryReport(
|
|
array_merge($filters, ['category' => '测试分类']), 1, 25,
|
|
);
|
|
$this->assertSame(2, $categorized['total']);
|
|
$this->assertSame(-892.0, (float) collect($categorized['data'])
|
|
->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓')->closing_quantity);
|
|
|
|
$exported = iterator_to_array($this->repository->exportMonthlyInventoryReportRows($filters, 1));
|
|
$this->assertCount(2, $exported);
|
|
$this->assertSame(110.0, (float) collect($exported)->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓')->purchase_in);
|
|
|
|
$september = $this->repository->paginateMonthlyInventoryReport(
|
|
array_merge($filters, ['month' => '2026-09']), 1, 25,
|
|
);
|
|
$nextRow = collect($september['data'])->firstWhere('warehouse', '简尚-崔桥工厂仓进货仓');
|
|
$this->assertSame(-892.0, (float) $nextRow->opening_quantity);
|
|
$this->assertSame(-892.0, (float) $nextRow->closing_quantity);
|
|
}
|
|
|
|
public function test_inventory_report_product_code_filter_is_exact(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('15962G');
|
|
$this->insertProduct('15962G-1');
|
|
$this->insertInventory('15962G', 1, '2026-09-01 00:00:00');
|
|
$this->insertInventory('15962G-1', 1, '2026-09-01 00:00:00');
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => '15962G',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame(['15962G'], collect($result['data'])->pluck('product_code')->all());
|
|
}
|
|
|
|
public function test_inventory_report_splits_main_return_inbound_and_other_subwarehouses(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-SUBWAREHOUSE', ['category' => '测试分类']);
|
|
$this->insertInventory('SKU-SUBWAREHOUSE', 10, '2026-09-01 00:00:00', [
|
|
'return_qty' => 4,
|
|
'in_qty' => 3,
|
|
'customize_qty_1' => 2,
|
|
]);
|
|
$this->insertMovement('SKU-SUBWAREHOUSE', '销售退货', 2, 1, '2026-08-20 10:00:00', ['wh_id' => 2]);
|
|
$this->insertMovement('SKU-SUBWAREHOUSE', '采购进仓', 3, 1, '2026-08-21 10:00:00', ['wh_id' => 3]);
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-SUBWAREHOUSE',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
$rows = collect($result['data'])->keyBy('warehouse');
|
|
|
|
$this->assertSame(4, $result['total']);
|
|
$this->assertSame([
|
|
'三方-南昌樵舍仓主仓',
|
|
'三方-南昌樵舍仓销退仓',
|
|
'三方-南昌樵舍仓进货仓',
|
|
'三方-南昌樵舍仓自定义仓1',
|
|
], $rows->keys()->all());
|
|
$this->assertSame(2.0, (float) $rows['三方-南昌樵舍仓销退仓']->opening_quantity);
|
|
$this->assertSame(4.0, (float) $rows['三方-南昌樵舍仓销退仓']->closing_quantity);
|
|
$this->assertSame(2.0, (float) $rows['三方-南昌樵舍仓销退仓']->sales_return);
|
|
$this->assertSame(0.0, (float) $rows['三方-南昌樵舍仓进货仓']->opening_quantity);
|
|
$this->assertSame(3.0, (float) $rows['三方-南昌樵舍仓进货仓']->closing_quantity);
|
|
}
|
|
|
|
public function test_inventory_report_uses_export_warehouse_names_for_special_subwarehouses(): void
|
|
{
|
|
$this->markReady();
|
|
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
|
'Id' => 3,
|
|
'wms_co_id' => 15033302,
|
|
'name' => '简尚-横林二仓',
|
|
]);
|
|
$this->insertProduct('SKU-SPECIAL-WAREHOUSE');
|
|
$this->insertInventory('SKU-SPECIAL-WAREHOUSE', 8, '2026-09-01 00:00:00', [
|
|
'wms_co_id' => 15033302,
|
|
'customize_qty_1' => 5,
|
|
'customize_qty_2' => 2,
|
|
]);
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-SPECIAL-WAREHOUSE',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
|
|
$this->assertSame([
|
|
'简尚-横林二仓主仓',
|
|
'客退临时上架仓',
|
|
'数量校验仓',
|
|
], collect($result['data'])->pluck('warehouse')->all());
|
|
}
|
|
|
|
public function test_inventory_report_uses_accessory_warehouse_name_for_company_inventory(): void
|
|
{
|
|
$this->markReady();
|
|
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
|
'Id' => 3,
|
|
'wms_co_id' => 14138566,
|
|
'name' => '常州简尚家居有限公司',
|
|
]);
|
|
$this->insertProduct('121630-49');
|
|
$this->insertInventory('121630-49', -2, '2026-09-01 00:00:00', [
|
|
'wms_co_id' => 14138566,
|
|
]);
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => '121630-49',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
|
|
$this->assertSame('配件单仓', $result['data'][0]->warehouse);
|
|
}
|
|
|
|
public function test_inventory_report_reads_and_filters_warehouses_outside_the_third_party_scope(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-ALL-WAREHOUSES', ['category' => '住宅家具 - 餐边柜']);
|
|
$this->insertInventory('SKU-ALL-WAREHOUSES', 9, '2026-09-01 00:00:00', [
|
|
'wms_co_id' => 15038426,
|
|
]);
|
|
$this->insertMovement('SKU-ALL-WAREHOUSES', '销售出仓', 20, -1, '2026-08-15 10:00:00', [
|
|
'warehouse_id' => 15038426,
|
|
]);
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-ALL-WAREHOUSES',
|
|
'warehouse_provider' => '崔桥',
|
|
'category' => '餐边柜',
|
|
], 1, 25);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame('简尚-崔桥工厂仓', $result['data'][0]->warehouse_provider);
|
|
$this->assertSame('SKU-ALL-WAREHOUSES', $result['data'][0]->product_code);
|
|
$this->assertSame(-20.0, (float) $result['data'][0]->sales_out);
|
|
}
|
|
|
|
public function test_inventory_report_uses_only_product_codes_matched_to_the_operations_replica_catalog(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-STOCK');
|
|
$this->insertInventory('SKU-STOCK', 2, '2026-09-01 00:00:00');
|
|
$this->insertInventory('LEGACY-CODE-NOT-IN-CATALOG', 7, '2026-09-01 00:00:00');
|
|
|
|
$result = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-08',
|
|
'product_code' => '',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
$codes = collect($result['data'])->pluck('product_code')->all();
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame(['SKU-STOCK'], $codes);
|
|
$this->assertNotContains('LEGACY-CODE-NOT-IN-CATALOG', $codes);
|
|
}
|
|
|
|
public function test_inventory_report_export_reads_roll_forward_rows_without_total_count(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-EXPORT');
|
|
$this->insertInventory('SKU-EXPORT', 999, '2026-09-11 03:21:00');
|
|
$this->insertInventoryReportAuthoritativeRow([
|
|
'source_month' => '2026-07-01',
|
|
'report_month' => '2026-07-01',
|
|
'warehouse_provider' => '三方-南昌樵舍仓',
|
|
'warehouse' => '三方-南昌樵舍仓主仓',
|
|
'product_code' => 'SKU-EXPORT',
|
|
'opening_quantity' => 10,
|
|
'closing_quantity' => 12,
|
|
]);
|
|
$this->insertMovement('SKU-EXPORT', '采购进仓', 3, 1, '2026-08-02 10:00:00');
|
|
|
|
$rows = iterator_to_array($this->repository->exportMonthlyInventoryReportRows([
|
|
'month' => '2026-08',
|
|
'product_code' => 'SKU-EXPORT',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1), false);
|
|
|
|
$this->assertCount(1, $rows);
|
|
$this->assertSame('SKU-EXPORT', $rows[0]->product_code);
|
|
$this->assertSame(12.0, (float) $rows[0]->opening_quantity);
|
|
$this->assertSame(15.0, (float) $rows[0]->closing_quantity);
|
|
}
|
|
|
|
public function test_third_party_page_remains_ready_when_only_the_all_warehouse_dimension_is_missing(): void
|
|
{
|
|
$this->markReady();
|
|
DB::connection('business_mirror')->table('business_mirror_table_states')
|
|
->where('dataset_key', 'operations_replica.rpt.jst_warehouseInfo')
|
|
->delete();
|
|
Schema::connection('business_mirror')->drop('ops_jst_warehouse_info');
|
|
|
|
$this->assertTrue($this->repository->ready());
|
|
$this->assertFalse($this->repository->allWarehousesReady());
|
|
}
|
|
|
|
public function test_monthly_inventory_report_supports_july_without_changing_flow_page_cutoff(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-CUTOFF');
|
|
$this->insertInventory('SKU-CUTOFF', 10, '2026-08-01 00:00:00');
|
|
$this->insertMovement('SKU-CUTOFF', '采购进仓', 2, 1, '2026-07-15 12:00:00');
|
|
$this->insertMovement('SKU-CUTOFF', '采购进仓', 2, 1, '2026-08-01 12:00:00');
|
|
|
|
$day = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-07-01',
|
|
]), 1, 25)['data'][0];
|
|
$inventoryReport = $this->repository->paginateMonthlyInventoryReport([
|
|
'month' => '2026-07',
|
|
'product_code' => 'SKU-CUTOFF',
|
|
'warehouse_provider' => '',
|
|
'category' => '',
|
|
], 1, 25);
|
|
|
|
$this->assertSame('2026-08-01', $day['date']);
|
|
$this->assertSame(1, $inventoryReport['total']);
|
|
$this->assertSame('2026-07-01', $inventoryReport['data'][0]->month);
|
|
$this->assertSame(8.0, (float) $inventoryReport['data'][0]->opening_quantity);
|
|
$this->assertSame(10.0, (float) $inventoryReport['data'][0]->closing_quantity);
|
|
}
|
|
|
|
public function test_product_catalog_must_finish_initial_sync_before_product_fields_are_exposed(): void
|
|
{
|
|
$this->markReady();
|
|
DB::connection('business_mirror')->table('business_mirror_table_states')
|
|
->where('dataset_key', 'operations_replica.rpt.jst_sku')
|
|
->update(['initial_completed_at' => null, 'status' => 'running']);
|
|
|
|
$this->assertFalse($this->repository->ready());
|
|
$this->assertSame('dependency_pending', $this->repository->syncStatus()['code']);
|
|
}
|
|
|
|
public function test_product_join_only_reads_normal_product_rows(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-SCOPE', [
|
|
'autoid' => 10,
|
|
'name' => '普通商品',
|
|
]);
|
|
$this->insertProduct('SKU-SCOPE', [
|
|
'autoid' => 10,
|
|
'sku_type' => 'combine',
|
|
'name' => '组合商品',
|
|
]);
|
|
$this->insertInventory('SKU-SCOPE', 1, '2026-08-15 12:00:00');
|
|
|
|
$result = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-15',
|
|
]), 1, 25);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertCount(1, $result['data']);
|
|
$this->assertSame('普通商品', $result['data'][0]['product_name']);
|
|
}
|
|
|
|
public function test_summary_without_product_filters_does_not_join_the_product_catalog(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-SUMMARY');
|
|
$this->insertInventory('SKU-SUMMARY', 1, '2026-08-15 12:00:00');
|
|
$queries = [];
|
|
DB::connection('business_mirror')->listen(function ($query) use (&$queries): void {
|
|
$queries[] = $query->sql;
|
|
});
|
|
|
|
$summary = $this->repository->summary($this->filters());
|
|
|
|
$this->assertSame(31, $summary['count']);
|
|
$this->assertFalse(collect($queries)->contains(
|
|
static fn (string $sql): bool => str_contains($sql, 'ops_jst_sku'),
|
|
));
|
|
}
|
|
|
|
public function test_day_dimension_uses_half_open_boundary(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-DAY', ['name' => '按天商品', 'purchase_price' => 10]);
|
|
$this->insertInventory('SKU-DAY', 22, '2026-08-17 00:00:00');
|
|
$this->insertMovement('SKU-DAY', '采购进仓', 10, 1, '2026-08-15 00:00:00');
|
|
$this->insertMovement('SKU-DAY', '销售出仓', 4, -1, '2026-08-15 23:59:59');
|
|
// 次日零点不属于 8 月 15 日,但属于日末到库存锚点之间的回推区间。
|
|
$this->insertMovement('SKU-DAY', '采购进仓', 3, 1, '2026-08-16 00:00:00');
|
|
|
|
$row = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-15',
|
|
'month' => '',
|
|
]), 1, 25)['data'][0];
|
|
|
|
$this->assertSame(10.0, $row['purchase_in']);
|
|
$this->assertSame(-4.0, $row['sales_out']);
|
|
$this->assertSame(19.0, $row['closing_qty']);
|
|
$this->assertSame(13.0, $row['opening_qty']);
|
|
}
|
|
|
|
public function test_authoritative_rows_override_historical_stream_and_anchor_rolls_into_september(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-A');
|
|
// 实时库存会持续变化;存在权威月末锚点后不能再用它回推历史。
|
|
$this->insertInventory('SKU-A', 999, '2026-09-11 03:21:00');
|
|
$this->insertAuthoritativeAnchor('SKU-A', 20, '2026-09-01 00:00:00');
|
|
$this->insertAuthoritativeRow('SKU-A', '2026-08-01', 17, 22, ['purchase_in' => 5]);
|
|
$this->insertAuthoritativeRow('SKU-A', '2026-08-24', 22, 20, ['sales_out' => -2]);
|
|
$this->insertAuthoritativeRow('SKU-A', '2026-08-31', 20, 20);
|
|
$this->insertMovement('SKU-A', '采购进仓', 5, 1, '2026-08-01 00:00:00');
|
|
$this->insertMovement('SKU-A', '销售出仓', 2, -1, '2026-08-24 12:00:00', [
|
|
'status' => 'OuterConfirming',
|
|
]);
|
|
$this->insertMovement('SKU-A', '采购进仓', 3, 1, '2026-09-01 00:00:00');
|
|
|
|
$august = $this->repository->paginate($this->filters(), 1, 100)['data'];
|
|
$augustFirst = collect($august)->firstWhere('date', '2026-08-01');
|
|
$augustOuterConfirming = collect($august)->firstWhere('date', '2026-08-24');
|
|
$augustLast = collect($august)->firstWhere('date', '2026-08-31');
|
|
$septemberFirst = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-09-01',
|
|
]), 1, 25)['data'][0];
|
|
|
|
$this->assertSame(17.0, $augustFirst['opening_qty']);
|
|
$this->assertSame(22.0, $augustFirst['closing_qty']);
|
|
$this->assertSame(-2.0, $augustOuterConfirming['sales_out']);
|
|
$this->assertSame(20.0, $augustOuterConfirming['closing_qty']);
|
|
$this->assertSame(20.0, $augustLast['closing_qty']);
|
|
$this->assertSame(20.0, $septemberFirst['opening_qty']);
|
|
$this->assertSame(3.0, $septemberFirst['purchase_in']);
|
|
$this->assertSame(23.0, $septemberFirst['closing_qty']);
|
|
|
|
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchor_states')
|
|
->update(['status' => 'failed']);
|
|
$afterFailedReplacement = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-31',
|
|
]), 1, 25)['data'][0];
|
|
$this->assertSame(20.0, $afterFailedReplacement['closing_qty']);
|
|
}
|
|
|
|
public function test_it_maps_all_twelve_types_and_excludes_wrong_scope_and_unknown_types(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-TYPES');
|
|
$this->insertInventory('SKU-TYPES', 50, '2026-09-01 00:00:00');
|
|
$types = [
|
|
'加工进仓' => [1, 1],
|
|
'加工出仓' => [2, -1],
|
|
'采购进仓' => [3, 1],
|
|
'采购退货' => [4, -1],
|
|
'其它进仓' => [5, 1],
|
|
'其它退货' => [6, -1],
|
|
'销售出仓' => [7, -1],
|
|
'销售退货' => [8, 1],
|
|
'盘点' => [9, -1],
|
|
'调拨出' => [10, -1],
|
|
'调拨入' => [11, 1],
|
|
'其它出库' => [12, -1],
|
|
];
|
|
foreach ($types as $type => [$qty, $qtyType]) {
|
|
$this->insertMovement('SKU-TYPES', $type, $qty, $qtyType, '2026-08-20 12:00:00');
|
|
}
|
|
$this->insertMovement('SKU-TYPES', '维修', 500, -1, '2026-08-20 12:00:00');
|
|
$this->insertMovement('SKU-TYPES', '虚拟仓调拨入', 500, 1, '2026-08-20 12:00:00');
|
|
$this->insertMovement('SKU-TYPES', '装箱快递', 500, -1, '2026-08-20 12:00:00');
|
|
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['status' => 'WaitConfirm']);
|
|
$this->insertMovement('SKU-TYPES', '采购进仓', 500, 1, '2026-08-20 12:00:00', ['warehouse_id' => 999]);
|
|
|
|
// 同一单据出现更新版本时只认最新头记录,避免重复累计旧版本。
|
|
$this->insertMovement('SKU-TYPES', '采购进仓', 600, 1, '2026-08-20 12:00:00', [
|
|
'io_id' => 9000,
|
|
'header_id' => 9000,
|
|
]);
|
|
$this->insertHeaderOnly(9001, 9000, '采购进仓', '2026-08-20 12:00:00', 'WaitConfirm');
|
|
|
|
$row = $this->repository->paginate($this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-20',
|
|
]), 1, 25)['data'][0];
|
|
|
|
$expected = [
|
|
'processing_in' => 1.0,
|
|
'processing_out' => -2.0,
|
|
'purchase_in' => 3.0,
|
|
'purchase_return' => -4.0,
|
|
'other_in' => 5.0,
|
|
'other_return' => -6.0,
|
|
'sales_out' => -7.0,
|
|
'sales_return' => 8.0,
|
|
'stocktake' => -9.0,
|
|
'transfer_out' => -10.0,
|
|
'transfer_in' => 11.0,
|
|
'other_out' => -12.0,
|
|
];
|
|
foreach ($expected as $column => $value) {
|
|
$this->assertSame($value, $row[$column], $column);
|
|
}
|
|
}
|
|
|
|
public function test_filters_paging_summary_options_and_export_share_the_non_zero_scope(): void
|
|
{
|
|
$this->markReady();
|
|
foreach ([
|
|
['SKU-C', '椅子 C', '供应商乙', '椅类', 3],
|
|
['SKU-A', '桌子 A', '供应商甲', '桌类', 1],
|
|
['SKU-B', '桌子 B', '供应商甲', '桌类', 2],
|
|
] as [$sku, $name, $supplier, $category, $qty]) {
|
|
$this->insertProduct($sku, [
|
|
'name' => $name,
|
|
'supplier_name' => $supplier,
|
|
'category' => $category,
|
|
]);
|
|
$this->insertInventory($sku, $qty, '2026-09-01 00:00:00');
|
|
}
|
|
$this->insertProduct('SKU-ZERO', ['name' => '零库存', 'supplier_name' => '供应商甲', 'category' => '桌类']);
|
|
$this->insertInventory('SKU-ZERO', 0, '2026-09-01 00:00:00');
|
|
// 没有库存快照但存在相关流水的 SKU 也必须进入候选集合。
|
|
$this->insertProduct('SKU-FLOW', ['name' => '桌子流水', 'supplier_name' => '供应商甲', 'category' => '桌类']);
|
|
$this->insertMovement('SKU-FLOW', '销售出仓', 2, -1, '2026-08-10 00:00:00');
|
|
$this->insertMovement('SKU-FLOW', '销售出仓', 3, -1, '2026-09-02 00:00:00');
|
|
|
|
$filters = $this->filters([
|
|
'dimension' => 'day',
|
|
'period' => '2026-08-10',
|
|
'product_name' => '桌子',
|
|
'supplier' => '供应商甲',
|
|
'category' => '桌类',
|
|
]);
|
|
$pageOne = $this->repository->paginate($filters, 1, 1);
|
|
$pageTwo = $this->repository->paginate($filters, 2, 1);
|
|
$summary = $this->repository->summary($filters);
|
|
$options = $this->repository->filterOptions($this->filters(['product_name' => '桌子']));
|
|
$export = iterator_to_array($this->repository->exportRows($filters, 1), false);
|
|
|
|
$this->assertSame(3, $pageOne['total']);
|
|
$this->assertSame('SKU-A', $pageOne['data'][0]['sku_code']);
|
|
$this->assertSame('SKU-B', $pageTwo['data'][0]['sku_code']);
|
|
$this->assertSame([
|
|
'count' => 3,
|
|
'opening_qty' => 8.0,
|
|
'closing_qty' => 6.0,
|
|
], $summary);
|
|
$this->assertSame(['供应商甲'], $options['suppliers']);
|
|
$this->assertSame(['桌类'], $options['categories']);
|
|
$this->assertSame(['SKU-A', 'SKU-B', 'SKU-FLOW'], array_column($export, 'sku_code'));
|
|
$this->assertSame(31, $this->repository->summary($this->filters(['sku_code' => 'SKU-B']))['count']);
|
|
}
|
|
|
|
public function test_export_keeps_its_initial_generation_when_publication_switches_between_batches(): void
|
|
{
|
|
$this->markReady();
|
|
foreach (['SKU-A', 'SKU-B'] as $sku) {
|
|
$this->insertProduct($sku);
|
|
$this->insertInventory($sku, 1, '2026-09-01 00:00:00');
|
|
}
|
|
|
|
$export = $this->repository->exportRows($this->filters(), 1);
|
|
$this->assertTrue($export->valid());
|
|
$this->assertSame('SKU-A', $export->current()['sku_code']);
|
|
|
|
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->update([
|
|
'active_header_generation' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
'active_item_generation' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
|
'active_inventory_generation' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
]);
|
|
$export->next();
|
|
|
|
$this->assertTrue($export->valid());
|
|
$this->assertSame('SKU-B', $export->current()['sku_code']);
|
|
}
|
|
|
|
public function test_daily_aggregation_matches_the_original_balance_query_across_snapshot_boundaries(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-FRACTION');
|
|
$this->insertInventory('SKU-FRACTION', 1.2345, '2026-09-03 12:00:00');
|
|
foreach ([
|
|
['采购进仓', 0.1234, 1, '2026-09-01 00:00:00'],
|
|
['销售出仓', 0.2345, -1, '2026-09-02 23:59:59'],
|
|
['采购进仓', 0.3456, 1, '2026-09-03 11:59:59'],
|
|
['采购进仓', 0.4567, 1, '2026-09-03 12:00:00'],
|
|
['销售出仓', 0.5678, -1, '2026-09-03 12:00:01'],
|
|
['采购进仓', 0.6789, 1, '2026-09-04 00:00:00'],
|
|
] as [$type, $qty, $direction, $time]) {
|
|
$this->insertMovement('SKU-FRACTION', $type, $qty, $direction, $time);
|
|
}
|
|
$filters = $this->filters(['period' => '2026-09']);
|
|
$queryMethod = new \ReflectionMethod($this->repository, 'filteredReportQuery');
|
|
$mapMethod = new \ReflectionMethod($this->repository, 'mapRow');
|
|
$expected = $queryMethod->invoke($this->repository, $filters, $this->repository->state())
|
|
->orderBy('report_date')->orderBy('sku_code')->get()
|
|
->map(fn ($row) => $mapMethod->invoke($this->repository, $row))->all();
|
|
|
|
$actual = $this->repository->paginate($filters, 1, 100);
|
|
$this->assertSame($expected, $actual['data']);
|
|
$this->assertSame(count($expected), $actual['total']);
|
|
$this->assertSame($expected, iterator_to_array($this->repository->exportRows($filters, 1), false));
|
|
}
|
|
|
|
public function test_paging_reuses_quantities_and_refreshes_after_a_new_mirror_publication(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-CACHE');
|
|
$this->insertInventory('SKU-CACHE', 5, '2026-09-01 00:00:00');
|
|
$filters = $this->filters(['period' => '2026-09']);
|
|
$first = $this->repository->paginate($filters, 1, 1);
|
|
DB::connection('business_mirror')->enableQueryLog();
|
|
$second = $this->repository->paginate($filters, 2, 1);
|
|
$queries = DB::connection('business_mirror')->getQueryLog();
|
|
DB::connection('business_mirror')->disableQueryLog();
|
|
|
|
$this->assertSame('2026-09-02', $second['data'][0]['date']);
|
|
$this->assertSame($first['summary'], $second['summary']);
|
|
$this->assertFalse(collect($queries)->contains(fn ($q) => str_contains($q['query'], 'from "third_party_warehouse_inout_items"')
|
|
|| str_contains($q['query'], 'join "third_party_warehouse_inout_items"')));
|
|
|
|
$this->insertMovement('SKU-CACHE', '采购进仓', 2, 1, '2026-09-01 12:00:00');
|
|
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
|
|
->update(['last_succeeded_at' => '2026-09-12 01:00:00']);
|
|
$refreshed = $this->repository->paginate($filters, 1, 1);
|
|
$this->assertSame(7.0, $refreshed['data'][0]['closing_qty']);
|
|
|
|
DB::connection('business_mirror')->table('ops_jst_sku')->update(['supplier_name' => '新供应商']);
|
|
$filtered = $this->repository->paginate($filters + ['unused' => 'ignored'], 1, 1);
|
|
$this->assertSame('新供应商', $filtered['data'][0]['supplier']);
|
|
}
|
|
|
|
public function test_cancelled_fractional_movements_do_not_leave_phantom_zero_inventory_rows(): void
|
|
{
|
|
$this->markReady();
|
|
$this->insertProduct('SKU-ZERO');
|
|
$this->insertInventory('SKU-ZERO', 0.3, '2026-09-01 00:00:00');
|
|
$this->insertMovement('SKU-ZERO', '销售出仓', 0.1, -1, '2026-09-01 08:00:00');
|
|
$this->insertMovement('SKU-ZERO', '销售出仓', 0.2, -1, '2026-09-01 09:00:00');
|
|
$result = $this->repository->paginate($this->filters(['period' => '2026-09']), 1, 100);
|
|
|
|
$this->assertSame(1, $result['total']);
|
|
$this->assertSame(0.0, $result['data'][0]['closing_qty']);
|
|
$this->assertSame(0.3, $result['data'][0]['opening_qty']);
|
|
}
|
|
|
|
private function createMirrorTables(): void
|
|
{
|
|
$schema = Schema::connection('business_mirror');
|
|
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->unsignedTinyInteger('scope_version')->default(1);
|
|
$table->string('status')->nullable();
|
|
$table->string('run_mode')->nullable();
|
|
$table->string('active_header_generation')->nullable();
|
|
$table->string('active_item_generation')->nullable();
|
|
$table->string('active_inventory_generation')->nullable();
|
|
$table->dateTime('initial_completed_at')->nullable();
|
|
$table->dateTime('last_succeeded_at')->nullable();
|
|
$table->string('last_error_code')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
|
|
$table->id('_mirror_id');
|
|
$table->string('generation');
|
|
$table->unsignedBigInteger('id');
|
|
$table->unsignedBigInteger('io_id');
|
|
$table->unsignedBigInteger('wms_co_id');
|
|
$table->unsignedBigInteger('wh_id')->default(0);
|
|
$table->string('status')->nullable();
|
|
$table->string('io_type')->nullable();
|
|
$table->dateTime('occurred_at')->nullable();
|
|
$table->dateTime('source_modified_at')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
|
|
$table->id('_mirror_id');
|
|
$table->string('generation');
|
|
$table->unsignedBigInteger('id');
|
|
$table->unsignedBigInteger('io_id');
|
|
$table->string('sku_code');
|
|
$table->decimal('qty', 20, 4);
|
|
$table->smallInteger('qty_type');
|
|
$table->dateTime('source_modified_at')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
|
|
$table->id('_mirror_id');
|
|
$table->string('generation');
|
|
$table->unsignedBigInteger('id');
|
|
$table->unsignedBigInteger('wms_co_id');
|
|
$table->string('sku_code');
|
|
$table->decimal('qty', 20, 4);
|
|
$table->dateTime('source_modified_at')->nullable();
|
|
$table->dateTime('captured_at')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void {
|
|
$table->id('_mirror_id');
|
|
$table->string('generation');
|
|
$table->unsignedBigInteger('id');
|
|
$table->unsignedBigInteger('wms_co_id');
|
|
$table->unsignedSmallInteger('wh_id');
|
|
$table->string('sku_code');
|
|
$table->decimal('qty', 20, 4);
|
|
$table->dateTime('captured_at')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->string('status');
|
|
$table->string('active_generation')->nullable();
|
|
$table->dateTime('anchor_at')->nullable();
|
|
$table->date('source_period_start')->nullable();
|
|
$table->date('source_period_end')->nullable();
|
|
});
|
|
$schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void {
|
|
$table->string('generation');
|
|
$table->string('sku_code');
|
|
$table->decimal('qty', 20, 4);
|
|
$table->dateTime('anchor_at');
|
|
$table->primary(['generation', 'sku_code']);
|
|
});
|
|
$schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void {
|
|
$table->string('generation');
|
|
$table->date('report_date');
|
|
$table->string('sku_code');
|
|
foreach ([
|
|
'opening_qty', 'closing_qty', 'processing_in', 'processing_out',
|
|
'purchase_in', 'purchase_return', 'other_in', 'other_return',
|
|
'sales_out', 'sales_return', 'stocktake', 'transfer_out',
|
|
'transfer_in', 'opening_aux', 'other_out',
|
|
] as $column) {
|
|
$table->decimal($column, 20, 4)->default(0);
|
|
}
|
|
$table->primary(['generation', 'report_date', 'sku_code']);
|
|
});
|
|
$schema->create('inventory_report_authoritative_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->string('status');
|
|
$table->string('active_generation')->nullable();
|
|
$table->date('source_month')->nullable();
|
|
});
|
|
$schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void {
|
|
$table->string('generation');
|
|
$table->string('row_key');
|
|
$table->date('report_month');
|
|
$table->string('owner');
|
|
$table->string('warehouse_provider');
|
|
$table->string('warehouse');
|
|
$table->string('product_code');
|
|
$table->string('category')->nullable();
|
|
foreach ([
|
|
'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out',
|
|
'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out',
|
|
'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out',
|
|
] as $column) {
|
|
$table->decimal($column, 20, 4)->default(0);
|
|
}
|
|
$table->primary(['generation', 'row_key']);
|
|
});
|
|
}
|
|
|
|
private function createProductTable(): void
|
|
{
|
|
Schema::connection('business_mirror')->create('ops_jst_sku', function (Blueprint $table): void {
|
|
$table->unsignedBigInteger('autoid');
|
|
$table->string('sku_id')->nullable();
|
|
$table->string('sku_type')->nullable();
|
|
foreach ([
|
|
'pic_big', 'pic', 'name', 'short_name', 'supplier_name', 'category',
|
|
'vc_name', 'supplier_sku_id', 'properties_value', 'color', 'brand',
|
|
'unit', 'other_price_1', 'other_price_2', 'other_price_3',
|
|
'other_price_4', 'other_1', 'other_2', 'other_3', 'other_4',
|
|
'other_5', 'other_6', 'other_7', 'other_8', 'other_9', 'other_10',
|
|
] as $column) {
|
|
$table->string($column)->nullable();
|
|
}
|
|
$table->decimal('pack_qty', 20, 4)->nullable();
|
|
$table->decimal('purchase_price', 20, 4)->nullable();
|
|
});
|
|
}
|
|
|
|
private function createDependencyStateTable(): void
|
|
{
|
|
$schema = Schema::connection('business_mirror');
|
|
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
|
|
$table->string('dataset_key')->primary();
|
|
$table->string('status')->nullable();
|
|
$table->dateTime('initial_completed_at')->nullable();
|
|
$table->dateTime('last_succeeded_at')->nullable();
|
|
});
|
|
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
|
[
|
|
'dataset_key' => 'operations_replica.rpt.jst_sku',
|
|
'status' => 'succeeded',
|
|
'initial_completed_at' => '2026-09-01 03:00:00',
|
|
'last_succeeded_at' => '2026-09-11 03:00:00',
|
|
],
|
|
[
|
|
'dataset_key' => 'operations_replica.rpt.jst_warehouseInfo',
|
|
'status' => 'succeeded',
|
|
'initial_completed_at' => '2026-09-01 03:00:00',
|
|
'last_succeeded_at' => '2026-09-11 03:00:00',
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function createWarehouseTable(): void
|
|
{
|
|
Schema::connection('business_mirror')->create('ops_jst_warehouse_info', function (Blueprint $table): void {
|
|
$table->unsignedBigInteger('Id');
|
|
$table->unsignedBigInteger('wms_co_id');
|
|
$table->string('name')->nullable();
|
|
});
|
|
DB::connection('business_mirror')->table('ops_jst_warehouse_info')->insert([
|
|
['Id' => 1, 'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID, 'name' => '三方-南昌樵舍仓'],
|
|
['Id' => 2, 'wms_co_id' => 15038426, 'name' => '简尚-崔桥工厂仓'],
|
|
]);
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function markReady(array $overrides = []): void
|
|
{
|
|
$this->insertState(array_merge([
|
|
'scope_version' => 2,
|
|
'status' => 'succeeded',
|
|
'run_mode' => null,
|
|
'active_header_generation' => self::HEADER_GENERATION,
|
|
'active_item_generation' => self::ITEM_GENERATION,
|
|
'active_inventory_generation' => self::INVENTORY_GENERATION,
|
|
'initial_completed_at' => '2026-09-11 03:20:00',
|
|
'last_succeeded_at' => '2026-09-11 03:21:00',
|
|
'last_error_code' => null,
|
|
], $overrides));
|
|
}
|
|
|
|
/** @param array<string,mixed> $values */
|
|
private function insertState(array $values): void
|
|
{
|
|
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert(
|
|
['dataset_key' => ThirdPartyWarehouseStorageMirrorRepository::DATASET_KEY] + $values,
|
|
);
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function insertProduct(string $sku, array $overrides = []): void
|
|
{
|
|
DB::connection('business_mirror')->table('ops_jst_sku')->insert(array_merge([
|
|
'autoid' => 1,
|
|
'sku_id' => $sku,
|
|
'sku_type' => 'normal',
|
|
'pic_big' => 'https://example.test/' . $sku . '.jpg',
|
|
'pic' => null,
|
|
'name' => '测试商品',
|
|
'short_name' => '测试简称',
|
|
'supplier_name' => '测试供应商',
|
|
'category' => '测试分类',
|
|
'vc_name' => '测试虚拟分类',
|
|
'supplier_sku_id' => 'SUP-' . $sku,
|
|
'properties_value' => '原木色',
|
|
'color' => null,
|
|
'brand' => '测试品牌',
|
|
'unit' => '件',
|
|
'purchase_price' => 55,
|
|
'other_price_1' => '采购价',
|
|
'other_price_2' => '按需叫货',
|
|
'other_price_3' => null,
|
|
'other_price_4' => null,
|
|
'pack_qty' => 1,
|
|
'other_1' => '型号',
|
|
'other_2' => '板材',
|
|
'other_3' => '采购',
|
|
'other_4' => null,
|
|
'other_5' => '在产',
|
|
'other_6' => null,
|
|
'other_7' => 'ITEM-' . $sku,
|
|
'other_8' => '单包',
|
|
'other_9' => 'DRAW-' . $sku,
|
|
'other_10' => '木',
|
|
], $overrides));
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function insertInventory(string $sku, float $qty, string $capturedAt, array $overrides = []): void
|
|
{
|
|
static $id = 1;
|
|
$subwarehouseValues = array_merge([
|
|
'qty' => $qty,
|
|
'return_qty' => 0,
|
|
'in_qty' => 0,
|
|
'defective_qty' => 0,
|
|
'customize_qty_1' => 0,
|
|
'customize_qty_2' => 0,
|
|
'customize_qty_3' => 0,
|
|
], array_intersect_key($overrides, array_flip([
|
|
'qty', 'return_qty', 'in_qty', 'defective_qty',
|
|
'customize_qty_1', 'customize_qty_2', 'customize_qty_3',
|
|
])));
|
|
$values = array_merge([
|
|
'generation' => self::INVENTORY_GENERATION,
|
|
'id' => $id++,
|
|
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
|
'sku_code' => $sku,
|
|
'qty' => $qty,
|
|
'captured_at' => $capturedAt,
|
|
], array_diff_key($overrides, $subwarehouseValues));
|
|
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert($values);
|
|
foreach ([
|
|
1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty',
|
|
6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3',
|
|
] as $warehouseId => $column) {
|
|
DB::connection('business_mirror')->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insert([
|
|
'generation' => $values['generation'],
|
|
'id' => $values['id'],
|
|
'wms_co_id' => $values['wms_co_id'],
|
|
'wh_id' => $warehouseId,
|
|
'sku_code' => $values['sku_code'],
|
|
'qty' => $subwarehouseValues[$column],
|
|
'captured_at' => $values['captured_at'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function insertAuthoritativeAnchor(string $sku, float $qty, string $anchorAt): void
|
|
{
|
|
$generation = '44444444-4444-4444-4444-444444444444';
|
|
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchor_states')->insert([
|
|
'dataset_key' => 'jiansun.third-party-warehouse-storage.authoritative-anchor',
|
|
'status' => 'succeeded',
|
|
'active_generation' => $generation,
|
|
'anchor_at' => $anchorAt,
|
|
'source_period_start' => '2026-08-01',
|
|
'source_period_end' => '2026-08-31',
|
|
]);
|
|
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_anchors')->insert([
|
|
'generation' => $generation,
|
|
'sku_code' => $sku,
|
|
'qty' => $qty,
|
|
'anchor_at' => $anchorAt,
|
|
]);
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function insertInventoryReportAuthoritativeRow(array $overrides = []): void
|
|
{
|
|
$generation = '55555555-5555-5555-5555-555555555555';
|
|
$sourceMonth = (string) ($overrides['source_month'] ?? '2026-08-01');
|
|
unset($overrides['source_month']);
|
|
DB::connection('business_mirror')->table('inventory_report_authoritative_states')->insert([
|
|
'dataset_key' => 'jiansun.inventory-report.authoritative-month',
|
|
'status' => 'succeeded',
|
|
'active_generation' => $generation,
|
|
'source_month' => $sourceMonth,
|
|
]);
|
|
DB::connection('business_mirror')->table('inventory_report_authoritative_rows')->insert(array_merge([
|
|
'generation' => $generation,
|
|
'row_key' => hash('sha256', 'authoritative-row'),
|
|
'report_month' => $sourceMonth,
|
|
'owner' => '常州简尚家居有限公司',
|
|
'warehouse_provider' => '仓储方',
|
|
'warehouse' => '子仓',
|
|
'product_code' => 'SKU-AUTH',
|
|
'category' => '分类',
|
|
'opening_quantity' => 0,
|
|
'closing_quantity' => 0,
|
|
], $overrides));
|
|
}
|
|
|
|
/** @param array<string,float|int> $movements */
|
|
private function insertAuthoritativeRow(
|
|
string $sku,
|
|
string $date,
|
|
float $opening,
|
|
float $closing,
|
|
array $movements = [],
|
|
): void {
|
|
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_rows')->insert(array_merge([
|
|
'generation' => '44444444-4444-4444-4444-444444444444',
|
|
'report_date' => $date,
|
|
'sku_code' => $sku,
|
|
'opening_qty' => $opening,
|
|
'closing_qty' => $closing,
|
|
], $movements));
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function insertMovement(
|
|
string $sku,
|
|
string $type,
|
|
float $qty,
|
|
int $qtyType,
|
|
string $occurredAt,
|
|
array $overrides = [],
|
|
): void {
|
|
$ioId = $overrides['io_id'] ?? (1000 + $this->nextHeaderId);
|
|
$headerGeneration = $overrides['header_generation'] ?? self::HEADER_GENERATION;
|
|
$itemGeneration = $overrides['item_generation'] ?? self::ITEM_GENERATION;
|
|
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
|
|
'generation' => $headerGeneration,
|
|
'id' => $overrides['header_id'] ?? $this->nextHeaderId++,
|
|
'io_id' => $ioId,
|
|
'wms_co_id' => $overrides['warehouse_id'] ?? ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
|
'wh_id' => $overrides['wh_id'] ?? 1,
|
|
'status' => $overrides['status'] ?? 'Confirmed',
|
|
'io_type' => $type,
|
|
'occurred_at' => $occurredAt,
|
|
]);
|
|
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
|
|
'generation' => $itemGeneration,
|
|
'id' => $this->nextItemId++,
|
|
'io_id' => $ioId,
|
|
'sku_code' => $sku,
|
|
'qty' => $qty,
|
|
'qty_type' => $qtyType,
|
|
]);
|
|
}
|
|
|
|
private function insertHeaderOnly(
|
|
int $id,
|
|
int $ioId,
|
|
string $type,
|
|
string $occurredAt,
|
|
string $status,
|
|
): void {
|
|
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
|
|
'generation' => self::HEADER_GENERATION,
|
|
'id' => $id,
|
|
'io_id' => $ioId,
|
|
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
|
|
'status' => $status,
|
|
'io_type' => $type,
|
|
'occurred_at' => $occurredAt,
|
|
]);
|
|
}
|
|
|
|
/** @param array<string,mixed> $overrides */
|
|
private function filters(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'dimension' => 'month',
|
|
'period' => '2026-08',
|
|
'month' => '2026-08',
|
|
'date' => '',
|
|
'sku_code' => '',
|
|
'product_name' => '',
|
|
'supplier' => '',
|
|
'category' => '',
|
|
], $overrides);
|
|
}
|
|
}
|