327 lines
9.6 KiB
PHP
327 lines
9.6 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
|
||
use XMLReader;
|
||
use ZipArchive;
|
||
|
||
/**
|
||
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
|
||
*
|
||
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
|
||
* 在覆盖的店铺/日期范围内直接按这些明细行展示。
|
||
*/
|
||
class ImportAfterSalesCostRows extends Command
|
||
{
|
||
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=500 : 每批写入行数} {--dry-run : 只解析不入库}';
|
||
|
||
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
|
||
|
||
public function handle(): int
|
||
{
|
||
$path = (string) $this->argument('file');
|
||
|
||
if (! is_file($path)) {
|
||
$this->error('文件不存在:' . $path);
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$headers = (array) config('order_after_sales_cost.headers', []);
|
||
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
|
||
|
||
if ($headers === []) {
|
||
$this->error('缺少表头映射:config/order_after_sales_cost.php');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$this->info('流式读取文件:' . $path);
|
||
|
||
$sheetPath = $this->firstSheetPath($path);
|
||
|
||
if ($sheetPath === null) {
|
||
$this->error('不是有效的 xlsx(缺少工作表)。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
try {
|
||
$sharedStrings = $this->readSharedStrings($path);
|
||
} catch (\Throwable $exception) {
|
||
$this->error('读取共享字符串失败:' . $exception->getMessage());
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$columns = [];
|
||
$first = true;
|
||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
$buffer = [];
|
||
$total = 0;
|
||
$written = 0;
|
||
$skipped = 0;
|
||
$shops = [];
|
||
$minDate = null;
|
||
$maxDate = null;
|
||
$importedAt = now()->toDateTimeString();
|
||
|
||
$reader = new XMLReader();
|
||
|
||
if (! $reader->open('zip://' . $path . '#' . $sheetPath)) {
|
||
$this->error('无法打开工作表:' . $sheetPath);
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
while ($reader->read()) {
|
||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||
continue;
|
||
}
|
||
|
||
$cells = $this->readRow($reader, $sharedStrings);
|
||
|
||
if ($first) {
|
||
$first = false;
|
||
|
||
foreach ($cells as $index => $header) {
|
||
$header = self::normalizeHeader((string) $header);
|
||
|
||
if ($header !== '' && isset($headers[$header])) {
|
||
$columns[$index] = $headers[$header];
|
||
}
|
||
}
|
||
|
||
if ($columns === []) {
|
||
$reader->close();
|
||
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$total++;
|
||
$payload = [];
|
||
|
||
foreach ($columns as $index => $key) {
|
||
$value = $cells[$index] ?? null;
|
||
|
||
if (is_string($value)) {
|
||
$value = trim($value);
|
||
}
|
||
|
||
if ($value === '' || $value === null) {
|
||
continue;
|
||
}
|
||
|
||
if (isset($numberKeys[$key])) {
|
||
$value = is_numeric($value) ? (float) $value : 0.0;
|
||
} elseif (self::isDateKey($key)) {
|
||
$value = self::dateValue($value);
|
||
}
|
||
|
||
$payload[$key] = $value;
|
||
}
|
||
|
||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||
|
||
if ($shop === '' || empty($payload['product_code'])) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
$buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt);
|
||
$shops[$shop] = true;
|
||
$orderTime = $payload['order_time'] ?? null;
|
||
|
||
if (is_string($orderTime) && $orderTime !== '') {
|
||
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
|
||
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
|
||
}
|
||
|
||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||
$written += $this->flush($buffer);
|
||
$buffer = [];
|
||
$this->output->write("\r写入 " . $written . ' 行');
|
||
}
|
||
|
||
if ($dryRun && $buffer !== []) {
|
||
$buffer = [];
|
||
}
|
||
}
|
||
|
||
$reader->close();
|
||
|
||
if (! $dryRun && $buffer !== []) {
|
||
$written += $this->flush($buffer);
|
||
}
|
||
|
||
$this->newLine(2);
|
||
$this->info(sprintf(
|
||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s;峰值内存 %.1f MB',
|
||
$total,
|
||
$written,
|
||
$skipped,
|
||
count($shops),
|
||
$minDate ?? '-',
|
||
$maxDate ?? '-',
|
||
memory_get_peak_usage(true) / 1048576,
|
||
));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
private function firstSheetPath(string $path): ?string
|
||
{
|
||
$zip = new ZipArchive();
|
||
|
||
if ($zip->open($path) !== true) {
|
||
return null;
|
||
}
|
||
|
||
$sheet = null;
|
||
|
||
if ($zip->locateName('xl/worksheets/sheet1.xml') !== false) {
|
||
$sheet = 'xl/worksheets/sheet1.xml';
|
||
} else {
|
||
for ($i = 1; $i <= 30; $i++) {
|
||
$name = 'xl/worksheets/sheet' . $i . '.xml';
|
||
|
||
if ($zip->locateName($name) !== false) {
|
||
$sheet = $name;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$zip->close();
|
||
|
||
return $sheet;
|
||
}
|
||
|
||
/** @return array<int,string> */
|
||
private function readSharedStrings(string $path): array
|
||
{
|
||
$strings = [];
|
||
$reader = new XMLReader();
|
||
|
||
if (! $reader->open('zip://' . $path . '#xl/sharedStrings.xml')) {
|
||
return $strings;
|
||
}
|
||
|
||
while ($reader->read()) {
|
||
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
|
||
$xml = $reader->readOuterXml();
|
||
$strings[] = html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||
}
|
||
}
|
||
|
||
$reader->close();
|
||
|
||
return $strings;
|
||
}
|
||
|
||
/** @param array<int,string> $shared @return array<int,mixed> */
|
||
private function readRow(XMLReader $reader, array $shared): array
|
||
{
|
||
$cells = [];
|
||
$xml = $reader->readOuterXml();
|
||
|
||
if ($xml === '') {
|
||
return $cells;
|
||
}
|
||
|
||
$row = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||
|
||
if ($row === false) {
|
||
return $cells;
|
||
}
|
||
|
||
foreach ($row->c as $cell) {
|
||
$index = self::columnIndex((string) $cell['r']);
|
||
$type = (string) $cell['t'];
|
||
|
||
if ($type === 's') {
|
||
$value = $shared[(int) $cell->v] ?? '';
|
||
} elseif ($type === 'inlineStr') {
|
||
$value = (string) $cell->is->t;
|
||
} else {
|
||
$value = (string) $cell->v;
|
||
}
|
||
|
||
$cells[$index] = $value;
|
||
}
|
||
|
||
return $cells;
|
||
}
|
||
|
||
private static function columnIndex(string $reference): int
|
||
{
|
||
$letters = preg_replace('/[^A-Z]/', '', strtoupper($reference)) ?? '';
|
||
$index = 0;
|
||
|
||
foreach (str_split($letters) as $letter) {
|
||
$index = $index * 26 + (ord($letter) - 64);
|
||
}
|
||
|
||
return $index - 1;
|
||
}
|
||
|
||
/** @param list<array<string,mixed>> $rows */
|
||
private function flush(array $rows): int
|
||
{
|
||
DB::table('ops_after_sales_cost_rows')->upsert(
|
||
$rows,
|
||
['row_key'],
|
||
[
|
||
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
|
||
'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
|
||
'combine_type', 'payload', 'imported_at', 'updated_at',
|
||
],
|
||
);
|
||
|
||
return count($rows);
|
||
}
|
||
|
||
private static function normalizeHeader(string $header): string
|
||
{
|
||
$header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header);
|
||
$header = preg_replace('/\s+/u', ' ', $header) ?? $header;
|
||
|
||
return trim($header);
|
||
}
|
||
|
||
private static function isDateKey(string $key): bool
|
||
{
|
||
return in_array($key, [
|
||
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
|
||
'confirmation_time', 'warehouse_entry_time',
|
||
], true);
|
||
}
|
||
|
||
private static function dateValue(mixed $value): ?string
|
||
{
|
||
if ($value instanceof \DateTimeInterface) {
|
||
return $value->format('Y-m-d H:i:s');
|
||
}
|
||
|
||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
|
||
// Excel 序列日期(1899-12-30 起算),按本地时间格式化。
|
||
$timestamp = ((float) $value - 25569) * 86400;
|
||
|
||
return gmdate('Y-m-d H:i:s', (int) $timestamp);
|
||
}
|
||
|
||
$timestamp = strtotime((string) $value);
|
||
|
||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||
}
|
||
}
|