> 开工时标题使用 `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
396 lines
15 KiB
PHP
396 lines
15 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,只能靠财务导出补)。
|
||
*
|
||
* 卖家备注包含「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;
|
||
}
|
||
}
|