This commit is contained in:
Your Name
2026-06-02 16:47:50 +08:00
parent 860b550180
commit 79719b801e
7 changed files with 469 additions and 68 deletions
@@ -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(
@@ -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'
. ')';
}
@@ -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<int, int> $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);
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\ExpressTrackingService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/**
* 批量回填处方业务订单「签收时间」到物流库(express_tracking / express_trace)。
*
* 背景:导出列表的「签收日期」来自库表(ExpressTrackingService::batchResolveSignUnixFromDbForOrders,无 HTTP)。
* 终态订单(已完成3/已签收6)被 express:auto-update 排除、或从未创建过物流追踪记录时,库内就没有签收时间,
* 导出便显示为空。本命令对这些订单逐单走一次快递100 并落库(queryAndUpdate 会写入 sign_time + 轨迹),
* 之后导出直接读库即可,既快又不空。
*
* 使用方法:
* php think tcm:backfill-sign-time --dry-run
* php think tcm:backfill-sign-time --limit=300 --sleep-ms=200
* php think tcm:backfill-sign-time --fulfillment-status=3,5,6 --days=90
* php think tcm:backfill-sign-time --order-id=474
* php think tcm:backfill-sign-time --force # 库内已有签收时间也重查
* php think tcm:backfill-sign-time --include-gancao # 同时处理甘草托管单(默认跳过,由甘草侧拉取)
*
* 履约状态代码(fulfillment_status):1待双审通过 2待发货 3已完成 4已取消 5已发货 6已签收
* 7进行中 8暂不制药 9拒收 10退款 11保留药方 12制药缓发
*
* 建议配 crontab(如每小时一次,限量推进历史欠数据):
* 0 * * * * cd /path/to/server && php think tcm:backfill-sign-time --limit=300 >> /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,无需修正 %ddry-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("<error>#{$id} 运单={$tn} 失败:{$e->getMessage()}</error>");
}
}
$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;
}
}
@@ -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<string, mixed> $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];
}
}