220 lines
8.7 KiB
PHP
220 lines
8.7 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 厂家对账单模板:列头、留空规则与严格校验。
|
||
* 按业务要求:不按模板形式上传的一律整份失败(表头列名、顺序、列数必须完全一致)。
|
||
*/
|
||
class SupplierStatementTemplate
|
||
{
|
||
/** 模板列(顺序固定):前 3 列允许留空,其余必填。 */
|
||
public const COLUMNS = [
|
||
'expressName' => '快递名称',
|
||
'expressNo' => '快递单号',
|
||
'purchaseNo' => '单据编号(采购单号)',
|
||
'createdAt' => '创建时间',
|
||
'supplier' => '供应商',
|
||
'barcode' => '条码',
|
||
'skuName' => '规格名称',
|
||
'qty' => '数量',
|
||
'unitPrice' => '单价',
|
||
'amount' => '金额',
|
||
];
|
||
|
||
private const MAX_ROWS = 200000;
|
||
|
||
/** 允许留空的列:快递信息可缺;单价/金额留空时按「金额÷数量」或「数量×单价」补齐,都空按 0。 */
|
||
private const OPTIONAL_COLUMNS = ['expressName', 'expressNo', 'purchaseNo', 'unitPrice', 'amount'];
|
||
|
||
/** 必须是数字的列。 */
|
||
private const NUMERIC_COLUMNS = ['qty', 'unitPrice', 'amount'];
|
||
|
||
public function path(): string
|
||
{
|
||
return resource_path('templates/supplier-reconciliation/statement-template.xlsx');
|
||
}
|
||
|
||
public function filename(): string
|
||
{
|
||
return '供应商对账单模板.xlsx';
|
||
}
|
||
|
||
/**
|
||
* 解析并严格校验厂家上传的对账单。
|
||
*
|
||
* @return list<array<string,mixed>> 模块内部键的记录(条码/单价/数量/金额等)
|
||
*/
|
||
public function parse(UploadedFile $file, ?string $expectedSupplier = null): array
|
||
{
|
||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
|
||
throw ValidationException::withMessages(['import' => '只能上传 Excel(xlsx / xls)或 CSV 文件。']);
|
||
}
|
||
|
||
try {
|
||
$reader = PayablesWorkbookStreamReader::forFile((string) $file->getRealPath(), $extension);
|
||
$header = $this->locateHeader($reader);
|
||
} catch (ValidationException $exception) {
|
||
throw $exception;
|
||
} catch (Throwable) {
|
||
throw ValidationException::withMessages(['import' => '无法读取文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。']);
|
||
}
|
||
|
||
$records = [];
|
||
$fields = array_keys(self::COLUMNS);
|
||
foreach ($reader->rows($header['sheet'], $header['row']) as $rowNumber => $cells) {
|
||
$values = [];
|
||
foreach ($fields as $index => $field) {
|
||
$value = trim((string) ($cells[$index] ?? ''));
|
||
$values[$field] = $value === '' ? null : $value;
|
||
}
|
||
|
||
if (collect($values)->every(static fn (mixed $value): bool => $value === null)) {
|
||
continue;
|
||
}
|
||
|
||
foreach (self::COLUMNS as $field => $label) {
|
||
if ($values[$field] === null) {
|
||
if (in_array($field, self::OPTIONAL_COLUMNS, true)) {
|
||
continue;
|
||
}
|
||
|
||
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」不能为空。"]);
|
||
}
|
||
|
||
if (in_array($field, self::NUMERIC_COLUMNS, true)) {
|
||
$normalized = str_replace([' ', ',', ','], '', (string) $values[$field]);
|
||
if (preg_match('/^-?\d+(?:\.\d+)?$/', $normalized) !== 1) {
|
||
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」必须是数字。"]);
|
||
}
|
||
$values[$field] = $normalized;
|
||
}
|
||
}
|
||
|
||
$values = $this->completeAmounts($values);
|
||
|
||
if ($expectedSupplier !== null && $values['supplier'] !== $expectedSupplier) {
|
||
throw ValidationException::withMessages([
|
||
'import' => "第 {$rowNumber} 行:供应商必须填写「{$expectedSupplier}」,不能提交其他厂家的数据。",
|
||
]);
|
||
}
|
||
|
||
$records[] = $values;
|
||
if (count($records) > self::MAX_ROWS) {
|
||
throw ValidationException::withMessages(['import' => '单次最多上传 200,000 行,请拆分文件后重试。']);
|
||
}
|
||
}
|
||
|
||
if ($records === []) {
|
||
throw ValidationException::withMessages(['import' => '表格里没有数据行,请按模板填写后重新上传。']);
|
||
}
|
||
|
||
return $records;
|
||
}
|
||
|
||
/**
|
||
* 找出模板表头:列名、顺序、起始列必须与模板完全一致。
|
||
*
|
||
* @return array{sheet:int,row:int}
|
||
*/
|
||
private function locateHeader(PayablesWorkbookStreamReader $reader): array
|
||
{
|
||
$expected = array_values(self::COLUMNS);
|
||
$expectedNormalized = array_map($this->normalizeLabel(...), $expected);
|
||
$bestLabels = null;
|
||
$bestScore = -1;
|
||
|
||
foreach (array_keys($reader->sheetNames()) as $sheetIndex) {
|
||
foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) {
|
||
$labels = [];
|
||
foreach ($row as $columnIndex => $value) {
|
||
$label = $this->normalizeLabel((string) $value);
|
||
if ($label !== '') {
|
||
$labels[$columnIndex] = $label;
|
||
}
|
||
}
|
||
ksort($labels);
|
||
|
||
if (array_values($labels) === $expectedNormalized && array_keys($labels) === range(0, count($expected) - 1)) {
|
||
return ['sheet' => $sheetIndex, 'row' => $rowNumber];
|
||
}
|
||
|
||
$score = count(array_intersect(array_values($labels), $expectedNormalized));
|
||
if ($score > $bestScore) {
|
||
$bestScore = $score;
|
||
$bestLabels = array_values($labels);
|
||
}
|
||
}
|
||
}
|
||
|
||
throw ValidationException::withMessages([
|
||
'import' => '表头与模板不一致:' . $this->describeHeader($bestLabels, $expectedNormalized) . '请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。',
|
||
]);
|
||
}
|
||
|
||
/** @param list<string>|null $actual 标准化后的实际表头;@param list<string> $expected 标准化后的模板表头 */
|
||
private function describeHeader(?array $actual, array $expected): string
|
||
{
|
||
if ($actual === null) {
|
||
return '';
|
||
}
|
||
|
||
$display = [];
|
||
foreach (self::COLUMNS as $label) {
|
||
$display[$this->normalizeLabel($label)] = $label;
|
||
}
|
||
|
||
$missing = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($expected, $actual)));
|
||
$extra = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($actual, $expected)));
|
||
|
||
$parts = [];
|
||
if ($missing !== []) {
|
||
$parts[] = '缺少「' . implode('」「', $missing) . '」';
|
||
}
|
||
if ($extra !== []) {
|
||
$parts[] = '多出「' . implode('」「', $extra) . '」';
|
||
}
|
||
$commonActual = array_values(array_filter($actual, static fn (string $key): bool => in_array($key, $expected, true)));
|
||
$commonExpected = array_values(array_filter($expected, static fn (string $key): bool => in_array($key, $actual, true)));
|
||
if ($commonActual !== $commonExpected) {
|
||
$parts[] = '列顺序与模板不同';
|
||
}
|
||
|
||
return $parts === [] ? '' : implode(',', $parts) . ';';
|
||
}
|
||
|
||
/** 比较表头时忽略空白与全半角括号差异。 */
|
||
private function normalizeLabel(string $label): string
|
||
{
|
||
$label = preg_replace('/\s+/u', '', trim($label)) ?? '';
|
||
$label = str_replace(['(', ')'], ['(', ')'], $label);
|
||
|
||
return mb_strtolower($label);
|
||
}
|
||
|
||
/** 单价/金额留空时互相补算;两者都空按 0(未定价行厂家直接导出时会出现)。 */
|
||
private function completeAmounts(array $values): array
|
||
{
|
||
$qty = (float) ($values['qty'] ?? 0);
|
||
$unitPrice = $values['unitPrice'] === null ? null : (float) $values['unitPrice'];
|
||
$amount = $values['amount'] === null ? null : (float) $values['amount'];
|
||
|
||
if ($unitPrice === null && $amount !== null && $qty != 0.0) {
|
||
$values['unitPrice'] = (string) round($amount / $qty, 4);
|
||
} elseif ($unitPrice === null) {
|
||
$values['unitPrice'] = '0';
|
||
}
|
||
|
||
if ($amount === null) {
|
||
$values['amount'] = (string) round($qty * (float) $values['unitPrice'], 2);
|
||
}
|
||
|
||
return $values;
|
||
}
|
||
}
|