接入并校准三方仓进销存 #176

Merged
limeng merged 9 commits from feature/limeng-third-party-warehouse-data into main 2026-09-12 20:29:44 +08:00
27 changed files with 6229 additions and 2 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageAuthoritativeAnchorImporter;
use Throwable;
class ImportThirdPartyWarehouseStorageAuthoritativeAnchor extends Command
{
protected $signature = 'business-mirror:import-third-party-warehouse-anchor
{path : 聚水潭三方仓按商品加按天多维分析完整 XLSX 导出文件}';
protected $description = '导入三方仓权威日进销存明细及月末库存锚点,用于稳定历史余额';
public function handle(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'));
} catch (Throwable $exception) {
report($exception);
$this->error('三方仓权威进销存导入失败,已保留上一版有效数据。');
return self::FAILURE;
}
$this->info(sprintf(
'三方仓权威日明细导入完成:校验 %s 条明细,生成 %s 个商品锚点,锚点时刻 %s。',
number_format($result['source_rows']),
number_format($result['anchor_skus']),
$result['anchor_at'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
use Throwable;
class SyncThirdPartyWarehouseStorage extends Command
{
protected $signature = 'business-mirror:sync-third-party-warehouse-storage
{--full : 启动或继续三方仓流水与库存的全量校准}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像';
public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
return self::SUCCESS;
}
try {
$result = $synchronizer->sync(
(bool) $this->option('full'),
$this->option('max-batches') === null ? null : (int) $this->option('max-batches'),
);
} catch (Throwable $exception) {
Log::error('三方仓仓储数据共享镜像同步失败。', [
'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
'exception' => $exception::class,
]);
$this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。');
return self::FAILURE;
}
if ($result['status'] === 'requires_full_sync') {
$this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。');
return self::SUCCESS;
}
if ($result['status'] === 'already_running') {
$this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。');
return self::SUCCESS;
}
$this->info(sprintf(
'三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。',
$result['mode'] === 'full' ? '全量校准' : '增量同步',
$result['status'] === 'succeeded' ? '完成' : '进行中',
$result['batches'],
$result['headers'],
$result['items'],
$result['inventory'],
));
return self::SUCCESS;
}
}

View File

@ -43,6 +43,11 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
->dailyAt('00:00')
->withoutOverlapping(180)
->runInBackground();
if ((bool) config('services.esign.polling_enabled', false)) {
$schedule->command('esign:poll-signatures --quiet')
->cron('*/2 * * * *')

View File

@ -30,6 +30,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
@ -80,6 +81,7 @@ class Pages extends Controller
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
private readonly ThirdPartyWarehouseStorageMirrorRepository $thirdPartyWarehouseStorage,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
@ -619,6 +621,7 @@ class Pages extends Controller
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'freight-reconciliation' => $this->contentForFreightReconciliation(),
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
@ -732,6 +735,182 @@ class Pages extends Controller
];
}
protected function contentForThirdPartyWarehouseStorage(): array
{
$request = request();
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = [];
$error = null;
$pending = false;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->ready()) {
$state = $this->thirdPartyWarehouseStorage->state();
$sync = $this->thirdPartyWarehouseStorage->syncStatus($state);
$pending = true;
$error = ($sync['code'] ?? null) === 'dependency_pending'
? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。'
: '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => $request->url(),
'query' => $request->except('page'),
],
);
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
$error = '三方仓进销存暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.third-party-warehouse-storage',
'thirdPartyWarehouseRows' => $rows,
'thirdPartyWarehouseFilters' => $filters,
'thirdPartyWarehouseSummary' => $summary,
'thirdPartyWarehouseState' => $state,
'thirdPartyWarehouseSync' => $sync,
'thirdPartyWarehouseError' => $error,
'thirdPartyWarehousePending' => $pending,
'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'),
];
}
public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse
{
abort_unless(
$this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(),
503,
);
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$columns = $this->filterExportColumns(
$this->thirdPartyWarehouseStorageExportColumns(),
$request->query('fields'),
);
$period = str_replace('-', '', $filters['period']);
return response()->streamDownload(function () use ($filters, $columns): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建三方仓进销存导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
return match ($column['type'] ?? 'text') {
'number', 'money' => $value === null || $value === '' ? '' : $value,
'code' => $this->csvIdentifier($value),
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function thirdPartyWarehouseStorageFilters(Request $request): array
{
$dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true)
? (string) $request->query('dimension')
: 'month';
$period = trim((string) $request->query(
'period',
$dimension === 'day' ? $request->query('date', '') : $request->query('month', ''),
));
if ($dimension === 'day') {
if ($this->dateFilter($period) === '') {
$period = now()->subDay()->toDateString();
}
if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) {
$period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE;
}
} elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$period = now()->startOfMonth()->subMonth()->format('Y-m');
}
if ($dimension === 'month'
&& $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) {
$period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
}
$clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length);
return [
'dimension' => $dimension,
'period' => $period,
'sku_code' => $clean($request->query('sku_code', ''), 80),
'product_name' => $clean($request->query('product_name', ''), 120),
'supplier' => $clean($request->query('supplier', ''), 120),
'category' => $clean($request->query('category', ''), 120),
];
}
/** @return array<int,array{label:string,field:string,type?:string}> */
private function thirdPartyWarehouseStorageExportColumns(): array
{
return [
['field' => 'owner', 'label' => '货主'],
['field' => 'date', 'label' => '日期'],
['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
['field' => 'image_url', 'label' => '图片'],
['field' => 'product_name', 'label' => '商品名称'],
['field' => 'short_name', 'label' => '商品简称'],
['field' => 'supplier', 'label' => '供应商'],
['field' => 'category', 'label' => '分类'],
['field' => 'virtual_category', 'label' => '虚拟分类'],
['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
['field' => 'color_spec', 'label' => '颜色规格'],
['field' => 'brand', 'label' => '品牌'],
['field' => 'unit', 'label' => '单位'],
['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
];
}
protected function contentForDingPayableWorkflows(): array
{
$normalizeDate = static function (mixed $value): string {

View File

@ -115,6 +115,9 @@ return [
'jiansun.dbo.jst_order_express_details' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账物流明细'],
'jiansun.dbo.Jst_SendData_View_extension' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账商品与收货区域'],
'jiansun.dbo.jst_salesOutboundInfo' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '运费对账出库运费'],
'jiansun.dbo.jst_inoutWater' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
'jiansun.dbo.jst_inoutWaterItem' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
'jiansun.dbo.jst_inventoryInfo' => ['role' => 'operational', 'target_store' => 'mariadb', 'consumer' => '三方仓进销存'],
'jiansun.dbo.finance_boardDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '板材扣款'],
'jiansun.dbo.pm_plainPlateData' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '素板资料'],
'jiansun.dbo.pm_boardPurchasePrice' => ['role' => 'dimension', 'target_store' => 'mariadb', 'consumer' => '板材采购价'],

View File

@ -497,6 +497,7 @@ $navigation = [
['slug' => 'courier-information', 'title' => '快递资料'],
['slug' => 'agreement-signing', 'title' => '协议签署'],
['slug' => 'freight-reconciliation', 'title' => '运费对账'],
['slug' => 'third-party-warehouse-storage', 'title' => '三方仓进销存'],
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
['slug' => 'compensation-details', 'title' => '赔偿明细'],
['slug' => 'advance-details', 'title' => '借支明细'],

View File

@ -87,6 +87,27 @@ return [
'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000),
],
// 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。
// 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。
'third_party_warehouse_storage' => [
'dataset_key' => 'jiansun.third-party-warehouse-storage',
'source_key' => 'jiansun',
'warehouse_id' => 15045364,
'warehouse_name' => '三方-南昌樵舍仓',
'owner_name' => '常州简尚家居有限公司',
'header_source_object' => 'dbo.jst_inoutWater',
'item_source_object' => 'dbo.jst_inoutWaterItem',
'inventory_source_object' => 'dbo.jst_inventoryInfo',
'header_scan_size' => (int) env('THIRD_PARTY_WAREHOUSE_HEADER_SCAN_SIZE', 10000),
'max_header_batches_per_run' => (int) env('THIRD_PARTY_WAREHOUSE_MAX_HEADER_BATCHES', 10),
'incremental_batch_size' => (int) env('THIRD_PARTY_WAREHOUSE_INCREMENTAL_BATCH_SIZE', 5000),
'incremental_overlap_hours' => (int) env('THIRD_PARTY_WAREHOUSE_INCREMENTAL_OVERLAP_HOURS', 48),
'inventory_batch_size' => (int) env('THIRD_PARTY_WAREHOUSE_INVENTORY_BATCH_SIZE', 2000),
'sync_lock_seconds' => (int) env('THIRD_PARTY_WAREHOUSE_SYNC_LOCK_SECONDS', 21600),
'generation_retention_hours' => (int) env('THIRD_PARTY_WAREHOUSE_GENERATION_RETENTION_HOURS', 48),
'stale_after_minutes' => (int) env('THIRD_PARTY_WAREHOUSE_STALE_MINUTES', 1560),
],
'product_prices' => [
'dataset_key' => 'supplier-payables.product-prices',
'source_key' => 'operations_replica',

View File

@ -0,0 +1,147 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('third_party_warehouse_sync_states')) {
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
$table->string('dataset_key', 100)->primary();
$table->string('status', 32)->default('pending');
$table->string('run_mode', 24)->nullable();
$table->string('sync_phase', 24)->nullable();
$table->uuid('run_token')->nullable();
$table->uuid('active_header_generation')->nullable();
$table->uuid('active_item_generation')->nullable();
$table->uuid('active_inventory_generation')->nullable();
$table->uuid('pending_header_generation')->nullable();
$table->uuid('pending_item_generation')->nullable();
$table->uuid('pending_inventory_generation')->nullable();
$table->unsignedBigInteger('header_cursor')->default(0);
$table->unsignedBigInteger('header_high_watermark')->nullable();
$table->dateTime('header_window_started_at', 3)->nullable();
$table->dateTime('header_window_ends_at', 3)->nullable();
$table->dateTime('header_modified_cursor_at', 3)->nullable();
$table->unsignedBigInteger('header_modified_cursor_id')->default(0);
$table->unsignedBigInteger('inventory_cursor')->default(0);
$table->unsignedBigInteger('inventory_high_watermark')->nullable();
$table->unsignedBigInteger('header_row_count')->default(0);
$table->unsignedBigInteger('item_row_count')->default(0);
$table->unsignedBigInteger('inventory_row_count')->default(0);
$table->dateTime('initial_completed_at', 6)->nullable();
$table->dateTime('last_attempted_at', 6)->nullable();
$table->dateTime('last_succeeded_at', 6)->nullable();
$table->dateTime('last_full_succeeded_at', 6)->nullable();
$table->string('last_error_code', 191)->nullable();
$table->timestamps(6);
});
}
if (! $schema->hasTable('third_party_warehouse_generation_releases')) {
$schema->create('third_party_warehouse_generation_releases', function (Blueprint $table): void {
$table->uuid('release_id')->primary();
$table->string('dataset_key', 100);
$table->uuid('header_generation');
$table->uuid('item_generation');
$table->uuid('inventory_generation');
$table->string('status', 16);
$table->dateTime('published_at', 6);
$table->dateTime('retired_at', 6)->nullable();
$table->timestamps(6);
$table->unique(
['dataset_key', 'header_generation', 'item_generation', 'inventory_generation'],
'tpw_generation_release_unique',
);
$table->index(
['dataset_key', 'status', 'retired_at'],
'tpw_generation_release_status_index',
);
});
}
if (! $schema->hasTable('third_party_warehouse_inout_headers')) {
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
$table->bigIncrements('_mirror_id');
$table->uuid('generation');
$table->string('source_key', 64)->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->unsignedBigInteger('wms_co_id');
$table->string('status', 32)->nullable();
$table->string('io_type', 64)->nullable();
$table->dateTime('occurred_at', 3)->nullable();
$table->dateTime('source_modified_at', 3)->nullable();
$table->string('row_hash', 64);
$table->dateTime('synced_at', 6);
$table->unique(['generation', 'id'], 'tpw_headers_generation_id_unique');
$table->index(['generation', 'io_id'], 'tpw_headers_generation_io_index');
$table->index(
['generation', 'status', 'occurred_at', 'io_type'],
'tpw_headers_report_index',
);
});
}
if (! $schema->hasTable('third_party_warehouse_inout_items')) {
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
$table->bigIncrements('_mirror_id');
$table->uuid('generation');
$table->string('source_key', 64)->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->smallInteger('qty_type')->default(1);
$table->dateTime('source_modified_at', 3)->nullable();
$table->string('row_hash', 64);
$table->dateTime('synced_at', 6);
$table->unique(['generation', 'id'], 'tpw_items_generation_id_unique');
$table->index(['generation', 'io_id', 'id'], 'tpw_items_generation_io_index');
$table->index(['generation', 'sku_code', 'io_id'], 'tpw_items_generation_sku_index');
});
}
if (! $schema->hasTable('third_party_warehouse_inventory_snapshots')) {
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
$table->bigIncrements('_mirror_id');
$table->uuid('generation');
$table->string('source_key', 64)->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->dateTime('source_modified_at', 3)->nullable();
$table->dateTime('captured_at', 6);
$table->string('row_hash', 64);
$table->dateTime('synced_at', 6);
$table->unique(['generation', 'id'], 'tpw_inventory_generation_id_unique');
$table->unique(
['generation', 'wms_co_id', 'sku_code'],
'tpw_inventory_generation_warehouse_sku_unique',
);
$table->index(['generation', 'sku_code', 'captured_at'], 'tpw_inventory_report_index');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
$schema->dropIfExists('third_party_warehouse_inventory_snapshots');
$schema->dropIfExists('third_party_warehouse_inout_items');
$schema->dropIfExists('third_party_warehouse_inout_headers');
$schema->dropIfExists('third_party_warehouse_generation_releases');
$schema->dropIfExists('third_party_warehouse_sync_states');
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('third_party_warehouse_sync_states')
&& ! $schema->hasColumn('third_party_warehouse_sync_states', 'run_token')) {
$schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void {
$table->uuid('run_token')->nullable()->after('sync_phase');
});
}
foreach ([
'third_party_warehouse_inout_headers',
'third_party_warehouse_inout_items',
'third_party_warehouse_inventory_snapshots',
] as $tableName) {
if ($schema->hasTable($tableName) && ! $schema->hasColumn($tableName, 'source_key')) {
$schema->table($tableName, function (Blueprint $table): void {
$table->string('source_key', 64)->default('jiansun')->after('generation');
});
}
}
}
public function down(): void
{
// These required columns may have been created by the base migration.
}
};

View File

