528 lines
20 KiB
PHP
528 lines
20 KiB
PHP
<?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;
|
||
}
|
||
}
|