502 lines
19 KiB
PHP
502 lines
19 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 ThirdPartyWarehouseStorageAuthoritativeAnchorImporter
|
||
{
|
||
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor';
|
||
|
||
private const STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states';
|
||
|
||
private const ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors';
|
||
|
||
private const ROW_TABLE = 'third_party_warehouse_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,anchor_skus:int,anchor_at: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->readSource($path);
|
||
$anchorAt = Carbon::parse($source['period_end'])->addDay()->startOfDay();
|
||
$anchors = array_map(
|
||
static fn (array $anchor): array => [
|
||
'generation' => $generation,
|
||
'sku_code' => $anchor['sku_code'],
|
||
'qty' => $anchor['qty'],
|
||
'anchor_at' => $anchorAt,
|
||
'imported_at' => $now,
|
||
],
|
||
$source['anchors'],
|
||
);
|
||
|
||
$authoritativeRows = array_map(
|
||
static fn (array $row): array => $row + [
|
||
'generation' => $generation,
|
||
'imported_at' => $now,
|
||
],
|
||
$source['rows'],
|
||
);
|
||
|
||
foreach (array_chunk($authoritativeRows, 500) as $batch) {
|
||
$this->connection->table(self::ROW_TABLE)->insert($batch);
|
||
}
|
||
|
||
foreach (array_chunk($anchors, 1000) as $batch) {
|
||
$this->connection->table(self::ANCHOR_TABLE)->insert($batch);
|
||
}
|
||
|
||
$this->connection->transaction(function () use (
|
||
$generation,
|
||
$path,
|
||
$hash,
|
||
$source,
|
||
$anchorAt,
|
||
$now,
|
||
): void {
|
||
$this->connection->table(self::STATE_TABLE)
|
||
->where('dataset_key', self::DATASET_KEY)
|
||
->update([
|
||
'status' => 'succeeded',
|
||
'active_generation' => $generation,
|
||
'pending_generation' => null,
|
||
'anchor_at' => $anchorAt,
|
||
'source_filename' => mb_substr(basename($path), 0, 255),
|
||
'source_sha256' => $hash,
|
||
'source_period_start' => $source['period_start'],
|
||
'source_period_end' => $source['period_end'],
|
||
'source_row_count' => $source['row_count'],
|
||
'anchor_sku_count' => count($source['anchors']),
|
||
'last_succeeded_at' => $now,
|
||
'last_error_code' => null,
|
||
'updated_at' => $now,
|
||
]);
|
||
});
|
||
} catch (Throwable $exception) {
|
||
$this->connection->table(self::ANCHOR_TABLE)
|
||
->where('generation', $generation)
|
||
->delete();
|
||
$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::ANCHOR_TABLE)
|
||
->where('generation', '!=', $generation)
|
||
->delete();
|
||
$this->connection->table(self::ROW_TABLE)
|
||
->where('generation', '!=', $generation)
|
||
->delete();
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
}
|
||
|
||
return [
|
||
'status' => 'succeeded',
|
||
'source_rows' => $source['row_count'],
|
||
'anchor_skus' => count($source['anchors']),
|
||
'anchor_at' => $anchorAt->toDateTimeString(),
|
||
'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::ANCHOR_TABLE, self::ROW_TABLE] as $table) {
|
||
if (! $schema->hasTable($table)) {
|
||
throw new RuntimeException('三方仓权威进销存表尚未完成迁移:' . $table);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array{period_start:string,period_end:string,row_count:int,rows:list<array<string,mixed>>,anchors:list<array{sku_code:string,qty:float}>}
|
||
*/
|
||
private function readSource(string $path): 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 = [];
|
||
$dates = [];
|
||
$anchors = [];
|
||
$rows = [];
|
||
$minimumDate = null;
|
||
$maximumDate = null;
|
||
$rowCount = 0;
|
||
|
||
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;
|
||
}
|
||
|
||
$date = $this->date($values[$columns['日期']] ?? null, $rowNumber);
|
||
$sku = $this->sku($values[$columns['商品编码']] ?? null, $rowNumber);
|
||
$key = $date . "\0" . $sku;
|
||
if (isset($seen[$key])) {
|
||
throw new InvalidArgumentException(
|
||
"第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和商品编码重复。",
|
||
);
|
||
}
|
||
$seen[$key] = $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} 行期初数量加进出数量不等于期末数量。",
|
||
);
|
||
}
|
||
|
||
$dates[$date] = true;
|
||
$minimumDate = $minimumDate === null || $date < $minimumDate ? $date : $minimumDate;
|
||
$maximumDate = $maximumDate === null || $date > $maximumDate ? $date : $maximumDate;
|
||
$rows[] = [
|
||
'report_date' => $date,
|
||
'sku_code' => $sku,
|
||
'opening_qty' => $opening,
|
||
'closing_qty' => $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_aux' => $movementValues['期初'],
|
||
'other_out' => $movementValues['其它出库'],
|
||
];
|
||
if (! isset($anchors[$sku]) || $date >= $anchors[$sku]['date']) {
|
||
$anchors[$sku] = ['sku_code' => $sku, 'qty' => $closing, 'date' => $date];
|
||
}
|
||
$rowCount++;
|
||
}
|
||
} finally {
|
||
$reader->close();
|
||
}
|
||
|
||
if ($columns === [] || $rowCount === 0 || $minimumDate === null || $maximumDate === null) {
|
||
throw new InvalidArgumentException('三方仓进销存导出文件中没有可用明细。');
|
||
}
|
||
|
||
$start = Carbon::parse($minimumDate);
|
||
$end = Carbon::parse($maximumDate);
|
||
if (! $start->isSameMonth($end)
|
||
|| $start->day !== 1
|
||
|| ! $end->isLastOfMonth()) {
|
||
throw new InvalidArgumentException('导出文件必须完整覆盖同一个自然月。');
|
||
}
|
||
for ($date = $start->copy(); $date->lte($end); $date->addDay()) {
|
||
if (! isset($dates[$date->toDateString()])) {
|
||
throw new InvalidArgumentException('导出文件缺少日期:' . $date->toDateString());
|
||
}
|
||
}
|
||
if ($anchors === []) {
|
||
throw new InvalidArgumentException('导出文件月末没有可用的商品期末数量。');
|
||
}
|
||
|
||
ksort($anchors, SORT_STRING);
|
||
|
||
$anchors = array_map(
|
||
static fn (array $anchor): array => [
|
||
'sku_code' => $anchor['sku_code'],
|
||
'qty' => $anchor['qty'],
|
||
],
|
||
$anchors,
|
||
);
|
||
|
||
return [
|
||
'period_start' => $minimumDate,
|
||
'period_end' => $maximumDate,
|
||
'row_count' => $rowCount,
|
||
'rows' => $rows,
|
||
'anchors' => array_values($anchors),
|
||
];
|
||
}
|
||
|
||
/** @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 date(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->toDateString();
|
||
}
|
||
|
||
private function sku(mixed $value, int $rowNumber): string
|
||
{
|
||
$sku = ProductCatalogAuthoritativeExportImporter::normalizeSkuId((string) $value);
|
||
if ($sku === '') {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。");
|
||
}
|
||
|
||
return $sku;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|