修复八月进销存报表重复计数
This commit is contained in:
parent
75f9c1b0b9
commit
4782cf62c8
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user