diff --git a/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php b/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php new file mode 100644 index 00000000..46d75528 --- /dev/null +++ b/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php @@ -0,0 +1,36 @@ +import((string) $this->argument('path')); + } catch (Throwable $exception) { + report($exception); + $this->error('进销存权威月报导入失败:' . $exception->getMessage()); + $this->warn('已保留上一版有效数据。'); + + return self::FAILURE; + } + + $this->info(sprintf( + '进销存权威月报导入完成:月份 %s,明细 %d 行。', + $result['source_month'], + $result['source_rows'], + )); + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php b/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php new file mode 100644 index 00000000..8d2e48f9 --- /dev/null +++ b/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php @@ -0,0 +1,75 @@ +connection()); + + if (! $schema->hasTable('inventory_report_authoritative_states')) { + $schema->create('inventory_report_authoritative_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->string('source_filename', 255)->nullable(); + $table->char('source_sha256', 64)->nullable(); + $table->date('source_month')->nullable(); + $table->unsignedBigInteger('source_row_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('inventory_report_authoritative_rows')) { + $schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->uuid('generation'); + $table->char('row_key', 64); + $table->date('report_month'); + $table->string('owner', 191); + $table->string('warehouse_provider', 191); + $table->string('warehouse', 191); + $table->string('product_code', 191); + $table->string('category', 191)->nullable(); + $table->decimal('opening_quantity', 20, 4); + $table->decimal('closing_quantity', 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', 'other_out', + ] as $column) { + $table->decimal($column, 20, 4)->default(0); + } + $table->dateTime('imported_at'); + + $table->primary(['generation', 'row_key'], 'inventory_report_authoritative_rows_primary'); + $table->index( + ['generation', 'report_month', 'product_code'], + 'inventory_report_authoritative_product_index', + ); + $table->index( + ['generation', 'report_month'], + 'inventory_report_authoritative_month_index', + ); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('inventory_report_authoritative_rows'); + $schema->dropIfExists('inventory_report_authoritative_states'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 7459c33e..35d0356c 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -42,6 +42,12 @@ class ThirdPartyWarehouseStorageMirrorRepository private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor'; + private const INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE = 'inventory_report_authoritative_states'; + + private const INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE = 'inventory_report_authoritative_rows'; + + private const INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY = 'jiansun.inventory-report.authoritative-month'; + private const PRODUCT_TABLE = 'ops_jst_sku'; private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info'; @@ -297,6 +303,47 @@ class ThirdPartyWarehouseStorageMirrorRepository 'dimension' => 'month', 'period' => $filters['month'] ?? '', ]); + $authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters); + if ($authoritative !== null) { + $total = $includeTotal ? (clone $authoritative)->count() : 0; + $rows = (clone $authoritative) + ->orderBy('warehouse_provider') + ->orderBy('warehouse') + ->orderBy('product_code') + ->forPage($page, $perPage) + ->get() + ->map(static fn (object $row): object => (object) [ + 'owner' => $row->owner, + 'month' => $row->report_month, + 'warehouse_provider' => $row->warehouse_provider, + 'warehouse' => $row->warehouse, + 'product_code' => $row->product_code, + 'category' => $row->category, + 'opening_quantity' => $row->opening_quantity, + 'closing_quantity' => $row->closing_quantity, + 'processing_in' => $row->processing_in, + 'processing_out' => $row->processing_out, + 'purchase_in' => $row->purchase_in, + 'purchase_return' => $row->purchase_return, + 'other_in' => $row->other_in, + 'other_return' => $row->other_return, + 'sales_out' => $row->sales_out, + 'sales_return' => $row->sales_return, + 'stocktake' => $row->stocktake, + 'transfer_out' => $row->transfer_out, + 'transfer_in' => $row->transfer_in, + 'opening' => $row->opening, + 'other_out' => $row->other_out, + ]) + ->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } $headerGeneration = $this->generation($state, 'active_header_generation'); $itemGeneration = $this->generation($state, 'active_item_generation'); $inventoryGeneration = $this->generation($state, 'active_inventory_generation'); @@ -329,7 +376,10 @@ class ThirdPartyWarehouseStorageMirrorRepository 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', 's.sku_id as sku_code', 's.category', ]); - $this->containsFilter($candidates, 's.sku_id', $filters['product_code'] ?? ''); + $productCode = trim((string) ($filters['product_code'] ?? '')); + if ($productCode !== '') { + $candidates->where('s.sku_id', $productCode); + } $warehouseFilter = trim((string) ($filters['warehouse_provider'] ?? '')); if ($warehouseFilter !== '') { $warehouseIds = $this->connection->query()->fromSub( @@ -522,6 +572,56 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + /** + * 用户确认的聚水潭月报保留了“仓储方 + 子仓 + 商品”维度。 + * 命中同月权威数据时整月直接读取,不再用当前库存倒推历史子仓余额。 + * + * @param array{start:string,end:string} $period + * @param array $filters + */ + private function monthlyInventoryAuthoritativeQuery(array $period, array $filters): ?Builder + { + try { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + || ! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)) { + return null; + } + + $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') + ->whereDate('source_month', $month) + ->first(); + if ($state === null || trim((string) $state->active_generation) === '') { + return null; + } + + $query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE) + ->where('generation', (string) $state->active_generation) + ->whereDate('report_month', $month) + ->select([ + 'owner', 'report_month', 'warehouse_provider', 'warehouse', + 'product_code', 'category', 'opening_quantity', 'closing_quantity', + 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', + 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', + 'transfer_out', 'transfer_in', 'opening', 'other_out', + ]); + + $productCode = trim((string) ($filters['product_code'] ?? '')); + if ($productCode !== '') { + $query->where('product_code', $productCode); + } + $this->containsFilter($query, 'warehouse_provider', $filters['warehouse_provider'] ?? ''); + $this->containsFilter($query, 'category', $filters['category'] ?? ''); + + return $query; + } catch (Throwable) { + return null; + } + } + /** * @param array $filters * @return array{count:int,opening_qty:float,closing_qty:float} diff --git a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php new file mode 100644 index 00000000..58810d75 --- /dev/null +++ b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php @@ -0,0 +1,462 @@ + */ + 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,source_month: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->readAndStore($path, $generation, $now); + $this->connection->transaction(function () use ( + $generation, + $path, + $hash, + $source, + $now, + ): void { + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'succeeded', + 'active_generation' => $generation, + 'pending_generation' => null, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'source_month' => $source['source_month'], + 'source_row_count' => $source['source_rows'], + 'last_succeeded_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + }); + } catch (Throwable $exception) { + $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::ROW_TABLE) + ->where('generation', '!=', $generation) + ->delete(); + } catch (Throwable $exception) { + report($exception); + } + + return [ + 'status' => 'succeeded', + 'source_rows' => $source['source_rows'], + 'source_month' => $source['source_month'], + '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::ROW_TABLE] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('进销存权威月报表尚未完成迁移:' . $table); + } + } + } + + /** @return array{source_rows:int,source_month:string} */ + private function readAndStore(string $path, string $generation, mixed $importedAt): 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 = []; + $sourceMonth = null; + $rowCount = 0; + $batch = []; + + 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; + } + + $month = $this->month($values[$columns['月份']] ?? null, $rowNumber); + if ($sourceMonth !== null && $sourceMonth !== $month) { + throw new InvalidArgumentException('进销存权威月报必须只包含一个月份。'); + } + $sourceMonth = $month; + + $owner = $this->requiredText($values[$columns['货主']] ?? null, $rowNumber, '货主'); + $warehouseProvider = $this->requiredText( + $values[$columns['仓储方']] ?? null, + $rowNumber, + '仓储方', + ); + $warehouse = $this->requiredText($values[$columns['仓库']] ?? null, $rowNumber, '仓库'); + $productCode = ProductCatalogAuthoritativeExportImporter::normalizeSkuId( + (string) ($values[$columns['商品编码']] ?? ''), + ); + if ($productCode === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。"); + } + + $rowKey = hash('sha256', implode("\0", [ + $month, + $warehouseProvider, + $warehouse, + $productCode, + ])); + if (isset($seen[$rowKey])) { + throw new InvalidArgumentException( + "第 {$rowNumber} 行与第 {$seen[$rowKey]} 行的月份、仓储方、仓库和商品编码重复。", + ); + } + $seen[$rowKey] = $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} 行期初数量加进出数量不等于期末数量。", + ); + } + + $batch[] = [ + 'generation' => $generation, + 'row_key' => $rowKey, + 'report_month' => $month, + 'owner' => mb_substr($owner, 0, 191), + 'warehouse_provider' => mb_substr($warehouseProvider, 0, 191), + 'warehouse' => mb_substr($warehouse, 0, 191), + 'product_code' => mb_substr($productCode, 0, 191), + 'category' => $this->nullableText($values[$columns['分类']] ?? null, 191), + 'opening_quantity' => $opening, + 'closing_quantity' => $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' => $movementValues['期初'], + 'other_out' => $movementValues['其它出库'], + 'imported_at' => $importedAt, + ]; + $rowCount++; + + if (count($batch) >= 500) { + $this->connection->table(self::ROW_TABLE)->insert($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($batch !== []) { + $this->connection->table(self::ROW_TABLE)->insert($batch); + } + if ($columns === [] || $rowCount === 0 || $sourceMonth === null) { + throw new InvalidArgumentException('进销存权威月报中没有可用明细。'); + } + + return ['source_rows' => $rowCount, 'source_month' => $sourceMonth]; + } + + /** @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 month(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->startOfMonth()->toDateString(); + } + + private function requiredText(mixed $value, int $rowNumber, string $label): string + { + $text = trim((string) $value); + if ($text === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不能为空。"); + } + + return $text; + } + + private function nullableText(mixed $value, int $length): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : mb_substr($text, 0, $length); + } + + 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/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php new file mode 100644 index 00000000..3b16044c --- /dev/null +++ b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php @@ -0,0 +1,182 @@ + [ + '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_monthly_warehouse_rows_and_preserves_opening_quantity(): void + { + $path = $this->workbook([ + $this->headers(), + $this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓主仓', '15962G', 14, 6, [ + '销售出仓' => -28, + '调拨入' => 20, + ]), + $this->row('2026-08-01', '简尚-崔桥工厂仓', '简尚-崔桥工厂仓进货仓', '15962G', 0, 0, [ + '采购进仓' => 20, + '调拨出' => -20, + ]), + ]); + + $this->artisan('business-mirror:import-inventory-report-month', ['path' => $path]) + ->assertSuccessful(); + + $state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first(); + $this->assertSame(InventoryReportAuthoritativeImporter::DATASET_KEY, $state->dataset_key); + $this->assertSame('2026-08-01', $state->source_month); + $this->assertSame(2, (int) $state->source_row_count); + $this->assertDatabaseHas('inventory_report_authoritative_rows', [ + 'generation' => $state->active_generation, + 'warehouse' => '简尚-崔桥工厂仓主仓', + 'product_code' => '15962G', + 'opening_quantity' => 14, + 'closing_quantity' => 6, + 'sales_out' => -28, + 'transfer_in' => 20, + ], 'business_mirror'); + } + + public function test_invalid_replacement_keeps_previous_generation(): void + { + $valid = app(InventoryReportAuthoritativeImporter::class)->import($this->workbook([ + $this->headers(), + $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 1), + ])); + $invalid = $this->workbook([ + $this->headers(), + $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 2), + ]); + + $this->artisan('business-mirror:import-inventory-report-month', ['path' => $invalid]) + ->expectsOutputToContain('已保留上一版有效数据') + ->assertFailed(); + + $state = DB::connection('business_mirror')->table('inventory_report_authoritative_states')->first(); + $this->assertSame($valid['generation'], $state->active_generation); + $this->assertSame(1, DB::connection('business_mirror')->table('inventory_report_authoritative_rows') + ->where('generation', $valid['generation'])->count()); + } + + private function createTables(): void + { + $schema = Schema::connection('business_mirror'); + $schema->create('inventory_report_authoritative_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status'); + $table->string('active_generation')->nullable(); + $table->string('pending_generation')->nullable(); + $table->string('source_filename')->nullable(); + $table->string('source_sha256')->nullable(); + $table->date('source_month')->nullable(); + $table->unsignedBigInteger('source_row_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('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->string('generation'); + $table->string('row_key'); + $table->date('report_month'); + $table->string('owner'); + $table->string('warehouse_provider'); + $table->string('warehouse'); + $table->string('product_code'); + $table->string('category')->nullable(); + foreach ([ + 'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out', + 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', + 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out', + ] as $column) { + $table->decimal($column, 20, 4)->default(0); + } + $table->dateTime('imported_at'); + $table->primary(['generation', 'row_key']); + }); + } + + private function headers(): array + { + return [ + '货主', '月份', '仓储方', '仓库', '商品编码', '图片', '商品名称', '商品简称', + '分类', '虚拟分类', '供应商款号', '颜色规格', '品牌', '单位', '期初数量', '期末数量', + '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', + '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', + ]; + } + + private function row( + string $month, + string $provider, + string $warehouse, + string $sku, + float $opening, + float $closing, + array $movements = [], + ): array { + $row = array_fill(0, 29, null); + $row[0] = '常州简尚家居有限公司'; + $row[1] = $month; + $row[2] = $provider; + $row[3] = $warehouse; + $row[4] = $sku; + $row[8] = '测试分类'; + $row[14] = $opening; + $row[15] = $closing; + foreach ([ + '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', + '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', + ] as $offset => $header) { + $row[16 + $offset] = $movements[$header] ?? 0; + } + + return $row; + } + + private function workbook(array $rows): string + { + $temporary = tempnam(sys_get_temp_dir(), 'inventory-report-month-'); + $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/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 287f439a..293655e9 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -170,6 +170,56 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(2.0, (float) $row->sales_return); } + public function test_inventory_report_prefers_authoritative_month_rows_with_exact_opening_quantities(): void + { + $this->markReady(); + $this->insertProduct('SKU-AUTH'); + $this->insertInventory('SKU-AUTH', 999, '2026-09-01 00:00:00'); + $this->insertMovement('SKU-AUTH', '采购进仓', 500, 1, '2026-08-20 10:00:00'); + $this->insertInventoryReportAuthoritativeRow([ + 'warehouse_provider' => '简尚-崔桥工厂仓', + 'warehouse' => '简尚-崔桥工厂仓进货仓', + 'product_code' => 'SKU-AUTH', + 'category' => '权威分类', + 'opening_quantity' => 14, + 'closing_quantity' => 20, + 'purchase_in' => 6, + ]); + + $result = $this->repository->paginateMonthlyInventoryReport([ + 'month' => '2026-08', + 'product_code' => 'SKU-AUTH', + 'warehouse_provider' => '崔桥', + 'category' => '权威', + ], 1, 25); + $row = $result['data'][0]; + + $this->assertSame(1, $result['total']); + $this->assertSame('简尚-崔桥工厂仓进货仓', $row->warehouse); + $this->assertSame(14.0, (float) $row->opening_quantity); + $this->assertSame(20.0, (float) $row->closing_quantity); + $this->assertSame(6.0, (float) $row->purchase_in); + } + + public function test_inventory_report_product_code_filter_is_exact(): void + { + $this->markReady(); + $this->insertProduct('15962G'); + $this->insertProduct('15962G-1'); + $this->insertInventory('15962G', 1, '2026-09-01 00:00:00'); + $this->insertInventory('15962G-1', 1, '2026-09-01 00:00:00'); + + $result = $this->repository->paginateMonthlyInventoryReport([ + 'month' => '2026-08', + 'product_code' => '15962G', + 'warehouse_provider' => '', + 'category' => '', + ], 1, 25); + + $this->assertSame(1, $result['total']); + $this->assertSame(['15962G'], collect($result['data'])->pluck('product_code')->all()); + } + public function test_inventory_report_splits_main_return_inbound_and_other_subwarehouses(): void { $this->markReady(); @@ -665,6 +715,30 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase } $table->primary(['generation', 'report_date', 'sku_code']); }); + $schema->create('inventory_report_authoritative_states', function (Blueprint $table): void { + $table->string('dataset_key')->primary(); + $table->string('status'); + $table->string('active_generation')->nullable(); + $table->date('source_month')->nullable(); + }); + $schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->string('generation'); + $table->string('row_key'); + $table->date('report_month'); + $table->string('owner'); + $table->string('warehouse_provider'); + $table->string('warehouse'); + $table->string('product_code'); + $table->string('category')->nullable(); + foreach ([ + 'opening_quantity', 'closing_quantity', 'processing_in', 'processing_out', + 'purchase_in', 'purchase_return', 'other_in', 'other_return', 'sales_out', + 'sales_return', 'stocktake', 'transfer_out', 'transfer_in', 'opening', 'other_out', + ] as $column) { + $table->decimal($column, 20, 4)->default(0); + } + $table->primary(['generation', 'row_key']); + }); } private function createProductTable(): void @@ -847,6 +921,30 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase ]); } + /** @param array $overrides */ + private function insertInventoryReportAuthoritativeRow(array $overrides = []): void + { + $generation = '55555555-5555-5555-5555-555555555555'; + DB::connection('business_mirror')->table('inventory_report_authoritative_states')->insert([ + 'dataset_key' => 'jiansun.inventory-report.authoritative-month', + 'status' => 'succeeded', + 'active_generation' => $generation, + 'source_month' => '2026-08-01', + ]); + DB::connection('business_mirror')->table('inventory_report_authoritative_rows')->insert(array_merge([ + 'generation' => $generation, + 'row_key' => hash('sha256', 'authoritative-row'), + 'report_month' => '2026-08-01', + 'owner' => '常州简尚家居有限公司', + 'warehouse_provider' => '仓储方', + 'warehouse' => '子仓', + 'product_code' => 'SKU-AUTH', + 'category' => '分类', + 'opening_quantity' => 0, + 'closing_quantity' => 0, + ], $overrides)); + } + /** @param array $movements */ private function insertAuthoritativeRow( string $sku,