CaiWuXiTong/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php
2026-09-26 22:45:37 +08:00

528 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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';
private const MONTH_TABLE = 'inventory_report_authoritative_months';
/** @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 {
if ($this->hasMonthlyStateTable()) {
$this->connection->table(self::MONTH_TABLE)->updateOrInsert(
[
'dataset_key' => self::DATASET_KEY,
'source_month' => $source['source_month'],
],
[
'status' => 'succeeded',
'active_generation' => $generation,
'source_filename' => mb_substr(basename($path), 0, 255),
'source_sha256' => $hash,
'source_row_count' => $source['source_rows'],
'last_succeeded_at' => $now,
'updated_at' => $now,
],
);
}
$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 {
$keepGenerations = [$generation];
if ($this->hasMonthlyStateTable()) {
$keepGenerations = array_merge(
$keepGenerations,
$this->connection->table(self::MONTH_TABLE)
->where('dataset_key', self::DATASET_KEY)
->whereNotNull('active_generation')
->pluck('active_generation')
->map(static fn (mixed $value): string => (string) $value)
->all(),
);
}
$keepGenerations = array_values(array_unique(array_filter($keepGenerations)));
$this->connection->table(self::ROW_TABLE)
->whereNotIn('generation', $keepGenerations)
->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);
}
}
}
private function hasMonthlyStateTable(): bool
{
return Schema::connection($this->connection->getName())->hasTable(self::MONTH_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 {
$sharedStrings = $this->sharedStrings($path, $archive);
$worksheet = $this->worksheetWithHeaders($path, $archive, $sharedStrings);
} 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;
}
/**
* 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++) {
$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 文件中没有工作表。');
}
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
{
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
}
}