diff --git a/database/migrations/2026_09_26_010000_create_inventory_report_authoritative_months.php b/database/migrations/2026_09_26_010000_create_inventory_report_authoritative_months.php new file mode 100644 index 00000000..5814a698 --- /dev/null +++ b/database/migrations/2026_09_26_010000_create_inventory_report_authoritative_months.php @@ -0,0 +1,70 @@ +connection(); + $schema = Schema::connection($connection); + + if (! $schema->hasTable('inventory_report_authoritative_months')) { + $schema->create('inventory_report_authoritative_months', function (Blueprint $table): void { + $table->string('dataset_key', 120); + $table->date('source_month'); + $table->string('status', 24)->default('succeeded'); + $table->uuid('active_generation')->nullable(); + $table->string('source_filename', 255)->nullable(); + $table->char('source_sha256', 64)->nullable(); + $table->unsignedBigInteger('source_row_count')->default(0); + $table->dateTime('last_succeeded_at')->nullable(); + $table->timestamps(); + + $table->primary(['dataset_key', 'source_month']); + $table->index( + ['dataset_key', 'source_month', 'active_generation'], + 'inventory_report_authoritative_month_lookup', + ); + }); + } + + // Preserve the already imported single-month snapshot when upgrading + // from the original one-row state table. + if ($schema->hasTable('inventory_report_authoritative_states')) { + $legacy = DB::connection($connection) + ->table('inventory_report_authoritative_states') + ->where('dataset_key', 'jiansun.inventory-report.authoritative-month') + ->whereNotNull('source_month') + ->whereNotNull('active_generation') + ->first(); + if ($legacy !== null) { + DB::connection($connection)->table('inventory_report_authoritative_months')->insertOrIgnore([ + 'dataset_key' => (string) $legacy->dataset_key, + 'source_month' => $legacy->source_month, + 'status' => (string) ($legacy->status ?: 'succeeded'), + 'active_generation' => $legacy->active_generation, + 'source_filename' => $legacy->source_filename, + 'source_sha256' => $legacy->source_sha256, + 'source_row_count' => (int) ($legacy->source_row_count ?? 0), + 'last_succeeded_at' => $legacy->last_succeeded_at, + 'created_at' => $legacy->created_at, + 'updated_at' => $legacy->updated_at, + ]); + } + } + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('inventory_report_authoritative_months'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index d4019f2b..f75e3500 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -726,17 +726,13 @@ class ThirdPartyWarehouseStorageMirrorRepository } $month = Carbon::parse($period['start'])->startOfMonth()->toDateString(); - $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) - ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) - ->whereNotNull('active_generation') - ->where('source_month', $month) - ->first(); - if ($state === null || trim((string) $state->active_generation) === '') { + $authority = $this->currentMonthlyInventoryAuthority($period); + if ($authority === null) { return null; } $query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE) - ->where('generation', (string) $state->active_generation) + ->where('generation', $authority['generation']) ->where('report_month', $month) ->select([ 'owner', 'report_month', 'warehouse_provider', 'warehouse', @@ -1267,11 +1263,16 @@ class ThirdPartyWarehouseStorageMirrorRepository private function currentMonthlyInventoryAuthority(array $period): ?array { $month = Carbon::parse($period['start'])->startOfMonth(); - $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) - ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) - ->whereNotNull('active_generation') - ->whereDate('source_month', '=', $month->toDateString()) + $state = $this->monthlyAuthorityStateQuery() + ?->whereDate('source_month', '=', $month->toDateString()) ->first(); + if ($state === null) { + $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) + ->whereNotNull('active_generation') + ->whereDate('source_month', '=', $month->toDateString()) + ->first(); + } if ($state === null || trim((string) $state->active_generation) === '') { return null; } @@ -1285,12 +1286,19 @@ class ThirdPartyWarehouseStorageMirrorRepository /** @param array{start:string,end:string} $period */ private function previousMonthlyInventoryAuthority(array $period): ?array { - $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) - ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) - ->whereNotNull('active_generation') - ->whereDate('source_month', '<', Carbon::parse($period['start'])->startOfMonth()) + $month = Carbon::parse($period['start'])->startOfMonth(); + $state = $this->monthlyAuthorityStateQuery() + ?->whereDate('source_month', '<', $month) ->orderByDesc('source_month') ->first(); + if ($state === null) { + $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) + ->whereNotNull('active_generation') + ->whereDate('source_month', '<', $month) + ->orderByDesc('source_month') + ->first(); + } if ($state === null || trim((string) $state->active_generation) === '') { return null; } @@ -1301,6 +1309,23 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } + private function monthlyAuthorityStateQuery(): ?Builder + { + try { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable('inventory_report_authoritative_months')) { + return null; + } + + return $this->connection->table('inventory_report_authoritative_months') + ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) + ->where('status', 'succeeded') + ->whereNotNull('active_generation'); + } catch (Throwable) { + return null; + } + } + /** @return array{0:string,1:list} */ private function subwarehouseIdSql(string $providerColumn, string $warehouseColumn): array { diff --git a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php index 7dc9118a..aabe3f60 100644 --- a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php +++ b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php @@ -24,6 +24,8 @@ class InventoryReportAuthoritativeImporter private const ROW_TABLE = 'inventory_report_authoritative_rows'; + private const MONTH_TABLE = 'inventory_report_authoritative_months'; + /** @var list */ private const REQUIRED_HEADERS = [ '货主', '月份', '仓储方', '仓库', '商品编码', '分类', '期初数量', '期末数量', @@ -84,6 +86,23 @@ class InventoryReportAuthoritativeImporter $source, $now, ): void { + if ($this->hasMonthlyStateTable()) { + $this->connection->table(self::MONTH_TABLE)->updateOrInsert( + [ + 'dataset_key' => self::DATASET_KEY, + 'source_month' => $source['source_month'], + ], + [ + 'status' => 'succeeded', + 'active_generation' => $generation, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'source_row_count' => $source['source_rows'], + 'last_succeeded_at' => $now, + 'updated_at' => $now, + ], + ); + } $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ @@ -116,8 +135,21 @@ class InventoryReportAuthoritativeImporter } try { + $keepGenerations = [$generation]; + if ($this->hasMonthlyStateTable()) { + $keepGenerations = array_merge( + $keepGenerations, + $this->connection->table(self::MONTH_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->whereNotNull('active_generation') + ->pluck('active_generation') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + ); + } + $keepGenerations = array_values(array_unique(array_filter($keepGenerations))); $this->connection->table(self::ROW_TABLE) - ->where('generation', '!=', $generation) + ->whereNotIn('generation', $keepGenerations) ->delete(); } catch (Throwable $exception) { report($exception); @@ -154,6 +186,11 @@ class InventoryReportAuthoritativeImporter } } + private function hasMonthlyStateTable(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::MONTH_TABLE); + } + /** @return array{source_rows:int,source_month:string} */ private function readAndStore(string $path, string $generation, mixed $importedAt): array { diff --git a/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php index 0084087b..e42f7831 100644 --- a/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php +++ b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php @@ -69,6 +69,32 @@ class InventoryReportAuthoritativeImportTest extends TestCase ], 'business_mirror'); } + public function test_it_keeps_authoritative_generations_for_multiple_months(): void + { + $august = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ + $this->headers(), + $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 10, 12, ['采购进仓' => 2]), + ])); + $july = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ + $this->headers(), + $this->row('2026-07-01', '仓储方', '主仓', 'SKU-A', 8, 10, ['采购进仓' => 2]), + ])); + + $months = DB::connection('business_mirror') + ->table('inventory_report_authoritative_months') + ->where('dataset_key', InventoryReportAuthoritativeImporter::DATASET_KEY) + ->orderBy('source_month') + ->get(); + + $this->assertCount(2, $months); + $this->assertSame('2026-07-01', (string) $months[0]->source_month); + $this->assertSame($july['generation'], (string) $months[0]->active_generation); + $this->assertSame('2026-08-01', (string) $months[1]->source_month); + $this->assertSame($august['generation'], (string) $months[1]->active_generation); + $this->assertSame(2, DB::connection('business_mirror') + ->table('inventory_report_authoritative_rows')->count()); + } + public function test_invalid_replacement_keeps_previous_generation(): void { $valid = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ @@ -158,6 +184,18 @@ class InventoryReportAuthoritativeImportTest extends TestCase $table->dateTime('imported_at'); $table->primary(['generation', 'row_key']); }); + $schema->create('inventory_report_authoritative_months', function (Blueprint $table): void { + $table->string('dataset_key'); + $table->date('source_month'); + $table->string('status')->default('succeeded'); + $table->string('active_generation')->nullable(); + $table->string('source_filename')->nullable(); + $table->string('source_sha256')->nullable(); + $table->unsignedBigInteger('source_row_count')->default(0); + $table->dateTime('last_succeeded_at')->nullable(); + $table->timestamps(); + $table->primary(['dataset_key', 'source_month']); + }); } private function headers(): array