223 lines
7.5 KiB
PHP
223 lines
7.5 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use Illuminate\Database\DatabaseManager;
|
||
use InvalidArgumentException;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* 快递运费导入(.xlsx / .csv)。
|
||
*
|
||
* 源表头(按表头名匹配,列顺序无关):
|
||
* - 必需:来源 / 成品配件(也接受「成品运费」)/ 交易单号 / 提供成本运费;
|
||
* - 可选:快递单号 / 条形码 / 店铺名称(「财务系统运费整理.xlsx」起提供的列,老文件没有也能导)。
|
||
* 快递方给的文件常见几十万行,xlsx 走 XlsxSheetReader 流式读取,不整表进内存。
|
||
*/
|
||
class CourierFreightImporter
|
||
{
|
||
/** 源表头 → 数据库列 */
|
||
private const COLUMNS = [
|
||
'来源' => 'source',
|
||
'成品配件' => 'part',
|
||
'成品运费' => 'part',
|
||
'交易单号' => 'transaction_no',
|
||
'快递单号' => 'express_no',
|
||
'条形码' => 'barcode',
|
||
'店铺名称' => 'shop_name',
|
||
'提供成本运费' => 'freight_amount',
|
||
];
|
||
|
||
private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费'];
|
||
|
||
/** 可选列:文件里有就导入,没有就留空(老文件兼容)。 */
|
||
private const OPTIONAL_HEADERS = ['快递单号', '条形码', '店铺名称'];
|
||
|
||
/** 表头别名:源文件的列名写法不统一(成品配件 / 成品运费;店铺名称 / 店铺)。 */
|
||
private const HEADER_ALIASES = [
|
||
'成品配件' => ['成品配件', '成品运费'],
|
||
'店铺名称' => ['店铺名称', '店铺'],
|
||
];
|
||
|
||
private const CHUNK = 2000;
|
||
|
||
public function __construct(
|
||
private readonly DatabaseManager $databases,
|
||
private readonly ?XlsxSheetReader $reader = null,
|
||
) {}
|
||
|
||
/**
|
||
* @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array<string,int>,seconds:float}
|
||
*/
|
||
public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array
|
||
{
|
||
if (! is_file($path)) {
|
||
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
|
||
}
|
||
|
||
$target = $this->databases->connection();
|
||
$startedAt = microtime(true);
|
||
$now = now();
|
||
|
||
if ($replace) {
|
||
$target->table('courier_freight_rows')->where('company_id', $companyId)->delete();
|
||
}
|
||
|
||
$inserted = 0;
|
||
$skipped = 0;
|
||
$amount = 0.0;
|
||
$sources = [];
|
||
$buffer = [];
|
||
|
||
$flush = function () use (&$buffer, &$inserted, $target): void {
|
||
foreach (array_chunk($buffer, self::CHUNK) as $chunk) {
|
||
$target->table('courier_freight_rows')->insert($chunk);
|
||
$inserted += count($chunk);
|
||
}
|
||
$buffer = [];
|
||
};
|
||
|
||
foreach ($this->lines($path) as $line) {
|
||
$sourceValue = $this->text($line['来源'] ?? null, 64);
|
||
$transactionNo = $this->text($line['交易单号'] ?? null, 191);
|
||
if ($sourceValue === null && $transactionNo === null) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
$value = $this->number($line['提供成本运费'] ?? null);
|
||
$sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1;
|
||
$amount += $value;
|
||
|
||
$buffer[] = [
|
||
'company_id' => $companyId,
|
||
'source' => $sourceValue ?? '',
|
||
'part' => $this->text($line['成品配件'] ?? null, 191),
|
||
'transaction_no' => $transactionNo ?? '',
|
||
'express_no' => $this->text($line['快递单号'] ?? null, 64),
|
||
'barcode' => $this->text($line['条形码'] ?? null, 191),
|
||
'shop_name' => $this->text($line['店铺名称'] ?? null, 191),
|
||
'freight_amount' => number_format($value, 4, '.', ''),
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
|
||
if (count($buffer) >= self::CHUNK * 4) {
|
||
$flush();
|
||
}
|
||
}
|
||
|
||
$flush();
|
||
arsort($sources);
|
||
|
||
return [
|
||
'file' => basename($path),
|
||
'rows' => $inserted + $skipped,
|
||
'inserted' => $inserted,
|
||
'skipped' => $skipped,
|
||
'amount' => number_format($amount, 2, '.', ''),
|
||
'sources' => $sources,
|
||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||
];
|
||
}
|
||
|
||
/** @return \Generator<int, array<string,string>> */
|
||
private function lines(string $path): \Generator
|
||
{
|
||
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') {
|
||
$handle = fopen($path, 'rb');
|
||
if ($handle === false) {
|
||
throw new RuntimeException('无法读取文件:' . $path);
|
||
}
|
||
|
||
try {
|
||
$header = null;
|
||
while (($cells = fgetcsv($handle)) !== false) {
|
||
if ($header === null) {
|
||
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? ''));
|
||
$header = array_map(static fn ($value): string => trim((string) $value), $cells);
|
||
$this->assertHeader($header);
|
||
|
||
continue;
|
||
}
|
||
|
||
$line = [];
|
||
foreach ($header as $index => $name) {
|
||
$line[$this->canonicalName($name)] = (string) ($cells[$index] ?? '');
|
||
}
|
||
|
||
yield $line;
|
||
}
|
||
} finally {
|
||
fclose($handle);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||
throw new InvalidArgumentException('只支持 xlsx、csv。');
|
||
}
|
||
|
||
$columns = array_map(
|
||
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
|
||
self::HEADERS,
|
||
);
|
||
$optionalColumns = array_map(
|
||
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
|
||
self::OPTIONAL_HEADERS,
|
||
);
|
||
|
||
yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns, $optionalColumns);
|
||
}
|
||
|
||
/** 把表头别名折回规范列名。 */
|
||
private function canonicalName(string $header): string
|
||
{
|
||
foreach (self::HEADER_ALIASES as $canonical => $names) {
|
||
if (in_array($header, $names, true)) {
|
||
return $canonical;
|
||
}
|
||
}
|
||
|
||
return $header;
|
||
}
|
||
|
||
/** @param list<string> $header */
|
||
private function assertHeader(array $header): void
|
||
{
|
||
foreach (self::HEADERS as $required) {
|
||
$names = self::HEADER_ALIASES[$required] ?? [$required];
|
||
if (array_intersect($names, $header) === []) {
|
||
throw new InvalidArgumentException('文件缺少列:' . $required);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function text(mixed $value, int $limit): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
|
||
$text = trim((string) (is_scalar($value) ? $value : ''));
|
||
|
||
return $text === '' ? null : mb_substr($text, 0, $limit);
|
||
}
|
||
|
||
private function number(mixed $value): float
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return 0.0;
|
||
}
|
||
if (is_numeric($value)) {
|
||
return (float) $value;
|
||
}
|
||
|
||
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
|
||
|
||
return is_numeric($text) ? (float) $text : 0.0;
|
||
}
|
||
}
|