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 */ 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 */ 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; } }