diff --git a/app/Console/Commands/ImportThirdPartyWarehouseStorageAuthoritativeAnchor.php b/app/Console/Commands/ImportThirdPartyWarehouseStorageAuthoritativeAnchor.php new file mode 100644 index 00000000..26e22866 --- /dev/null +++ b/app/Console/Commands/ImportThirdPartyWarehouseStorageAuthoritativeAnchor.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/app/Console/Commands/SyncThirdPartyWarehouseStorage.php b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php new file mode 100644 index 00000000..7cb1d85c --- /dev/null +++ b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 65613faa..d7558795 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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 * * * *') diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index af78481b..9ad3bf8f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 */ + 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 */ + 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 { diff --git a/config/business_data.php b/config/business_data.php index 8a73151d..7a1c31aa 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -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' => '板材采购价'], diff --git a/config/business_navigation.php b/config/business_navigation.php index 4dcc9b95..4386a3bc 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -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' => '借支明细'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index a477c8eb..6f12ab05 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -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', diff --git a/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php new file mode 100644 index 00000000..9713eaa8 --- /dev/null +++ b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php @@ -0,0 +1,147 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_12_140500_upgrade_third_party_warehouse_storage_mirror_schema.php b/database/migrations/2026_09_12_140500_upgrade_third_party_warehouse_storage_mirror_schema.php new file mode 100644 index 00000000..9344005a --- /dev/null +++ b/database/migrations/2026_09_12_140500_upgrade_third_party_warehouse_storage_mirror_schema.php @@ -0,0 +1,42 @@ +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. + } +}; diff --git a/database/migrations/2026_09_12_220000_create_third_party_warehouse_authoritative_anchor_tables.php b/database/migrations/2026_09_12_220000_create_third_party_warehouse_authoritative_anchor_tables.php new file mode 100644 index 00000000..dfd39481 --- /dev/null +++ b/database/migrations/2026_09_12_220000_create_third_party_warehouse_authoritative_anchor_tables.php @@ -0,0 +1,86 @@ +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'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 5306007e..f2c46895 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 17d90254..aff037bf 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index b95baa54..4c8e7d68 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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. 公司、店铺、商标等小型维表。 diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php new file mode 100644 index 00000000..93423574 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -0,0 +1,852 @@ + */ + 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 */ + 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|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|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 $filters + * @return array{data:list>,total:int,summary:array,state:array,sync:array} + */ + 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 $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 $filters + * @return array{suppliers:list,categories:list} + */ + 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 $filters + * @return Generator> + */ + 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 $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 $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 $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 $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 */ + 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 $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 */ + 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 $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) . '%'; + } +} diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php new file mode 100644 index 00000000..a128dfac --- /dev/null +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php @@ -0,0 +1,201 @@ +query()->scalar( + 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?', + [self::WAREHOUSE_ID], + ); + } + + /** @return list> */ + 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> */ + 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 $ioIds + * @return list> + */ + 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 $ioIds + * @return list> + */ + 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> */ + 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); + } +} diff --git a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageAuthoritativeAnchorImporter.php b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageAuthoritativeAnchorImporter.php new file mode 100644 index 00000000..bd77fb94 --- /dev/null +++ b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageAuthoritativeAnchorImporter.php @@ -0,0 +1,501 @@ + */ + private const REQUIRED_HEADERS = [ + '日期', '商品编码', '期初数量', '期末数量', '加工进仓', '加工出仓', + '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', + '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', + ]; + + /** @var list */ + 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>,anchors:list} + */ + 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 */ + 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 $values @return array */ + 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 $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 */ + 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; + } +} diff --git a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php new file mode 100644 index 00000000..87945062 --- /dev/null +++ b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php @@ -0,0 +1,1284 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + $this->cache = $cacheManager->store( + (string) config('upstreams.snapshots.cache_store', 'upstream_shared'), + ); + } + + /** + * @return array{status:string,mode:string,phase:string,batches:int,headers:int,items:int,inventory:int} + */ + public function sync(bool $full = false, ?int $maxBatches = null, ?int $scanSpan = null): array + { + $lock = $this->cache->lock(self::LOCK_KEY, max((int) config( + 'upstream_mirrors.third_party_warehouse_storage.sync_lock_seconds', + 21600, + ), 300)); + + if (! $lock->get()) { + return $this->result( + 'already_running', + $full ? 'full' : 'incremental', + 'locked', + 0, + 0, + 0, + 0, + ); + } + + try { + return $this->syncUnlocked($full, $maxBatches, $scanSpan); + } finally { + $lock->release(); + } + } + + /** + * @return array{status:string,mode:string,phase:string,batches:int,headers:int,items:int,inventory:int} + */ + private function syncUnlocked(bool $full, ?int $maxBatches, ?int $scanSpan): array + { + $this->assertSchemaReady(); + + $state = $this->state(); + $resumeFull = $this->resumable($state, 'full'); + if (! $full && ! $this->ready($state) && ! $resumeFull) { + return $this->result('requires_full_sync', 'incremental', 'headers', 0, 0, 0, 0); + } + + $mode = $full || $resumeFull ? 'full' : 'incremental'; + $resume = $this->resumable($state, $mode); + $scanSpan = min(max( + $scanSpan ?? (int) config( + 'upstream_mirrors.third_party_warehouse_storage.header_scan_size', + (int) config( + 'upstream_mirrors.third_party_warehouse_storage.scan_span', + 20000, + ), + ), + 100, + ), 100000); + $maxBatches = max( + $maxBatches ?? (int) config( + 'upstream_mirrors.third_party_warehouse_storage.max_header_batches_per_run', + (int) config( + 'upstream_mirrors.third_party_warehouse_storage.max_batches_per_run', + 5, + ), + ), + 0, + ); + $inventoryBatchSize = min(max( + (int) config( + 'upstream_mirrors.third_party_warehouse_storage.inventory_batch_size', + 2000, + ), + 1, + ), 5000); + $incrementalBatchSize = min(max( + (int) config( + 'upstream_mirrors.third_party_warehouse_storage.incremental_batch_size', + 2000, + ), + 1, + ), 5000); + + $batches = 0; + $headerRows = 0; + $itemRows = 0; + $inventoryRows = 0; + $runToken = null; + + try { + $state = $resume ? $this->resumeRun($state) : $this->startRun($state, $mode); + $runToken = $this->requiredRunToken($state); + $headerGeneration = (string) $state['pending_header_generation']; + $itemGeneration = (string) $state['pending_item_generation']; + $phase = (string) ($state['sync_phase'] ?? ($mode === 'full' ? 'headers' : 'inventory')); + + if ($phase === 'headers') { + if ($mode !== 'full') { + throw new RuntimeException('third_party_warehouse_incremental_header_phase_invalid'); + } + + $headerHighWatermark = $state['header_high_watermark'] ?? null; + if ($headerHighWatermark === null) { + $headerHighWatermark = $this->upstream->headerHighWatermark(); + $this->updateState(['header_high_watermark' => $headerHighWatermark], $runToken); + } + + $headerCursor = (int) ($state['header_cursor'] ?? 0); + while ($headerCursor < (int) $headerHighWatermark) { + $throughId = min($headerCursor + $scanSpan, (int) $headerHighWatermark); + $headers = $this->upstream->headerRange($headerCursor, $throughId); + $items = $this->upstream->itemsForIoIds(array_map( + static fn (array $row): mixed => $row['io_id'] ?? null, + $headers, + )); + + $this->storeHeaderRange( + $headers, + $items, + $headerGeneration, + $itemGeneration, + $headerCursor, + $throughId, + $runToken, + ); + + $headerCursor = $throughId; + $headerRows += count($headers); + $itemRows += count($items); + $batches++; + + if ($headerCursor < (int) $headerHighWatermark + && $this->batchLimitReached($batches, $maxBatches)) { + return $this->result( + 'running', $mode, 'headers', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + } + $state = $this->startInventorySnapshot($runToken); + $phase = 'inventory'; + } + + if ($phase === 'inventory' && ($state['pending_inventory_generation'] ?? null) === null) { + $state = $this->startInventorySnapshot($runToken); + } + + if ($phase === 'inventory' && $this->batchLimitReached($batches, $maxBatches)) { + return $this->result( + 'running', $mode, 'inventory', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + + if ($phase === 'inventory') { + $inventoryGeneration = (string) $state['pending_inventory_generation']; + $inventoryCursor = (int) ($state['inventory_cursor'] ?? 0); + $inventoryHighWatermark = (int) ($state['inventory_high_watermark'] ?? 0); + + while ($inventoryCursor < $inventoryHighWatermark) { + $rows = $this->upstream->inventoryBatch( + $inventoryCursor, + $inventoryHighWatermark, + $inventoryBatchSize, + ); + $nextCursor = $this->inventoryNextCursor( + $rows, + $inventoryCursor, + $inventoryHighWatermark, + $inventoryBatchSize, + ); + $this->storeInventoryRange($rows, $inventoryGeneration, $nextCursor, $runToken); + + $inventoryCursor = $nextCursor; + $inventoryRows += count($rows); + $batches++; + + if ($inventoryCursor < $inventoryHighWatermark + && $this->batchLimitReached($batches, $maxBatches)) { + return $this->result( + 'running', $mode, 'inventory', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + } + + $state = $this->startHeaderCatchup($runToken); + $phase = 'catchup'; + } + + if ($phase !== 'catchup') { + throw new RuntimeException('third_party_warehouse_sync_phase_invalid'); + } + + if ($this->batchLimitReached($batches, $maxBatches)) { + return $this->result( + 'running', $mode, 'catchup', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + + $cursorAt = $this->dateTime($state['header_modified_cursor_at'] ?? null); + $throughAt = $this->dateTime($state['header_window_ends_at'] ?? null); + $cursorId = (int) ($state['header_modified_cursor_id'] ?? 0); + if ($cursorAt === null || $throughAt === null) { + throw new RuntimeException('third_party_warehouse_catchup_window_missing'); + } + + while (true) { + $changedHeaders = $this->upstream->modifiedHeadersBatch( + $cursorAt, + $cursorId, + $throughAt, + $incrementalBatchSize, + ); + if ($changedHeaders === []) { + break; + } + + $changedIoIds = array_values(array_unique(array_filter(array_map( + static fn (array $row): int => (int) ($row['io_id'] ?? 0), + $changedHeaders, + )))); + $latestHeaders = $this->upstream->latestHeadersForIoIds($changedIoIds, $throughAt); + $targetHeaders = array_values(array_filter( + $latestHeaders, + static fn (array $row): bool => + (int) ($row['wms_co_id'] ?? 0) + === ThirdPartyWarehouseStorageUpstreamRepository::WAREHOUSE_ID, + )); + $items = $this->upstream->itemsForIoIds(array_map( + static fn (array $row): mixed => $row['io_id'] ?? null, + $targetHeaders, + )); + $last = $changedHeaders[array_key_last($changedHeaders)]; + $nextCursorAt = $this->dateTime($last['modified_at'] ?? null); + $nextCursorId = (int) ($last['source_id'] ?? 0); + if ($nextCursorAt === null + || $nextCursorAt < $cursorAt + || ($nextCursorAt === $cursorAt && $nextCursorId <= $cursorId)) { + throw new RuntimeException('third_party_warehouse_modified_cursor_invalid'); + } + + $this->storeModifiedHeaderBatch( + $changedIoIds, + $targetHeaders, + $items, + $headerGeneration, + $itemGeneration, + $nextCursorAt, + $nextCursorId, + $runToken, + ); + + $cursorAt = $nextCursorAt; + $cursorId = $nextCursorId; + $headerRows += count($targetHeaders); + $itemRows += count($items); + $batches++; + + if (count($changedHeaders) < $incrementalBatchSize) { + break; + } + if ($this->batchLimitReached($batches, $maxBatches)) { + return $this->result( + 'running', $mode, 'catchup', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + } + + $inventoryGeneration = (string) $state['pending_inventory_generation']; + $counts = $this->complete( + $mode, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $runToken, + ); + + return $this->result( + 'succeeded', + $mode, + 'complete', + $batches, + $counts['headers'], + $counts['items'], + $counts['inventory'], + ); + } catch (Throwable $exception) { + if ($exception instanceof RuntimeException + && $exception->getMessage() === 'third_party_warehouse_sync_run_superseded') { + return $this->result( + 'already_running', $mode, 'superseded', $batches, $headerRows, $itemRows, $inventoryRows, + ); + } + + $this->fail($exception::class, $runToken); + + throw $exception; + } + } + + /** @param array $state */ + private function startRun(array $state, string $mode): array + { + $now = now(); + $runToken = (string) Str::uuid(); + $expectedRunToken = $this->nullableRunToken($state); + $overlapHours = max((int) config( + 'upstream_mirrors.third_party_warehouse_storage.incremental_overlap_hours', + 48, + ), 0); + try { + $sourceBoundary = $mode === 'full' + ? $now + : ($state['header_modified_cursor_at'] ?? $state['last_succeeded_at'] ?? $now); + $windowStart = Carbon::parse((string) $sourceBoundary) + ->subHours($overlapHours); + } catch (Throwable) { + $windowStart = $now->copy()->subHours($overlapHours); + } + $values = [ + 'status' => 'running', + 'run_mode' => $mode, + 'sync_phase' => $mode === 'full' ? 'headers' : 'inventory', + 'run_token' => $runToken, + 'header_cursor' => $mode === 'full' ? 0 : (int) ($state['header_cursor'] ?? 0), + 'header_high_watermark' => null, + 'header_window_started_at' => $windowStart->format('Y-m-d H:i:s.v'), + 'header_window_ends_at' => null, + 'header_modified_cursor_at' => $windowStart->format('Y-m-d H:i:s.v'), + 'header_modified_cursor_id' => 0, + 'inventory_cursor' => 0, + 'inventory_high_watermark' => null, + 'pending_header_generation' => (string) Str::uuid(), + 'pending_item_generation' => (string) Str::uuid(), + 'pending_inventory_generation' => null, + 'last_attempted_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + + $this->connection->transaction(function () use ( + $state, + $mode, + $values, + $expectedRunToken, + ): void { + $this->assertExpectedRunToken($expectedRunToken); + $this->updateStateForToken($values, $expectedRunToken); + + if ($mode === 'incremental') { + $this->cloneActiveGeneration( + self::HEADER_TABLE, + (string) ($state['active_header_generation'] ?? ''), + (string) $values['pending_header_generation'], + ['source_key', 'id', 'io_id', 'wms_co_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'], + ); + $this->cloneActiveGeneration( + self::ITEM_TABLE, + (string) ($state['active_item_generation'] ?? ''), + (string) $values['pending_item_generation'], + ['source_key', 'id', 'io_id', 'sku_code', 'qty', 'qty_type', 'source_modified_at', 'row_hash', 'synced_at'], + ); + } + }); + + return $this->stateForRun($runToken); + } + + /** @param array $state */ + private function resumeRun(array $state): array + { + $expectedRunToken = $this->nullableRunToken($state); + $runToken = (string) Str::uuid(); + $this->connection->transaction(function () use ($expectedRunToken, $runToken): void { + $this->assertExpectedRunToken($expectedRunToken); + $this->updateStateForToken([ + 'status' => 'running', + 'run_token' => $runToken, + 'last_attempted_at' => now(), + 'last_error_code' => null, + 'updated_at' => now(), + ], $expectedRunToken); + }); + + return $this->stateForRun($runToken); + } + + /** @return array */ + private function startInventorySnapshot(string $runToken): array + { + $highWatermark = $this->upstream->inventoryHighWatermark(); + $this->updateState([ + 'pending_inventory_generation' => (string) Str::uuid(), + 'sync_phase' => 'inventory', + 'inventory_cursor' => 0, + 'inventory_high_watermark' => $highWatermark, + ], $runToken); + + return $this->stateForRun($runToken); + } + + /** @return array */ + private function startHeaderCatchup(string $runToken): array + { + $state = $this->stateForRun($runToken); + $windowStart = $this->dateTime($state['header_window_started_at'] ?? null); + if ($windowStart === null) { + throw new RuntimeException('third_party_warehouse_catchup_start_missing'); + } + + $this->updateState([ + 'sync_phase' => 'catchup', + 'header_window_ends_at' => now()->format('Y-m-d H:i:s.v'), + 'header_modified_cursor_at' => $windowStart, + 'header_modified_cursor_id' => 0, + ], $runToken); + + return $this->stateForRun($runToken); + } + + /** + * @param list> $headers + * @param list> $items + */ + private function storeHeaderRange( + array $headers, + array $items, + string $headerGeneration, + string $itemGeneration, + int $afterId, + int $throughId, + string $runToken, + ): void { + $now = now(); + $mappedHeaders = array_values(array_filter(array_map( + fn (array $row): ?array => $this->mapHeader($row, $headerGeneration, $now), + $headers, + ))); + $modifiedByIoId = []; + foreach ($mappedHeaders as $header) { + $modifiedByIoId[(string) $header['io_id']] = $header['source_modified_at']; + } + $mappedItems = array_values(array_filter(array_map( + fn (array $row): ?array => $this->mapItem( + $row, + $itemGeneration, + $modifiedByIoId[(string) ($row['io_id'] ?? '')] ?? null, + $now, + ), + $items, + ))); + + $this->connection->transaction(function () use ( + $mappedHeaders, + $mappedItems, + $headerGeneration, + $itemGeneration, + $afterId, + $throughId, + $now, + $runToken, + ): void { + $this->assertCurrentRunToken($runToken); + $ioIds = array_values(array_unique(array_filter(array_column($mappedHeaders, 'io_id')))); + $oldIoIds = $this->connection->table(self::HEADER_TABLE) + ->where('generation', $headerGeneration) + ->where('id', '>', $afterId) + ->where('id', '<=', $throughId) + ->pluck('io_id') + ->map(static fn (mixed $id): string => (string) $id) + ->all(); + $ioIds = array_values(array_unique(array_merge($ioIds, $oldIoIds))); + + if ($ioIds !== []) { + $this->connection->table(self::ITEM_TABLE) + ->where('generation', $itemGeneration) + ->whereIn('io_id', $ioIds) + ->delete(); + } + $this->connection->table(self::HEADER_TABLE) + ->where('generation', $headerGeneration) + ->where('id', '>', $afterId) + ->where('id', '<=', $throughId) + ->delete(); + + $this->upsertRows(self::HEADER_TABLE, $mappedHeaders, ['generation', 'id']); + $this->upsertRows(self::ITEM_TABLE, $mappedItems, ['generation', 'id']); + + $this->updateStateForToken([ + 'header_cursor' => $throughId, + 'updated_at' => $now, + ], $runToken); + }); + } + + /** + * @param list $changedIoIds + * @param list> $targetHeaders + * @param list> $items + */ + private function storeModifiedHeaderBatch( + array $changedIoIds, + array $targetHeaders, + array $items, + string $headerGeneration, + string $itemGeneration, + string $cursorAt, + int $cursorId, + string $runToken, + ): void { + $now = now(); + $mappedHeaders = array_values(array_filter(array_map( + fn (array $row): ?array => $this->mapHeader($row, $headerGeneration, $now), + $targetHeaders, + ))); + $modifiedByIoId = []; + foreach ($mappedHeaders as $header) { + $modifiedByIoId[(string) $header['io_id']] = $header['source_modified_at']; + } + $mappedItems = array_values(array_filter(array_map( + fn (array $row): ?array => $this->mapItem( + $row, + $itemGeneration, + $modifiedByIoId[(string) ($row['io_id'] ?? '')] ?? null, + $now, + ), + $items, + ))); + $this->connection->transaction(function () use ( + $mappedHeaders, + $mappedItems, + $changedIoIds, + $headerGeneration, + $itemGeneration, + $cursorAt, + $cursorId, + $now, + $runToken, + ): void { + $this->assertCurrentRunToken($runToken); + if ($changedIoIds !== []) { + $this->connection->table(self::ITEM_TABLE) + ->where('generation', $itemGeneration) + ->whereIn('io_id', $changedIoIds) + ->delete(); + $this->connection->table(self::HEADER_TABLE) + ->where('generation', $headerGeneration) + ->whereIn('io_id', $changedIoIds) + ->delete(); + } + + $this->upsertRows(self::HEADER_TABLE, $mappedHeaders, ['generation', 'id']); + $this->upsertRows(self::ITEM_TABLE, $mappedItems, ['generation', 'id']); + $this->updateStateForToken([ + 'header_modified_cursor_at' => $cursorAt, + 'header_modified_cursor_id' => $cursorId, + 'updated_at' => $now, + ], $runToken); + }); + } + + /** @param list> $rows */ + private function storeInventoryRange( + array $rows, + string $generation, + int $cursor, + string $runToken, + ): void + { + $now = now(); + $mapped = array_values(array_filter(array_map( + fn (array $row): ?array => $this->mapInventory($row, $generation, $now), + $rows, + ))); + + $this->connection->transaction(function () use ( + $mapped, + $generation, + $cursor, + $now, + $runToken, + ): void { + $this->assertCurrentRunToken($runToken); + $this->upsertRows(self::INVENTORY_TABLE, $mapped, ['generation', 'id']); + $this->updateStateForToken([ + 'inventory_cursor' => $cursor, + 'updated_at' => $now, + ], $runToken); + }); + } + + /** @return array{headers:int,items:int,inventory:int} */ + private function complete( + string $mode, + string $headerGeneration, + string $itemGeneration, + string $inventoryGeneration, + string $runToken, + ): array { + $counts = [ + 'headers' => (int) $this->connection->table(self::HEADER_TABLE) + ->where('generation', $headerGeneration)->count(), + 'items' => (int) $this->connection->table(self::ITEM_TABLE) + ->where('generation', $itemGeneration)->count(), + 'inventory' => (int) $this->connection->table(self::INVENTORY_TABLE) + ->where('generation', $inventoryGeneration)->count(), + ]; + + if ($counts['inventory'] === 0) { + throw new RuntimeException('third_party_warehouse_inventory_unexpected_empty_source'); + } + if ($mode === 'full' && $counts['headers'] === 0) { + throw new RuntimeException('third_party_warehouse_headers_unexpected_empty_source'); + } + + $now = now(); + $this->connection->transaction(function () use ( + $mode, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $counts, + $now, + $runToken, + ): void { + $state = $this->assertCurrentRunToken($runToken); + $values = [ + 'status' => 'succeeded', + 'run_mode' => null, + 'sync_phase' => null, + 'run_token' => null, + 'active_header_generation' => $headerGeneration, + 'active_item_generation' => $itemGeneration, + 'active_inventory_generation' => $inventoryGeneration, + 'pending_header_generation' => null, + 'pending_item_generation' => null, + 'pending_inventory_generation' => null, + 'header_window_started_at' => null, + 'header_window_ends_at' => null, + 'header_row_count' => $counts['headers'], + 'item_row_count' => $counts['items'], + 'inventory_row_count' => $counts['inventory'], + 'last_succeeded_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]; + $values['header_modified_cursor_at'] = $this->dateTime( + $state['header_window_ends_at'] ?? $now, + ); + $values['header_modified_cursor_id'] = 0; + if ($mode === 'full') { + $values['initial_completed_at'] = $now; + $values['last_full_succeeded_at'] = $now; + } + + $this->publishGenerationRelease( + $state, + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + $now, + ); + $this->updateStateForToken($values, $runToken); + }); + + try { + $this->cleanupRetiredGenerations(); + } catch (Throwable $exception) { + Log::warning('三方仓仓储数据旧代次清理失败,已保留数据等待下次重试。', [ + 'dataset' => self::DATASET_KEY, + 'exception' => $exception::class, + ]); + } + + return $counts; + } + + private function fail(string $errorCode, ?string $runToken): void + { + if ($runToken === null + || ! Schema::connection($this->connection->getName())->hasTable(self::STATE_TABLE)) { + return; + } + + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->where('run_token', $runToken) + ->update([ + 'status' => 'failed', + 'last_error_code' => mb_substr($errorCode, 0, 120), + 'updated_at' => now(), + ]); + } + + /** @return array */ + private function state(): array + { + $now = now(); + $this->connection->table(self::STATE_TABLE)->insertOrIgnore([ + 'dataset_key' => self::DATASET_KEY, + 'status' => 'pending', + 'header_row_count' => 0, + 'item_row_count' => 0, + 'inventory_row_count' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (array) $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->first(); + } + + /** @param array $state */ + private function ready(array $state): bool + { + return ($state['initial_completed_at'] ?? null) !== null + && trim((string) ($state['active_header_generation'] ?? '')) !== '' + && trim((string) ($state['active_item_generation'] ?? '')) !== '' + && trim((string) ($state['active_inventory_generation'] ?? '')) !== ''; + } + + /** @param array $state */ + private function resumable(array $state, string $mode): bool + { + if (($state['run_mode'] ?? null) !== $mode + || ! in_array($state['status'] ?? null, ['running', 'failed'], true)) { + return false; + } + + if ($mode === 'full') { + return trim((string) ($state['pending_header_generation'] ?? '')) !== '' + && trim((string) ($state['pending_item_generation'] ?? '')) !== ''; + } + + return trim((string) ($state['active_header_generation'] ?? '')) !== '' + && trim((string) ($state['active_item_generation'] ?? '')) !== '' + && trim((string) ($state['pending_header_generation'] ?? '')) !== '' + && trim((string) ($state['pending_item_generation'] ?? '')) !== ''; + } + + /** @param array $values */ + private function updateState(array $values, string $runToken): void + { + $this->connection->transaction(function () use ($values, $runToken): void { + $this->assertCurrentRunToken($runToken); + $this->updateStateForToken($values + ['updated_at' => now()], $runToken); + }); + } + + /** @param array $values */ + private function updateStateForToken(array $values, ?string $expectedRunToken): void + { + $query = $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY); + $expectedRunToken === null + ? $query->whereNull('run_token') + : $query->where('run_token', $expectedRunToken); + + if ($query->update($values) !== 1) { + throw new RuntimeException('third_party_warehouse_sync_run_superseded'); + } + } + + /** @return array */ + private function assertCurrentRunToken(string $runToken): array + { + return $this->assertExpectedRunToken($runToken); + } + + /** @return array */ + private function assertExpectedRunToken(?string $expectedRunToken): array + { + $state = $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->lockForUpdate() + ->first(); + if ($state === null + || $this->nullableRunToken((array) $state) !== $expectedRunToken) { + throw new RuntimeException('third_party_warehouse_sync_run_superseded'); + } + + return (array) $state; + } + + /** @param array $state */ + private function nullableRunToken(array $state): ?string + { + $runToken = trim((string) ($state['run_token'] ?? '')); + + return $runToken === '' ? null : $runToken; + } + + /** @param array $state */ + private function requiredRunToken(array $state): string + { + $runToken = $this->nullableRunToken($state); + if ($runToken === null) { + throw new RuntimeException('third_party_warehouse_sync_run_token_missing'); + } + + return $runToken; + } + + /** @return array */ + private function stateForRun(string $runToken): array + { + $state = $this->state(); + if ($this->nullableRunToken($state) !== $runToken) { + throw new RuntimeException('third_party_warehouse_sync_run_superseded'); + } + + return $state; + } + + /** + * @param array $state + */ + private function publishGenerationRelease( + array $state, + string $headerGeneration, + string $itemGeneration, + string $inventoryGeneration, + mixed $now, + ): void { + $this->connection->table(self::RELEASE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->where('status', 'active') + ->update([ + 'status' => 'retired', + 'retired_at' => $now, + 'updated_at' => $now, + ]); + + $previous = [ + trim((string) ($state['active_header_generation'] ?? '')), + trim((string) ($state['active_item_generation'] ?? '')), + trim((string) ($state['active_inventory_generation'] ?? '')), + ]; + $current = [$headerGeneration, $itemGeneration, $inventoryGeneration]; + if (! in_array('', $previous, true) && $previous !== $current) { + $this->recordGenerationRelease( + $previous[0], + $previous[1], + $previous[2], + 'retired', + $state['last_succeeded_at'] ?? $now, + $now, + ); + } + + $this->recordGenerationRelease( + $headerGeneration, + $itemGeneration, + $inventoryGeneration, + 'active', + $now, + null, + ); + } + + private function recordGenerationRelease( + string $headerGeneration, + string $itemGeneration, + string $inventoryGeneration, + string $status, + mixed $publishedAt, + mixed $retiredAt, + ): void { + $key = [ + 'dataset_key' => self::DATASET_KEY, + 'header_generation' => $headerGeneration, + 'item_generation' => $itemGeneration, + 'inventory_generation' => $inventoryGeneration, + ]; + $existing = $this->connection->table(self::RELEASE_TABLE)->where($key)->first(); + $values = [ + 'status' => $status, + 'published_at' => $publishedAt, + 'retired_at' => $retiredAt, + 'updated_at' => now(), + ]; + + if ($existing !== null) { + $this->connection->table(self::RELEASE_TABLE) + ->where('release_id', (string) $existing->release_id) + ->update($values); + + return; + } + + $this->connection->table(self::RELEASE_TABLE)->insert($key + $values + [ + 'release_id' => (string) Str::uuid(), + 'created_at' => now(), + ]); + } + + private function cleanupRetiredGenerations(): void + { + $retentionHours = max((int) config( + 'upstream_mirrors.third_party_warehouse_storage.generation_retention_hours', + 48, + ), 1); + $cutoff = now()->subHours($retentionHours); + $releaseIds = $this->connection->table(self::RELEASE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->where('status', 'retired') + ->whereNotNull('retired_at') + ->where('retired_at', '<=', $cutoff) + ->orderBy('retired_at') + ->limit(50) + ->pluck('release_id') + ->map(static fn (mixed $releaseId): string => (string) $releaseId) + ->all(); + + foreach ($releaseIds as $releaseId) { + $this->cleanupRetiredGeneration($releaseId, $cutoff); + } + } + + private function cleanupRetiredGeneration(string $releaseId, mixed $cutoff): void + { + $this->connection->transaction(function () use ($releaseId, $cutoff): void { + $state = (array) $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->lockForUpdate() + ->first(); + $release = $this->connection->table(self::RELEASE_TABLE) + ->where('release_id', $releaseId) + ->lockForUpdate() + ->first(); + if ($release === null + || (string) $release->status !== 'retired' + || $release->retired_at === null + || Carbon::parse((string) $release->retired_at)->gt($cutoff)) { + return; + } + + $protected = array_filter([ + $state['active_header_generation'] ?? null, + $state['active_item_generation'] ?? null, + $state['active_inventory_generation'] ?? null, + $state['pending_header_generation'] ?? null, + $state['pending_item_generation'] ?? null, + $state['pending_inventory_generation'] ?? null, + ]); + foreach ([ + [self::HEADER_TABLE, 'header_generation', (string) $release->header_generation], + [self::ITEM_TABLE, 'item_generation', (string) $release->item_generation], + [self::INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation], + ] as [$table, $column, $generation]) { + if ($generation === '' || in_array($generation, $protected, true)) { + continue; + } + + $stillReferenced = $this->connection->table(self::RELEASE_TABLE) + ->where('release_id', '!=', $releaseId) + ->where($column, $generation) + ->where(function ($query) use ($cutoff): void { + $query->where('status', 'active') + ->orWhereNull('retired_at') + ->orWhere('retired_at', '>', $cutoff); + }) + ->exists(); + if (! $stillReferenced) { + $this->connection->table($table)->where('generation', $generation)->delete(); + } + } + + $this->connection->table(self::RELEASE_TABLE) + ->where('release_id', $releaseId) + ->delete(); + }); + } + + /** @return array|null */ + private function mapHeader(array $row, string $generation, mixed $now): ?array + { + $id = (int) ($row['source_id'] ?? 0); + if ($id <= 0) { + return null; + } + + $ioId = (int) ($row['io_id'] ?? 0); + $warehouseId = (int) ($row['wms_co_id'] ?? 0); + if ($ioId <= 0 || $warehouseId <= 0) { + return null; + } + + $values = [ + 'generation' => $generation, + 'source_key' => ThirdPartyWarehouseStorageUpstreamRepository::SOURCE_KEY, + 'id' => $id, + 'io_id' => $ioId, + 'wms_co_id' => $warehouseId, + 'status' => $this->text($row['status'] ?? null, 32), + 'io_type' => $this->text($row['io_type'] ?? null, 64), + 'occurred_at' => $this->dateTime($row['occurred_at'] ?? null), + 'source_modified_at' => $this->dateTime($row['modified_at'] ?? null), + ]; + + return $values + [ + 'row_hash' => $this->rowHash($values), + 'synced_at' => $now, + ]; + } + + /** @return array|null */ + private function mapItem(array $row, string $generation, ?string $modifiedAt, mixed $now): ?array + { + $id = (int) ($row['source_id'] ?? 0); + if ($id <= 0) { + return null; + } + + $ioId = (int) ($row['io_id'] ?? 0); + $skuCode = $this->text($row['sku_code'] ?? null, 191); + $qtyType = (int) ($row['qty_type'] ?? 0); + if ($ioId <= 0 || $skuCode === null || ! in_array($qtyType, [-1, 1], true)) { + return null; + } + + $values = [ + 'generation' => $generation, + 'source_key' => ThirdPartyWarehouseStorageUpstreamRepository::SOURCE_KEY, + 'id' => $id, + 'io_id' => $ioId, + 'sku_code' => $skuCode, + 'qty' => (float) ($row['qty'] ?? 0), + 'qty_type' => $qtyType, + // jst_inoutWaterItem has no update timestamp; the owning header's + // modified time is the auditable refresh marker for all its items. + 'source_modified_at' => $modifiedAt, + ]; + + return $values + [ + 'row_hash' => $this->rowHash($values), + 'synced_at' => $now, + ]; + } + + /** @return array|null */ + private function mapInventory(array $row, string $generation, mixed $now): ?array + { + $id = (int) ($row['source_id'] ?? 0); + if ($id <= 0) { + return null; + } + + $warehouseId = (int) ($row['wms_co_id'] ?? 0); + $skuCode = $this->text($row['sku_code'] ?? null, 191); + if ($warehouseId <= 0 || $skuCode === null) { + return null; + } + + $values = [ + 'generation' => $generation, + 'source_key' => ThirdPartyWarehouseStorageUpstreamRepository::SOURCE_KEY, + 'id' => $id, + 'wms_co_id' => $warehouseId, + 'sku_code' => $skuCode, + 'qty' => (float) ($row['qty'] ?? 0), + 'source_modified_at' => $this->dateTime($row['source_modified_at'] ?? null), + 'captured_at' => $now->format('Y-m-d H:i:s.u'), + ]; + + return $values + [ + 'row_hash' => $this->rowHash($values), + 'synced_at' => $now, + ]; + } + + /** + * @param list> $rows + * @param list $uniqueBy + */ + private function upsertRows(string $table, array $rows, array $uniqueBy): void + { + if ($rows === []) { + return; + } + + $updates = array_values(array_diff(array_keys($rows[0]), $uniqueBy)); + $columnsPerRow = max(count($rows[0]), 1); + $chunkSize = max(1, min(1000, intdiv(50000, $columnsPerRow))); + foreach (array_chunk($rows, $chunkSize) as $chunk) { + $this->connection->table($table)->upsert($chunk, $uniqueBy, $updates); + } + } + + /** @param list $columns */ + private function cloneActiveGeneration( + string $table, + string $activeGeneration, + string $pendingGeneration, + array $columns, + ): void { + if ($activeGeneration === '' || $pendingGeneration === '') { + throw new RuntimeException('third_party_warehouse_active_generation_missing'); + } + + $source = $this->connection->table($table) + ->where('generation', $activeGeneration) + ->selectRaw('? AS generation', [$pendingGeneration]) + ->addSelect($columns); + + $this->connection->table($table)->insertUsing(['generation', ...$columns], $source); + } + + /** @param array $values */ + private function rowHash(array $values): string + { + unset($values['generation']); + + return hash('sha256', json_encode( + $values, + JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION, + )); + } + + private function text(mixed $value, int $maxLength): ?string + { + $value = trim((string) ($value ?? '')); + + return $value === '' ? null : mb_substr($value, 0, $maxLength); + } + + private function dateTime(mixed $value): ?string + { + $value = trim((string) ($value ?? '')); + + return $value === '' ? null : Carbon::parse($value)->format('Y-m-d H:i:s.v'); + } + + private function batchLimitReached(int $batches, int $maxBatches): bool + { + return $maxBatches > 0 && $batches >= $maxBatches; + } + + /** @param list> $rows */ + private function inventoryNextCursor( + array $rows, + int $cursor, + int $highWatermark, + int $batchSize, + ): int { + if ($rows === []) { + return $highWatermark; + } + + $nextCursor = max(array_map( + static fn (array $row): int => (int) ($row['source_id'] ?? 0), + $rows, + )); + if ($nextCursor <= $cursor || $nextCursor > $highWatermark) { + throw new RuntimeException('third_party_warehouse_inventory_cursor_invalid'); + } + + return count($rows) < $batchSize ? $highWatermark : $nextCursor; + } + + /** + * @return array{status:string,mode:string,phase:string,batches:int,headers:int,items:int,inventory:int} + */ + private function result( + string $status, + string $mode, + string $phase, + int $batches, + int $headers, + int $items, + int $inventory, + ): array { + return compact('status', 'mode', 'phase', 'batches', 'headers', 'items', 'inventory'); + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([ + self::STATE_TABLE, + self::RELEASE_TABLE, + self::HEADER_TABLE, + self::ITEM_TABLE, + self::INVENTORY_TABLE, + ] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('third_party_warehouse_mirror_schema_missing:' . $table); + } + } + + if (! $schema->hasColumn(self::ITEM_TABLE, 'qty_type')) { + throw new RuntimeException('third_party_warehouse_mirror_item_missing:qty_type'); + } + foreach ([self::HEADER_TABLE, self::ITEM_TABLE] as $table) { + if (! $schema->hasColumn($table, 'source_key')) { + throw new RuntimeException('third_party_warehouse_mirror_source_key_missing:' . $table); + } + if (! $schema->hasColumn($table, 'source_modified_at')) { + throw new RuntimeException('third_party_warehouse_mirror_modified_at_missing:' . $table); + } + } + foreach (['source_modified_at', 'captured_at'] as $column) { + if (! $schema->hasColumn(self::INVENTORY_TABLE, $column)) { + throw new RuntimeException('third_party_warehouse_mirror_inventory_missing:' . $column); + } + } + if (! $schema->hasColumn(self::INVENTORY_TABLE, 'source_key')) { + throw new RuntimeException('third_party_warehouse_mirror_source_key_missing:' . self::INVENTORY_TABLE); + } + + foreach ([ + 'run_mode', + 'sync_phase', + 'run_token', + 'active_header_generation', + 'active_item_generation', + 'active_inventory_generation', + 'pending_header_generation', + 'pending_item_generation', + 'pending_inventory_generation', + 'header_cursor', + 'header_high_watermark', + 'header_window_started_at', + 'header_window_ends_at', + 'header_modified_cursor_at', + 'header_modified_cursor_id', + 'inventory_cursor', + 'inventory_high_watermark', + 'header_row_count', + 'item_row_count', + 'inventory_row_count', + 'initial_completed_at', + 'last_full_succeeded_at', + ] as $column) { + if (! $schema->hasColumn(self::STATE_TABLE, $column)) { + throw new RuntimeException('third_party_warehouse_mirror_state_missing:' . $column); + } + } + } +} diff --git a/resources/views/business/pages/courier-payables/third-party-warehouse-storage.blade.php b/resources/views/business/pages/courier-payables/third-party-warehouse-storage.blade.php new file mode 100644 index 00000000..de7a1948 --- /dev/null +++ b/resources/views/business/pages/courier-payables/third-party-warehouse-storage.blade.php @@ -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 + +
+ + +
+
+ + {{ $statusLabel }} + @if ($showingLastVersion) + · 当前展示最近一次成功同步的数据 + @endif +
+
+ 三方-南昌樵舍仓 · 查询维度:商品 + 按天 + 每日 00:00 自动同步 + @if (! empty($sync['last_succeeded_at'])) + 最近成功同步 {{ $sync['last_succeeded_at'] }} + @endif + @if ($lagLabel !== null) + 延迟 {{ $lagLabel }} + @endif + @if ($error === '' && ! empty($sync['message'])) + {{ $sync['message'] }} + @endif +
+
+ + @if ($error !== '') + + @endif + + @if ($pending) +
+ + 首次同步尚未完成,当前没有可展示的正式镜像数据;同步成功后页面会自动使用镜像数据。 +
+ @endif + +
+
+ 查询维度 +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ @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 +
+ {{ $metric['label'] }} + {{ $metricDisplay }} +
+ @endforeach +
+ +
+
+ + + + @foreach ($columns as $column) + + @endforeach + + + + @forelse ($rows ?? [] as $row) + + @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 + + @endforeach + + @empty + + + + @endforelse + +
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'] }}
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) + + + + @else + - + @endif + @else + {{ $displayValue }} + @endif +
+ @if ($pending) + 首次同步尚未完成,当前不会显示空白数据冒充正式结果。 + @elseif ($error !== '') + 镜像数据读取失败,当前没有可展示的数据。 + @elseif (in_array($statusCode, ['initial_failed', 'unavailable'], true)) + 镜像尚未准备好,请等待同步恢复后再查看。 + @else + 当前期间及筛选条件下没有数据,请调整条件或重置筛选。 + @endif +
+
+ +
+ 共 {{ number_format($rowTotal) }} 条记录,本页 {{ number_format($rowCount) }} 条 + @if (is_object($rows) && method_exists($rows, 'currentPage')) + + @endif +
+
+ + +
diff --git a/routes/admin.php b/routes/admin.php index 5a9b34bc..d2cfcd1a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php b/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php new file mode 100644 index 00000000..250e50c1 --- /dev/null +++ b/tests/Feature/Business/ThirdPartyWarehouseStoragePageTest.php @@ -0,0 +1,240 @@ +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 */ + 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 */ + 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, + ], + ]; + } +} diff --git a/tests/Feature/Console/ThirdPartyWarehouseStorageAuthoritativeAnchorImportTest.php b/tests/Feature/Console/ThirdPartyWarehouseStorageAuthoritativeAnchorImportTest.php new file mode 100644 index 00000000..a8445b3d --- /dev/null +++ b/tests/Feature/Console/ThirdPartyWarehouseStorageAuthoritativeAnchorImportTest.php @@ -0,0 +1,236 @@ + */ + 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 */ + private function headers(): array + { + return [ + '货主', '日期', '商品编码', '图片', '商品名称', '商品简称', '供应商', + '分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量', + '期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', + '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', + '其它出库', + ]; + } + + /** @param array $movements @return list */ + 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> $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; + } +} diff --git a/tests/Feature/Console/ThirdPartyWarehouseStorageSyncCommandTest.php b/tests/Feature/Console/ThirdPartyWarehouseStorageSyncCommandTest.php new file mode 100644 index 00000000..24885a68 --- /dev/null +++ b/tests/Feature/Console/ThirdPartyWarehouseStorageSyncCommandTest.php @@ -0,0 +1,81 @@ + 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(); + } +} diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index cb42b966..bd09eae7 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -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); diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php new file mode 100644 index 00000000..5bb97a92 --- /dev/null +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -0,0 +1,698 @@ + [ + '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 $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 $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 $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 $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 $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 $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 $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); + } +} diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php new file mode 100644 index 00000000..8242500f --- /dev/null +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageSynchronizerTest.php @@ -0,0 +1,720 @@ + [ + '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 */ + public array $headerCalls = []; + + /** @var list */ + public array $modifiedHeaderCalls = []; + + /** @var list> */ + public array $lastModifiedHeaders = []; + + /** @var list> */ + public array $latestHeaderCalls = []; + + /** @var list */ + 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); + } +} diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php new file mode 100644 index 00000000..62674989 --- /dev/null +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageUpstreamRepositoryTest.php @@ -0,0 +1,192 @@ +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>> */ + public array $selectResults = []; + + /** @var list */ + public array $selectCalls = []; + + /** @var list */ + 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; + } +} diff --git a/tests/Unit/View/ThirdPartyWarehouseStorageViewTest.php b/tests/Unit/View/ThirdPartyWarehouseStorageViewTest.php new file mode 100644 index 00000000..bd57203d --- /dev/null +++ b/tests/Unit/View/ThirdPartyWarehouseStorageViewTest.php @@ -0,0 +1,90 @@ +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; + } +}