CaiWuXiTong/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageAuthoritativeAnchorImporter.php
limeng 179b2cdbcc 接入并校准三方仓进销存 (#176)
新增三方仓进销存共享镜像、按日查询汇总导出、每日零点增量同步和权威历史锚点校准。
2026-09-12 20:29:43 +08:00

502 lines
19 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 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;
}
}