CaiWuXiTong/app/Business/JstOrderLabelImporter.php
qiuxiang e00d7cf871 feature/qiuxiang-jst-ssdd-seller-remark (#725)
> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: #725
2026-10-28 13:48:37 +08:00

396 lines
15 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\Business;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use RuntimeException;
/**
* 聚水潭订单标签明细导入(拆标签写入 jst_order_labels)。
*
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
*
* 两个数据源:
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
*
* 卖家备注包含「SSDD」时同时生成 SSDD 标签。卖家备注不是聚水潭系统标签,
* 但种菜判断需要把这类订单纳入标签明细。
*/
class JstOrderLabelImporter
{
public const NO_LABEL = '(无标签)';
public const SELLER_REMARK_LABEL = 'SSDD';
private const READ_CHUNK = 2000;
private const INSERT_CHUNK = 2000;
/** 聚水潭导出文件里用到的表头。 */
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
private const EXPORT_OPTIONAL_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->labelsForRecord(
(string) ($record->labels ?? ''),
(string) ($record->remark ?? ''),
);
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, self::EXPORT_OPTIONAL_COLUMNS) as $line) {
$anyRow = true;
if ($this->labelsForRecord(
(string) ($line['标签'] ?? ''),
(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, self::EXPORT_OPTIONAL_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->labelsForRecord(
(string) ($line['标签'] ?? ''),
(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));
}
/** @return list<string> */
private function labelsForRecord(string $labels, string $sellerRemark): array
{
$labels = $this->splitLabels($labels);
if (stripos($sellerRemark, self::SELLER_REMARK_LABEL) !== false
&& ! in_array(self::SELLER_REMARK_LABEL, $labels, true)) {
$labels[] = self::SELLER_REMARK_LABEL;
}
return $labels;
}
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;
}
}