368 lines
14 KiB
PHP
368 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use Illuminate\Database\DatabaseManager;
|
||
use InvalidArgumentException;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* 聚水潭订单标签明细导入(拆标签写入 jst_order_labels)。
|
||
*
|
||
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
|
||
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
|
||
*
|
||
* 两个数据源:
|
||
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
|
||
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
|
||
*/
|
||
class JstOrderLabelImporter
|
||
{
|
||
public const NO_LABEL = '(无标签)';
|
||
|
||
private const READ_CHUNK = 2000;
|
||
|
||
private const INSERT_CHUNK = 2000;
|
||
|
||
/** 聚水潭导出文件里用到的表头。 */
|
||
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
|
||
|
||
public function __construct(
|
||
private readonly DatabaseManager $databases,
|
||
private readonly ?XlsxSheetReader $exportReader = null,
|
||
private readonly ?JstOrderLabelItemCodes $itemCodes = null,
|
||
) {}
|
||
|
||
/**
|
||
* @return array{from:string,to:string,orders:int,rows:int,no_label_orders:int,labels:int,seconds:float}
|
||
*/
|
||
public function import(string $from, string $to, int $companyId = 1, bool $replace = false, string $source = 'jst-order-label-import'): array
|
||
{
|
||
$from = $this->normalizeDate($from, '起始日期');
|
||
$to = $this->normalizeDate($to, '结束日期');
|
||
if ($from > $to) {
|
||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||
}
|
||
|
||
$start = $from . ' 00:00:00';
|
||
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||
|
||
$mirror = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||
if (! $mirror->getSchemaBuilder()->hasTable('ops_jst_orders')) {
|
||
throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。');
|
||
}
|
||
|
||
$target = $this->databases->connection();
|
||
$startedAt = microtime(true);
|
||
$itemCodes = $this->itemCodes ?? new JstOrderLabelItemCodes();
|
||
$hasItemTable = $mirror->getSchemaBuilder()->hasTable('ops_jst_order_item');
|
||
|
||
if ($replace) {
|
||
$target->table('jst_order_labels')
|
||
->where('company_id', $companyId)
|
||
->where('ordered_at', '>=', $start)
|
||
->where('ordered_at', '<', $end)
|
||
->delete();
|
||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||
}
|
||
|
||
$now = now();
|
||
$orders = 0;
|
||
$rows = 0;
|
||
$noLabelOrders = 0;
|
||
$buffer = [];
|
||
|
||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||
$target->table('jst_order_labels')->insert($chunk);
|
||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||
$rows += count($chunk);
|
||
}
|
||
$buffer = [];
|
||
};
|
||
|
||
$mirror->table('ops_jst_orders')
|
||
->where('order_date', '>=', $start)
|
||
->where('order_date', '<', $end)
|
||
->orderBy('Id')
|
||
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now, $itemCodes, $hasItemTable, $mirror): void {
|
||
// 货号:同批订单的商品编码(镜像商品明细,按 o_id 取),导入时一并写入
|
||
$orderCodes = $hasItemTable
|
||
? $itemCodes->forOrderIds($mirror, 'ops_jst_order_item', $records->pluck('o_id')->all())
|
||
: [];
|
||
|
||
foreach ($records as $record) {
|
||
$orders++;
|
||
$orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19);
|
||
if ($orderedAt === null || $orderedAt === '') {
|
||
continue;
|
||
}
|
||
|
||
$labels = $this->splitLabels((string) ($record->labels ?? ''));
|
||
if ($labels === []) {
|
||
$labels = [self::NO_LABEL];
|
||
$noLabelOrders++;
|
||
}
|
||
|
||
$itemCode = $orderCodes[(int) $record->o_id] ?? null;
|
||
|
||
foreach ($labels as $label) {
|
||
$buffer[] = [
|
||
'company_id' => $companyId,
|
||
'order_no' => mb_substr((string) $record->so_id, 0, 100),
|
||
'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191),
|
||
'ordered_at' => $orderedAt,
|
||
'label' => $label,
|
||
'item_code' => $itemCode,
|
||
'created_from' => $source,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
}
|
||
|
||
if (count($buffer) >= self::INSERT_CHUNK * 2) {
|
||
$flush();
|
||
}
|
||
}
|
||
}, 'Id');
|
||
|
||
$flush();
|
||
|
||
$labels = $target->table('jst_order_labels')
|
||
->where('company_id', $companyId)
|
||
->where('ordered_at', '>=', $start)
|
||
->where('ordered_at', '<', $end)
|
||
->distinct()
|
||
->count('label');
|
||
|
||
// 导入会改变筛选选项,重新预热缓存,避免页面下次打开时做几秒的 distinct 扫描。
|
||
app(JstOrderLabelOptions::class)->forget($companyId);
|
||
app(JstOrderLabelOptions::class)->warm($companyId);
|
||
|
||
return [
|
||
'from' => $from,
|
||
'to' => $to,
|
||
'orders' => $orders,
|
||
'rows' => $rows,
|
||
'no_label_orders' => $noLabelOrders,
|
||
'labels' => (int) $labels,
|
||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 从聚水潭「导出订单」Excel 导入。
|
||
*
|
||
* 导出文件是一单多行(商品明细),且同一单里可能只有部分明细行带标签:
|
||
* 第一遍先挑出真正有标签的订单号,第二遍再按「线上订单号 + 标签」去重拆行写入,
|
||
* 只对整单没标签的订单写「(无标签)」。同时兼容只勾 4 列导出的极简表头。
|
||
*
|
||
* 传 $from/$to 时会顺带按窗口过滤;带 $replace 时必须给窗口,
|
||
* 以便在写入前先清掉该区间,避免重复导入翻倍。
|
||
*
|
||
* @return array{file:string,company_id:int,from:?string,to:?string,orders:int,rows:int,no_label_orders:int,skipped:int,labels:int,seconds:float}
|
||
*/
|
||
public function importFromFile(
|
||
string $path,
|
||
?string $from = null,
|
||
?string $to = null,
|
||
int $companyId = 1,
|
||
bool $replace = false,
|
||
string $source = 'jst-order-label-file-import',
|
||
): array {
|
||
$from = $from === null || trim($from) === '' ? null : $this->normalizeDate(trim($from), '起始日期');
|
||
$to = $to === null || trim($to) === '' ? null : $this->normalizeDate(trim($to), '结束日期');
|
||
if ($from !== null && $to !== null && $from > $to) {
|
||
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
|
||
}
|
||
if ($replace && ($from === null || $to === null)) {
|
||
throw new InvalidArgumentException('按文件导入并使用 --replace 时,必须同时给出 --from 与 --to,才能确定要清空的区间。');
|
||
}
|
||
|
||
$start = $from === null ? null : $from . ' 00:00:00';
|
||
$end = $to === null ? null : date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
|
||
|
||
$target = $this->databases->connection();
|
||
$startedAt = microtime(true);
|
||
|
||
// 清空区间推迟到第一次成功读到数据行之后:万一文件打不开或表头不对,
|
||
// 不能先把线上已有行删掉又导入失败。
|
||
$clearWindow = function () use ($target, $companyId, $start, $end): void {
|
||
$target->table('jst_order_labels')
|
||
->where('company_id', $companyId)
|
||
->where('ordered_at', '>=', $start)
|
||
->where('ordered_at', '<', $end)
|
||
->delete();
|
||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||
};
|
||
$cleared = ! $replace;
|
||
|
||
$now = now();
|
||
$orders = 0;
|
||
$rows = 0;
|
||
$noLabelOrders = 0;
|
||
$skipped = 0;
|
||
$seen = [];
|
||
$orderSeen = [];
|
||
$buffer = [];
|
||
|
||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||
$target->table('jst_order_labels')->insert($chunk);
|
||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||
$rows += count($chunk);
|
||
}
|
||
$buffer = [];
|
||
};
|
||
|
||
$reader = $this->exportReader ?? app(XlsxSheetReader::class);
|
||
|
||
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
|
||
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
|
||
$labeledOrders = [];
|
||
$anyRow = false;
|
||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||
$anyRow = true;
|
||
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
|
||
continue;
|
||
}
|
||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||
if ($orderNo !== '') {
|
||
$labeledOrders[$orderNo] = true;
|
||
}
|
||
}
|
||
if (! $anyRow) {
|
||
throw new RuntimeException('订单导出文件里没有数据行:' . basename($path));
|
||
}
|
||
|
||
if (! $cleared) {
|
||
$clearWindow();
|
||
$cleared = true;
|
||
}
|
||
|
||
// 第二遍:按「线上订单号 + 标签」去重后写入。
|
||
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
|
||
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
|
||
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
|
||
if ($orderNo === '' || $orderedAt === null) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
if ($start !== null && ($orderedAt < $start || $orderedAt >= $end)) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
|
||
$withoutLabel = $labels === [];
|
||
if ($withoutLabel) {
|
||
if (isset($labeledOrders[$orderNo])) {
|
||
continue;
|
||
}
|
||
$labels = [self::NO_LABEL];
|
||
}
|
||
|
||
if (! isset($orderSeen[$orderNo])) {
|
||
$orderSeen[$orderNo] = true;
|
||
$orders++;
|
||
}
|
||
|
||
$shopName = mb_substr(trim((string) ($line['店铺名称'] ?? '')), 0, 191);
|
||
|
||
foreach ($labels as $label) {
|
||
$fingerprint = hash('xxh3', $orderNo . "\0" . $label);
|
||
if (isset($seen[$fingerprint])) {
|
||
continue;
|
||
}
|
||
$seen[$fingerprint] = true;
|
||
if ($withoutLabel) {
|
||
$noLabelOrders++;
|
||
}
|
||
|
||
$buffer[] = [
|
||
'company_id' => $companyId,
|
||
'order_no' => mb_substr($orderNo, 0, 100),
|
||
'shop_name' => $shopName,
|
||
'ordered_at' => $orderedAt,
|
||
'label' => $label,
|
||
'created_from' => $source,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
}
|
||
|
||
if (count($buffer) >= self::INSERT_CHUNK * 2) {
|
||
$flush();
|
||
}
|
||
}
|
||
|
||
$flush();
|
||
|
||
$labelQuery = $target->table('jst_order_labels')->where('company_id', $companyId);
|
||
if ($start !== null) {
|
||
$labelQuery->where('ordered_at', '>=', $start)->where('ordered_at', '<', $end);
|
||
}
|
||
$labels = $labelQuery->distinct()->count('label');
|
||
|
||
app(JstOrderLabelOptions::class)->forget($companyId);
|
||
app(JstOrderLabelOptions::class)->warm($companyId);
|
||
|
||
return [
|
||
'file' => basename($path),
|
||
'company_id' => $companyId,
|
||
'from' => $from,
|
||
'to' => $to,
|
||
'orders' => $orders,
|
||
'rows' => $rows,
|
||
'no_label_orders' => $noLabelOrders,
|
||
'skipped' => $skipped,
|
||
'labels' => (int) $labels,
|
||
'seconds' => round(microtime(true) - $startedAt, 1),
|
||
];
|
||
}
|
||
|
||
/** 下单时间归一成 Y-m-d H:i:s;解析不了就返回 null(该行跳过)。 */
|
||
private function normalizeOrderedAt(string $value): ?string
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
$timestamp = strtotime($value);
|
||
|
||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function splitLabels(string $labels): array
|
||
{
|
||
$out = [];
|
||
foreach (explode(',', $labels) as $label) {
|
||
$label = trim($label);
|
||
if ($label !== '') {
|
||
$out[] = mb_substr($label, 0, 191);
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($out));
|
||
}
|
||
|
||
private function normalizeDate(string $value, string $label): string
|
||
{
|
||
$value = trim($value);
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
|
||
}
|
||
|
||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||
if (! checkdate($month, $day, $year)) {
|
||
throw new InvalidArgumentException($label . '不是有效日期。');
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
}
|