修复进销存八月期初数量
This commit is contained in:
parent
54c419ff0e
commit
32755bf7ad
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
182
tests/Feature/Console/InventoryReportAuthoritativeImportTest.php
Normal file
182
tests/Feature/Console/InventoryReportAuthoritativeImportTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user