diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index d79c3dbe..c44d48cd 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -293,7 +293,7 @@ :fetch-fun="prescriptionOrderExport" :params="prescriptionOrderExportParams" :page-size="pager.size" - export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源:优先库表轨迹;库内无轨迹时导出会按 logisticsTrace 口径补查快递100 并落库(如 PO 单在详情已签收但库内为空)。" + export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。" /> diff --git a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php index aabe99ae..c3371ae1 100755 --- a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php +++ b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php @@ -560,6 +560,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn $poIds = array_values(array_filter(array_map('intval', array_column($lists, 'id')))); $paidSumByPo = []; + // 关联支付单数量:批量统计,避免后续逐行 count() 造成 N+1 查询(导出/大列表明显变慢) + $linkCountByPo = []; if ($poIds !== []) { $links = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds) ->field(['prescription_order_id', 'pay_order_id']) @@ -569,6 +571,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn foreach ($links as $l) { $poid = (int) ($l['prescription_order_id'] ?? 0); $oid = (int) ($l['pay_order_id'] ?? 0); + if ($poid > 0) { + $linkCountByPo[$poid] = ($linkCountByPo[$poid] ?? 0) + 1; + } if ($poid > 0 && $oid > 0) { $byPo[$poid][] = $oid; } @@ -664,10 +669,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo); PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo); $pid = (int) ($item['id'] ?? 0); - $item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where( - 'prescription_order_id', - $pid - )->count(); + $item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0); $item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0; $item['deposit_min_amount'] = $depMin; $item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel( diff --git a/server/app/adminapi/logic/stats/YejiStatsLogic.php b/server/app/adminapi/logic/stats/YejiStatsLogic.php index 7acedadd..0f40178e 100755 --- a/server/app/adminapi/logic/stats/YejiStatsLogic.php +++ b/server/app/adminapi/logic/stats/YejiStatsLogic.php @@ -5638,20 +5638,33 @@ class YejiStatsLogic */ private static function commissionSettlementExpressRowSignExpr(string $etTable, string $traceTable): string { - $sigTraceCond = "`tr_sig`.`status_code` IN ('3','301','302','304') OR `tr_sig`.`status` LIKE '%签收%' " + // 与 ExpressTrackingService::contextLooksSigned 对齐: + // 正向「签收/妥投/送达/本人签收/已放在」(顺丰末端 status=送达、context=快件已放在…), + // 先排除「准备签收/待签收/即将送达/预计」等「尚未签收」文案(含「签收」「送达」二字易误判)。 + $sigByCode = "`tr_sig`.`status_code` IN ('3','301','302','304')"; + $sigByText = "(`tr_sig`.`status` LIKE '%签收%' OR `tr_sig`.`status` LIKE '%送达%' " . "OR `tr_sig`.`trace_context` LIKE '%签收%' OR `tr_sig`.`trace_context` LIKE '%妥投%' " - . "OR `tr_sig`.`trace_context` LIKE '%已送达%' OR `tr_sig`.`trace_context` LIKE '%本人签收%'"; + . "OR `tr_sig`.`trace_context` LIKE '%送达%' OR `tr_sig`.`trace_context` LIKE '%本人签收%' " + . "OR `tr_sig`.`trace_context` LIKE '%已放在%')"; + $sigNotText = "(`tr_sig`.`trace_context` LIKE '%准备签收%' OR `tr_sig`.`trace_context` LIKE '%待签收%' " + . "OR `tr_sig`.`trace_context` LIKE '%等待签收%' OR `tr_sig`.`trace_context` LIKE '%即将送达%' " + . "OR `tr_sig`.`trace_context` LIKE '%预计%')"; + $sigTraceCond = $sigByCode . ' OR (' . $sigByText . ' AND NOT ' . $sigNotText . ')'; - return 'GREATEST(' - . 'IF(IFNULL(`et`.`sign_time`, 0) > 0, `et`.`sign_time`, 0), ' - . 'IFNULL((SELECT MAX(`tr_sig`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_sig` ' + // 与 effectiveSignUnixFromTrackingDetail 一致:轨迹时间最权威,sign_time 列仅兜底。 + // 用 COALESCE(NULLIF(...,0)) 实现「优先签收轨迹 → 已签收态最新轨迹 → sign_time 列」的取值顺序, + // 避免历史同步写入 sign_time 的「拉取当天」把签收时间顶高(旧 GREATEST 会取最大值)。 + return 'COALESCE(' + . 'NULLIF((SELECT MAX(`tr_sig`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_sig` ' . 'WHERE `tr_sig`.`tracking_id` = `et`.`id` AND IFNULL(`tr_sig`.`trace_time_stamp`, 0) > 0 AND (' . $sigTraceCond . ')), 0), ' - . 'IF((`et`.`current_state` = \'3\' OR IFNULL(`et`.`is_signed`, 0) = 1), ' + . 'NULLIF(IF((`et`.`current_state` = \'3\' OR IFNULL(`et`.`is_signed`, 0) = 1), ' . 'IFNULL((SELECT MAX(`tr_any`.`trace_time_stamp`) FROM `' . $traceTable . '` `tr_any` ' . 'WHERE `tr_any`.`tracking_id` = `et`.`id` AND IFNULL(`tr_any`.`trace_time_stamp`, 0) > 0), 0), ' - . '0)' + . '0), 0), ' + . 'NULLIF(IF(IFNULL(`et`.`sign_time`, 0) > 0, `et`.`sign_time`, 0), 0), ' + . '0' . ')'; } diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index 5f5f44ae..e380b898 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -3150,7 +3150,9 @@ class PrescriptionOrderLogic } } - // 签收时间:与详情 logisticsTrace / 业绩提成同源;库内无轨迹时导出阶段补查快递100 并落库。 + // 签收时间:纯读物流库(express_tracking / express_trace),不在导出阶段发起任何快递100 HTTP。 + // 签收时间的落库由 `php think tcm:backfill-sign-time` 与 `express:auto-update` 定时任务负责, + // 导出只读脚本/定时任务处理好的结果——避免逐单 HTTP 让导出变慢、耗时不可控。 /** @var array $signTsByPoId order_id => Unix 秒 */ $signTsByPoId = []; if ($poIds !== []) { @@ -3171,24 +3173,6 @@ class PrescriptionOrderLogic Log::warning('appendPrescriptionOrderListExportRows batch sign_time failed: ' . $e->getMessage()); $signTsByPoId = []; } - - foreach ($lists as $row) { - $oid = (int) ($row['id'] ?? 0); - if ($oid <= 0 || (int) ($signTsByPoId[$oid] ?? 0) > 0) { - continue; - } - if (trim((string) ($row['tracking_number'] ?? '')) === '') { - continue; - } - try { - $synced = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($oid, true); - if ($synced > 0) { - $signTsByPoId[$oid] = $synced; - } - } catch (\Throwable $e) { - Log::warning('appendPrescriptionOrderListExportRows sync sign_time order ' . $oid . ': ' . $e->getMessage()); - } - } } $firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById); diff --git a/server/app/command/TcmBackfillPrescriptionOrderSignTime.php b/server/app/command/TcmBackfillPrescriptionOrderSignTime.php new file mode 100644 index 00000000..f39c1648 --- /dev/null +++ b/server/app/command/TcmBackfillPrescriptionOrderSignTime.php @@ -0,0 +1,303 @@ +> /dev/null 2>&1 + */ +class TcmBackfillPrescriptionOrderSignTime extends Command +{ + /** + * 默认不按履约状态过滤:只要订单有运单号(tracking_number)即视为已发货,纳入回填。 + * 按 fulfillment_status 过滤会漏掉「物流已送达、但业务状态滞后(如进行中)」的单子。 + * 仍可用 --fulfillment-status 显式收窄。 + */ + private const DEFAULT_FULFILLMENT_STATUSES = []; + + protected function configure(): void + { + $this->setName('tcm:backfill-sign-time') + ->setDescription('批量回填处方业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)') + ->addOption('dry-run', null, Option::VALUE_NONE, '只列出待处理订单,不发起查询/不落库') + ->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多补查(HTTP)订单数', 300) + ->addOption('order-id', null, Option::VALUE_OPTIONAL, '仅处理指定 prescription_order.id', null) + ->addOption('min-id', null, Option::VALUE_OPTIONAL, '仅 id>=该值', null) + ->addOption('max-id', null, Option::VALUE_OPTIONAL, '仅 id<=该值', null) + ->addOption('days', null, Option::VALUE_OPTIONAL, '仅处理近 N 天创建的订单(0=不限)', 0) + ->addOption( + 'fulfillment-status', + null, + Option::VALUE_OPTIONAL, + '仅处理指定履约状态,逗号分隔数字(如 3,5,6);默认不限(有运单号即处理)', + null + ) + ->addOption('repair-db', null, Option::VALUE_NONE, '仅按库内轨迹重算并修复 sign_time 列(零 HTTP,修复历史「拉取当天」污染)') + ->addOption('include-gancao', null, Option::VALUE_NONE, '同时处理甘草托管单(默认跳过)') + ->addOption('force', 'f', Option::VALUE_NONE, '库内已有签收时间也重新查询落库') + ->addOption('sleep-ms', null, Option::VALUE_OPTIONAL, '每次 HTTP 查询后休眠毫秒,减轻快递100 压力', 200) + ->addOption('detail', 'd', Option::VALUE_NONE, '打印每条明细'); + } + + /** + * @return int[]|null 解析失败(传了非法值)返回 null;未传返回默认集 + */ + private static function parseFulfillmentStatusOption(?string $raw): ?array + { + if ($raw === null || trim($raw) === '') { + return self::DEFAULT_FULFILLMENT_STATUSES; + } + $seen = []; + foreach (explode(',', $raw) as $part) { + $n = (int) trim($part); + if ($n >= 1 && $n <= 12) { + $seen[$n] = true; + } + } + $ids = array_keys($seen); + sort($ids); + + return $ids !== [] ? $ids : null; + } + + protected function execute(Input $input, Output $output): int + { + $dryRun = (bool) $input->getOption('dry-run'); + $verbose = (bool) $input->getOption('detail'); + $force = (bool) $input->getOption('force'); + $includeGancao = (bool) $input->getOption('include-gancao'); + $limit = max(1, (int) $input->getOption('limit')); + $sleepMs = max(0, (int) $input->getOption('sleep-ms')); + $days = max(0, (int) $input->getOption('days')); + + $onlyOrderId = $input->getOption('order-id'); + $onlyOrderId = $onlyOrderId !== null && $onlyOrderId !== '' ? (int) $onlyOrderId : null; + $minId = $input->getOption('min-id'); + $minId = $minId !== null && $minId !== '' ? (int) $minId : null; + $maxId = $input->getOption('max-id'); + $maxId = $maxId !== null && $maxId !== '' ? (int) $maxId : null; + + $fsRaw = $input->getOption('fulfillment-status'); + $fulfillmentStatuses = self::parseFulfillmentStatusOption($fsRaw !== null ? (string) $fsRaw : null); + if ($fulfillmentStatuses === null) { + $output->error('--fulfillment-status 格式无效,请传入 1–12 的数字,英文逗号分隔,例如:3,5,6'); + + return 1; + } + + $output->writeln('========================================'); + $output->writeln('批量回填业务订单签收时间(快递100 落库)'); + $output->writeln('========================================'); + + // 纯库内修复模式:用现有轨迹重算签收时间,覆盖被写成「同步当天」的 sign_time 列(零 HTTP) + if ((bool) $input->getOption('repair-db')) { + $output->writeln('模式:仅修复 sign_time 列(按库内轨迹重算,不查快递100)...'); + $stat = ExpressTrackingService::repairSignTimeFromDbTraces($dryRun, $dryRun ? 0 : $limit); + $output->writeln(''); + $output->writeln('========================================'); + $output->writeln(sprintf( + '修复完成:扫描已签收运单 %d,修正 sign_time %d,无需修正 %d(dry-run=%s)', + $stat['scanned'], + $stat['updated'], + $stat['skipped'], + $dryRun ? 'yes' : 'no' + )); + $output->writeln('========================================'); + + return 0; + } + + $q = PrescriptionOrder::whereNull('delete_time') + ->whereRaw("TRIM(COALESCE(`tracking_number`, '')) <> ''") + ->order('id', 'desc'); + + if ($onlyOrderId !== null && $onlyOrderId > 0) { + $q->where('id', $onlyOrderId); + } else { + // 空集 = 不按履约状态过滤(默认):有运单号即纳入,避免漏掉业务状态滞后的已送达单 + if ($fulfillmentStatuses !== []) { + $q->whereIn('fulfillment_status', $fulfillmentStatuses); + } + if (!$includeGancao) { + $q->whereRaw("TRIM(COALESCE(`gancao_reciperl_order_no`, '')) = ''"); + } + if ($minId !== null && $minId > 0) { + $q->where('id', '>=', $minId); + } + if ($maxId !== null && $maxId > 0) { + $q->where('id', '<=', $maxId); + } + if ($days > 0) { + $q->where('create_time', '>=', time() - $days * 86400); + } + // 多取一些候选,DB 已可解析的会被跳过,确保「需要补查」的能凑够 limit + $q->limit($limit * 5); + } + + $rows = $q->field(['id', 'order_no', 'tracking_number', 'fulfillment_status', 'create_time']) + ->select() + ->toArray(); + if ($rows === []) { + $output->warning('没有符合条件的订单。'); + + return 0; + } + + // 先批量从库表解析,跳过已有签收时间的(除非 --force),避免无谓 HTTP + $dbSignByPo = []; + if (!$force) { + $signItems = []; + foreach ($rows as $r) { + $signItems[] = [ + 'order_id' => (int) ($r['id'] ?? 0), + 'tracking_number' => trim((string) ($r['tracking_number'] ?? '')), + ]; + } + try { + $dbSignByPo = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($signItems); + } catch (\Throwable $e) { + $output->warning('批量库表解析失败,将逐单补查:' . $e->getMessage()); + $dbSignByPo = []; + } + } + + $candidates = []; + $alreadyInDb = 0; + foreach ($rows as $r) { + $id = (int) ($r['id'] ?? 0); + if ($id <= 0) { + continue; + } + if (!$force && (int) ($dbSignByPo[$id] ?? 0) > 0) { + $alreadyInDb++; + continue; + } + $candidates[] = $r; + if (count($candidates) >= $limit) { + break; + } + } + + $output->writeln(sprintf( + '候选 %d 条(库内已有签收 %d 条已跳过);本次最多补查 %d 条(dry-run=%s force=%s sleep-ms=%d)', + count($rows), + $alreadyInDb, + $limit, + $dryRun ? 'yes' : 'no', + $force ? 'yes' : 'no', + $sleepMs + )); + + if ($candidates === []) { + $output->writeln('无需补查:候选订单签收时间均已在库。'); + + return 0; + } + + $resolved = 0; // 查到并落库了签收时间 + $empty = 0; // 查询成功但暂无签收(在途等) + $failed = 0; + + $totalToDo = count($candidates); + // 每单一次快递100 HTTP + sleep,串行较慢;定期打印进度,避免看起来卡住。 + $progressEvery = max(1, (int) min(20, max(5, (int) ceil($totalToDo / 20)))); + if (!$dryRun) { + $output->writeln(sprintf( + '开始逐单补查(共 %d 单,每单一次快递100 查询 + %dms 间隔,请耐心等待,每 %d 单报告一次进度)...', + $totalToDo, + $sleepMs, + $progressEvery + )); + } + $startTs = microtime(true); + + $done = 0; + foreach ($candidates as $r) { + $id = (int) ($r['id'] ?? 0); + $tn = trim((string) ($r['tracking_number'] ?? '')); + + if ($dryRun) { + if ($verbose) { + $output->writeln(sprintf('#%d %s 运单=%s (dry-run,跳过查询)', $id, (string) ($r['order_no'] ?? ''), $tn)); + } + continue; + } + + try { + $signTs = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($id, true); + if ($signTs > 0) { + $resolved++; + if ($verbose) { + $output->writeln(sprintf('#%d 运单=%s → 签收 %s', $id, $tn, date('Y-m-d H:i:s', $signTs))); + } + } else { + $empty++; + if ($verbose) { + $output->writeln(sprintf('#%d 运单=%s → 暂无签收(在途/未匹配)', $id, $tn)); + } + } + } catch (\Throwable $e) { + $failed++; + if ($verbose) { + $output->writeln("#{$id} 运单={$tn} 失败:{$e->getMessage()}"); + } + } + + $done++; + // 非明细模式也给出心跳进度(明细模式已逐单打印,无需重复) + if (!$verbose && ($done % $progressEvery === 0 || $done === $totalToDo)) { + $elapsed = max(0.001, microtime(true) - $startTs); + $rate = $done / $elapsed; + $eta = $rate > 0 ? (int) round(($totalToDo - $done) / $rate) : 0; + $output->writeln(sprintf( + ' 进度 %d/%d(落库 %d,暂无 %d,失败 %d)已用 %ds,预计剩余 %ds', + $done, + $totalToDo, + $resolved, + $empty, + $failed, + (int) round($elapsed), + $eta + )); + } + + if ($sleepMs > 0) { + usleep($sleepMs * 1000); + } + } + + $output->writeln(''); + $output->writeln('========================================'); + $output->writeln(sprintf('完成:落库签收 %d,暂无签收 %d,失败 %d', $resolved, $empty, $failed)); + $output->writeln('========================================'); + + return $failed > 0 ? 2 : 0; + } +} diff --git a/server/app/common/service/ExpressTrackingService.php b/server/app/common/service/ExpressTrackingService.php index 6c1d7624..e3232579 100755 --- a/server/app/common/service/ExpressTrackingService.php +++ b/server/app/common/service/ExpressTrackingService.php @@ -333,7 +333,14 @@ class ExpressTrackingService // 检查是否签收 if (ExpressTracking::isFinalState($tracking->current_state)) { $tracking->is_signed = 1; - $tracking->sign_time = $now; + // 签收时间只取真实「妥投/签收/送达」轨迹时间;解析不到就保持原值, + // 绝不用同步当天 $now 冒充签收日(否则 sign_time 列会被写成「拉取当天」,导出/详情/业绩全错)。 + $signFromTrace = (!empty($result['traces']) && is_array($result['traces'])) + ? self::effectiveSignUnixFromKuaidiPayload($result) + : 0; + if ($signFromTrace > 0) { + $tracking->sign_time = $signFromTrace; + } $tracking->auto_update = 0; // 停止自动更新 } @@ -1247,8 +1254,24 @@ class ExpressTrackingService */ private static function effectiveSignUnixFromTrackingDetail(array $detail): int { - $a = (int) ($detail['sign_time'] ?? 0); + $traceBased = self::traceBasedSignUnixFromTrackingDetail($detail); + if ($traceBased > 0) { + return $traceBased; + } + // 最后兜底才用 sign_time 列(历史同步可能把「拉取当天」写进该列,故放最后)。 + return (int) ($detail['sign_time'] ?? 0); + } + + /** + * 纯轨迹/主表推导的签收时间(不含 sign_time 列兜底): + * 签收/妥投/送达轨迹($b)→ 已签收态下最新轨迹($c)→ 主表 latest_trace_time 回填。 + * 修复 sign_time 列时用它判断「列值是否被污染」(与轨迹真实时间不符)。 + * + * @param array $detail + */ + private static function traceBasedSignUnixFromTrackingDetail(array $detail): int + { $traces = $detail['traces'] ?? []; if (!is_array($traces)) { $traces = []; @@ -1283,7 +1306,12 @@ class ExpressTrackingService } } - return max($a, $b, $c, self::signedTrackingHeadFallbackUnix($detail, $stateOk)); + $traceBased = $b > 0 ? $b : $c; + if ($traceBased > 0) { + return $traceBased; + } + + return self::signedTrackingHeadFallbackUnix($detail, $stateOk); } /** @@ -1316,8 +1344,28 @@ class ExpressTrackingService } $st = (string) ($tr['status'] ?? ''); $ctx = (string) ($tr['trace_context'] ?? ''); - $hay = $st . ' ' . $ctx; - foreach (['签收', '妥投', '已送达', '本人签收'] as $k) { + + return self::contextLooksSigned($st . ' ' . $ctx); + } + + /** + * 文案是否表示「已签收/已送达」。 + * 先排除「派送中,请您准备签收 / 待签收」等「尚未签收」文案(含「签收」二字易误判),再匹配正向关键词。 + */ + private static function contextLooksSigned(string $hay): bool + { + if ($hay === '') { + return false; + } + // 负向:出库派送阶段的「准备签收/待签收」等,并非真正签收 + foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) { + if (mb_stripos($hay, $neg) !== false) { + return false; + } + } + // 「送达」不带「已」(顺丰末端 status=送达 / context=快件已放在…)也算签收; + // 「预计送达 / 即将送达」已在上面负向词排除,不会误判。 + foreach (['签收', '妥投', '送达', '本人签收', '已放在'] as $k) { if (mb_stripos($hay, $k) !== false) { return true; } @@ -1363,13 +1411,14 @@ class ExpressTrackingService } $tstr = (string) ($row['time'] ?? ''); $ctx = (string) ($row['context'] ?? ''); + // 顺丰等末端把签收信息放在 status(如「送达」)而非 context,故连同 status 一起判断 + $st = (string) ($row['status'] ?? ''); $p = strtotime($tstr); $ts = $p !== false ? (int) $p : 0; if ($ts <= 0) { continue; } - $signedLike = self::plainContextLooksSigned($ctx); - if ($signedLike) { + if (self::contextLooksSigned($ctx . ' ' . $st)) { $best = max($best, $ts); } } @@ -1395,17 +1444,6 @@ class ExpressTrackingService return $best; } - private static function plainContextLooksSigned(string $context): bool - { - foreach (['签收', '妥投', '已送达', '本人签收', '快件已送达'] as $k) { - if (mb_stripos($context, $k) !== false) { - return true; - } - } - - return false; - } - /** * 提成结算 overview 等大批量场景:按订单批量从库表推导签收时间(不调用快递100)。 * 语义与 YejiStatsLogic 物流子查询 / effectiveSignUnixFromTrackingDetail 对齐。 @@ -1443,27 +1481,35 @@ class ExpressTrackingService return []; } - $trackingById = []; + // 仅保留「每个运单算好的签收 Unix」(int),不保留整行轨迹,避免一次性 hydrate 大量 express_trace + // 行导致内存溢出(导出全量 / 批量回填会传入上千运单,每单又有几十条轨迹)。 + $signUnixByTrackingId = []; $orderToTrackingIds = []; $tnToTrackingIds = []; - $attachTrackingRow = static function (array $etRow) use (&$trackingById, &$orderToTrackingIds, &$tnToTrackingIds): void { - $tid = (int) ($etRow['id'] ?? 0); + $consumeTracking = static function ($tracking) use (&$signUnixByTrackingId, &$orderToTrackingIds, &$tnToTrackingIds): void { + $tid = (int) ($tracking->id ?? 0); if ($tid <= 0) { return; } - $trackingById[$tid] = $etRow; - $oid = (int) ($etRow['order_id'] ?? 0); + if (!array_key_exists($tid, $signUnixByTrackingId)) { + $row = $tracking->toArray(); + $row['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; + // 算完即丢弃整行轨迹,仅留下结果 int + $signUnixByTrackingId[$tid] = (int) self::effectiveSignUnixFromTrackingDetail($row); + } + $oid = (int) ($tracking->order_id ?? 0); if ($oid > 0) { $orderToTrackingIds[$oid][$tid] = true; } - $tn = trim((string) ($etRow['tracking_number'] ?? '')); + $tn = trim((string) ($tracking->tracking_number ?? '')); if ($tn !== '') { $tnToTrackingIds[$tn][$tid] = true; } }; - foreach (array_chunk($orderIds, 800) as $chunk) { + // 小批量流式处理,每批结束后大对象即可回收,峰值内存只与单批运单的轨迹量相关 + foreach (array_chunk($orderIds, 200) as $chunk) { $byOrderId = ExpressTracking::with(['traces' => static function ($query): void { $query->order('trace_time_stamp', 'desc'); }]) @@ -1471,15 +1517,14 @@ class ExpressTrackingService ->whereNull('delete_time') ->select(); foreach ($byOrderId as $tracking) { - $row = $tracking->toArray(); - $row['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; - $attachTrackingRow($row); + $consumeTracking($tracking); } + unset($byOrderId); } $trackingNumList = array_keys($trackingNums); if ($trackingNumList !== []) { - foreach (array_chunk($trackingNumList, 400) as $chunk) { + foreach (array_chunk($trackingNumList, 200) as $chunk) { $byTn = ExpressTracking::with(['traces' => static function ($query): void { $query->order('trace_time_stamp', 'desc'); }]) @@ -1487,10 +1532,9 @@ class ExpressTrackingService ->whereNull('delete_time') ->select(); foreach ($byTn as $tracking) { - $row = $tracking->toArray(); - $row['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; - $attachTrackingRow($row); + $consumeTracking($tracking); } + unset($byTn); } } @@ -1509,11 +1553,7 @@ class ExpressTrackingService } $best = 0; foreach (array_keys($candidateIds) as $tid) { - $detail = $trackingById[(int) $tid] ?? null; - if (!is_array($detail)) { - continue; - } - $best = max($best, self::effectiveSignUnixFromTrackingDetail($detail)); + $best = max($best, (int) ($signUnixByTrackingId[(int) $tid] ?? 0)); } if ($best > 0) { $out[$oid] = $best; @@ -1522,4 +1562,61 @@ class ExpressTrackingService return $out; } + + /** + * 纯库内修复:按现有 express_trace 轨迹重算每个「已签收」运单的真实签收时间, + * 回写 express_tracking.sign_time。不调用快递100(零 HTTP)。 + * + * 处理历史污染:早期 queryAndUpdate 把「同步当天 $now」写进 sign_time,签收后 auto_update=0 不再纠正, + * 导致 sign_time 列=拉取当天而非真实送达日。本方法用轨迹时间覆盖被污染的列值。 + * + * @return array{scanned:int, updated:int, skipped:int} 统计 + */ + public static function repairSignTimeFromDbTraces(bool $dryRun = false, int $limit = 0): array + { + $scanned = 0; + $updated = 0; + $skipped = 0; + + $query = ExpressTracking::with(['traces' => static function ($q): void { + $q->order('trace_time_stamp', 'desc'); + }]) + ->whereNull('delete_time') + ->where(function ($q): void { + $q->where('is_signed', 1) + ->whereOr('current_state', ExpressTracking::STATE_SIGNED); + }) + ->order('id', 'asc'); + + $query->chunk(200, function ($trackings) use (&$scanned, &$updated, &$skipped, $dryRun, $limit): bool { + foreach ($trackings as $tracking) { + $scanned++; + $detail = $tracking->toArray(); + $detail['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; + + $trueSign = self::traceBasedSignUnixFromTrackingDetail($detail); + $cur = (int) ($tracking->sign_time ?? 0); + + // 仅当轨迹能推导出真实签收时间、且与现列值不同(按秒)时才修复 + if ($trueSign <= 0 || $trueSign === $cur) { + $skipped++; + continue; + } + + if (!$dryRun) { + $tracking->sign_time = $trueSign; + $tracking->save(); + } + $updated++; + + if ($limit > 0 && $updated >= $limit) { + return false; // 终止 chunk 遍历 + } + } + + return true; + }, 'id'); + + return ['scanned' => $scanned, 'updated' => $updated, 'skipped' => $skipped]; + } } diff --git a/server/config/console.php b/server/config/console.php index 8c1f7e4e..7ba22ee0 100755 --- a/server/config/console.php +++ b/server/config/console.php @@ -34,6 +34,8 @@ return [ 'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute', // 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW) 'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost', + // 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP) + 'tcm:backfill-sign-time' => 'app\\command\\TcmBackfillPrescriptionOrderSignTime', // 迁移诊单图片到医生备注表 'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote', // 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息