修复八月进销存报表重复计数

This commit is contained in:
chenxi 2026-09-26 19:40:23 +08:00
parent 75f9c1b0b9
commit 4782cf62c8
4 changed files with 187 additions and 10 deletions

View File

@ -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<string,mixed> $filters
* @param array<string,mixed> $state
* @return array{data:list<object>,total:int,state:array<string,mixed>,sync:array<string,mixed>}
*/
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;

View File

@ -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<int,string> $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

View File

@ -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');

View File

@ -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