@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('third_party_warehouse_authoritative_anchor_states')) {
$schema->create('third_party_warehouse_authoritative_anchor_states', function (Blueprint $table): void {
$table->string('dataset_key', 120)->primary();
$table->string('status', 24)->default('pending');
$table->uuid('active_generation')->nullable();
$table->uuid('pending_generation')->nullable();
$table->dateTime('anchor_at')->nullable();
$table->string('source_filename', 255)->nullable();
$table->char('source_sha256', 64)->nullable();
$table->date('source_period_start')->nullable();
$table->date('source_period_end')->nullable();
$table->unsignedBigInteger('source_row_count')->default(0);
$table->unsignedBigInteger('anchor_sku_count')->default(0);
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->string('last_error_code', 120)->nullable();
$table->timestamps();
});
}
if (! $schema->hasTable('third_party_warehouse_authoritative_anchors')) {
$schema->create('third_party_warehouse_authoritative_anchors', function (Blueprint $table): void {
$table->uuid('generation');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4);
$table->dateTime('anchor_at');
$table->dateTime('imported_at');
$table->primary(
['generation', 'sku_code'],
'tpw_authoritative_anchors_primary',
);
$table->index(['generation', 'anchor_at'], 'tpw_authoritative_anchors_time_index');
});
}
if (! $schema->hasTable('third_party_warehouse_authoritative_rows')) {
$schema->create('third_party_warehouse_authoritative_rows', function (Blueprint $table): void {
$table->uuid('generation');
$table->date('report_date');
$table->string('sku_code', 191);
$table->decimal('opening_qty', 20, 4);
$table->decimal('closing_qty', 20, 4);
foreach ([
'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->dateTime('imported_at');
$table->primary(
['generation', 'report_date', 'sku_code'],
'tpw_authoritative_rows_primary',
);
$table->index(['generation', 'report_date'], 'tpw_authoritative_rows_date_index');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
$schema->dropIfExists('third_party_warehouse_authoritative_rows');
$schema->dropIfExists('third_party_warehouse_authoritative_anchors');
$schema->dropIfExists('third_party_warehouse_authoritative_anchor_states');
}
};

View File

@ -174,10 +174,12 @@ printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 只运行已登记的上游后台读取和 E 签宝状态轮询,不启用整个 Laravel
# 调度器,避免意外启动账单提醒等既有任务。不同任务使用独立锁。
# 调度器,避免意外启动账单提醒等既有任务。上游读取共用一把锁,E 签宝单独加锁。
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -178,10 +178,12 @@ printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 只运行已登记的上游后台读取和 E 签宝状态轮询,不启用整个 Laravel
# 调度器,避免意外启动账单提醒等既有任务。不同任务使用独立锁。
# 调度器,避免意外启动账单提醒等既有任务。上游读取共用一把锁,E 签宝单独加锁。
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/caiwu-jiansun-snapshots

View File

@ -12,6 +12,7 @@
| --- | --- |
| 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 |
| 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 |
| 本次接入 | 聚水潭进销存“商品 + 按天”数据固定读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 中仓库 `三方-南昌樵舍仓`(`wms_co_id = 15045364`),进入专用流水、明细、库存快照及同步状态镜像。页面位于“快递应付 / 三方仓进销存”,仅展示 2026-08-01 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 |
| 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 |
| 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 |
| 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 |
@ -58,6 +59,7 @@
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` |
| 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` |
| 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` |
| 三方仓进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(仅 `wms_co_id = 15045364`、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像) |
| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` |
| 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` |
| 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` |
@ -83,6 +85,7 @@
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
12. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
工作平台采购价格首次全量需在备份和负责人授权后单独执行:
@ -90,6 +93,12 @@
php artisan business-mirror:sync-tables jiansun.dbo.pm_priceAdjustment --full
```
三方仓进销存首次全量使用专用命令显式启动;中断后重复执行同一命令会从稳定游标继续,只有流水、明细和库存三部分全部完成后才切换页面代次:
```bash
php artisan business-mirror:sync-third-party-warehouse-storage --full
```
## 后续切换顺序
1. 公司、店铺、商标等小型维表。

View File

@ -0,0 +1,852 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Generator;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
/**
* 三方仓进销存的共享业务镜像只读查询。
*
* 已导入的历史月份直接读取权威日明细;后续月份从每个 SKU 的固定
* 月末锚点按已确认流水前推。未导入锚点时保留实时库存兼容逻辑。
*/
class ThirdPartyWarehouseStorageMirrorRepository
{
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
public const WAREHOUSE_ID = 15045364;
private const STATE_TABLE = 'third_party_warehouse_sync_states';
private const HEADER_TABLE = 'third_party_warehouse_inout_headers';
private const ITEM_TABLE = 'third_party_warehouse_inout_items';
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
private const AUTHORITATIVE_ROW_TABLE = 'third_party_warehouse_authoritative_rows';
private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
private const PRODUCT_TABLE = 'ops_jst_sku';
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
public const START_DATE = '2026-08-01';
/** @var array<string,string> */
private const MOVEMENT_COLUMNS = [
'processing_in' => '加工进仓',
'processing_out' => '加工出仓',
'purchase_in' => '采购进仓',
'purchase_return' => '采购退货',
'other_in' => '其它进仓',
'other_return' => '其它退货',
'sales_out' => '销售出仓',
'sales_return' => '销售退货',
'stocktake' => '盘点',
'transfer_out' => '调拨出',
'transfer_in' => '调拨入',
'other_out' => '其它出库',
];
/** @var list<string> */
private const OUTPUT_COLUMNS = [
'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name',
'supplier', 'category', 'virtual_category', 'supplier_style_no',
'color_spec', 'brand', 'unit', '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',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public function available(): bool
{
try {
$schema = Schema::connection($this->connection->getName());
foreach ([
self::STATE_TABLE,
self::HEADER_TABLE,
self::ITEM_TABLE,
self::INVENTORY_TABLE,
self::PRODUCT_TABLE,
self::GENERIC_STATE_TABLE,
] as $table) {
if (! $schema->hasTable($table)) {
return false;
}
}
return $schema->hasColumn(self::ITEM_TABLE, 'qty_type')
&& $schema->hasColumn(self::HEADER_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::ITEM_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'source_modified_at')
&& $schema->hasColumn(self::INVENTORY_TABLE, 'captured_at');
} catch (Throwable) {
return false;
}
}
public function ready(): bool
{
$state = $this->state();
return is_array($state)
&& ($state['initial_completed_at'] ?? null) !== null
&& $this->generation($state, 'active_header_generation') !== null
&& $this->generation($state, 'active_item_generation') !== null
&& $this->generation($state, 'active_inventory_generation') !== null
&& $this->dependenciesReady();
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! $this->available()) {
return null;
}
try {
$row = $this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->first();
return $row === null ? null : (array) $row;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string,mixed>|null $state
* @return array{code:string,last_succeeded_at:?string,lag_minutes:?int}
*/
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
{
$state ??= $this->state();
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
}
$lastSucceededAt = $this->timestamp($state['last_succeeded_at'] ?? null);
$lastSucceeded = $lastSucceededAt === null
? null
: Carbon::createFromTimestamp($lastSucceededAt)->toDateTimeString();
$lagMinutes = $lastSucceededAt === null
? null
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceededAt) / 60);
$status = (string) ($state['status'] ?? 'pending');
if (($state['initial_completed_at'] ?? null) === null) {
$code = $status === 'failed'
? 'initial_failed'
: ($status === 'running' ? 'initializing' : 'pending');
} elseif (! $this->dependenciesReady()) {
$code = 'dependency_pending';
} elseif ($status === 'failed') {
$code = 'failed';
} elseif ($status === 'running') {
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
} elseif ($lagMinutes === null
|| $lagMinutes > max((int) config(
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes',
1560,
), 1)) {
$code = 'delayed';
} else {
$code = 'healthy';
}
return [
'code' => $code,
'last_succeeded_at' => $lastSucceeded,
'lag_minutes' => $lagMinutes,
];
}
/**
* @param array<string,mixed> $filters
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,int|float>,state:array<string,mixed>,sync:array<string,mixed>}
*/
public function paginate(array $filters, int $page, int $perPage): array
{
$state = $this->requireReadyState();
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->filteredReportQuery($filters, $state);
$summary = $this->summarizeQuery($this->filteredReportQuery(
$filters,
$state,
$this->hasProductFilters($filters),
));
$rows = $query->orderBy('report_date')->orderBy('sku_code')
->forPage($page, $perPage)
->get()
->map(fn (object $row): array => $this->mapRow($row))
->all();
return [
'data' => $rows,
'total' => $summary['count'],
'summary' => $summary,
'state' => $state,
'sync' => $this->syncStatus($state),
];
}
/**
* @param array<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float}
*/
public function summary(array $filters): array
{
$state = $this->requireReadyState();
return $this->summarizeQuery($this->filteredReportQuery(
$filters,
$state,
$this->hasProductFilters($filters),
));
}
/**
* @param array<string,mixed> $filters
* @return array{suppliers:list<string>,categories:list<string>}
*/
public function filterOptions(array $filters = []): array
{
$state = $this->requireReadyState();
$query = $this->filteredReportQuery($filters, $state);
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
->distinct()->orderBy('supplier')->pluck('supplier')
->map(static fn (mixed $value): string => (string) $value)->all();
$categories = (clone $query)->whereNotNull('category')->where('category', '!=', '')
->distinct()->orderBy('category')->pluck('category')
->map(static fn (mixed $value): string => (string) $value)->all();
return ['suppliers' => $suppliers, 'categories' => $categories];
}
/**
* @param array<string,mixed> $filters
* @return Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $batchSize = 500): Generator
{
$state = $this->requireReadyState();
$batchSize = min(max($batchSize, 1), 5000);
$lastDate = null;
$lastSku = null;
while (true) {
$query = $this->filteredReportQuery($filters, $state);
if ($lastDate !== null && $lastSku !== null) {
$query->where(function (Builder $cursor) use ($lastDate, $lastSku): void {
$cursor->where('report_date', '>', $lastDate)
->orWhere(function (Builder $sameDate) use ($lastDate, $lastSku): void {
$sameDate->where('report_date', $lastDate)
->where('sku_code', '>', $lastSku);
});
});
}
$rows = $query->orderBy('report_date')->orderBy('sku_code')->limit($batchSize)->get();
if ($rows->isEmpty()) {
return;
}
foreach ($rows as $row) {
yield $this->mapRow($row);
}
$lastDate = (string) $rows->last()->report_date;
$lastSku = (string) $rows->last()->sku_code;
if ($rows->count() < $batchSize) {
return;
}
}
}
/** @param array<string,mixed> $state */
private function filteredReportQuery(
array $filters,
array $state,
bool $includeProductDetails = true,
): Builder
{
$query = $this->connection->query()->fromSub(
$this->reportQuery($filters, $state, $includeProductDetails),
'report',
);
$this->containsFilter($query, 'sku_code', $filters['sku_code'] ?? '');
$this->containsFilter($query, 'product_name', $filters['product_name'] ?? '');
$this->containsFilter($query, 'supplier', $filters['supplier'] ?? '');
$this->containsFilter($query, 'category', $filters['category'] ?? '');
$nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)];
$query->where(function (Builder $values) use ($nonZero): void {
foreach ($nonZero as $index => $column) {
$method = $index === 0 ? 'where' : 'orWhere';
$values->{$method}($column, '<>', 0);
}
});
return $query;
}
/** @param array<string,mixed> $state */
private function reportQuery(
array $filters,
array $state,
bool $includeProductDetails,
): Builder
{
$period = $this->period($filters);
$headerGeneration = $this->generation($state, 'active_header_generation');
$itemGeneration = $this->generation($state, 'active_item_generation');
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
$authoritativeAnchor = $this->authoritativeAnchor();
$anchorAt = $authoritativeAnchor['anchor_at'] ?? $this->stateAnchor($state);
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
$authoritativeRows = $this->authoritativeReportQuery(
$period,
$authoritativeAnchor,
$includeProductDetails,
);
if ($authoritativeRows !== null) {
return $authoritativeRows;
}
$skuRangeStart = min($period['start'], $anchorAt);
$skuRangeEnd = max($period['end'], $anchorAt);
$movementSkus = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $skuRangeStart)
->where('h.occurred_at', '<', $skuRangeEnd)
->select('i.sku_code');
if ($authoritativeAnchor !== null) {
$skuUnion = $this->connection->table(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory_skus')
->where('inventory_skus.generation', $authoritativeAnchor['generation'])
->whereNotNull('inventory_skus.sku_code')
->where('inventory_skus.sku_code', '!=', '')
->select('inventory_skus.sku_code')
->union($movementSkus);
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
->leftJoin(self::AUTHORITATIVE_ANCHOR_TABLE . ' as inventory', function ($join) use ($authoritativeAnchor): void {
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
->where('inventory.generation', '=', $authoritativeAnchor['generation']);
})
->select('sku_set.sku_code')
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.anchor_at, ?) AS snapshot_at', [$anchorAt]);
} else {
$skuUnion = $this->connection->table(self::INVENTORY_TABLE . ' as inventory_skus')
->where('inventory_skus.generation', $inventoryGeneration)
->where('inventory_skus.wms_co_id', self::WAREHOUSE_ID)
->whereNotNull('inventory_skus.sku_code')
->where('inventory_skus.sku_code', '!=', '')
->select('inventory_skus.sku_code')
->union($movementSkus);
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
->where('inventory.generation', '=', $inventoryGeneration)
->where('inventory.wms_co_id', '=', self::WAREHOUSE_ID);
})
->select('sku_set.sku_code')
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
}
$dateSkuAnchors = $this->connection->query()
->fromSub($this->dateSeries($period['start'], $period['end']), 'd')
->crossJoinSub($anchors, 'a')
->select([
'd.report_date',
'd.day_start',
'd.day_end',
'a.sku_code',
'a.snapshot_qty',
'a.snapshot_at',
]);
$signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))';
$forwardAnchorOperator = $authoritativeAnchor === null ? '>' : '>=';
$closingExpression = 'da.snapshot_qty + COALESCE(SUM(CASE '
. 'WHEN da.snapshot_at < da.day_end AND mv.occurred_at ' . $forwardAnchorOperator . ' da.snapshot_at '
. 'AND mv.occurred_at < da.day_end THEN ' . $signedQuantity . ' '
. 'WHEN da.snapshot_at >= da.day_end AND mv.occurred_at >= da.day_end '
. 'AND mv.occurred_at < da.snapshot_at THEN -' . $signedQuantity . ' '
. 'ELSE 0 END), 0)';
$allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $skuRangeStart)
->where('h.occurred_at', '<', $skuRangeEnd)
->select([
'i.sku_code',
'i.qty as movement_qty',
'i.qty_type as movement_qty_type',
'h.occurred_at',
]);
$balances = $this->connection->query()->fromSub($dateSkuAnchors, 'da')
->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'da.sku_code')
->groupBy(
'da.report_date',
'da.day_start',
'da.day_end',
'da.sku_code',
'da.snapshot_qty',
'da.snapshot_at',
)
->select(['da.report_date', 'da.sku_code'])
->selectRaw($closingExpression . ' AS closing_qty');
$periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
->where('h.occurred_at', '>=', $period['start'])
->where('h.occurred_at', '<', $period['end'])
->groupBy('i.sku_code')
->groupByRaw('DATE(h.occurred_at)')
->select('i.sku_code')
->selectRaw('DATE(h.occurred_at) AS report_date');
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
$periodMovements->selectRaw(
'COALESCE(SUM(CASE WHEN h.io_type = ? THEN '
. '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0)) ELSE 0 END), 0) AS ' . $column,
[$type],
);
}
$report = $this->connection->query()->fromSub($balances, 'b')
->leftJoinSub($periodMovements, 'm', function ($join): void {
$join->on('m.sku_code', '=', 'b.sku_code')
->on('m.report_date', '=', 'b.report_date');
});
if ($includeProductDetails) {
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$report->leftJoinSub(
$latestProducts,
'latest_product',
'latest_product.sku_id',
'=',
'b.sku_code',
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid')
->where('s.sku_type', '=', 'normal');
});
}
$movementSelects = [];
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
$movementSelects[$column] = 'COALESCE(m.' . $column . ', 0)';
}
$netMovement = implode(' + ', array_values($movementSelects));
$openingQuantity = '(b.closing_qty - (' . $netMovement . '))';
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
$report->selectRaw('? AS owner', [$owner])
->addSelect('b.report_date')
->addSelect('b.sku_code');
if ($includeProductDetails) {
$report->addSelect([
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
$this->connection->raw('s.name AS product_name'),
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
$this->connection->raw('s.supplier_name AS supplier'),
$this->connection->raw('s.category AS category'),
$this->connection->raw('s.vc_name AS virtual_category'),
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
$this->connection->raw('s.brand AS brand'),
$this->connection->raw('s.unit AS unit'),
]);
}
$report->selectRaw($openingQuantity . ' AS opening_qty')
->selectRaw('b.closing_qty AS closing_qty');
foreach ($movementSelects as $column => $expression) {
$report->selectRaw($expression . ' AS ' . $column);
}
$report->selectRaw('0 AS opening_aux');
return $report;
}
private function dateSeries(string $start, string $end): Builder
{
$cursor = Carbon::parse($start)->startOfDay();
$through = Carbon::parse($end)->startOfDay();
$dates = null;
while ($cursor < $through) {
$dayStart = $cursor->copy();
$dayEnd = $cursor->copy()->addDay();
$row = $this->connection->query()->selectRaw(
'? AS report_date, ? AS day_start, ? AS day_end',
[$dayStart->toDateString(), $dayStart->toDateTimeString(), $dayEnd->toDateTimeString()],
);
$dates = $dates === null ? $row : $dates->unionAll($row);
$cursor->addDay();
}
if (! $dates instanceof Builder) {
throw new RuntimeException('third_party_warehouse_period_invalid');
}
return $dates;
}
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
{
return $this->connection->table(self::ITEM_TABLE . ' as i')
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
$join->on('h.io_id', '=', 'i.io_id')
->where('h.generation', '=', $headerGeneration);
})
->where('i.generation', $itemGeneration)
->where('h.wms_co_id', self::WAREHOUSE_ID)
->where('h.status', 'Confirmed')
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
$newer->selectRaw('1')->from(self::HEADER_TABLE . ' as newer')
->where('newer.generation', $headerGeneration)
->whereColumn('newer.io_id', 'h.io_id')
->whereColumn('newer.id', '>', 'h.id');
})
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
}
/**
* @param array<string,mixed> $filters
* @return array{dimension:string,value:string,start:string,end:string,days:int}
*/
private function period(array $filters): array
{
$dimension = ($filters['dimension'] ?? 'month') === 'day' ? 'day' : 'month';
$candidate = trim((string) ($filters['period'] ?? ''));
if ($dimension === 'day') {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['date'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m-d', $candidate)
: now()->startOfDay();
} catch (Throwable) {
$start = now()->startOfDay();
}
if ($start->lt(Carbon::parse(self::START_DATE)->startOfDay())) {
$start = Carbon::parse(self::START_DATE)->startOfDay();
}
return [
'dimension' => 'day',
'value' => $start->format('Y-m-d'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addDay()->format('Y-m-d 00:00:00'),
'days' => 1,
];
}
if (preg_match('/^\d{4}-\d{2}$/', $candidate) !== 1) {
$candidate = trim((string) ($filters['month'] ?? ''));
}
try {
$start = preg_match('/^\d{4}-\d{2}$/', $candidate) === 1
? Carbon::createFromFormat('!Y-m', $candidate)->startOfMonth()
: now()->startOfMonth();
} catch (Throwable) {
$start = now()->startOfMonth();
}
$minimum = Carbon::parse(self::START_DATE)->startOfMonth();
if ($start->lt($minimum)) {
$start = $minimum;
}
return [
'dimension' => 'month',
'value' => $start->format('Y-m'),
'start' => $start->format('Y-m-d 00:00:00'),
'end' => $start->copy()->addMonth()->format('Y-m-d 00:00:00'),
'days' => $start->daysInMonth,
];
}
/** @param array<string,mixed> $state */
private function stateAnchor(array $state): string
{
$timestamp = $this->timestamp($state['last_succeeded_at'] ?? null);
return $timestamp === null
? now()->toDateTimeString()
: Carbon::createFromTimestamp($timestamp)->toDateTimeString();
}
/**
* @param array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null $authoritativeAnchor
*/
private function authoritativeReportQuery(
array $period,
?array $authoritativeAnchor,
bool $includeProductDetails,
): ?Builder {
if ($authoritativeAnchor === null) {
return null;
}
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::AUTHORITATIVE_ROW_TABLE)) {
return null;
}
$sourceStart = $authoritativeAnchor['source_period_start'] ?? null;
$sourceEnd = $authoritativeAnchor['source_period_end'] ?? null;
if (! is_string($sourceStart) || ! is_string($sourceEnd)
|| $sourceStart === '' || $sourceEnd === '') {
return null;
}
$sourceStartAt = Carbon::parse($sourceStart)->startOfDay();
$sourceEndAt = Carbon::parse($sourceEnd)->addDay()->startOfDay();
$periodStartAt = Carbon::parse($period['start']);
$periodEndAt = Carbon::parse($period['end']);
if ($periodStartAt->lt($sourceStartAt) || $periodEndAt->gt($sourceEndAt)) {
return null;
}
$rows = $this->connection->table(self::AUTHORITATIVE_ROW_TABLE . ' as b')
->where('b.generation', $authoritativeAnchor['generation'])
->where('b.report_date', '>=', $periodStartAt->toDateString())
->where('b.report_date', '<', $periodEndAt->toDateString());
$owner = (string) config(
'upstream_mirrors.third_party_warehouse_storage.owner_name',
'常州简尚家居有限公司',
);
$rows->selectRaw('? AS owner', [$owner])->addSelect(['b.report_date', 'b.sku_code']);
if ($includeProductDetails) {
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereNotNull('sku_id')->where('sku_id', '!=', '')
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
$rows->leftJoinSub(
$latestProducts,
'latest_product',
'latest_product.sku_id',
'=',
'b.sku_code',
)->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
$join->on('s.sku_id', '=', 'latest_product.sku_id')
->on('s.autoid', '=', 'latest_product.autoid')
->where('s.sku_type', '=', 'normal');
})->addSelect([
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
$this->connection->raw('s.name AS product_name'),
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
$this->connection->raw('s.supplier_name AS supplier'),
$this->connection->raw('s.category AS category'),
$this->connection->raw('s.vc_name AS virtual_category'),
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
$this->connection->raw('s.brand AS brand'),
$this->connection->raw('s.unit AS unit'),
]);
}
$rows->addSelect([
'b.opening_qty', 'b.closing_qty', 'b.processing_in', 'b.processing_out',
'b.purchase_in', 'b.purchase_return', 'b.other_in', 'b.other_return',
'b.sales_out', 'b.sales_return', 'b.stocktake', 'b.transfer_out',
'b.transfer_in', 'b.opening_aux', 'b.other_out',
]);
return $rows;
} catch (Throwable) {
return null;
}
}
/** @return array{generation:string,anchor_at:string,source_period_start:?string,source_period_end:?string}|null */
private function authoritativeAnchor(): ?array
{
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::AUTHORITATIVE_STATE_TABLE)
|| ! $schema->hasTable(self::AUTHORITATIVE_ANCHOR_TABLE)) {
return null;
}
$state = $this->connection->table(self::AUTHORITATIVE_STATE_TABLE)
->where('dataset_key', self::AUTHORITATIVE_DATASET_KEY)
->whereNotNull('active_generation')
->whereNotNull('anchor_at')
->first();
if ($state === null) {
return null;
}
$generation = trim((string) $state->active_generation);
$anchorAt = $this->timestamp($state->anchor_at ?? null);
if ($generation === '' || $anchorAt === null) {
return null;
}
return [
'generation' => $generation,
'anchor_at' => Carbon::createFromTimestamp($anchorAt)->toDateTimeString(),
'source_period_start' => isset($state->source_period_start)
? (string) $state->source_period_start : null,
'source_period_end' => isset($state->source_period_end)
? (string) $state->source_period_end : null,
];
} catch (Throwable) {
return null;
}
}
/** @return array<string,mixed> */
private function requireReadyState(): array
{
$state = $this->state();
if (! is_array($state)
|| ($state['initial_completed_at'] ?? null) === null
|| $this->generation($state, 'active_header_generation') === null
|| $this->generation($state, 'active_item_generation') === null
|| $this->generation($state, 'active_inventory_generation') === null
|| ! $this->dependenciesReady()) {
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
}
return $state;
}
private function dependenciesReady(): bool
{
try {
$required = [self::PRODUCT_DATASET_KEY];
$ready = $this->connection->table(self::GENERIC_STATE_TABLE)
->whereIn('dataset_key', $required)
->whereNotNull('initial_completed_at')
->pluck('dataset_key')
->map(static fn (mixed $dataset): string => (string) $dataset)
->all();
return array_diff($required, $ready) === [];
} catch (Throwable) {
return false;
}
}
/** @param array<string,mixed> $state */
private function generation(array $state, string $key): ?string
{
$value = trim((string) ($state[$key] ?? ''));
return $value === '' ? null : $value;
}
private function timestamp(mixed $value): ?int
{
if ($value instanceof CarbonInterface) {
return $value->getTimestamp();
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return Carbon::parse($value)->getTimestamp();
} catch (Throwable) {
return null;
}
}
/**
* @return array{count:int,opening_qty:float,closing_qty:float}
*/
private function summarizeQuery(Builder $query): array
{
$row = $query->selectRaw(
'COUNT(*) AS row_count, '
. 'COALESCE(SUM(opening_qty), 0) AS opening_qty, '
. 'COALESCE(SUM(closing_qty), 0) AS closing_qty'
)->first();
return [
'count' => (int) ($row->row_count ?? 0),
'opening_qty' => round((float) ($row->opening_qty ?? 0), 4),
'closing_qty' => round((float) ($row->closing_qty ?? 0), 4),
];
}
/** @return array<string,mixed> */
private function mapRow(object $row): array
{
$mapped = [];
foreach (self::OUTPUT_COLUMNS as $column) {
$mapped[$column] = $column === 'date'
? ($row->report_date ?? null)
: ($row->{$column} ?? null);
}
foreach (['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS), 'opening_aux'] as $column) {
$mapped[$column] = round((float) ($mapped[$column] ?? 0), 4);
}
return $mapped;
}
private function containsFilter(Builder $query, string $column, mixed $value): void
{
$value = trim((string) $value);
if ($value !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
}
}
/** @param array<string,mixed> $filters */
private function hasProductFilters(array $filters): bool
{
foreach (['product_name', 'supplier', 'category'] as $filter) {
if (trim((string) ($filters[$filter] ?? '')) !== '') {
return true;
}
}
return false;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
class ThirdPartyWarehouseStorageUpstreamRepository
{
public const SOURCE_KEY = 'jiansun';
public const WAREHOUSE_ID = 15045364;
public const START_DATE = '2026-08-01';
private ?UpstreamReadOnlyQuery $query = null;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
public function headerHighWatermark(): int
{
return (int) $this->query()->scalar(
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?',
[self::WAREHOUSE_ID],
);
}
/** @return list<array<string,mixed>> */
public function headerRange(int $afterId, int $throughId): array
{
if ($throughId <= $afterId) {
return [];
}
return $this->query()->select(<<<'SQL'
SELECT
h.[Id] AS source_id,
h.[io_id],
h.[wms_co_id],
h.[status],
h.[type] AS io_type,
CONVERT(varchar(27), h.[io_date], 126) AS occurred_at,
CONVERT(varchar(27), h.[modified], 126) AS modified_at
FROM [dbo].[jst_inoutWater] h
WHERE h.[Id] > ?
AND h.[Id] <= ?
AND h.[wms_co_id] = ?
AND h.[io_date] >= ?
AND NOT EXISTS (
SELECT 1 FROM [dbo].[jst_inoutWater] newer
WHERE newer.[io_id] = h.[io_id] AND newer.[Id] > h.[Id]
)
ORDER BY h.[Id]
SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]);
}
/** @return list<array<string,mixed>> */
public function modifiedHeadersBatch(
string $cursorAt,
int $cursorId,
string $throughAt,
int $limit,
): array {
$limit = min(max($limit, 1), 5000);
return $this->query()->select('SELECT TOP (' . $limit . ') '
. <<<'SQL'
[Id] AS source_id,
[io_id],
[wms_co_id],
[status],
[type] AS io_type,
CONVERT(varchar(27), [io_date], 126) AS occurred_at,
CONVERT(varchar(27), [modified], 126) AS modified_at
FROM [dbo].[jst_inoutWater]
WHERE [modified] >= ?
AND [modified] < ?
AND ([modified] > ? OR ([modified] = ? AND [Id] > ?))
ORDER BY [modified], [Id]
SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]);
}
/**
* Reload the current version for every changed document before replacing
* its mirror rows. A document can have more than one historical header Id.
*
* @param list<int|string> $ioIds
* @return list<array<string,mixed>>
*/
public function latestHeadersForIoIds(array $ioIds, string $throughAt): array
{
$ioIds = array_values(array_unique(array_filter(
array_map(static fn (mixed $id): int => (int) $id, $ioIds),
static fn (int $id): bool => $id > 0,
)));
if ($ioIds === []) {
return [];
}
$rows = [];
foreach (array_chunk($ioIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$batch = $this->query()->select(
'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[status], '
. 'h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, '
. 'CONVERT(varchar(27), h.[modified], 126) AS modified_at '
. 'FROM [dbo].[jst_inoutWater] h '
. 'INNER JOIN (SELECT [io_id], MAX([Id]) AS latest_id '
. 'FROM [dbo].[jst_inoutWater] '
. "WHERE [io_id] IN ({$placeholders}) AND [modified] < ? GROUP BY [io_id]) latest "
. 'ON latest.[latest_id] = h.[Id] '
. 'WHERE h.[io_date] >= ? '
. 'ORDER BY h.[Id]',
[...$chunk, $throughAt, self::START_DATE],
);
array_push($rows, ...$batch);
}
usort($rows, static fn (array $left, array $right): int =>
((int) ($left['source_id'] ?? 0)) <=> ((int) ($right['source_id'] ?? 0))
);
return $rows;
}
/**
* The upstream item table has an index on io_id. Fetching items only after
* the warehouse headers have been selected avoids scanning unrelated rows.
*
* @param list<int|string> $ioIds
* @return list<array<string,mixed>>
*/
public function itemsForIoIds(array $ioIds): array
{
$ioIds = array_values(array_unique(array_filter(
array_map(static fn (mixed $id): int => (int) $id, $ioIds),
static fn (int $id): bool => $id > 0,
)));
if ($ioIds === []) {
return [];
}
$rows = [];
foreach (array_chunk($ioIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$batch = $this->query()->select(
'SELECT [Id] AS source_id, [io_id], [sku_id] AS sku_code, [qty], [qty_type] '
. 'FROM [dbo].[jst_inoutWaterItem] '
. "WHERE [io_id] IN ({$placeholders}) ORDER BY [Id]",
$chunk,
);
array_push($rows, ...$batch);
}
usort($rows, static fn (array $left, array $right): int =>
((int) ($left['source_id'] ?? 0)) <=> ((int) ($right['source_id'] ?? 0))
);
return $rows;
}
public function inventoryHighWatermark(): int
{
return (int) $this->query()->scalar(
'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo] WHERE [wms_co_id] = ?',
[self::WAREHOUSE_ID],
);
}
/** @return list<array<string,mixed>> */
public function inventoryBatch(int $afterId, int $highWatermark, int $limit): array
{
if ($highWatermark <= $afterId) {
return [];
}
$limit = min(max($limit, 1), 5000);
return $this->query()->select('SELECT TOP (' . $limit . ') '
. <<<'SQL'
[Id] AS source_id,
[wms_co_id],
[sku_id] AS sku_code,
[qty],
CONVERT(varchar(27), [modified], 126) AS source_modified_at
FROM [dbo].[jst_inventoryInfo]
WHERE [Id] > ?
AND [Id] <= ?
AND [wms_co_id] = ?
ORDER BY [Id]
SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]);
}
private function query(): UpstreamReadOnlyQuery
{
return $this->query ??= $this->queries->forSource(self::SOURCE_KEY);
}
}

View File

@ -0,0 +1,501 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class ThirdPartyWarehouseStorageAuthoritativeAnchorImporter
{
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
private const STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
private const ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
private const ROW_TABLE = 'third_party_warehouse_authoritative_rows';
/** @var list<string> */
private const REQUIRED_HEADERS = [
'日期', '商品编码', '期初数量', '期末数量', '加工进仓', '加工出仓',
'采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓',
'销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
];
/** @var list<string> */
private const MOVEMENT_HEADERS = [
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓',
'其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入',
'期初', '其它出库',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{status:string,source_rows:int,anchor_skus:int,anchor_at:string,generation:string}
*/
public function import(string $path): array
{
$path = $this->validatedPath($path);
$this->assertSchemaReady();
$generation = (string) Str::uuid();
$now = now();
$hash = hash_file('sha256', $path);
if (! is_string($hash)) {
throw new RuntimeException('无法计算进销存导出文件摘要。');
}
$this->connection->table(self::STATE_TABLE)->insertOrIgnore([
'dataset_key' => self::DATASET_KEY,
'status' => 'pending',
'created_at' => $now,
'updated_at' => $now,
]);
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update([
'status' => 'running',
'pending_generation' => $generation,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
try {
$source = $this->readSource($path);
$anchorAt = Carbon::parse($source['period_end'])->addDay()->startOfDay();
$anchors = array_map(
static fn (array $anchor): array => [
'generation' => $generation,
'sku_code' => $anchor['sku_code'],
'qty' => $anchor['qty'],
'anchor_at' => $anchorAt,
'imported_at' => $now,
],
$source['anchors'],
);
$authoritativeRows = array_map(
static fn (array $row): array => $row + [
'generation' => $generation,
'imported_at' => $now,
],
$source['rows'],
);
foreach (array_chunk($authoritativeRows, 500) as $batch) {
$this->connection->table(self::ROW_TABLE)->insert($batch);
}
foreach (array_chunk($anchors, 1000) as $batch) {
$this->connection->table(self::ANCHOR_TABLE)->insert($batch);
}
$this->connection->transaction(function () use (
$generation,
$path,
$hash,
$source,
$anchorAt,
$now,
): void {
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update([
'status' => 'succeeded',
'active_generation' => $generation,
'pending_generation' => null,
'anchor_at' => $anchorAt,
'source_filename' => mb_substr(basename($path), 0, 255),
'source_sha256' => $hash,
'source_period_start' => $source['period_start'],
'source_period_end' => $source['period_end'],
'source_row_count' => $source['row_count'],
'anchor_sku_count' => count($source['anchors']),
'last_succeeded_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
});
} catch (Throwable $exception) {
$this->connection->table(self::ANCHOR_TABLE)
->where('generation', $generation)
->delete();
$this->connection->table(self::ROW_TABLE)
->where('generation', $generation)
->delete();
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update([
'status' => 'failed',
'pending_generation' => null,
'last_error_code' => class_basename($exception),
'updated_at' => now(),
]);
throw $exception;
}
try {
$this->connection->table(self::ANCHOR_TABLE)
->where('generation', '!=', $generation)
->delete();
$this->connection->table(self::ROW_TABLE)
->where('generation', '!=', $generation)
->delete();
} catch (Throwable $exception) {
report($exception);
}
return [
'status' => 'succeeded',
'source_rows' => $source['row_count'],
'anchor_skus' => count($source['anchors']),
'anchor_at' => $anchorAt->toDateTimeString(),
'generation' => $generation,
];
}
private function validatedPath(string $path): string
{
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new InvalidArgumentException('三方仓进销存导出文件不存在或不可读。');
}
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new InvalidArgumentException('三方仓权威进销存导入只支持 XLSX 文件。');
}
return $realPath;
}
private function assertSchemaReady(): void
{
$schema = Schema::connection($this->connection->getName());
foreach ([self::STATE_TABLE, self::ANCHOR_TABLE, self::ROW_TABLE] as $table) {
if (! $schema->hasTable($table)) {
throw new RuntimeException('三方仓权威进销存表尚未完成迁移:' . $table);
}
}
}
/**
* @return array{period_start:string,period_end:string,row_count:int,rows:list<array<string,mixed>>,anchors:list<array{sku_code:string,qty:float}>}
*/
private function readSource(string $path): array
{
$archive = new ZipArchive();
if ($archive->open($path) !== true) {
throw new InvalidArgumentException('无法打开三方仓进销存 XLSX 导出文件。');
}
try {
$worksheet = $this->firstWorksheet($archive);
$sharedStrings = $this->sharedStrings($path, $archive);
} finally {
$archive->close();
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new InvalidArgumentException('无法读取三方仓进销存 XLSX 工作表。');
}
$columns = [];
$seen = [];
$dates = [];
$anchors = [];
$rows = [];
$minimumDate = null;
$maximumDate = null;
$rowCount = 0;
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
$values = $this->row($reader->readOuterXml(), $sharedStrings);
if ($columns === []) {
$columns = $this->headerColumns($values);
if ($columns === [] && $rowNumber < 20) {
continue;
}
if ($columns === []) {
throw new InvalidArgumentException('前 20 行中未找到三方仓进销存标准表头。');
}
continue;
}
if ($this->emptyRow($values)) {
continue;
}
$date = $this->date($values[$columns['日期']] ?? null, $rowNumber);
$sku = $this->sku($values[$columns['商品编码']] ?? null, $rowNumber);
$key = $date . "\0" . $sku;
if (isset($seen[$key])) {
throw new InvalidArgumentException(
"第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和商品编码重复。",
);
}
$seen[$key] = $rowNumber;
$opening = $this->number($values[$columns['期初数量']] ?? null, $rowNumber, '期初数量');
$closing = $this->number($values[$columns['期末数量']] ?? null, $rowNumber, '期末数量');
$movement = 0.0;
$movementValues = [];
foreach (self::MOVEMENT_HEADERS as $header) {
$movementValues[$header] = $this->number($values[$columns[$header]] ?? null, $rowNumber, $header);
$movement += $movementValues[$header];
}
if (abs(($opening + $movement) - $closing) > 0.0001) {
throw new InvalidArgumentException(
"第 {$rowNumber} 行期初数量加进出数量不等于期末数量。",
);
}
$dates[$date] = true;
$minimumDate = $minimumDate === null || $date < $minimumDate ? $date : $minimumDate;
$maximumDate = $maximumDate === null || $date > $maximumDate ? $date : $maximumDate;
$rows[] = [
'report_date' => $date,
'sku_code' => $sku,
'opening_qty' => $opening,
'closing_qty' => $closing,
'processing_in' => $movementValues['加工进仓'],
'processing_out' => $movementValues['加工出仓'],
'purchase_in' => $movementValues['采购进仓'],
'purchase_return' => $movementValues['采购退货'],
'other_in' => $movementValues['其它进仓'],
'other_return' => $movementValues['其它退货'],
'sales_out' => $movementValues['销售出仓'],
'sales_return' => $movementValues['销售退货'],
'stocktake' => $movementValues['盘点'],
'transfer_out' => $movementValues['调拨出'],
'transfer_in' => $movementValues['调拨入'],
'opening_aux' => $movementValues['期初'],
'other_out' => $movementValues['其它出库'],
];
if (! isset($anchors[$sku]) || $date >= $anchors[$sku]['date']) {
$anchors[$sku] = ['sku_code' => $sku, 'qty' => $closing, 'date' => $date];
}
$rowCount++;
}
} finally {
$reader->close();
}
if ($columns === [] || $rowCount === 0 || $minimumDate === null || $maximumDate === null) {
throw new InvalidArgumentException('三方仓进销存导出文件中没有可用明细。');
}
$start = Carbon::parse($minimumDate);
$end = Carbon::parse($maximumDate);
if (! $start->isSameMonth($end)
|| $start->day !== 1
|| ! $end->isLastOfMonth()) {
throw new InvalidArgumentException('导出文件必须完整覆盖同一个自然月。');
}
for ($date = $start->copy(); $date->lte($end); $date->addDay()) {
if (! isset($dates[$date->toDateString()])) {
throw new InvalidArgumentException('导出文件缺少日期:' . $date->toDateString());
}
}
if ($anchors === []) {
throw new InvalidArgumentException('导出文件月末没有可用的商品期末数量。');
}
ksort($anchors, SORT_STRING);
$anchors = array_map(
static fn (array $anchor): array => [
'sku_code' => $anchor['sku_code'],
'qty' => $anchor['qty'],
],
$anchors,
);
return [
'period_start' => $minimumDate,
'period_end' => $maximumDate,
'row_count' => $rowCount,
'rows' => $rows,
'anchors' => array_values($anchors),
];
}
/** @return array<int,string> */
private function row(string $xml, array $sharedStrings): array
{
$row = simplexml_load_string($xml);
if (! $row instanceof SimpleXMLElement) {
return [];
}
$values = [];
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
$reference = (string) ($cell['r'] ?? '');
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
continue;
}
$column = Coordinate::columnIndexFromString(strtoupper($matches[1]));
$type = (string) ($cell['t'] ?? '');
if ($type === 'inlineStr') {
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
} else {
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
}
$values[$column] = $value;
}
return $values;
}
/** @param array<int,string> $values @return array<string,int> */
private function headerColumns(array $values): array
{
$lookup = [];
foreach ($values as $column => $value) {
$heading = trim((string) $value);
if ($heading !== '') {
$lookup[$heading] = $column;
}
}
foreach (self::REQUIRED_HEADERS as $header) {
if (! isset($lookup[$header])) {
return [];
}
}
return array_intersect_key($lookup, array_flip(self::REQUIRED_HEADERS));
}
/** @param array<int,string> $values */
private function emptyRow(array $values): bool
{
foreach ($values as $value) {
if (trim((string) $value) !== '') {
return false;
}
}
return true;
}
private function date(mixed $value, int $rowNumber): string
{
try {
$date = is_numeric($value)
? Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value))
: Carbon::parse(str_replace('/', '-', trim((string) $value)));
} catch (Throwable) {
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。");
}
return $date->toDateString();
}
private function sku(mixed $value, int $rowNumber): string
{
$sku = ProductCatalogAuthoritativeExportImporter::normalizeSkuId((string) $value);
if ($sku === '') {
throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。");
}
return $sku;
}
private function number(mixed $value, int $rowNumber, string $label): float
{
if ($value === null || trim((string) $value) === '') {
return 0.0;
}
$normalized = str_replace([',', ','], '', trim((string) $value));
if (! is_numeric($normalized)) {
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效数字。");
}
return round((float) $normalized, 4);
}
/** @return list<string> */
private function sharedStrings(string $path, ZipArchive $archive): array
{
if ($archive->locateName('xl/sharedStrings.xml') === false) {
return [];
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new InvalidArgumentException('无法读取 XLSX 共享文本。');
}
$strings = [];
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
continue;
}
$item = simplexml_load_string($reader->readOuterXml());
$nodes = $item instanceof SimpleXMLElement
? ($item->xpath('.//*[local-name()="t"]') ?: [])
: [];
$strings[] = implode('', array_map(
static fn (SimpleXMLElement $node): string => (string) $node,
$nodes,
));
}
} finally {
$reader->close();
}
return $strings;
}
private function firstWorksheet(ZipArchive $archive): string
{
$worksheets = [];
for ($index = 0; $index < $archive->numFiles; $index++) {
$name = $archive->getNameIndex($index);
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
$worksheets[] = $name;
}
}
sort($worksheets, SORT_NATURAL);
if ($worksheets === []) {
throw new InvalidArgumentException('XLSX 文件中没有工作表。');
}
return $worksheets[0];
}
private function zipUri(string $path, string $entry): string
{
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,520 @@
@php
$rows = $thirdPartyWarehouseRows ?? null;
$rawFilters = is_array($thirdPartyWarehouseFilters ?? null) ? $thirdPartyWarehouseFilters : [];
$dimension = in_array(($rawFilters['dimension'] ?? 'month'), ['month', 'day'], true)
? $rawFilters['dimension']
: 'month';
$period = trim((string) ($rawFilters['period'] ?? (
$dimension === 'day' ? ($rawFilters['date'] ?? '') : ($rawFilters['month'] ?? '')
)));
$filters = [
'dimension' => $dimension,
'period' => $period,
'sku_code' => trim((string) ($rawFilters['sku_code'] ?? '')),
'product_name' => trim((string) ($rawFilters['product_name'] ?? '')),
'supplier' => trim((string) ($rawFilters['supplier'] ?? '')),
'category' => trim((string) ($rawFilters['category'] ?? '')),
];
$summary = $thirdPartyWarehouseSummary ?? [];
$sync = array_replace([
'code' => 'unavailable',
'last_succeeded_at' => null,
'lag_minutes' => null,
'message' => null,
], $thirdPartyWarehouseSync ?? []);
$error = trim((string) ($thirdPartyWarehouseError ?? ''));
$pending = (bool) ($thirdPartyWarehousePending ?? false);
$exportUrl = trim((string) ($thirdPartyWarehouseExportUrl ?? ''));
$columns = [
['key' => 'owner', 'label' => '货主'],
['key' => 'date', 'label' => '日期'],
['key' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
['key' => 'image_url', 'label' => '图片', 'type' => 'image'],
['key' => 'product_name', 'label' => '商品名称', 'type' => 'wide'],
['key' => 'short_name', 'label' => '商品简称'],
['key' => 'supplier', 'label' => '供应商', 'type' => 'wide'],
['key' => 'category', 'label' => '分类'],
['key' => 'virtual_category', 'label' => '虚拟分类'],
['key' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
['key' => 'color_spec', 'label' => '颜色规格'],
['key' => 'brand', 'label' => '品牌'],
['key' => 'unit', 'label' => '单位'],
['key' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
['key' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
['key' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
['key' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
['key' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
['key' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
['key' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
['key' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
['key' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
['key' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
['key' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
['key' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
['key' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
['key' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
['key' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
];
$statusLabels = [
'healthy' => '同步正常',
'fresh' => '同步正常',
'syncing' => '同步中,显示最近数据',
'reconciling' => '全量校准中,显示最近数据',
'stale' => '同步延迟,显示最近数据',
'delayed' => '同步延迟,显示最近数据',
'failed' => '同步失败,显示最近数据',
'initializing' => '首次同步中',
'dependency_pending' => '商品资料同步中',
'pending' => '等待首次同步',
'initial_failed' => '首次同步失败',
'unavailable' => '镜像不可用',
];
$statusCode = (string) ($sync['code'] ?? 'unavailable');
$statusLabel = $statusLabels[$statusCode] ?? '同步状态未知';
$statusTone = match ($statusCode) {
'failed', 'initial_failed', 'unavailable' => 'danger',
'stale', 'delayed', 'initializing', 'dependency_pending', 'pending' => 'warning',
'syncing', 'reconciling' => 'info',
default => 'success',
};
$rowTotal = is_object($rows) && method_exists($rows, 'total')
? (int) $rows->total()
: (is_countable($rows) ? count($rows) : 0);
$rowCount = is_object($rows) && method_exists($rows, 'count')
? (int) $rows->count()
: (is_countable($rows) ? count($rows) : 0);
$summaryReady = ! $pending && collect(['count', 'opening_qty', 'closing_qty'])
->every(static fn (string $key): bool => array_key_exists($key, $summary));
$showingLastVersion = $rowTotal > 0 && ($error !== '' || in_array($statusCode, [
'syncing', 'reconciling', 'stale', 'delayed', 'failed',
], true));
$canExport = $exportUrl !== '' && $rowTotal > 0 && ! $pending;
$activeFilters = array_filter(
$filters,
static fn (mixed $value): bool => $value !== null && $value !== ''
);
if (is_object($rows) && method_exists($rows, 'appends')) {
$rows->appends($activeFilters);
}
$fallbackExportUrl = $exportUrl;
if ($exportUrl !== '' && $activeFilters !== []) {
$fallbackExportUrl .= (str_contains($exportUrl, '?') ? '&' : '?') . http_build_query($activeFilters);
}
$text = static function (mixed $value): string {
if ($value === null || $value === '') {
return '-';
}
if (is_bool($value)) {
return $value ? '是' : '否';
}
if (is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '-';
};
$number = static function (mixed $value) use ($text): string {
if ($value === null || $value === '') {
return '-';
}
if (! is_numeric($value)) {
return $text($value);
}
return rtrim(rtrim(number_format((float) $value, 4, '.', ','), '0'), '.');
};
$money = static function (mixed $value) use ($text): string {
if ($value === null || $value === '') {
return '-';
}
return is_numeric($value) ? number_format((float) $value, 2, '.', ',') : $text($value);
};
$imageUrl = static function (mixed $value): ?string {
if (is_array($value)) {
$value = $value['url'] ?? $value['src'] ?? reset($value);
}
if (! is_string($value)) {
return null;
}
$candidate = trim($value);
if (str_starts_with($candidate, '[') || str_starts_with($candidate, '{')) {
$decoded = json_decode($candidate, true);
if (is_array($decoded)) {
$candidate = trim((string) ($decoded['url'] ?? $decoded['src'] ?? reset($decoded)));
}
}
$scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
return filter_var($candidate, FILTER_VALIDATE_URL) && in_array($scheme, ['http', 'https'], true)
? $candidate
: null;
};
$lagMinutes = is_numeric($sync['lag_minutes'] ?? null) ? max(0, (int) $sync['lag_minutes']) : null;
$lagLabel = $lagMinutes === null
? null
: ($lagMinutes < 60 ? $lagMinutes . ' 分钟' : floor($lagMinutes / 60) . ' 小时 ' . ($lagMinutes % 60) . ' 分钟');
@endphp
<div class="third-party-warehouse-page" data-finance-table-surface>
<style>
.third-party-warehouse-page {
--warehouse-border: var(--vip-border, #D8D2C7);
--warehouse-ink: var(--vip-content-ink, #171717);
--warehouse-muted: var(--vip-content-muted, #595959);
--warehouse-accent: var(--vip-content-accent-dark, #856632);
--warehouse-soft: var(--vip-content-accent-soft, #F7F0DE);
display: flex;
flex-direction: column;
gap: .75rem;
margin: 1.5rem 0;
color: var(--warehouse-ink);
font-size: .8125rem;
}
.third-party-warehouse-status { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1rem; min-height:2.625rem; padding:.5rem .75rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:var(--warehouse-soft); }
.third-party-warehouse-status__primary,.third-party-warehouse-status__meta { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
.third-party-warehouse-status__primary { font-weight:600; }
.third-party-warehouse-status__meta { color:var(--warehouse-muted); font-size:.75rem; }
.third-party-warehouse-status__dot { width:.5rem; height:.5rem; flex:0 0 auto; border-radius:50%; background:var(--vip-success,#53793E); }
.third-party-warehouse-status__dot.is-warning { background:var(--vip-warning,#935F07); }
.third-party-warehouse-status__dot.is-danger { background:var(--vip-danger,#B80000); }
.third-party-warehouse-status__dot.is-info { background:var(--vip-info,#006395); }
.third-party-warehouse-alert { display:flex; align-items:flex-start; gap:.5rem; padding:.625rem .75rem; border:1px solid rgba(184,0,0,.35); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.third-party-warehouse-notice { display:flex; align-items:flex-start; gap:.5rem; padding:.625rem .75rem; border:1px solid rgba(147,95,7,.35); border-radius:.5rem; background:#fffaf0; color:var(--vip-warning,#935F07); }
.third-party-warehouse-alert .material-icons-outlined,.third-party-warehouse-notice .material-icons-outlined { margin-top:.05rem; font-size:1.1rem; }
.third-party-warehouse-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.65rem; padding:.75rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:var(--vip-surface,#fff); }
.third-party-warehouse-field { display:flex; min-width:8.75rem; flex:1 1 10rem; flex-direction:column; gap:.3rem; }
.third-party-warehouse-field:first-of-type { flex:0 1 9.5rem; }
.third-party-warehouse-field label { color:var(--warehouse-muted); font-size:.75rem; font-weight:600; }
.third-party-warehouse-field input { width:100%; height:2.125rem; padding:0 .625rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; color:var(--warehouse-ink); font:inherit; }
.third-party-warehouse-field input:focus { border-color:var(--warehouse-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.third-party-warehouse-dimension { display:flex; min-width:10.75rem; flex:0 0 auto; flex-direction:column; gap:.3rem; margin:0; padding:0; border:0; }
.third-party-warehouse-dimension__label { color:var(--warehouse-muted); font-size:.75rem; font-weight:600; }
.third-party-warehouse-segmented { display:inline-grid; height:2.125rem; grid-template-columns:repeat(2,1fr); overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
.third-party-warehouse-segmented label { position:relative; display:flex; min-width:5rem; align-items:center; justify-content:center; padding:0 .65rem; color:var(--warehouse-muted); cursor:pointer; font-size:.75rem; font-weight:600; }
.third-party-warehouse-segmented label + label { border-left:1px solid var(--warehouse-border); }
.third-party-warehouse-segmented input { position:absolute; width:1px; height:1px; overflow:hidden; opacity:0; pointer-events:none; }
.third-party-warehouse-segmented label:has(input:checked) { background:var(--warehouse-accent); color:#fff; }
.third-party-warehouse-segmented label:has(input:focus-visible) { outline:2px solid var(--warehouse-accent); outline-offset:-3px; }
.third-party-warehouse-actions { display:flex; flex:1 1 29rem; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:.4rem; }
.third-party-warehouse-button,.third-party-warehouse-actions [data-finance-server-export] { display:inline-flex; height:2.125rem; align-items:center; justify-content:center; gap:.3rem; padding:0 .7rem; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; color:var(--warehouse-ink); cursor:pointer; font:inherit; font-weight:600; line-height:1; text-decoration:none; white-space:nowrap; }
.third-party-warehouse-button:hover,.third-party-warehouse-actions [data-finance-server-export]:hover { border-color:var(--warehouse-accent); background:var(--warehouse-soft); }
.third-party-warehouse-button:focus-visible,.third-party-warehouse-actions [data-finance-server-export]:focus-visible { border-color:var(--warehouse-accent); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.third-party-warehouse-button.is-primary { border-color:var(--warehouse-accent); background:var(--warehouse-accent); color:#fff; }
.third-party-warehouse-button.is-primary:hover { background:#6A5129; }
.third-party-warehouse-button.is-disabled,.third-party-warehouse-actions [data-finance-server-export][disabled] { opacity:.5; cursor:not-allowed; pointer-events:none; }
.third-party-warehouse-button .material-icons-outlined,.third-party-warehouse-actions .material-icons-outlined { font-size:1rem; }
.third-party-warehouse-actions [data-finance-server-export] { display:none; }
.third-party-warehouse-page[data-finance-enhanced] .third-party-warehouse-actions [data-finance-server-export] { display:inline-flex; }
.third-party-warehouse-page .finance-table-field-picker__panel { right:0; left:auto; }
.third-party-warehouse-summary { display:grid; grid-template-columns:repeat(5,minmax(8rem,1fr)); overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
.third-party-warehouse-summary__item { min-width:0; padding:.625rem .75rem; border-right:1px solid var(--warehouse-border); }
.third-party-warehouse-summary__item:last-child { border-right:0; }
.third-party-warehouse-summary__label { display:block; color:var(--warehouse-muted); font-size:.7rem; }
.third-party-warehouse-summary__value { display:block; margin-top:.15rem; overflow:hidden; color:#4B3A24; font-size:.9375rem; font-variant-numeric:tabular-nums; font-weight:700; text-overflow:ellipsis; white-space:nowrap; }
.third-party-warehouse-table-panel { overflow:hidden; border:1px solid var(--warehouse-border); border-radius:.5rem; background:#fff; }
.third-party-warehouse-table-scroll { width:100%; height:min(56vh,42rem); min-height:16rem; overflow:auto; overscroll-behavior:contain; scrollbar-color:var(--vip-content-accent,#B9954F) #EFEDE8; scrollbar-gutter:stable both-edges; scrollbar-width:thin; }
.third-party-warehouse-table-scroll::-webkit-scrollbar { width:.5rem; height:.5rem; }
.third-party-warehouse-table-scroll::-webkit-scrollbar-thumb { border-radius:.25rem; background:var(--vip-content-accent,#B9954F); }
.third-party-warehouse-table-scroll::-webkit-scrollbar-track { background:#EFEDE8; }
.third-party-warehouse-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.78125rem; }
.third-party-warehouse-table th,.third-party-warehouse-table td { min-width:7.25rem; max-width:15rem; padding:.5rem .625rem; border-right:1px solid var(--warehouse-border); border-bottom:1px solid #EEEAE3; background:#fff; line-height:1.45; text-align:left; vertical-align:middle; white-space:nowrap; }
.third-party-warehouse-table th { position:sticky; top:0; z-index:4; border-bottom-color:var(--warehouse-border); background:var(--warehouse-soft); color:#4F4A43; font-size:.75rem; font-weight:600; }
.third-party-warehouse-table tbody tr:hover td { background:#FCFAF5; }
.third-party-warehouse-table .is-number { text-align:right; font-variant-numeric:tabular-nums; }
.third-party-warehouse-table .is-wide { min-width:14rem; max-width:20rem; overflow:hidden; text-overflow:ellipsis; }
.third-party-warehouse-table .is-code { min-width:10rem; font-variant-numeric:tabular-nums; }
.third-party-warehouse-table .is-image { min-width:4.75rem; width:4.75rem; text-align:center; }
.third-party-warehouse-thumb { display:block; width:2.5rem; height:2.5rem; margin:auto; border:1px solid var(--warehouse-border); border-radius:.25rem; background:#F8F6F1; object-fit:cover; }
.third-party-warehouse-empty { height:15rem; color:var(--warehouse-muted); text-align:center !important; white-space:normal !important; }
.third-party-warehouse-pagination { display:flex; min-height:2.75rem; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .75rem; border-top:1px solid var(--warehouse-border); color:var(--warehouse-muted); font-size:.75rem; }
.third-party-warehouse-pagination__controls { display:flex; align-items:center; gap:.4rem; }
@media (max-width: 960px) {
.third-party-warehouse-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
.third-party-warehouse-summary__item { border-bottom:1px solid var(--warehouse-border); }
.third-party-warehouse-actions { justify-content:flex-start; }
}
@media (max-width: 640px) {
.third-party-warehouse-field { flex-basis:100%; }
.third-party-warehouse-actions { flex-basis:100%; }
.third-party-warehouse-summary { grid-template-columns:1fr; }
.third-party-warehouse-summary__item { border-right:0; }
.third-party-warehouse-table-scroll { height:52vh; min-height:14rem; }
.third-party-warehouse-pagination { align-items:flex-start; flex-direction:column; }
.third-party-warehouse-page .finance-table-field-picker__panel { right:auto; left:0; }
}
</style>
<div class="third-party-warehouse-status" role="status" aria-live="polite">
<div class="third-party-warehouse-status__primary">
<span @class(['third-party-warehouse-status__dot', 'is-' . $statusTone]) aria-hidden="true"></span>
<span>{{ $statusLabel }}</span>
@if ($showingLastVersion)
<span>· 当前展示最近一次成功同步的数据</span>
@endif
</div>
<div class="third-party-warehouse-status__meta">
<span>三方-南昌樵舍仓 · 查询维度:商品 + 按天</span>
<span>每日 00:00 自动同步</span>
@if (! empty($sync['last_succeeded_at']))
<span>最近成功同步 {{ $sync['last_succeeded_at'] }}</span>
@endif
@if ($lagLabel !== null)
<span>延迟 {{ $lagLabel }}</span>
@endif
@if ($error === '' && ! empty($sync['message']))
<span>{{ $sync['message'] }}</span>
@endif
</div>
</div>
@if ($error !== '')
<div class="third-party-warehouse-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>
@if ($showingLastVersion)
本次同步或读取异常,已保留上一版数据:{{ $error }}
@elseif ($pending)
首次同步未完成:{{ $error }}
@else
数据暂时无法读取:{{ $error }}
@endif
</span>
</div>
@endif
@if ($pending)
<div class="third-party-warehouse-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">schedule</span>
<span>首次同步尚未完成,当前没有可展示的正式镜像数据;同步成功后页面会自动使用镜像数据。</span>
</div>
@endif
<form method="GET" class="third-party-warehouse-filters" aria-label="三方仓进销存筛选" data-finance-controls>
<fieldset class="third-party-warehouse-dimension">
<legend class="third-party-warehouse-dimension__label">查询维度</legend>
<div class="third-party-warehouse-segmented" data-warehouse-dimension>
<label>
<input type="radio" name="dimension" value="month" @checked($dimension === 'month')>
<span>按月</span>
</label>
<label>
<input type="radio" name="dimension" value="day" @checked($dimension === 'day')>
<span>按天</span>
</label>
</div>
</fieldset>
<div class="third-party-warehouse-field">
<label for="warehouse-period" data-warehouse-period-label>{{ $dimension === 'day' ? '日期' : '月份' }}</label>
<input
id="warehouse-period"
type="{{ $dimension === 'day' ? 'date' : 'month' }}"
name="period"
value="{{ $filters['period'] }}"
data-warehouse-period
>
</div>
<div class="third-party-warehouse-field">
<label for="warehouse-sku-code">商品编码</label>
<input id="warehouse-sku-code" type="search" name="sku_code" value="{{ $filters['sku_code'] }}" placeholder="输入商品编码">
</div>
<div class="third-party-warehouse-field">
<label for="warehouse-product-name">商品名称</label>
<input id="warehouse-product-name" type="search" name="product_name" value="{{ $filters['product_name'] }}" placeholder="支持关键词筛选">
</div>
<div class="third-party-warehouse-field">
<label for="warehouse-supplier">供应商</label>
<input id="warehouse-supplier" type="search" name="supplier" value="{{ $filters['supplier'] }}" placeholder="支持关键词筛选">
</div>
<div class="third-party-warehouse-field">
<label for="warehouse-category">分类</label>
<input id="warehouse-category" type="search" name="category" value="{{ $filters['category'] }}" placeholder="支持关键词筛选">
</div>
<div class="third-party-warehouse-actions">
<button class="third-party-warehouse-button is-primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span><span>筛选</span>
</button>
<a class="third-party-warehouse-button" href="{{ url()->current() }}">
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span><span>重置</span>
</a>
<a
@class(['third-party-warehouse-button', 'finance-table-fallback-export', 'is-disabled' => ! $canExport])
data-finance-fallback-export
href="{{ $canExport ? $fallbackExportUrl : '#' }}"
@if (! $canExport) aria-disabled="true" @endif
>
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出筛选结果</span>
</a>
<button type="button" data-finance-server-export="visible" @disabled(! $canExport)>
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出显示列</span>
</button>
<button type="button" data-finance-server-export="all" @disabled(! $canExport)>
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出全部列</span>
</button>
<div class="finance-table-field-picker" data-finance-field-picker>
<button type="button" class="third-party-warehouse-button" aria-expanded="false">
<span class="material-icons-outlined" aria-hidden="true">view_column</span><span>列设置</span>
</button>
<div class="finance-table-field-picker__panel" role="group" aria-label="选择展示字段" hidden></div>
</div>
</div>
</form>
<div class="third-party-warehouse-summary" aria-label="当前筛选汇总">
@foreach ([
['key' => 'count', 'label' => '明细记录数', 'type' => 'count'],
['key' => 'opening_qty', 'label' => '期初数量合计', 'type' => 'number'],
['key' => 'closing_qty', 'label' => '期末数量合计', 'type' => 'number'],
] as $metric)
@php
$metricValue = $summaryReady ? $summary[$metric['key']] : null;
$metricDisplay = match ($metric['type']) {
'count' => $metricValue === null ? '—' : number_format((int) $metricValue),
'money' => $metricValue === null ? '—' : '¥ ' . $money($metricValue),
default => $metricValue === null ? '—' : $number($metricValue),
};
@endphp
<div class="third-party-warehouse-summary__item">
<span class="third-party-warehouse-summary__label">{{ $metric['label'] }}</span>
<span class="third-party-warehouse-summary__value">{{ $metricDisplay }}</span>
</div>
@endforeach
</div>
<div class="third-party-warehouse-table-panel">
<div class="third-party-warehouse-table-scroll finance-table-scroll-owner">
<table
class="third-party-warehouse-table"
aria-label="三方仓进销存"
data-finance-table
@if ($exportUrl !== '') data-finance-export-base="{{ $exportUrl }}" @endif
data-finance-export-query="{{ json_encode($activeFilters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}"
data-finance-export-disabled="{{ $canExport ? 'false' : 'true' }}"
>
<thead>
<tr>
@foreach ($columns as $column)
<th
scope="col"
data-finance-column-key="{{ $column['key'] }}"
data-export-field="{{ $column['key'] }}"
@class([
'is-number' => in_array($column['type'] ?? null, ['number', 'money'], true),
'is-image' => ($column['type'] ?? null) === 'image',
'is-wide' => ($column['type'] ?? null) === 'wide',
'is-code' => ($column['type'] ?? null) === 'code',
])
>{{ $column['label'] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
@foreach ($columns as $column)
@php
$value = data_get($row, $column['key']);
$type = $column['type'] ?? 'text';
$displayValue = match ($type) {
'number' => $number($value),
'money' => $money($value),
default => $text($value),
};
@endphp
<td
@class([
'is-number' => in_array($type, ['number', 'money'], true),
'is-image' => $type === 'image',
'is-wide' => $type === 'wide',
'is-code' => $type === 'code',
])
@if ($type === 'wide' && $displayValue !== '-') title="{{ $displayValue }}" @endif
@if ($type === 'image') data-export-value="{{ $text($value) }}" @endif
>
@if ($type === 'image')
@php($safeImageUrl = $imageUrl($value))
@if ($safeImageUrl !== null)
<a href="{{ $safeImageUrl }}" target="_blank" rel="noopener noreferrer" aria-label="查看{{ $text(data_get($row, 'product_name')) }}图片">
<img class="third-party-warehouse-thumb" src="{{ $safeImageUrl }}" alt="" width="40" height="40" loading="lazy" decoding="async" referrerpolicy="no-referrer">
</a>
@else
-
@endif
@else
{{ $displayValue }}
@endif
</td>
@endforeach
</tr>
@empty
<tr class="is-empty">
<td class="third-party-warehouse-empty" colspan="{{ count($columns) }}">
@if ($pending)
首次同步尚未完成,当前不会显示空白数据冒充正式结果。
@elseif ($error !== '')
镜像数据读取失败,当前没有可展示的数据。
@elseif (in_array($statusCode, ['initial_failed', 'unavailable'], true))
镜像尚未准备好,请等待同步恢复后再查看。
@else
当前期间及筛选条件下没有数据,请调整条件或重置筛选。
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="third-party-warehouse-pagination">
<span>共 {{ number_format($rowTotal) }} 条记录,本页 {{ number_format($rowCount) }} 条</span>
@if (is_object($rows) && method_exists($rows, 'currentPage'))
<div class="third-party-warehouse-pagination__controls" aria-label="三方仓进销存分页">
<a
@class(['third-party-warehouse-button', 'is-disabled' => $rows->onFirstPage()])
href="{{ $rows->previousPageUrl() ?: '#' }}"
aria-label="上一页"
@if ($rows->onFirstPage()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
<a
@class(['third-party-warehouse-button', 'is-disabled' => ! $rows->hasMorePages()])
href="{{ $rows->nextPageUrl() ?: '#' }}"
aria-label="下一页"
@if (! $rows->hasMorePages()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
</div>
@endif
</div>
</div>
<script>
(() => {
const page = document.currentScript.closest('.third-party-warehouse-page');
const period = page?.querySelector('[data-warehouse-period]');
const periodLabel = page?.querySelector('[data-warehouse-period-label]');
if (!period || !periodLabel) return;
page.querySelectorAll('[data-warehouse-dimension] input[name="dimension"]').forEach((control) => {
control.addEventListener('change', () => {
const nextType = control.value === 'day' ? 'date' : 'month';
if (period.type !== nextType) period.value = '';
period.type = nextType;
periodLabel.textContent = nextType === 'date' ? '日期' : '月份';
});
});
})();
</script>
</div>

View File

@ -142,6 +142,10 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.export');
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');

View File

@ -0,0 +1,240 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Carbon;
use Mockery;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Tests\Feature\FeatureTestCase;
class ThirdPartyWarehouseStoragePageTest extends FeatureTestCase
{
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_page_passes_a_daily_period_and_filters_to_the_mirror(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
setting()->set('default.list_limit', 25);
$filters = [
'dimension' => 'day',
'period' => '2026-08-15',
'sku_code' => 'BG002950N956V01',
'product_name' => '餐椅',
'supplier' => '测试供应商',
'category' => '家具',
];
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->with($filters, 1, 25)->andReturn([
'data' => [$this->row()],
'total' => 1,
'summary' => [
'count' => 1,
'opening_qty' => 0,
'closing_qty' => 10,
],
'state' => ['status' => 'succeeded'],
'sync' => [
'code' => 'healthy',
'last_succeeded_at' => '2026-09-11 03:20:00',
'lag_minutes' => 10,
],
]);
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$this->loginAs();
$this->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'third-party-warehouse-storage',
]) . '?' . http_build_query($filters))
->assertOk()
->assertSee('三方仓进销存')
->assertSee('查询维度:商品 + 按天')
->assertSee('value="day" checked', false)
->assertSee('type="date"', false)
->assertSee('value="2026-08-15"', false)
->assertSee('BG002950N956V01')
->assertSee('测试供应商')
->assertSee('每日 00:00 自动同步');
}
public function test_invalid_daily_period_falls_back_to_the_previous_complete_day(): void
{
Carbon::setTestNow('2026-09-11 16:00:00');
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->with(
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'day'
&& $filters['period'] === '2026-09-10'),
1,
Mockery::type('int'),
)->andReturn($this->emptyResult());
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$this->loginAs();
$this->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'third-party-warehouse-storage',
'dimension' => 'day',
'period' => '2026-02-31',
]))->assertOk()
->assertSee('value="2026-09-10"', false);
}
public function test_period_before_supported_start_date_is_clamped(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->twice()->andReturnTrue();
$repository->shouldReceive('ready')->twice()->andReturnTrue();
$repository->shouldReceive('paginate')->once()->with(
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'day'
&& $filters['period'] === '2026-08-01'),
1,
Mockery::type('int'),
)->andReturn($this->emptyResult());
$repository->shouldReceive('paginate')->once()->with(
Mockery::on(static fn (array $filters): bool => $filters['dimension'] === 'month'
&& $filters['period'] === '2026-08'),
1,
Mockery::type('int'),
)->andReturn($this->emptyResult());
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$this->loginAs();
$route = route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'third-party-warehouse-storage',
]);
$this->get($route . '?dimension=day&period=2026-07-31')
->assertOk()
->assertSee('value="2026-08-01"', false);
$this->get($route . '?dimension=month&period=2026-07')
->assertOk()
->assertSee('value="2026-08"', false);
}
public function test_export_keeps_daily_filters_and_only_accepts_declared_fields(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$filters = [
'dimension' => 'day',
'period' => '2026-08-15',
'sku_code' => 'BG002950N956V01',
'product_name' => '',
'supplier' => '',
'category' => '',
];
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnTrue();
$repository->shouldReceive('exportRows')->once()->with($filters)->andReturn((static function (): \Generator {
yield self::row();
})());
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$response = $this->loginAs()->get(route(
'business.courier-payables.third-party-warehouse-storage.export',
$filters + ['fields' => 'sku_code,closing_qty,not_allowed'],
));
$response->assertOk();
$this->assertStringContainsString('text/csv', (string) $response->headers->get('content-type'));
$content = $response->streamedContent();
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
$this->assertStringContainsString('商品编码,期末数量', $content);
$this->assertStringContainsString("'BG002950N956V01,10", $content);
$this->assertStringNotContainsString('货主', $content);
$this->assertStringNotContainsString('not_allowed', $content);
}
public function test_page_explains_that_an_uninitialized_mirror_is_not_empty_business_data(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class);
$repository->shouldReceive('available')->once()->andReturnTrue();
$repository->shouldReceive('ready')->once()->andReturnFalse();
$repository->shouldReceive('state')->once()->andReturn(['status' => 'pending']);
$repository->shouldReceive('syncStatus')->once()->andReturn([
'code' => 'pending',
'last_succeeded_at' => null,
'lag_minutes' => null,
]);
$repository->shouldNotReceive('paginate');
$this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository);
$this->loginAs();
$this->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'third-party-warehouse-storage',
]))->assertOk()
->assertSee('首次同步尚未完成')
->assertSee('不会显示空白数据冒充正式结果');
}
/** @return array<string,mixed> */
private static function row(): array
{
return [
'owner' => '常州简尚家居有限公司',
'date' => '2026-08-15',
'sku_code' => 'BG002950N956V01',
'image_url' => '',
'product_name' => '测试餐椅',
'short_name' => '餐椅',
'supplier' => '测试供应商',
'category' => '家具',
'virtual_category' => '',
'supplier_style_no' => 'SUP-001',
'color_spec' => '原木色',
'brand' => '测试品牌',
'unit' => '件',
'opening_qty' => 0,
'closing_qty' => 10,
'processing_in' => 0,
'processing_out' => 0,
'purchase_in' => 34,
'purchase_return' => 0,
'other_in' => 0,
'other_return' => 0,
'sales_out' => -24,
'sales_return' => 0,
'stocktake' => 0,
'transfer_out' => 0,
'transfer_in' => 0,
'opening_aux' => 0,
'other_out' => 0,
];
}
/** @return array<string,mixed> */
private function emptyResult(): array
{
return [
'data' => [],
'total' => 0,
'summary' => [
'count' => 0,
'opening_qty' => 0,
'closing_qty' => 0,
],
'state' => ['status' => 'succeeded'],
'sync' => [
'code' => 'healthy',
'last_succeeded_at' => '2026-09-11 03:20:00',
'lag_minutes' => 10,
],
];
}
}

View File

@ -0,0 +1,236 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageAuthoritativeAnchorImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class ThirdPartyWarehouseStorageAuthoritativeAnchorImportTest extends TestCase
{
/** @var list<string> */
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createTables();
}
protected function tearDown(): void
{
foreach ($this->temporaryFiles as $file) {
@unlink($file);
}
parent::tearDown();
}
public function test_it_imports_authoritative_daily_rows_and_month_end_anchor(): void
{
$path = $this->validWorkbook();
$this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $path])
->assertSuccessful();
$state = DB::connection('business_mirror')
->table('third_party_warehouse_authoritative_anchor_states')
->where('dataset_key', ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::DATASET_KEY)
->first();
$this->assertSame('succeeded', $state->status);
$this->assertSame('2026-08-01', $state->source_period_start);
$this->assertSame('2026-08-31', $state->source_period_end);
$this->assertSame(32, (int) $state->source_row_count);
$this->assertSame(2, (int) $state->anchor_sku_count);
$this->assertSame('2026-09-01 00:00:00', $state->anchor_at);
$this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [
'generation' => $state->active_generation,
'sku_code' => 'SKU-A',
'qty' => 41,
'anchor_at' => '2026-09-01 00:00:00',
], 'business_mirror');
$this->assertDatabaseHas('third_party_warehouse_authoritative_anchors', [
'generation' => $state->active_generation,
'sku_code' => 'CODE+PLUS',
'qty' => 7,
], 'business_mirror');
$this->assertSame(
32,
DB::connection('business_mirror')->table('third_party_warehouse_authoritative_rows')
->where('generation', $state->active_generation)->count(),
);
$this->assertDatabaseHas('third_party_warehouse_authoritative_rows', [
'generation' => $state->active_generation,
'report_date' => '2026-08-01',
'sku_code' => 'SKU-A',
'opening_qty' => 10,
'closing_qty' => 11,
'purchase_in' => 1,
], 'business_mirror');
}
public function test_invalid_replacement_keeps_the_previous_active_generation(): void
{
$valid = app(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter::class)
->import($this->validWorkbook());
$invalid = $this->workbook([
$this->headers(),
$this->row('2026-08-01', 'SKU-A', 10, 9, ['销售出仓' => -2]),
]);
$this->artisan('business-mirror:import-third-party-warehouse-anchor', ['path' => $invalid])
->expectsOutputToContain('已保留上一版有效数据')
->assertFailed();
$state = DB::connection('business_mirror')
->table('third_party_warehouse_authoritative_anchor_states')
->first();
$this->assertSame('failed', $state->status);
$this->assertSame($valid['generation'], $state->active_generation);
$this->assertNull($state->pending_generation);
$this->assertSame(
2,
DB::connection('business_mirror')
->table('third_party_warehouse_authoritative_anchors')
->where('generation', $valid['generation'])
->count(),
);
$this->assertSame(
32,
DB::connection('business_mirror')
->table('third_party_warehouse_authoritative_rows')
->where('generation', $valid['generation'])
->count(),
);
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$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->string('pending_generation')->nullable();
$table->dateTime('anchor_at')->nullable();
$table->string('source_filename')->nullable();
$table->string('source_sha256')->nullable();
$table->date('source_period_start')->nullable();
$table->date('source_period_end')->nullable();
$table->unsignedBigInteger('source_row_count')->default(0);
$table->unsignedBigInteger('anchor_sku_count')->default(0);
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->string('last_error_code')->nullable();
$table->timestamps();
});
$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->dateTime('imported_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');
$table->decimal('opening_qty', 20, 4);
$table->decimal('closing_qty', 20, 4);
foreach ([
'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->dateTime('imported_at');
$table->primary(['generation', 'report_date', 'sku_code']);
});
}
private function validWorkbook(): string
{
$rows = [$this->headers()];
for ($day = 1; $day <= 31; $day++) {
$rows[] = $this->row(
sprintf('2026-08-%02d', $day),
'SKU-A',
10 + $day - 1,
10 + $day,
['采购进仓' => 1],
);
}
// 聚水潭会省略部分没有变化的月末日期;每个商品取自身最后一行期末数。
$rows[] = $this->row('2026-08-30', 'CODE_x002B_PLUS', 7, 7);
return $this->workbook($rows);
}
/** @return list<string> */
private function headers(): array
{
return [
'货主', '日期', '商品编码', '图片', '商品名称', '商品简称', '供应商',
'分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量',
'期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓',
'其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初',
'其它出库',
];
}
/** @param array<string,int|float> $movements @return list<string|int|float|null> */
private function row(
string $date,
string $sku,
float $opening,
float $closing,
array $movements = [],
): array {
$row = array_fill(0, 28, null);
$row[0] = '常州简尚家居有限公司';
$row[1] = $date;
$row[2] = $sku;
$row[13] = $opening;
$row[14] = $closing;
foreach ([
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
] as $offset => $header) {
$row[15 + $offset] = $movements[$header] ?? 0;
}
return $row;
}
/** @param list<list<string|int|float|null>> $rows */
private function workbook(array $rows): string
{
$temporary = tempnam(sys_get_temp_dir(), 'tpw-anchor-');
$path = $temporary . '.xlsx';
rename($temporary, $path);
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($rows);
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
$this->temporaryFiles[] = $path;
return $path;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Console;
use Mockery;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
use RuntimeException;
use Tests\TestCase;
class ThirdPartyWarehouseStorageSyncCommandTest extends TestCase
{
public function test_disabled_refresh_environment_skips_automatic_sync(): void
{
config(['upstream_mirrors.refresh_enabled' => false]);
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
$synchronizer->shouldNotReceive('sync');
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
->assertSuccessful();
}
public function test_explicit_full_sync_and_batch_limit_are_forwarded(): void
{
config(['upstream_mirrors.refresh_enabled' => false]);
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
$synchronizer->shouldReceive('sync')->once()->with(true, 7)->andReturn([
'status' => 'running', 'mode' => 'full', 'phase' => 'headers', 'batches' => 7,
'headers' => 10, 'items' => 20, 'inventory' => 0,
]);
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-third-party-warehouse-storage', [
'--full' => true,
'--max-batches' => 7,
])->expectsOutputToContain('全量校准:进行中')
->assertSuccessful();
}
public function test_uninitialized_incremental_sync_prints_the_full_sync_instruction(): void
{
config(['upstream_mirrors.refresh_enabled' => true]);
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
$synchronizer->shouldReceive('sync')->once()->with(false, null)->andReturn([
'status' => 'requires_full_sync', 'mode' => 'incremental', 'phase' => 'headers',
'batches' => 0, 'headers' => 0, 'items' => 0, 'inventory' => 0,
]);
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
->expectsOutputToContain('显式使用 --full')
->assertSuccessful();
}
public function test_concurrent_sync_is_skipped_without_reporting_a_failure(): void
{
config(['upstream_mirrors.refresh_enabled' => true]);
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
$synchronizer->shouldReceive('sync')->once()->with(false, null)->andReturn([
'status' => 'already_running', 'mode' => 'incremental', 'phase' => 'locked',
'batches' => 0, 'headers' => 0, 'items' => 0, 'inventory' => 0,
]);
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
->expectsOutputToContain('已有同步任务运行')
->assertSuccessful();
}
public function test_sync_failure_reports_that_the_previous_complete_data_remains_available(): void
{
config(['upstream_mirrors.refresh_enabled' => true]);
$synchronizer = Mockery::mock(ThirdPartyWarehouseStorageSynchronizer::class);
$synchronizer->shouldReceive('sync')->once()->andThrow(new RuntimeException('offline'));
$this->app->instance(ThirdPartyWarehouseStorageSynchronizer::class, $synchronizer);
$this->artisan('business-mirror:sync-third-party-warehouse-storage')
->expectsOutputToContain('页面继续读取上一版完整数据')
->assertFailed();
}
}

View File

@ -34,6 +34,18 @@ class BusinessMirrorScheduleTest extends TestCase
$this->assertSame(1, substr_count($contents, 'business-mirror:sync-store-report --quiet'), $script);
$this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script);
$this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script);
$this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script);
$this->assertStringContainsString('0 0 * * * root', $contents, $script);
$this->assertStringContainsString(
'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet',
$contents,
$script,
);
$this->assertSame(
1,
substr_count($contents, 'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet'),
$script,
);
$this->assertStringContainsString('*/2 * * * * root', $contents, $script);
$this->assertStringContainsString('esign:poll-signatures --quiet', $contents, $script);
$this->assertStringContainsString('/run/caiwu-esign-polling.lock', $contents, $script);

View File

@ -0,0 +1,698 @@
<?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->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_periods_before_the_supported_start_date_are_clamped(): void
{
$this->markReady();
$this->insertProduct('SKU-CUTOFF');
$this->insertInventory('SKU-CUTOFF', 2, '2026-08-02 00: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];
$month = $this->repository->paginate($this->filters(['period' => '2026-07']), 1, 100)['data'];
$this->assertSame('2026-08-01', $day['date']);
$this->assertSame('2026-08-01', $month[0]['date']);
$this->assertSame('2026-08-31', $month[30]['date']);
}
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']);
}
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->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->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_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']);
});
}
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',
],
]);
}
/** @param array<string,mixed> $overrides */
private function markReady(array $overrides = []): void
{
$this->insertState(array_merge([
'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;
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert(array_merge([
'generation' => self::INVENTORY_GENERATION,
'id' => $id++,
'wms_co_id' => ThirdPartyWarehouseStorageMirrorRepository::WAREHOUSE_ID,
'sku_code' => $sku,
'qty' => $qty,
'captured_at' => $capturedAt,
], $overrides));
}
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,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,
'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);
}
}

View File

@ -0,0 +1,720 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Closure;
use Illuminate\Cache\CacheManager;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageUpstreamRepository;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageSynchronizer;
use RuntimeException;
use Tests\TestCase;
class ThirdPartyWarehouseStorageSynchronizerTest extends TestCase
{
private FakeThirdPartyWarehouseUpstreamRepository $upstream;
private ThirdPartyWarehouseStorageSynchronizer $synchronizer;
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' => [
'header_scan_size' => 100,
'max_header_batches_per_run' => 10,
'inventory_batch_size' => 5,
'incremental_batch_size' => 2,
'incremental_overlap_hours' => 48,
'generation_retention_hours' => 48,
],
'upstreams.snapshots.cache_store' => 'array',
]);
DB::purge('business_mirror');
$this->createMirrorSchema();
$this->upstream = new FakeThirdPartyWarehouseUpstreamRepository();
$this->synchronizer = new ThirdPartyWarehouseStorageSynchronizer(
$this->upstream,
app('db'),
app(CacheManager::class),
);
}
public function test_shared_lock_prevents_a_second_sync_from_replacing_the_pending_generation(): void
{
$lock = app(CacheManager::class)->store('array')->lock(
ThirdPartyWarehouseStorageSynchronizer::LOCK_KEY,
60,
);
$this->assertTrue($lock->get());
try {
$result = $this->synchronizer->sync(true, 0, 100);
} finally {
$lock->release();
}
$this->assertSame('already_running', $result['status']);
$this->assertSame('locked', $result['phase']);
$this->assertSame([], $this->upstream->headerCalls);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_sync_states')->count());
}
public function test_empty_header_id_interval_still_advances_the_checkpoint(): void
{
$this->upstream->headerHighWatermark = 150;
$result = $this->synchronizer->sync(true, 1, 100);
$this->assertSame('running', $result['status']);
$this->assertSame('headers', $result['phase']);
$this->assertSame([[0, 100]], $this->upstream->headerCalls);
$state = $this->state();
$this->assertSame(100, (int) $state->header_cursor);
$this->assertSame('full', $state->run_mode);
}
public function test_interrupted_full_sync_resumes_with_the_same_generation_and_activates_only_when_complete(): void
{
$this->upstream->headerHighWatermark = 150;
$this->upstream->inventoryHighWatermark = 20;
$this->upstream->headerResolver = static fn (int $after): array => $after === 100 ? [[
'source_id' => 120,
'io_id' => 9001,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-08-10T10:00:00.000',
'modified_at' => '2026-08-10T10:01:00.000',
]] : [];
$this->upstream->itemResolver = static fn (array $ioIds): array => $ioIds === [9001] ? [[
'source_id' => 501,
'io_id' => 9001,
'sku_code' => str_repeat('S', 200),
'qty' => 3,
'qty_type' => -1,
]] : [];
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 15,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-1',
'qty' => 8,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$first = $this->synchronizer->sync(true, 1, 100);
$interruptedState = $this->state();
$pendingGeneration = $interruptedState->pending_header_generation;
$second = $this->synchronizer->sync(false, 0, 100);
$this->assertSame('running', $first['status']);
$this->assertNull($interruptedState->active_header_generation);
$this->assertNull($interruptedState->active_item_generation);
$this->assertNull($interruptedState->active_inventory_generation);
$this->assertSame('succeeded', $second['status']);
$this->assertSame('full', $second['mode']);
$this->assertSame($pendingGeneration, $this->state()->active_header_generation);
$this->assertNull($this->state()->pending_header_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->count());
$this->assertSame(191, mb_strlen((string) DB::connection('business_mirror')
->table('third_party_warehouse_inout_items')->value('sku_code')));
$this->assertSame(-1, (int) DB::connection('business_mirror')
->table('third_party_warehouse_inout_items')->value('qty_type'));
}
public function test_inventory_snapshot_switches_generation_after_a_complete_read(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 125,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 12,
'source_modified_at' => '2026-09-11T03:19:00.000',
], [
'source_id' => 190,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-2',
'qty' => 4,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('succeeded', $result['status']);
$this->assertSame('incremental', $result['mode']);
$this->assertSame(100000, (int) $state->header_cursor);
$this->assertNotSame('inventory-old', $state->active_inventory_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
$this->assertSame(['NEW-1', 'NEW-2'], DB::connection('business_mirror')
->table('third_party_warehouse_inventory_snapshots')
->where('generation', $state->active_inventory_generation)
->orderBy('id')->pluck('sku_code')->all());
$this->assertSame([[0, 200, 5]], $this->upstream->inventoryCalls);
}
public function test_inventory_keyset_checkpoint_resumes_at_the_last_received_target_row(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryResolver = static fn (int $after): array => $after === 0
? array_map(static fn (int $id): array => [
'source_id' => $id,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-' . $id,
'qty' => 1,
'source_modified_at' => '2026-09-11T03:19:00.000',
], [10, 20, 30, 40, 50])
: [[
'source_id' => 190,
'wms_co_id' => 15045364,
'sku_code' => 'SKU-190',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$first = $this->synchronizer->sync(false, 1, 100);
$pendingGeneration = $this->state()->pending_inventory_generation;
$this->assertSame(50, (int) $this->state()->inventory_cursor);
$this->assertSame(200, (int) $this->state()->inventory_high_watermark);
$second = $this->synchronizer->sync(false, 0, 100);
$this->assertSame('running', $first['status']);
$this->assertSame('inventory', $first['phase']);
$this->assertSame('succeeded', $second['status']);
$this->assertSame($pendingGeneration, $this->state()->active_inventory_generation);
$this->assertSame([[0, 200, 5], [50, 200, 5]], $this->upstream->inventoryCalls);
}
public function test_inventory_failure_keeps_the_previous_active_generation(): void
{
$this->seedReadyMirror();
$this->upstream->headerHighWatermark = 100000;
$this->upstream->inventoryHighWatermark = 200;
$this->upstream->inventoryFailure = new RuntimeException('upstream unavailable');
try {
$this->synchronizer->sync(false, 0, 100);
$this->fail('库存读取异常必须向命令层抛出。');
} catch (RuntimeException $exception) {
$this->assertSame('upstream unavailable', $exception->getMessage());
}
$state = $this->state();
$this->assertSame('failed', $state->status);
$this->assertSame('inventory-old', $state->active_inventory_generation);
$this->assertNotNull($state->pending_inventory_generation);
$this->assertSame(1, DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
}
public function test_incremental_sync_uses_a_resumable_modified_and_id_cursor(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
'source_id' => 700,
'io_id' => 700,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
], [
'source_id' => 701,
'io_id' => 701,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
]];
$result = $this->synchronizer->sync(false, 1, 100);
$this->assertSame('running', $result['status']);
$this->assertSame([
['2026-09-09 03:20:00.000', 0, '2026-09-11 03:20:00.000', 2],
], $this->upstream->modifiedHeaderCalls);
$this->assertSame('2026-09-10 10:01:00.000', $this->state()->header_modified_cursor_at);
$this->assertSame(701, (int) $this->state()->header_modified_cursor_id);
$this->assertSame('2026-09-11 03:20:00.000', $this->state()->header_window_ends_at);
$this->assertSame('header-old', $this->state()->active_header_generation);
$this->assertSame('item-old', $this->state()->active_item_generation);
$this->assertSame('inventory-old', $this->state()->active_inventory_generation);
$this->assertNotSame('header-old', $this->state()->pending_header_generation);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->where('id', 700)->count());
}
public function test_incremental_overlap_removes_rows_that_left_the_target_warehouse(): void
{
$this->seedReadyMirror();
$db = DB::connection('business_mirror');
$db->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-old', 'id' => 50050, 'io_id' => 50050,
'wms_co_id' => 15045364, 'status' => 'Confirmed', 'io_type' => '采购进仓',
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-old', 'id' => 50050, 'io_id' => 50050,
'sku_code' => 'MOVED-AWAY', 'qty' => 2, 'qty_type' => 1,
'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
]);
$this->upstream->modifiedHeaderResolver = static fn (): array => [[
'source_id' => 60000,
'io_id' => 50050,
'wms_co_id' => 999,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => now()->subHour()->format('Y-m-d\\TH:i:s.u'),
]];
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'OLD-1',
'qty' => 1,
'source_modified_at' => now()->subMinute()->format('Y-m-d\\TH:i:s.u'),
]];
$this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
->where('generation', $state->active_header_generation)->where('id', 50050)->count());
$this->assertSame(0, $db->table('third_party_warehouse_inout_items')
->where('generation', $state->active_item_generation)->where('io_id', 50050)->count());
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->where('id', 50050)->count());
}
public function test_full_sync_catches_up_changes_through_the_inventory_capture_boundary(): void
{
Carbon::setTestNow('2026-09-01 03:20:00');
$this->upstream->headerHighWatermark = 10;
$this->upstream->headerResolver = static fn (): array => [[
'source_id' => 10,
'io_id' => 10,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-08-01T10:00:00.000',
'modified_at' => '2026-08-01T10:01:00.000',
]];
$this->upstream->itemResolver = static fn (array $ioIds): array => [[
'source_id' => $ioIds === [10] ? 10 : 20,
'io_id' => $ioIds[0],
'sku_code' => $ioIds === [10] ? 'OLD-SKU' : 'NEW-SKU',
'qty' => 1,
'qty_type' => 1,
]];
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static function (): array {
Carbon::setTestNow('2026-09-04 03:20:00');
return [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-SKU',
'qty' => 1,
'source_modified_at' => '2026-09-04T03:19:00.000',
]];
};
$this->upstream->modifiedHeaderResolver = static function (): array {
Carbon::setTestNow('2026-09-05 03:20:00');
return [[
'source_id' => 20,
'io_id' => 20,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-03T10:00:00.000',
'modified_at' => '2026-09-03T10:01:00.000',
]];
};
$result = $this->synchronizer->sync(true, 0, 100);
$this->assertSame('succeeded', $result['status']);
$this->assertSame([
['2026-08-30 03:20:00.000', 0, '2026-09-04 03:20:00.000', 2],
], $this->upstream->modifiedHeaderCalls);
$this->assertSame([10, 20], DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')->orderBy('id')->pluck('id')->all());
$this->assertSame('2026-09-04 03:20:00.000', $this->state()->header_modified_cursor_at);
$this->assertSame('2026-09-05 03:20:00', $this->state()->last_succeeded_at);
$this->upstream->modifiedHeaderCalls = [];
$this->upstream->modifiedHeaderResolver = static fn (): array => [];
$this->synchronizer->sync(false, 0, 100);
$this->assertSame('2026-09-02 03:20:00.000', $this->upstream->modifiedHeaderCalls[0][0]);
}
public function test_publish_retains_previous_generation_and_cleans_only_expired_releases(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$db = DB::connection('business_mirror');
$db->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-expired', 'id' => 99, 'io_id' => 99, 'wms_co_id' => 15045364,
'row_hash' => str_repeat('d', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-expired', 'id' => 99, 'io_id' => 99, 'sku_code' => 'EXPIRED',
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('e', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_inventory_snapshots')->insert([
'generation' => 'inventory-expired', 'id' => 99, 'wms_co_id' => 15045364,
'sku_code' => 'EXPIRED', 'qty' => 1, 'captured_at' => now(),
'row_hash' => str_repeat('f', 64), 'synced_at' => now(),
]);
$db->table('third_party_warehouse_generation_releases')->insert([
'release_id' => 'release-expired',
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
'header_generation' => 'header-expired',
'item_generation' => 'item-expired',
'inventory_generation' => 'inventory-expired',
'status' => 'retired',
'published_at' => '2026-09-01 03:20:00',
'retired_at' => '2026-09-08 03:19:59',
'created_at' => now(),
'updated_at' => now(),
]);
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('succeeded', $result['status']);
$this->assertSame(1, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-old')->count());
$this->assertSame(1, $db->table('third_party_warehouse_inout_items')
->where('generation', 'item-old')->count());
$this->assertSame(1, $db->table('third_party_warehouse_inventory_snapshots')
->where('generation', 'inventory-old')->count());
$this->assertSame('retired', $db->table('third_party_warehouse_generation_releases')
->where('header_generation', 'header-old')->value('status'));
$this->assertSame('active', $db->table('third_party_warehouse_generation_releases')
->where('header_generation', $state->active_header_generation)->value('status'));
$this->assertSame(0, $db->table('third_party_warehouse_inout_headers')
->where('generation', 'header-expired')->count());
$this->assertSame(0, $db->table('third_party_warehouse_generation_releases')
->where('release_id', 'release-expired')->count());
}
public function test_superseded_worker_cannot_write_rows_or_switch_the_active_generation(): void
{
Carbon::setTestNow('2026-09-11 03:20:00');
$this->seedReadyMirror();
$this->upstream->inventoryHighWatermark = 1;
$this->upstream->inventoryResolver = static fn (): array => [[
'source_id' => 1,
'wms_co_id' => 15045364,
'sku_code' => 'NEW-1',
'qty' => 2,
'source_modified_at' => '2026-09-11T03:19:00.000',
]];
$this->upstream->modifiedHeaderResolver = static function (): array {
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)
->update(['run_token' => 'replacement-run-token']);
return [[
'source_id' => 700,
'io_id' => 700,
'wms_co_id' => 15045364,
'status' => 'Confirmed',
'io_type' => '采购进仓',
'occurred_at' => '2026-09-10T10:00:00.000',
'modified_at' => '2026-09-10T10:01:00.000',
]];
};
$result = $this->synchronizer->sync(false, 0, 100);
$state = $this->state();
$this->assertSame('already_running', $result['status']);
$this->assertSame('superseded', $result['phase']);
$this->assertSame('replacement-run-token', $state->run_token);
$this->assertSame('header-old', $state->active_header_generation);
$this->assertSame('inventory-old', $state->active_inventory_generation);
$this->assertSame(0, DB::connection('business_mirror')
->table('third_party_warehouse_inout_headers')
->where('generation', $state->pending_header_generation)
->where('id', 700)
->count());
}
private function createMirrorSchema(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->default('pending');
$table->string('run_mode')->nullable();
$table->string('sync_phase')->nullable();
$table->string('run_token')->nullable();
$table->string('active_header_generation')->nullable();
$table->string('active_item_generation')->nullable();
$table->string('active_inventory_generation')->nullable();
$table->string('pending_header_generation')->nullable();
$table->string('pending_item_generation')->nullable();
$table->string('pending_inventory_generation')->nullable();
$table->unsignedBigInteger('header_cursor')->default(0);
$table->unsignedBigInteger('header_high_watermark')->nullable();
$table->dateTime('header_window_started_at')->nullable();
$table->dateTime('header_window_ends_at')->nullable();
$table->dateTime('header_modified_cursor_at')->nullable();
$table->unsignedBigInteger('header_modified_cursor_id')->default(0);
$table->unsignedBigInteger('inventory_cursor')->default(0);
$table->unsignedBigInteger('inventory_high_watermark')->nullable();
$table->unsignedBigInteger('header_row_count')->default(0);
$table->unsignedBigInteger('item_row_count')->default(0);
$table->unsignedBigInteger('inventory_row_count')->default(0);
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_attempted_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
$table->dateTime('last_full_succeeded_at')->nullable();
$table->string('last_error_code')->nullable();
$table->timestamps();
});
$schema->create('third_party_warehouse_generation_releases', function (Blueprint $table): void {
$table->string('release_id')->primary();
$table->string('dataset_key');
$table->string('header_generation');
$table->string('item_generation');
$table->string('inventory_generation');
$table->string('status');
$table->dateTime('published_at');
$table->dateTime('retired_at')->nullable();
$table->timestamps();
$table->unique(['dataset_key', 'header_generation', 'item_generation', 'inventory_generation']);
});
$schema->create('third_party_warehouse_inout_headers', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->unsignedBigInteger('wms_co_id');
$table->string('status')->nullable();
$table->string('io_type')->nullable();
$table->dateTime('occurred_at')->nullable();
$table->dateTime('source_modified_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id']);
});
$schema->create('third_party_warehouse_inout_items', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('io_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->smallInteger('qty_type');
$table->dateTime('source_modified_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id']);
});
$schema->create('third_party_warehouse_inventory_snapshots', function (Blueprint $table): void {
$table->id('_mirror_id');
$table->string('generation');
$table->string('source_key')->default('jiansun');
$table->unsignedBigInteger('id');
$table->unsignedBigInteger('wms_co_id');
$table->string('sku_code', 191);
$table->decimal('qty', 20, 4)->default(0);
$table->dateTime('source_modified_at')->nullable();
$table->dateTime('captured_at')->nullable();
$table->string('row_hash');
$table->dateTime('synced_at');
$table->unique(['generation', 'id']);
$table->unique(['generation', 'wms_co_id', 'sku_code']);
});
}
private function seedReadyMirror(): void
{
$now = now();
DB::connection('business_mirror')->table('third_party_warehouse_sync_states')->insert([
'dataset_key' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY,
'status' => 'succeeded',
'active_header_generation' => 'header-old',
'active_item_generation' => 'item-old',
'active_inventory_generation' => 'inventory-old',
'header_cursor' => 100000,
'header_row_count' => 1,
'item_row_count' => 1,
'inventory_row_count' => 1,
'initial_completed_at' => $now,
'last_succeeded_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inout_headers')->insert([
'generation' => 'header-old', 'id' => 1, 'io_id' => 11, 'wms_co_id' => 15045364,
'status' => 'Confirmed', 'io_type' => '采购进仓', 'row_hash' => str_repeat('a', 64),
'synced_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inout_items')->insert([
'generation' => 'item-old', 'id' => 1, 'io_id' => 11, 'sku_code' => 'OLD-1',
'qty' => 1, 'qty_type' => 1, 'row_hash' => str_repeat('b', 64), 'synced_at' => $now,
]);
DB::connection('business_mirror')->table('third_party_warehouse_inventory_snapshots')->insert([
'generation' => 'inventory-old', 'id' => 1, 'wms_co_id' => 15045364,
'sku_code' => 'OLD-1', 'qty' => 1, 'row_hash' => str_repeat('c', 64),
'captured_at' => $now,
'synced_at' => $now,
]);
}
private function state(): object
{
return DB::connection('business_mirror')->table('third_party_warehouse_sync_states')
->where('dataset_key', ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY)->first();
}
}
class FakeThirdPartyWarehouseUpstreamRepository extends ThirdPartyWarehouseStorageUpstreamRepository
{
public int $headerHighWatermark = 0;
public int $inventoryHighWatermark = 0;
public ?Closure $headerResolver = null;
public ?Closure $modifiedHeaderResolver = null;
public ?Closure $latestHeaderResolver = null;
public ?Closure $itemResolver = null;
public ?Closure $inventoryResolver = null;
public ?RuntimeException $inventoryFailure = null;
/** @var list<array{int,int}> */
public array $headerCalls = [];
/** @var list<array{string,int,string,int}> */
public array $modifiedHeaderCalls = [];
/** @var list<array<string,mixed>> */
public array $lastModifiedHeaders = [];
/** @var list<list<int|string>> */
public array $latestHeaderCalls = [];
/** @var list<array{int,int,int}> */
public array $inventoryCalls = [];
public function __construct()
{
}
public function headerHighWatermark(): int
{
return $this->headerHighWatermark;
}
public function headerRange(int $afterId, int $throughId): array
{
$this->headerCalls[] = [$afterId, $throughId];
return $this->headerResolver === null
? []
: ($this->headerResolver)($afterId, $throughId);
}
public function modifiedHeadersBatch(
string $cursorAt,
int $cursorId,
string $throughAt,
int $limit,
): array {
$this->modifiedHeaderCalls[] = [$cursorAt, $cursorId, $throughAt, $limit];
$rows = $this->modifiedHeaderResolver === null
? []
: ($this->modifiedHeaderResolver)($cursorAt, $cursorId, $throughAt, $limit);
$this->lastModifiedHeaders = $rows;
return $rows;
}
public function latestHeadersForIoIds(array $ioIds, string $throughAt): array
{
$this->latestHeaderCalls[] = $ioIds;
if ($this->latestHeaderResolver !== null) {
return ($this->latestHeaderResolver)($ioIds);
}
$byIoId = [];
foreach ($this->lastModifiedHeaders as $row) {
$ioId = (int) ($row['io_id'] ?? 0);
if (! in_array($ioId, $ioIds, true)) {
continue;
}
if (! isset($byIoId[$ioId])
|| (int) ($row['source_id'] ?? 0) > (int) ($byIoId[$ioId]['source_id'] ?? 0)) {
$byIoId[$ioId] = $row;
}
}
return array_values($byIoId);
}
public function itemsForIoIds(array $ioIds): array
{
return $this->itemResolver === null ? [] : ($this->itemResolver)($ioIds);
}
public function inventoryHighWatermark(): int
{
return $this->inventoryHighWatermark;
}
public function inventoryBatch(int $afterId, int $highWatermark, int $limit): array
{
$this->inventoryCalls[] = [$afterId, $highWatermark, $limit];
if ($this->inventoryFailure !== null) {
throw $this->inventoryFailure;
}
return $this->inventoryResolver === null
? []
: ($this->inventoryResolver)($afterId, $highWatermark, $limit);
}
}

View File

@ -0,0 +1,192 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageUpstreamRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class ThirdPartyWarehouseStorageUpstreamRepositoryTest extends TestCase
{
public function test_header_queries_use_the_fixed_source_table_warehouse_and_id_window(): void
{
$query = new RecordingThirdPartyWarehouseQuery();
$query->scalarResult = 987654;
$repository = $this->repository($query);
$this->assertSame(987654, $repository->headerHighWatermark());
$repository->headerRange(100, 200);
$this->assertStringContainsString('MAX([Id])', $query->scalarCalls[0]['sql']);
$this->assertStringContainsString('[dbo].[jst_inoutWater]', $query->scalarCalls[0]['sql']);
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[io_date]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('AS occurred_at', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('NOT EXISTS', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('newer.[io_id] = h.[io_id]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('newer.[Id] > h.[Id]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('ORDER BY h.[Id]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('h.[io_date] >= ?', $query->selectCalls[0]['sql']);
$this->assertSame([100, 200, 15045364, '2026-08-01'], $query->selectCalls[0]['bindings']);
}
public function test_items_are_parameterized_and_split_into_bounded_io_id_batches(): void
{
$query = new RecordingThirdPartyWarehouseQuery();
$query->selectResults = [[
['source_id' => 2, 'io_id' => 2],
], [
['source_id' => 1, 'io_id' => 501],
]];
$repository = $this->repository($query);
$rows = $repository->itemsForIoIds(range(1, 501));
$this->assertCount(2, $query->selectCalls);
$this->assertCount(500, $query->selectCalls[0]['bindings']);
$this->assertCount(1, $query->selectCalls[1]['bindings']);
$this->assertStringContainsString('[dbo].[jst_inoutWaterItem]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[qty_type]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[io_id] IN (', $query->selectCalls[0]['sql']);
$this->assertStringNotContainsString('[io_id] IN (1,2', $query->selectCalls[0]['sql']);
$this->assertSame([1, 2], array_column($rows, 'source_id'));
}
public function test_incremental_headers_use_a_bounded_modified_id_cursor_without_a_warehouse_filter(): void
{
$query = new RecordingThirdPartyWarehouseQuery();
$repository = $this->repository($query);
$repository->modifiedHeadersBatch(
'2026-09-09 03:20:00.000',
123,
'2026-09-11 03:20:00.000',
99999,
);
$call = $query->selectCalls[0];
$this->assertStringContainsString('SELECT TOP (5000)', $call['sql']);
$this->assertStringContainsString('[modified] >= ?', $call['sql']);
$this->assertStringContainsString('[modified] < ?', $call['sql']);
$this->assertStringContainsString('[modified] = ? AND [Id] > ?', $call['sql']);
$this->assertStringContainsString('ORDER BY [modified], [Id]', $call['sql']);
$this->assertStringNotContainsString('[wms_co_id] = ?', $call['sql']);
$this->assertSame([
'2026-09-09 03:20:00.000',
'2026-09-11 03:20:00.000',
'2026-09-09 03:20:00.000',
'2026-09-09 03:20:00.000',
123,
], $call['bindings']);
}
public function test_changed_documents_reload_only_the_latest_header_in_bounded_batches(): void
{
$query = new RecordingThirdPartyWarehouseQuery();
$query->selectResults = [[
['source_id' => 2, 'io_id' => 2],
], [
['source_id' => 1, 'io_id' => 501],
]];
$repository = $this->repository($query);
$rows = $repository->latestHeadersForIoIds(range(1, 501), '2026-09-11 03:20:00.000');
$this->assertCount(2, $query->selectCalls);
$this->assertCount(502, $query->selectCalls[0]['bindings']);
$this->assertCount(3, $query->selectCalls[1]['bindings']);
$this->assertStringContainsString('MAX([Id]) AS latest_id', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[io_id] IN (', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[modified] < ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('ON latest.[latest_id] = h.[Id] WHERE h.[io_date] >= ?', $query->selectCalls[0]['sql']);
$this->assertStringNotContainsString('[modified] < ? AND [io_date] >= ?', $query->selectCalls[0]['sql']);
$this->assertStringNotContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
$this->assertSame('2026-09-11 03:20:00.000', $query->selectCalls[0]['bindings'][500]);
$this->assertSame('2026-08-01', $query->selectCalls[0]['bindings'][501]);
$this->assertSame([1, 2], array_column($rows, 'source_id'));
$repository->latestHeadersForIoIds([], '2026-09-11 03:20:00.000');
$this->assertCount(2, $query->selectCalls);
}
public function test_inventory_uses_target_warehouse_keyset_pagination_and_a_bounded_limit(): void
{
$query = new RecordingThirdPartyWarehouseQuery();
$query->scalarResult = 191818;
$repository = $this->repository($query);
$this->assertSame(191818, $repository->inventoryHighWatermark());
$repository->inventoryBatch(12350, 191818, 99999);
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->scalarCalls[0]['sql']);
$this->assertStringContainsString('[wms_co_id] = ?', $query->scalarCalls[0]['sql']);
$this->assertSame([15045364], $query->scalarCalls[0]['bindings']);
$this->assertStringContainsString('SELECT TOP (5000)', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[dbo].[jst_inventoryInfo]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[Id] > ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[Id] <= ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('[wms_co_id] = ?', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('ORDER BY [Id]', $query->selectCalls[0]['sql']);
$this->assertStringContainsString('AS source_modified_at', $query->selectCalls[0]['sql']);
$this->assertSame([12350, 191818, 15045364], $query->selectCalls[0]['bindings']);
$repository->inventoryBatch(191818, 191818, 2000);
$this->assertCount(1, $query->selectCalls);
}
private function repository(UpstreamReadOnlyQuery $query): ThirdPartyWarehouseStorageUpstreamRepository
{
$factory = new class($query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query)
{
}
public function forSource(string $source): UpstreamReadOnlyQuery
{
TestCase::assertSame('jiansun', $source);
return $this->query;
}
};
return new ThirdPartyWarehouseStorageUpstreamRepository($factory);
}
}
class RecordingThirdPartyWarehouseQuery extends UpstreamReadOnlyQuery
{
public mixed $scalarResult = 0;
/** @var list<list<array<string,mixed>>> */
public array $selectResults = [];
/** @var list<array{sql:string,bindings:array}> */
public array $selectCalls = [];
/** @var list<array{sql:string,bindings:array}> */
public array $scalarCalls = [];
public function __construct()
{
parent::__construct('jiansun');
}
public function select(string $sql, array $bindings = []): array
{
$this->selectCalls[] = compact('sql', 'bindings');
return array_shift($this->selectResults) ?? [];
}
public function scalar(string $sql, array $bindings = []): mixed
{
$this->scalarCalls[] = compact('sql', 'bindings');
return $this->scalarResult;
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Unit\View;
use PHPUnit\Framework\TestCase;
class ThirdPartyWarehouseStorageViewTest extends TestCase
{
public function test_view_keeps_the_excel_columns_in_the_required_order(): void
{
$source = $this->viewSource();
preg_match_all(
"/\\['key' => '([^']+)', 'label' => '([^']+)'(?:, 'type' => '[^']+')?\\],/",
$source,
$matches,
PREG_SET_ORDER
);
$columns = array_map(
static fn (array $match): array => [$match[1], $match[2]],
array_slice($matches, 0, 28)
);
$this->assertSame([
['owner', '货主'],
['date', '日期'],
['sku_code', '商品编码'],
['image_url', '图片'],
['product_name', '商品名称'],
['short_name', '商品简称'],
['supplier', '供应商'],
['category', '分类'],
['virtual_category', '虚拟分类'],
['supplier_style_no', '供应商款号'],
['color_spec', '颜色规格'],
['brand', '品牌'],
['unit', '单位'],
['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', '其它出库'],
], $columns);
}
public function test_view_reuses_finance_table_preferences_and_has_explicit_sync_states(): void
{
$source = $this->viewSource();
$this->assertStringContainsString('data-finance-table-surface', $source);
$this->assertStringContainsString('data-finance-field-picker', $source);
$this->assertStringContainsString('data-finance-table', $source);
$this->assertStringContainsString('data-finance-export-base', $source);
$this->assertStringContainsString('data-finance-export-query', $source);
$this->assertStringContainsString('name="dimension" value="month"', $source);
$this->assertStringContainsString('name="dimension" value="day"', $source);
$this->assertStringContainsString('name="period"', $source);
$this->assertStringContainsString("\$rows->appends(\$activeFilters)", $source);
$this->assertStringContainsString('data-warehouse-period', $source);
$this->assertStringContainsString('查询维度:商品 + 按天', $source);
$this->assertStringContainsString('每日 00:00 自动同步', $source);
$this->assertStringContainsString('首次同步尚未完成', $source);
$this->assertStringContainsString('当前展示最近一次成功同步的数据', $source);
$this->assertStringContainsString("in_array(\$scheme, ['http', 'https'], true)", $source);
$this->assertStringContainsString('referrerpolicy="no-referrer"', $source);
}
private function viewSource(): string
{
$root = dirname(__DIR__, 3);
$source = file_get_contents(
$root . '/resources/views/business/pages/courier-payables/third-party-warehouse-storage.blade.php'
);
$this->assertIsString($source);
return $source;
}
}