From 4782cf62c85ee4ee2cb52c0bb504fdf79659250a Mon Sep 17 00:00:00 2001 From: chenxi Date: Sat, 26 Sep 2026 19:40:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=AB=E6=9C=88=E8=BF=9B?= =?UTF-8?q?=E9=94=80=E5=AD=98=E6=8A=A5=E8=A1=A8=E9=87=8D=E5=A4=8D=E8=AE=A1?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dPartyWarehouseStorageMirrorRepository.php | 98 ++++++++++++++++++- .../InventoryReportAuthoritativeImporter.php | 34 ++++++- ...InventoryReportAuthoritativeImportTest.php | 32 ++++++ ...tyWarehouseStorageMirrorRepositoryTest.php | 33 ++++++- 4 files changed, 187 insertions(+), 10 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index 6455a304..d4019f2b 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -327,7 +327,22 @@ class ThirdPartyWarehouseStorageMirrorRepository $perPage = min(max($perPage, 1), $prefetchedMovementRows === null ? 5000 : self::REPORT_EXPORT_BATCH_SIZE); $queryTimeoutSeconds ??= self::REPORT_QUERY_TIMEOUT_SECONDS; $period = $this->inventoryReportPeriod((string) ($filters['month'] ?? '')); - // 月报的进出数量统一来自聚水潭进出仓明细;权威月报只提供同月的期初维度。 + // 已导入的当前月份是对账基准,直接返回工作簿中的完整月度明细。 + // 这样不会再把同月镜像流水重复叠加到权威的各类进出数量上。 + $currentAuthority = $this->currentMonthlyInventoryAuthority($period); + if ($currentAuthority !== null) { + return $this->paginateCurrentMonthlyInventoryAuthority( + $period, + $filters, + $page, + $perPage, + $includeTotal, + $state, + $queryTimeoutSeconds, + ); + } + + // 没有当前月权威数据时,保留原有的上月期末结转和已确认流水滚算。 $headerGeneration = $this->generation($state, 'active_header_generation'); $itemGeneration = $this->generation($state, 'active_item_generation'); $inventoryGeneration = $this->generation($state, 'active_inventory_generation'); @@ -335,9 +350,8 @@ class ThirdPartyWarehouseStorageMirrorRepository throw new RuntimeException('inventory_report_mirror_not_ready'); } - $currentAuthority = $this->currentMonthlyInventoryAuthority($period); - $authority = $currentAuthority ?? $this->previousMonthlyInventoryAuthority($period); - $authorityIsCurrentMonth = $currentAuthority !== null; + $authority = $this->previousMonthlyInventoryAuthority($period); + $authorityIsCurrentMonth = false; $resolveRollForward = fn (): ?array => $authority === null ? null : $this->monthlyInventoryRollForward( @@ -549,6 +563,77 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } + /** + * Read one authoritative month without joining the movement mirror. + * The imported workbook already contains the opening balance, every movement + * column and the closing balance at the requested warehouse/SKU grain. + * + * @param array{start:string,end:string} $period + * @param array $filters + * @param array $state + * @return array{data:list,total:int,state:array,sync:array} + */ + private function paginateCurrentMonthlyInventoryAuthority( + array $period, + array $filters, + int $page, + int $perPage, + bool $includeTotal, + array $state, + ?int $queryTimeoutSeconds, + ): array { + $query = $this->monthlyInventoryAuthoritativeQuery($period, $filters); + if ($query === null) { + return [ + 'data' => [], + 'total' => 0, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + $total = $includeTotal ? $this->boundedCount(clone $query, $queryTimeoutSeconds) : 0; + $rows = $this->boundedGet( + (clone $query) + ->orderBy('warehouse_provider') + ->orderBy('warehouse') + ->orderBy('product_code') + ->forPage($page, $perPage), + $queryTimeoutSeconds, + )->map(static function (object $row): object { + return (object) [ + 'owner' => (string) $row->owner, + 'month' => Carbon::parse($row->report_month)->toDateString(), + 'warehouse_provider' => (string) $row->warehouse_provider, + 'warehouse' => (string) $row->warehouse, + 'product_code' => (string) $row->product_code, + 'category' => (string) ($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), + ]; + } + private function subwarehouseName(string $warehouseProvider, int $subwarehouseId): string { $specialName = self::SPECIAL_SUBWAREHOUSE_NAMES[$warehouseProvider][$subwarehouseId] ?? null; @@ -1050,6 +1135,11 @@ class ThirdPartyWarehouseStorageMirrorRepository } $currentAuthority = $this->currentMonthlyInventoryAuthority($period); + if ($currentAuthority !== null) { + // The current authoritative month already contains all movement + // columns; exporting it must not scan the mirror movement tables. + return collect(); + } $authority = $currentAuthority ?? $this->previousMonthlyInventoryAuthority($period); if ($authority === null) { return null; diff --git a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php index 58810d75..7dc9118a 100644 --- a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php +++ b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php @@ -163,8 +163,8 @@ class InventoryReportAuthoritativeImporter } try { - $worksheet = $this->firstWorksheet($archive); $sharedStrings = $this->sharedStrings($path, $archive); + $worksheet = $this->worksheetWithHeaders($path, $archive, $sharedStrings); } finally { $archive->close(); } @@ -438,7 +438,14 @@ class InventoryReportAuthoritativeImporter return $strings; } - private function firstWorksheet(ZipArchive $archive): string + /** + * Exports may contain pivot summaries or helper sheets before the detail + * sheet. Select the worksheet that actually contains the authoritative + * monthly-report headers instead of assuming sheet1 is the data sheet. + * + * @param array $sharedStrings + */ + private function worksheetWithHeaders(string $path, ZipArchive $archive, array $sharedStrings): string { $worksheets = []; for ($index = 0; $index < $archive->numFiles; $index++) { @@ -452,7 +459,28 @@ class InventoryReportAuthoritativeImporter throw new InvalidArgumentException('XLSX 文件中没有工作表。'); } - return $worksheets[0]; + foreach ($worksheets as $worksheet) { + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + continue; + } + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { + continue; + } + + if ($this->headerColumns($this->row($reader->readOuterXml(), $sharedStrings)) !== []) { + return $worksheet; + } + } + } finally { + $reader->close(); + } + } + + throw new InvalidArgumentException('XLSX 文件中未找到包含完整进销存月报表头的工作表。'); } private function zipUri(string $path, string $entry): string diff --git a/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php index 3b16044c..0084087b 100644 --- a/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php +++ b/tests/Feature/Console/InventoryReportAuthoritativeImportTest.php @@ -90,6 +90,38 @@ class InventoryReportAuthoritativeImportTest extends TestCase ->where('generation', $valid['generation'])->count()); } + public function test_it_finds_the_detail_sheet_when_a_pivot_sheet_comes_first(): void + { + $temporary = tempnam(sys_get_temp_dir(), 'inventory-report-multi-sheet-'); + $path = $temporary . '.xlsx'; + rename($temporary, $path); + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->setTitle('汇总'); + $spreadsheet->getActiveSheet()->fromArray([ + ['条码+仓库', '求和项:期初数量'], + ['SKU-A主仓', 99], + ]); + $detail = $spreadsheet->createSheet(); + $detail->setTitle('明细'); + $detail->fromArray([ + $this->headers(), + $this->row('2026-08-01', '仓储方', '主仓', 'SKU-A', 1, 1), + ]); + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + $this->temporaryFiles[] = $path; + + $result = app(InventoryReportAuthoritativeImporter::class)->import($path); + + $this->assertSame('2026-08-01', $result['source_month']); + $this->assertSame(1, $result['source_rows']); + $this->assertDatabaseHas('inventory_report_authoritative_rows', [ + 'generation' => $result['generation'], + 'product_code' => 'SKU-A', + 'warehouse' => '主仓', + ], 'business_mirror'); + } + private function createTables(): void { $schema = Schema::connection('business_mirror'); diff --git a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php index 0905df76..5b038e55 100644 --- a/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ThirdPartyWarehouseStorageMirrorRepositoryTest.php @@ -208,9 +208,36 @@ class ThirdPartyWarehouseStorageMirrorRepositoryTest extends TestCase $this->assertSame(1, $result['total']); $this->assertSame('简尚-崔桥工厂仓进货仓', $row->warehouse); $this->assertSame(14.0, (float) $row->opening_quantity); - $this->assertSame(512.0, (float) $row->closing_quantity); - $this->assertSame(500.0, (float) $row->purchase_in); - $this->assertSame(-2.0, (float) $row->sales_out); + $this->assertSame(20.0, (float) $row->closing_quantity); + $this->assertSame(6.0, (float) $row->purchase_in); + $this->assertSame(0.0, (float) $row->sales_out); + } + + public function test_inventory_report_export_reads_current_authoritative_month_without_movement_scan(): void + { + $this->markReady(); + $this->insertProduct('SKU-AUTH-EXPORT'); + $this->insertMovement('SKU-AUTH-EXPORT', '采购进仓', 999, 1, '2026-08-20 10:00:00'); + $this->insertInventoryReportAuthoritativeRow([ + 'warehouse_provider' => '简尚-崔桥工厂仓', + 'warehouse' => '简尚-崔桥工厂仓进货仓', + 'product_code' => 'SKU-AUTH-EXPORT', + 'opening_quantity' => 14, + 'closing_quantity' => 20, + 'purchase_in' => 6, + ]); + + $rows = iterator_to_array($this->repository->exportMonthlyInventoryReportRows([ + 'month' => '2026-08', + 'product_code' => 'SKU-AUTH-EXPORT', + 'warehouse_provider' => '', + 'category' => '', + ], 1), false); + + $this->assertCount(1, $rows); + $this->assertSame(14.0, (float) $rows[0]->opening_quantity); + $this->assertSame(6.0, (float) $rows[0]->purchase_in); + $this->assertSame(20.0, (float) $rows[0]->closing_quantity); } public function test_inventory_report_rolls_previous_month_closing_into_next_month_opening(): void