修复进销存八月期初数量

This commit is contained in:
chenxi 2026-09-21 01:08:12 +08:00
parent 54c419ff0e
commit 32755bf7ad
6 changed files with 954 additions and 1 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
use Throwable;
class ImportInventoryReportAuthoritativeMonth extends Command
{
protected $signature = 'business-mirror:import-inventory-report-month
{path : 聚水潭按商品、按仓库、按月多维分析完整 XLSX 导出文件}';
protected $description = '导入进销存报表权威月明细,保留仓储方和子仓维度';
public function handle(InventoryReportAuthoritativeImporter $importer): int
{
try {
$result = $importer->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;
}
}

View File

@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -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<string,mixed> $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<string,mixed> $filters
* @return array{count:int,opening_qty:float,closing_qty:float}

View File

@ -0,0 +1,462 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class InventoryReportAuthoritativeImporter
{
public const DATASET_KEY = 'jiansun.inventory-report.authoritative-month';
private const STATE_TABLE = 'inventory_report_authoritative_states';
private const ROW_TABLE = 'inventory_report_authoritative_rows';
/** @var list<string> */
private const REQUIRED_HEADERS = [
'货主', '月份', '仓储方', '仓库', '商品编码', '分类', '期初数量', '期末数量',
'加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货',
'销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库',
];
/** @var list<string> */
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<int,string> */
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<int,string> $values @return array<string,int> */
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<int,string> $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<string> */
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;
}
}

View File

@ -0,0 +1,182 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class InventoryReportAuthoritativeImportTest extends TestCase
{
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'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;
}
}

View File

@ -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<string,mixed> $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<string,float|int> $movements */
private function insertAuthoritativeRow(
string $sku,