242 lines
8.2 KiB
PHP
242 lines
8.2 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
class ProductPriceWorkbookImporter
|
||
{
|
||
/** @var array<string,string> */
|
||
private const HEADERS = [
|
||
'记录标识' => 'upstream_key',
|
||
'商品编码' => 'barcode',
|
||
'SPU' => 'spu',
|
||
'商品名称' => 'product_name',
|
||
'类目' => 'category',
|
||
'生产状态' => 'production_status',
|
||
'最低售价' => 'minimum_price',
|
||
'计算限价' => 'price_limit',
|
||
'采购价' => 'purchase_price',
|
||
'运费' => 'shipping_fee',
|
||
'京喜限价' => 'jingxi_limit_price',
|
||
];
|
||
|
||
/** @var array<string,int> */
|
||
private const TEXT_LIMITS = [
|
||
'barcode' => 191,
|
||
'spu' => 191,
|
||
'product_name' => 5000,
|
||
'category' => 255,
|
||
'production_status' => 120,
|
||
];
|
||
|
||
public function __construct(private readonly ProductPriceMirrorRepository $repository)
|
||
{
|
||
}
|
||
|
||
/** @return array<string,array{label:string,type:string}> */
|
||
public function templateFields(): array
|
||
{
|
||
$fields = [];
|
||
|
||
foreach (self::HEADERS as $label => $field) {
|
||
if ($field === 'upstream_key') {
|
||
continue;
|
||
}
|
||
|
||
$fields[$field] = [
|
||
'label' => $label,
|
||
'type' => isset(self::TEXT_LIMITS[$field]) ? 'text' : 'number',
|
||
];
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
|
||
/** @return array{created:int,updated:int,rows:int} */
|
||
public function import(UploadedFile $file, int $companyId, ?int $userId): array
|
||
{
|
||
try {
|
||
$spreadsheet = IOFactory::load($file->getRealPath());
|
||
[$rows, $headerRow, $columns] = $this->bestMatchingSheet($spreadsheet);
|
||
} catch (Throwable) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||
]);
|
||
} finally {
|
||
if (isset($spreadsheet)) {
|
||
$spreadsheet->disconnectWorksheets();
|
||
}
|
||
}
|
||
|
||
if ($rows === null || ! in_array('upstream_key', $columns, true)) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '未找到“记录标识”列,请先导出当前数据,再使用导出的表格进行修改。',
|
||
]);
|
||
}
|
||
|
||
if (count(array_intersect($columns, ProductPriceMirrorRepository::EDITABLE_FIELDS)) === 0) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '表格中没有可导入的商品价格字段。',
|
||
]);
|
||
}
|
||
|
||
$records = [];
|
||
|
||
foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) {
|
||
$line = $rowIndex + 1;
|
||
$upstreamKey = '';
|
||
$changes = [];
|
||
|
||
foreach ($columns as $columnIndex => $field) {
|
||
$value = $row[$columnIndex] ?? null;
|
||
|
||
if ($field === 'upstream_key') {
|
||
$upstreamKey = strtolower(trim((string) $value));
|
||
continue;
|
||
}
|
||
|
||
$changes[$field] = $this->normalizeValue($field, $value, $line);
|
||
}
|
||
|
||
if ($upstreamKey === '' && collect($changes)->every(
|
||
static fn (mixed $value): bool => $value === null || $value === ''
|
||
)) {
|
||
continue;
|
||
}
|
||
|
||
if (preg_match('/^[a-f0-9]{64}$/', $upstreamKey) !== 1) {
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“记录标识”无效,请勿修改导出文件中的该列。",
|
||
]);
|
||
}
|
||
|
||
if (array_key_exists($upstreamKey, $records)) {
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“记录标识”重复。",
|
||
]);
|
||
}
|
||
|
||
if (array_key_exists('barcode', $changes) && trim((string) $changes['barcode']) === '') {
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“商品编码”不能为空。",
|
||
]);
|
||
}
|
||
|
||
if (collect($changes)->every(static fn (mixed $value): bool => $value === null || $value === '')) {
|
||
continue;
|
||
}
|
||
|
||
$records[$upstreamKey] = $changes;
|
||
|
||
if (count($records) > 10000) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '单次最多导入 10,000 条商品价格记录,请拆分文件后重试。',
|
||
]);
|
||
}
|
||
}
|
||
|
||
if ($records === []) {
|
||
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
|
||
}
|
||
|
||
try {
|
||
$result = $this->repository->applyOverrides($records, $companyId, $userId, 'import');
|
||
} catch (RuntimeException $exception) {
|
||
if (str_starts_with($exception->getMessage(), 'product_price_record_not_found:')) {
|
||
throw ValidationException::withMessages([
|
||
'import' => '导入文件包含当前商品价格镜像中不存在的记录,请重新导出后再导入。',
|
||
]);
|
||
}
|
||
|
||
if ($exception->getMessage() === 'product_price_maintenance_schema_missing') {
|
||
throw ValidationException::withMessages([
|
||
'import' => '商品价格维护功能尚未完成初始化,请稍后重试。',
|
||
]);
|
||
}
|
||
|
||
throw $exception;
|
||
}
|
||
|
||
return $result + ['rows' => count($records)];
|
||
}
|
||
|
||
private function bestMatchingSheet($spreadsheet): array
|
||
{
|
||
$lookup = [];
|
||
|
||
foreach (self::HEADERS as $label => $field) {
|
||
$lookup[$this->normalizeHeader($label)] = $field;
|
||
$lookup[$this->normalizeHeader($field)] = $field;
|
||
}
|
||
|
||
$best = [null, null, []];
|
||
$bestScore = 0;
|
||
|
||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||
$rows = $worksheet->toArray(null, false, false, false);
|
||
|
||
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
|
||
$columns = [];
|
||
|
||
foreach ($row as $columnIndex => $heading) {
|
||
$key = $this->normalizeHeader($heading);
|
||
|
||
if ($key !== '' && isset($lookup[$key])) {
|
||
$columns[$columnIndex] = $lookup[$key];
|
||
}
|
||
}
|
||
|
||
if (count($columns) > $bestScore) {
|
||
$best = [$rows, $rowIndex, $columns];
|
||
$bestScore = count($columns);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
private function normalizeHeader(mixed $value): string
|
||
{
|
||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
|
||
}
|
||
|
||
private function normalizeValue(string $field, mixed $value, int $line): mixed
|
||
{
|
||
if ($value === null || trim((string) $value) === '') {
|
||
return null;
|
||
}
|
||
|
||
if (isset(self::TEXT_LIMITS[$field])) {
|
||
$text = trim((string) $value);
|
||
|
||
if (mb_strlen($text) > self::TEXT_LIMITS[$field]) {
|
||
$label = array_search($field, self::HEADERS, true) ?: $field;
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“{$label}”内容过长。",
|
||
]);
|
||
}
|
||
|
||
return $text;
|
||
}
|
||
|
||
$number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
|
||
|
||
if (preg_match('/^(?:\d+(?:\.\d{1,4})?|\.\d{1,4})$/', $number) !== 1
|
||
|| bccomp($number, '9999999999999999.9999', 4) === 1) {
|
||
$label = array_search($field, self::HEADERS, true) ?: $field;
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$line} 行“{$label}”必须是大于或等于 0、最多 4 位小数的有效金额。",
|
||
]);
|
||
}
|
||
|
||
return ltrim($number, '+');
|
||
}
|
||
}
|