CaiWuXiTong/app/Console/Commands/ImportAfterSalesCostRows.php
renshen 0bf1ae67af 导入订单售后与商品推广月度明细 (#578)
新增 2026-06 月度订单售后及商品推广导入能力
2026-10-26 11:09:22 +08:00

206 lines
6.9 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 App\Console\Commands;
use App\Business\XlsxSheetReader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
*
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
* 在覆盖的店铺/月份范围内直接按这些明细行展示。
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost
{file : Excel 文件路径}
{--period= : 数据月份,格式 YYYY-MM}
{--chunk=500 : 每批写入行数}
{--dry-run : 只解析不入库}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
public function handle(XlsxSheetReader $reader): int
{
$path = (string) $this->argument('file');
$period = trim((string) $this->option('period'));
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
if ($period !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
$this->error('数据月份格式必须是 YYYY-MM:' . $period);
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);
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$shops = [];
$mappedRows = 0;
$minDate = null;
$maxDate = null;
$importedAt = now()->toDateTimeString();
try {
foreach ($reader->rows($path, [], array_keys($headers)) as $row) {
$total++;
$payload = [];
foreach ($headers as $header => $key) {
if (! array_key_exists($header, $row)) {
continue;
}
$value = trim((string) $row[$header]);
if ($value === '') {
continue;
}
if (isset($numberKeys[$key])) {
$value = is_numeric($value) ? (float) $value : 0.0;
} elseif (self::isDateKey($key)) {
$value = self::dateValue($value);
}
$payload[$key] = $value;
}
if ($payload !== []) {
$mappedRows++;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
$productCode = trim((string) ($payload['product_code'] ?? ''));
if ($shop === '' || $productCode === '') {
$skipped++;
continue;
}
$buffer[] = AfterSalesCostRowRepository::toRecord(
$payload,
$importedAt,
$period !== '' ? $period : null,
);
$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 = [];
}
}
} catch (\Throwable $exception) {
if (! $dryRun) {
Cache::forget('order-data.after-sales.imported-shop-options');
}
$this->error('读取失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($mappedRows === 0) {
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
}
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
if (! $dryRun) {
Cache::forget('order-data.after-sales.imported-shop-options');
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;订单时间 %s ~ %s;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$period !== '' ? $period : '-',
$minDate ?? '-',
$maxDate ?? '-',
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
/** @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',
'style_code', 'shop_product_code', 'shop_style_code',
'order_time', 'source_period', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
'combine_type', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
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) {
$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);
}
}