订单ID, * 'order_type' => 订单类型, * 'tracking_number' => 快递单号, * 'express_company' => 快递公司, * 'recipient_phone' => 收件人手机, * 'recipient_name' => 收件人姓名, * 'recipient_address' => 收件地址, * ] * @return ExpressTracking|null */ public static function createOrUpdate(array $params): ?ExpressTracking { $trackingNumber = trim($params['tracking_number'] ?? ''); if ($trackingNumber === '') { return null; } $tracking = ExpressTracking::where('tracking_number', $trackingNumber) ->whereNull('delete_time') ->find(); $now = time(); if (!$tracking) { $tracking = new ExpressTracking(); $tracking->tracking_number = $trackingNumber; $tracking->create_time = $now; } $tracking->order_id = (int) ($params['order_id'] ?? 0); $tracking->order_type = (string) ($params['order_type'] ?? 'prescription'); $tracking->express_company = ExpressTrackService::normalizeExpressCompanyCode( $trackingNumber, (string) ($params['express_company'] ?? 'auto') ); $tracking->recipient_phone = (string) ($params['recipient_phone'] ?? ''); $tracking->recipient_name = (string) ($params['recipient_name'] ?? ''); $tracking->recipient_address = (string) ($params['recipient_address'] ?? ''); $tracking->update_time = $now; // 设置下次更新时间(立即查询) if ($tracking->next_update_time === 0) { $tracking->next_update_time = $now; } $tracking->save(); // 立即查询一次物流信息 self::queryAndUpdate($tracking->id); return $tracking; } /** * 按快递单号走快递100 查询并落库(CLI:gancao:sync-logistics -t) * * @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_lines:list, details:array>} */ public static function queryKuaidiByTrackingNumber(string $trackingNumber): array { $stats = [ 'total' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0, 'assistant_cleared' => 0, 'assistant_skipped_assign_log' => 0, 'assistant_lines' => [], 'details' => [], ]; $tn = trim($trackingNumber); if ($tn === '') { $stats['failed'] = 1; $stats['details'][] = [ 'success' => false, 'tracking_number' => '', 'message' => '快递单号不能为空', ]; return $stats; } $tracking = self::resolveTrackingByNumber($tn); if (!$tracking) { $stats['failed'] = 1; $stats['details'][] = [ 'success' => false, 'tracking_number' => $tn, 'message' => '未找到该快递单号对应的业务订单或物流追踪记录', ]; return $stats; } $stats['total'] = 1; self::backfillRecipientPhoneFromPrescriptionOrder($tracking); self::syncTrackingExpressCompanyFromNumber($tracking); try { $result = self::queryAndUpdate((int) $tracking->id, false); $ok = self::isKuaidiQuerySuccessful($result); $detail = [ 'order_id' => (int) $tracking->order_id, 'tracking_number' => (string) $tracking->tracking_number, 'success' => $ok, 'message' => trim((string) ($result['hint'] ?? '')) ?: 'ok', 'traces' => count($result['traces'] ?? []), 'state' => (string) ($result['state_text'] ?? $tracking->current_state_text ?? ''), 'source' => (string) ($result['source'] ?? ''), ]; if ($ok) { $stats['success'] = 1; } else { $stats['failed'] = 1; } $stats['details'][] = $detail; } catch (\Throwable $e) { $stats['failed'] = 1; $stats['details'][] = [ 'order_id' => (int) $tracking->order_id, 'tracking_number' => (string) $tracking->tracking_number, 'success' => false, 'message' => '异常:' . $e->getMessage(), ]; Log::error('queryKuaidiByTrackingNumber failed', [ 'tracking_number' => $tn, 'error' => $e->getMessage(), ]); } return $stats; } /** * 确保 express_tracking 存在(不发起查询) */ private static function resolveTrackingByNumber(string $trackingNumber): ?ExpressTracking { $tn = trim($trackingNumber); if ($tn === '') { return null; } $tracking = ExpressTracking::whereRaw('TRIM(`tracking_number`) = ?', [$tn]) ->whereNull('delete_time') ->find(); $order = PrescriptionOrder::whereNull('delete_time') ->whereRaw('TRIM(`tracking_number`) = ?', [$tn]) ->order('id', 'desc') ->find(); if ($tracking && $order) { $dirty = false; if ((int) $tracking->order_id !== (int) $order->id) { $tracking->order_id = (int) $order->id; $dirty = true; } if (trim((string) ($tracking->recipient_phone ?? '')) === '') { $tracking->recipient_phone = (string) ($order->recipient_phone ?? ''); $dirty = true; } if ($dirty) { $tracking->update_time = time(); $tracking->save(); } return $tracking; } if ($tracking) { self::backfillRecipientPhoneFromPrescriptionOrder($tracking); return $tracking; } if (!$order) { return null; } $now = time(); $tracking = new ExpressTracking(); $tracking->tracking_number = mb_substr($tn, 0, 80); $tracking->create_time = $now; $tracking->order_id = (int) $order->id; $tracking->order_type = 'prescription'; $tracking->express_company = ExpressTrackService::normalizeExpressCompanyCode( $tn, (string) ($order->express_company ?? 'auto') ); $tracking->recipient_phone = (string) ($order->recipient_phone ?? ''); $tracking->recipient_name = (string) ($order->recipient_name ?? ''); $tracking->recipient_address = (string) ($order->shipping_address ?? ''); $tracking->next_update_time = $now; $tracking->update_time = $now; $tracking->save(); return $tracking; } /** * @param array|null $result */ private static function isKuaidiQuerySuccessful(?array $result): bool { if (!is_array($result)) { return false; } $source = (string) ($result['source'] ?? ''); if ($source !== 'kuaidi100') { return false; } $hint = trim((string) ($result['hint'] ?? '')); if ($hint === '') { return true; } if (str_contains($hint, '暂无轨迹')) { return true; } return false; } /** * 查询并更新物流信息 * * @param int $trackingId * @param bool $isAuto 是否自动查询 * @return array|null 快递100 查询结果;自动任务时可能含 _assistant_sync(医助移除/跳过) */ public static function queryAndUpdate(int $trackingId, bool $isAuto = false): ?array { $tracking = ExpressTracking::where('id', $trackingId) ->whereNull('delete_time') ->find(); if (!$tracking) { return null; } self::backfillRecipientPhoneFromPrescriptionOrder($tracking); self::syncTrackingExpressCompanyFromNumber($tracking); $startTime = microtime(true); // 调用快递100查询(顺丰等单号需带收件人手机号后四位,见 ExpressTrackService) $result = ExpressTrackService::query( (string) $tracking->express_company, (string) $tracking->tracking_number, (string) $tracking->recipient_phone ); $responseTime = (int) ((microtime(true) - $startTime) * 1000); // 记录查询日志 self::logQuery( $tracking->tracking_number, $tracking->express_company, $isAuto ? 'auto' : 'manual', $result, $responseTime ); // 更新追踪记录 $now = time(); $oldState = $tracking->current_state; $tracking->express_company_name = $result['carrier_label'] ?? ''; $tracking->current_state = $result['state'] ?? '0'; $tracking->current_state_text = $result['state_text'] ?? ''; $tracking->data_source = $result['source'] ?? ''; $tracking->last_query_time = $now; $tracking->query_count = $tracking->query_count + 1; // 更新预计信息 if (!empty($result['arrivalTime'])) { $tracking->estimated_arrival_time = $result['arrivalTime']; } if (!empty($result['totalTime'])) { $tracking->total_time = $result['totalTime']; } if (!empty($result['remainTime'])) { $tracking->remain_time = $result['remainTime']; } // 更新路由信息 if (!empty($result['routeInfo'])) { $tracking->route_info = json_encode($result['routeInfo'], JSON_UNESCAPED_UNICODE); } // 更新最新轨迹 if (!empty($result['traces']) && is_array($result['traces'])) { $latestTrace = $result['traces'][0]; $tracking->latest_trace_time = $latestTrace['time'] ?? ''; $tracking->latest_trace_context = $latestTrace['context'] ?? ''; $tracking->latest_location = $latestTrace['location'] ?? ''; // 保存轨迹明细 self::saveTraces($tracking->id, $tracking->tracking_number, $result['traces']); } // 检查是否签收 if (ExpressTracking::isFinalState($tracking->current_state)) { $tracking->is_signed = 1; // 签收时间只取真实「妥投/签收/送达」轨迹时间;解析不到就保持原值, // 绝不用同步当天 $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; // 停止自动更新 } // 物流为「签收」(STATE_SIGNED) → 自动推进关联业务订单到「已签收」(fulfillment_status=6) // 排除「退签/拒签」等其它终态,避免误把退/拒签的订单标成已签收 if ($tracking->current_state === ExpressTracking::STATE_SIGNED) { self::autoMarkOrderAsSigned($tracking); } // 计算下次更新时间 if ($tracking->auto_update == 1 && !ExpressTracking::isFinalState($tracking->current_state)) { $tracking->next_update_time = $now + $tracking->update_interval; } $tracking->update_time = $now; $tracking->save(); // 记录状态变更 if ($oldState !== $tracking->current_state) { self::logStateChange($tracking, $oldState); } // 定时拉轨迹:履约已发货/已签收时与甘草侧共用 applyAssistantReleaseForShippedPrescriptionOrder,释放诊单医助并写指派日志 $assistantSync = null; if ($isAuto) { $assistantSync = self::maybeClearDiagnosisAssistantWhenShippedPrescription($tracking); } return $assistantSync !== null ? array_merge($result, ['_assistant_sync' => $assistantSync]) : $result; } /** * 物流签收时,自动将关联处方业务订单的履约状态推进到「已签收」(6) * * 仅在当前 fulfillment_status 属于「待双审通过(1)/待发货(2)/已发货(5)」时升级, * 避免覆盖「已完成(3)/已取消(4)/已签收(6)/拒收(9)/退款(10)」等业务终态。 */ private static function autoMarkOrderAsSigned(ExpressTracking $tracking): void { if ((string) $tracking->order_type !== 'prescription') { return; } $orderId = (int) $tracking->order_id; if ($orderId <= 0) { return; } try { $order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find(); if (!$order) { return; } $currentFs = (int) $order->fulfillment_status; if (!in_array($currentFs, [1, 2, 5], true)) { return; } $order->fulfillment_status = 6; $order->save(); try { $log = new PrescriptionOrderLog(); $log->prescription_order_id = $orderId; $log->admin_id = 0; $log->admin_name = '系统'; $log->action = 'auto_sign'; $log->summary = mb_substr( '物流签收(运单:' . (string) $tracking->tracking_number . '),订单状态自动更新为「已签收」', 0, 500 ); $log->create_time = time(); $log->save(); } catch (\Throwable $e) { Log::warning('Auto sign order log save failed: ' . $e->getMessage(), [ 'order_id' => $orderId, ]); } } catch (\Throwable $e) { Log::warning('Auto sign order failed: ' . $e->getMessage(), [ 'tracking_id' => (int) $tracking->id, 'order_id' => $orderId, ]); } } /** * 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。 * 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。 * 不自动清空的情形:(1)按业务订单 creator_id(代建场景)或回退身份判断是否在「二中心」部门子树;(2)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(3)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`(系统任务为 operator_admin_id=0);或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。 * * @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta * @return array{ * action: 'cleared', * source?: string, * diagnosis_id: int, * prescription_order_id: int, * tracking_id: int, * tracking_number: string, * former_assistant_id?: string, * fulfillment_status?: int * }|null */ public static function applyAssistantReleaseForShippedPrescriptionOrder(PrescriptionOrder $order, array $meta = []): ?array { $orderId = (int) $order->id; if ($orderId <= 0) { return null; } $source = (string) ($meta['source'] ?? ''); $trackingId = (int) ($meta['tracking_id'] ?? 0); $tn = (string) ($meta['tracking_number'] ?? $order->tracking_number ?? ''); try { $fs = (int) $order->fulfillment_status; if (!in_array($fs, [5, 6], true)) { return null; } $diagnosisId = (int) ($order->diagnosis_id ?? 0); if ($diagnosisId <= 0) { return null; } $diag = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find(); if (!$diag) { return null; } // 后台曾人工指派到新医助(operator_admin_id>0 且 to>0,与系统写入的 operator=0 区分)时不再自动清空;另保留快照 related_po=from 的旧数据兼容 if (self::diagnosisShouldSkipAutoAssistantReleaseDueToManualAssignLog($diagnosisId)) { Log::info('Skipped auto assistant release (shipped/signed): manual assign history exists (human operator + to_assistant_id>0, or snapshot-tied pattern)', [ 'diagnosis_id' => $diagnosisId, 'prescription_order_id' => $orderId, 'source' => $source, ]); return null; } $shippedClearedAt = (int) ($diag->getAttr('shipped_non_er_assistant_cleared_at') ?? 0); $diagAssistantIdNow = (int) ($diag->getAttr('assistant_id') ?? 0); if ($shippedClearedAt > 0 && $diagAssistantIdNow === 0) { return null; } $poCreatorId = (int) ($order->getAttr('creator_id') ?? 0); $rxId = (int) ($order->getAttr('prescription_id') ?? 0); $rxCreator = 0; if ($rxId > 0) { $rxCreator = (int) Prescription::where('id', $rxId)->whereNull('delete_time')->value('creator_id'); } // 诊单 assistant_id 与列表「医助」筛选一致:医助常代建单但诊单未写 assistant_id,此时用创建人作为待释放身份(创建人即开方医生时不采用,避免误记指派日志) $fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0); if ($fromAssistantId <= 0) { if ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator)) { $fromAssistantId = $poCreatorId; } } if ($fromAssistantId <= 0) { return null; } /** 是否因「二中心」跳过释放:优先看业务订单创建人 creator_id(代建场景);创建人即开方医生时用 fromAssistantId */ $erZhongxinCheckAdminId = ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator)) ? $poCreatorId : $fromAssistantId; if ($erZhongxinCheckAdminId > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($erZhongxinCheckAdminId)) { Log::info('Skipped auto assistant release (shipped/signed): order creator (or fallback from-id) under 二中心 dept tree', [ 'diagnosis_id' => $diagnosisId, 'prescription_order_id' => $orderId, 'source' => $source, 'er_zhongxin_check_admin_id' => $erZhongxinCheckAdminId, 'prescription_order_creator_id' => $poCreatorId, 'from_assistant_id_for_log' => $fromAssistantId, ]); return null; } $operatorName = match ($source) { 'express_auto_update' => '系统·物流自动同步', 'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步', 'gancao_callback' => '系统·甘草回调', 'shipped_fulfillment_reconcile' => '系统·已发货履约核对', default => '系统·订单已发货/签收', }; $relatedPoCreatorId = (int) ($order->getAttr('creator_id') ?? 0); $relatedPoCreateTime = (int) ($order->getAttr('create_time') ?? 0); Db::startTrans(); try { Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update([ 'assistant_id' => 0, 'shipped_non_er_assistant_cleared_at' => time(), ]); DiagnosisAssignLog::create([ 'diagnosis_id' => $diagnosisId, 'from_assistant_id' => $fromAssistantId, 'to_assistant_id' => 0, 'operator_admin_id' => 0, 'operator_name' => $operatorName, 'operator_account' => '', 'ip' => '', 'related_po_creator_id' => $relatedPoCreatorId, 'related_po_create_time' => $relatedPoCreateTime, 'create_time' => time(), ]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } Log::info('Cleared diagnosis assistant (prescription shipped/signed), assign log written', [ 'source' => $source, 'diagnosis_id' => $diagnosisId, 'prescription_order_id' => $orderId, 'tracking_id' => $trackingId, 'fulfillment_status' => $fs, 'former_assistant_id' => $fromAssistantId, 'related_po_creator_id' => $relatedPoCreatorId, 'related_po_create_time' => $relatedPoCreateTime, 'assistant_created_order' => ($relatedPoCreatorId === $fromAssistantId && $relatedPoCreateTime > 0) ? 1 : 0, ]); return [ 'action' => 'cleared', 'source' => $source, 'diagnosis_id' => $diagnosisId, 'prescription_order_id' => $orderId, 'tracking_id' => $trackingId, 'tracking_number' => $tn, 'former_assistant_id' => (string) $fromAssistantId, 'fulfillment_status' => $fs, ]; } catch (\Throwable $e) { Log::warning('applyAssistantReleaseForShippedPrescriptionOrder failed: ' . $e->getMessage(), [ 'source' => $source, 'order_id' => $orderId, ]); return null; } } /** * 部门名称为「二中心」的根节点 id(启用、未删除的首条),无则 0。 * 与后台 adminapi 部门数据源一致(表 zyt_dept,树由 pid 构成)。 */ private static function getErzhongxinRootDeptId(): int { static $cached = null; if ($cached !== null) { return $cached; } $id = (int) Dept::where('name', '二中心') ->where('status', 1) ->whereNull('delete_time') ->order('id', 'asc') ->value('id'); $cached = $id > 0 ? $id : 0; return $cached; } /** * 诊单当前医助(管理员)是否归属「二中心」或其任意下级部门(admin_dept.dept_id 落在该子树内)。 * 与 DeptLogic::getSelfAndDescendantIds 行为对齐于部门列表接口的树展开。 */ private static function currentAssistantBelongsToErzhongxinDeptTree(int $assistantAdminId): bool { if ($assistantAdminId <= 0) { return false; } $rootId = self::getErzhongxinRootDeptId(); if ($rootId <= 0) { return false; } $treeIds = DeptLogic::getSelfAndDescendantIds($rootId); if ($treeIds === []) { return false; } $allowed = array_flip(array_map('intval', $treeIds)); $deptIds = AdminDept::where('admin_id', $assistantAdminId)->column('dept_id'); foreach ($deptIds as $did) { if (isset($allowed[(int) $did])) { return true; } } return false; } /** * 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)。 * 判定:operator_admin_id>0 且 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0。 * 另保留 related_po_creator_id=from_assistant_id 且 to>0 的窄条件,兼容早期数据。 */ private static function diagnosisShouldSkipAutoAssistantReleaseDueToManualAssignLog(int $diagnosisId): bool { if ($diagnosisId <= 0) { return false; } $tbl = 'tcm_diagnosis_assign_log'; $humanReassign = (int) Db::name($tbl) ->where('diagnosis_id', $diagnosisId) ->where('to_assistant_id', '>', 0) ->where('operator_admin_id', '>', 0) ->count(); if ($humanReassign > 0) { return true; } $snapshotTied = (int) Db::name($tbl) ->where('diagnosis_id', $diagnosisId) ->where('to_assistant_id', '>', 0) ->where('from_assistant_id', '>', 0) ->where('related_po_creator_id', '>', 0) ->whereRaw('`related_po_creator_id` = `from_assistant_id`') ->count(); return $snapshotTied > 0; } private static function maybeClearDiagnosisAssistantWhenShippedPrescription(ExpressTracking $tracking): ?array { if ((string) $tracking->order_type !== 'prescription') { return null; } $orderId = (int) $tracking->order_id; if ($orderId <= 0) { return null; } try { $order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find(); if (!$order) { return null; } return self::applyAssistantReleaseForShippedPrescriptionOrder($order, [ 'tracking_id' => (int) $tracking->id, 'tracking_number' => (string) $tracking->tracking_number, 'source' => 'express_auto_update', ]); } catch (\Throwable $e) { Log::warning('maybeClearDiagnosisAssistantWhenShippedPrescription failed: ' . $e->getMessage(), [ 'tracking_id' => (int) $tracking->id, 'order_id' => $orderId, ]); return null; } } /** * 物流表未存收件人手机时,从关联业务订单回填(顺丰/京东等查件必填后四位) */ private static function backfillRecipientPhoneFromPrescriptionOrder(ExpressTracking $tracking): void { $current = trim((string) $tracking->recipient_phone); if ($current !== '') { return; } if ((string) $tracking->order_type !== 'prescription' || (int) $tracking->order_id <= 0) { return; } $po = PrescriptionOrder::where('id', (int) $tracking->order_id) ->whereNull('delete_time') ->find(); if ($po === null) { return; } $p = trim((string) ($po->recipient_phone ?? '')); if ($p !== '') { $tracking->recipient_phone = $p; } } /** * 运单号与 express_company 不一致时按单号纠正(如京东单误存 sf) */ private static function syncTrackingExpressCompanyFromNumber(ExpressTracking $tracking): void { $num = trim((string) $tracking->tracking_number); if ($num === '') { return; } $normalized = ExpressTrackService::normalizeExpressCompanyCode( $num, (string) ($tracking->express_company ?? 'auto') ); if ($normalized === (string) $tracking->express_company) { return; } $tracking->express_company = $normalized; $tracking->update_time = time(); $tracking->save(); } /** * 保存轨迹明细 */ private static function saveTraces(int $trackingId, string $trackingNumber, array $traces): void { foreach ($traces as $trace) { $traceTime = $trace['time'] ?? $trace['ftime'] ?? ''; if ($traceTime === '') { continue; } // 检查是否已存在 $exists = ExpressTrace::where('tracking_id', $trackingId) ->where('trace_time', $traceTime) ->where('trace_context', $trace['context'] ?? '') ->count(); if ($exists > 0) { continue; } $model = new ExpressTrace(); $model->tracking_id = $trackingId; $model->tracking_number = $trackingNumber; $model->trace_time = $traceTime; $model->trace_time_stamp = strtotime($traceTime) ?: time(); $model->trace_context = $trace['context'] ?? ''; $model->status = $trace['status'] ?? ''; $model->status_code = $trace['statusCode'] ?? ''; $model->location = $trace['location'] ?? ''; $model->area_code = $trace['areaCode'] ?? ''; $model->area_name = $trace['areaName'] ?? ''; $model->area_center = $trace['areaCenter'] ?? ''; $model->extra_data = json_encode($trace, JSON_UNESCAPED_UNICODE); $model->create_time = time(); $model->save(); } } /** * 记录状态变更 */ private static function logStateChange(ExpressTracking $tracking, string $oldState): void { $log = new ExpressStateLog(); $log->tracking_id = $tracking->id; $log->tracking_number = $tracking->tracking_number; $log->old_state = $oldState; $log->old_state_text = ExpressTracking::getStateText($oldState); $log->new_state = $tracking->current_state; $log->new_state_text = $tracking->current_state_text; $log->change_time = time(); $log->change_reason = $tracking->latest_trace_context; $log->create_time = time(); $log->save(); // 检查是否需要通知 $shouldNotify = false; // 签收通知 if ($tracking->notify_on_sign == 1 && ExpressTracking::isFinalState($tracking->current_state)) { $shouldNotify = true; } // 异常通知 if ($tracking->notify_on_problem == 1 && ExpressTracking::isProblemState($tracking->current_state)) { $shouldNotify = true; } if ($shouldNotify) { self::sendNotification($tracking, $log); } } /** * 发送通知(可扩展对接企业微信、短信等) */ private static function sendNotification(ExpressTracking $tracking, ExpressStateLog $log): void { // TODO: 对接通知渠道 // 1. 企业微信消息 // 2. 短信通知 // 3. 系统内通知 Log::info('Express state changed notification', [ 'tracking_number' => $tracking->tracking_number, 'old_state' => $log->old_state_text, 'new_state' => $log->new_state_text, 'context' => $log->change_reason, ]); $log->is_notified = 1; $log->notify_time = time(); $log->notify_result = 'logged'; $log->save(); } /** * 记录查询日志 */ private static function logQuery( string $trackingNumber, string $expressCompany, string $queryType, array $result, int $responseTime ): void { $log = new ExpressQueryLog(); $log->tracking_number = $trackingNumber; $log->express_company = $expressCompany; $log->query_type = $queryType; $log->query_source = $result['source'] ?? ''; $log->query_time = time(); $log->is_success = !empty($result['traces']) ? 1 : 0; $log->error_code = $result['hint'] ? '400' : ''; $log->error_message = $result['hint'] ?? ''; $log->response_time = $responseTime; $log->trace_count = count($result['traces'] ?? []); $log->create_time = time(); $log->save(); } /** * 按处方订单履约核对:已发货(5)/已签收(6) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder。 * 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。 * * @return array{scanned: int, cleared: int, lines: list} */ public static function reconcileAssistantReleaseForShippedPrescriptionOrders(int $limit = 200): array { $dTable = (new Diagnosis())->getTable(); $rxTable = (new Prescription())->getTable(); $orders = PrescriptionOrder::alias('po') ->join($dTable . ' d', 'd.id = po.diagnosis_id') ->leftJoin($rxTable . ' rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL') ->whereNull('po.delete_time') ->whereNull('d.delete_time') ->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)') ->whereIn('po.fulfillment_status', [5, 6]) ->where('po.diagnosis_id', '>', 0) ->where(function ($w) { $w->where('d.assistant_id', '>', 0) ->whereOr(function ($w2) { $w2->where('po.creator_id', '>', 0) ->whereRaw('(rx.id IS NULL OR po.creator_id <> rx.creator_id)'); }); }) ->order('po.id', 'asc') ->limit($limit) ->field('po.*') ->select(); $cleared = 0; $lines = []; foreach ($orders as $order) { $sync = self::applyAssistantReleaseForShippedPrescriptionOrder($order, [ 'source' => 'shipped_fulfillment_reconcile', 'tracking_number' => (string) ($order->tracking_number ?? ''), ]); if (is_array($sync) && ($sync['action'] ?? '') === 'cleared') { $cleared++; $lines[] = sprintf( '[履约核对·已发货/签收] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d', (int) ($sync['diagnosis_id'] ?? 0), (int) ($sync['prescription_order_id'] ?? 0), (string) ($sync['tracking_number'] ?? ''), (string) ($sync['former_assistant_id'] ?? ''), (int) ($sync['fulfillment_status'] ?? 0) ); } } return [ 'scanned' => count($orders), 'cleared' => $cleared, 'lines' => $lines, ]; } /** * 批量自动更新(定时任务调用) * * @param int $limit 每次处理数量 * @return array{ * total: int, * success: int, * failed: int, * assistant_cleared: int, * assistant_skipped_assign_log: int, * assistant_lines: list * } assistant_skipped_assign_log 保留兼容恒为 0 */ public static function autoUpdateBatch(int $limit = 50): array { $now = time(); $poTable = (new PrescriptionOrder())->getTable(); // 查询需要更新的记录 // 条件: // 1. auto_update = 1(启用自动更新) // 2. next_update_time <= now(到达更新时间) // 3. is_signed = 0(未签收) // 4. current_state 不是终态(排除已签收、退签、拒签) // 5. 关联业务订单 tcm_prescription_order:已存在甘草单号 gancao_reciperl_order_no 的不再走本任务(由甘草侧物流拉取) // 6. 关联业务订单 fulfillment_status 不在终态白名单:3/4/6/8/9/10/11/12(已完成/已取消/已签收/暂不制药/拒收/退款/保留药方/制药缓发) $list = ExpressTracking::alias('et') ->leftJoin("{$poTable} po", "et.order_id = po.id AND et.order_type = 'prescription'") ->where('et.auto_update', 1) ->where('et.next_update_time', '<=', $now) ->where('et.is_signed', 0) ->whereNotIn('et.current_state', [ ExpressTracking::STATE_SIGNED, ExpressTracking::STATE_RETURN_SIGNED, ExpressTracking::STATE_REJECTED, ]) ->whereNull('et.delete_time') ->whereRaw( "(et.order_type <> ? OR TRIM(COALESCE(po.gancao_reciperl_order_no, '')) = '')", ['prescription'] ) ->whereRaw( "(et.order_type <> ? OR po.id IS NULL OR po.fulfillment_status NOT IN (3,4,6,8,9,10,11,12))", ['prescription'] ) ->field('et.*') ->limit($limit) ->select(); $success = 0; $failed = 0; $assistantCleared = 0; $assistantLines = []; foreach ($list as $tracking) { try { $ret = self::queryAndUpdate($tracking->id, true); $success++; if (is_array($ret) && isset($ret['_assistant_sync']) && is_array($ret['_assistant_sync'])) { $sync = $ret['_assistant_sync']; $act = (string) ($sync['action'] ?? ''); if ($act === 'cleared') { $assistantCleared++; $assistantLines[] = sprintf( '[移除医助+指派日志] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d', (int) ($sync['diagnosis_id'] ?? 0), (int) ($sync['prescription_order_id'] ?? 0), (string) ($sync['tracking_number'] ?? ''), (string) ($sync['former_assistant_id'] ?? ''), (int) ($sync['fulfillment_status'] ?? 0) ); } } } catch (\Throwable $e) { $failed++; Log::error('Auto update express tracking failed', [ 'tracking_id' => $tracking->id, 'tracking_number' => $tracking->tracking_number, 'error' => $e->getMessage(), ]); } } return [ 'total' => count($list), 'success' => $success, 'failed' => $failed, 'assistant_cleared' => $assistantCleared, 'assistant_skipped_assign_log' => 0, 'assistant_lines' => $assistantLines, ]; } /** * 获取追踪详情(包含轨迹) */ public static function getDetail(int $trackingId): ?array { $tracking = ExpressTracking::with(['traces', 'stateLogs']) ->where('id', $trackingId) ->whereNull('delete_time') ->find(); if (!$tracking) { return null; } $data = $tracking->toArray(); $data['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; $data['state_logs'] = $tracking->stateLogs ? $tracking->stateLogs->toArray() : []; return $data; } /** * 根据快递单号获取追踪详情(包含轨迹) */ public static function getDetailByTrackingNumber(string $trackingNumber): ?array { $tracking = ExpressTracking::with(['traces' => function($query) { // 按时间倒序排列(最新的在前) $query->order('trace_time_stamp', 'desc'); }]) ->where('tracking_number', $trackingNumber) ->whereNull('delete_time') ->find(); if (!$tracking) { return null; } $data = $tracking->toArray(); $data['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; return $data; } /** * 与 PrescriptionOrderLogic::logisticsTrace 一致:优先库表 express_tracking + express_trace; * 库内算不出签收时(或库无记录),可选走快递100(与详情「刷新轨迹」同源)。 * * 提成结算 orderLines 等对 SQL JOIN 无法覆盖「仅 API 有轨迹」的运单做补算。 * * @param bool $queryKuaidiWhenDbUnusable true 时与 logisticsTrace 完全同路径;false 时仅数据库推导(overview 大批量避免 HTTP) */ public static function resolveSignUnixTimeForCommission( string $trackingNumber, string $expressCompany, string $recipientPhone, bool $queryKuaidiWhenDbUnusable = true ): int { $num = trim($trackingNumber); if ($num === '') { return 0; } $cacheKey = $num . '|' . ($queryKuaidiWhenDbUnusable ? '1' : '0'); static $memo = []; if (isset($memo[$cacheKey])) { return $memo[$cacheKey]; } $detail = self::loadTrackingDetailNormalized($num); if (is_array($detail)) { // 与 logisticsTrace:库内有轨迹明细时才视为「库内可查」 if (!empty($detail['traces'])) { $fromDb = self::effectiveSignUnixFromTrackingDetail($detail); if ($fromDb > 0) { $memo[$cacheKey] = $fromDb; return $fromDb; } } else { $fromHead = self::effectiveSignUnixFromTrackingDetail($detail); if ($fromHead > 0) { $memo[$cacheKey] = $fromHead; return $fromHead; } } } if (!$queryKuaidiWhenDbUnusable) { $memo[$cacheKey] = 0; return 0; } $ec = ExpressTrackService::normalizeExpressCompanyCode($num, (string) $expressCompany); $api = ExpressTrackService::query($ec, $num, $recipientPhone, ''); $fromApi = self::effectiveSignUnixFromKuaidiPayload($api); $memo[$cacheKey] = $fromApi; return $fromApi; } /** * 按业务订单 id 解析签收时间,与 PrescriptionOrderLogic::logisticsTrace 同口径。 * allowHttp 为 true 时:库内无轨迹则 queryAndUpdate 落库后再读,避免「快递单 API 已签收、提成结算库内为空」。 */ public static function syncSignUnixFromLogisticsForPrescriptionOrder(int $orderId, bool $allowHttp = true): int { static $memo = []; $cacheKey = $orderId . '|' . ($allowHttp ? '1' : '0'); if (isset($memo[$cacheKey])) { return $memo[$cacheKey]; } $order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find(); if (!$order) { $memo[$cacheKey] = 0; return 0; } $num = trim((string) ($order->tracking_number ?? '')); if ($num === '') { $memo[$cacheKey] = 0; return 0; } $detail = self::loadTrackingDetailNormalized($num); if (is_array($detail)) { if (!empty($detail['traces'])) { $fromDb = self::effectiveSignUnixFromTrackingDetail($detail); if ($fromDb > 0) { $memo[$cacheKey] = $fromDb; return $fromDb; } } else { $fromHead = self::effectiveSignUnixFromTrackingDetail($detail); if ($fromHead > 0) { $memo[$cacheKey] = $fromHead; return $fromHead; } } } if (!$allowHttp) { $memo[$cacheKey] = 0; return 0; } $tracking = self::resolveTrackingByNumber($num); if ($tracking) { try { self::queryAndUpdate((int) $tracking->id, false); } catch (\Throwable $e) { Log::warning('syncSignUnixFromLogisticsForPrescriptionOrder queryAndUpdate failed: ' . $e->getMessage(), [ 'order_id' => $orderId, 'tracking_number' => $num, ]); } $detailAfter = self::loadTrackingDetailNormalized($num); if (is_array($detailAfter)) { $fromDb = self::effectiveSignUnixFromTrackingDetail($detailAfter); if ($fromDb > 0) { $memo[$cacheKey] = $fromDb; return $fromDb; } } } $ec = ExpressTrackService::normalizeExpressCompanyCode( $num, (string) ($order->express_company ?? 'auto') ); $api = ExpressTrackService::query( $ec, $num, (string) ($order->recipient_phone ?? ''), '' ); $fromApi = self::effectiveSignUnixFromKuaidiPayload($api); $memo[$cacheKey] = $fromApi; return $fromApi; } /** * 单号精确匹配失败时尝试 TRIM(TrackingNumber)(历史数据首尾空格)。 * * @return array|null */ private static function loadTrackingDetailNormalized(string $trackingNumber): ?array { $n = trim($trackingNumber); if ($n === '') { return null; } $first = self::getDetailByTrackingNumber($n); if ($first !== null) { return $first; } $tracking = ExpressTracking::with(['traces' => function ($query) { $query->order('trace_time_stamp', 'desc'); }]) ->whereRaw('TRIM(`tracking_number`) = ?', [$n]) ->whereNull('delete_time') ->find(); if (!$tracking) { return null; } $data = $tracking->toArray(); $data['traces'] = $tracking->traces ? $tracking->traces->toArray() : []; return $data; } /** * 与 YejiStatsLogic 物流子查询中每条 express_tracking 的 GREATEST 语义对齐。 * * @param array $detail getDetail* / loadTrackingDetailNormalized 结果 */ private static function effectiveSignUnixFromTrackingDetail(array $detail): int { $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 = []; } $b = 0; foreach ($traces as $tr) { if (!is_array($tr)) { continue; } if (!self::dbTraceRowLooksSigned($tr)) { continue; } $ts = self::traceRowToUnix($tr); if ($ts > 0) { $b = max($b, $ts); } } $c = 0; $stateOk = ((string) ($detail['current_state'] ?? '') === ExpressTracking::STATE_SIGNED || (int) ($detail['is_signed'] ?? 0) === 1); if ($stateOk) { foreach ($traces as $tr) { if (!is_array($tr)) { continue; } $ts = self::traceRowToUnix($tr); if ($ts > 0) { $c = max($c, $ts); } } } $traceBased = $b > 0 ? $b : $c; if ($traceBased > 0) { return $traceBased; } return self::signedTrackingHeadFallbackUnix($detail, $stateOk); } /** * 已签收但 sign_time / 轨迹时间戳均未写入时,用主表 latest_trace_time 回填(甘草等同步路径常见)。 * * @param array $detail */ private static function signedTrackingHeadFallbackUnix(array $detail, bool $stateOk): int { if (!$stateOk) { return 0; } $ltRaw = trim((string) ($detail['latest_trace_time'] ?? '')); if ($ltRaw === '') { return 0; } $parsed = strtotime($ltRaw); return ($parsed !== false && $parsed > 0) ? (int) $parsed : 0; } /** * @param array $tr express_trace 行 */ private static function dbTraceRowLooksSigned(array $tr): bool { $code = (string) ($tr['status_code'] ?? ''); if (in_array($code, ['3', '301', '302', '304'], true)) { return true; } $st = (string) ($tr['status'] ?? ''); $ctx = (string) ($tr['trace_context'] ?? ''); 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; } } return false; } /** * @param array $tr */ private static function traceRowToUnix(array $tr): int { $ts = (int) ($tr['trace_time_stamp'] ?? 0); if ($ts > 0) { return $ts; } $raw = trim((string) ($tr['trace_time'] ?? '')); if ($raw === '') { return 0; } $p = strtotime($raw); return $p !== false ? (int) $p : 0; } /** * @param array $api ExpressTrackService::query 返回值 */ private static function effectiveSignUnixFromKuaidiPayload(array $api): int { $traces = $api['traces'] ?? []; if (!is_array($traces) || $traces === []) { return 0; } $state = (string) ($api['state'] ?? ''); $best = 0; foreach ($traces as $row) { if (!is_array($row)) { continue; } $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; } if (self::contextLooksSigned($ctx . ' ' . $st)) { $best = max($best, $ts); } } if ($best > 0) { return $best; } if ($state === '3') { foreach ($traces as $row) { if (!is_array($row)) { continue; } $tstr = (string) ($row['time'] ?? ''); $p = strtotime($tstr); $ts = $p !== false ? (int) $p : 0; if ($ts > 0) { $best = max($best, $ts); } } } return $best; } /** * 提成结算 overview 等大批量场景:按订单批量从库表推导签收时间(不调用快递100)。 * 语义与 YejiStatsLogic 物流子查询 / effectiveSignUnixFromTrackingDetail 对齐。 * * @param list $items * * @return array order_id => sign unix */ public static function batchResolveSignUnixFromDbForOrders(array $items): array { if ($items === []) { return []; } $orderIds = []; $tnByOrder = []; $trackingNums = []; foreach ($items as $it) { if (!is_array($it)) { continue; } $oid = (int) ($it['order_id'] ?? 0); if ($oid <= 0) { continue; } $orderIds[$oid] = true; $tn = trim((string) ($it['tracking_number'] ?? '')); $tnByOrder[$oid] = $tn; if ($tn !== '') { $trackingNums[$tn] = true; } } $orderIds = array_keys($orderIds); if ($orderIds === []) { return []; } // 仅保留「每个运单算好的签收 Unix」(int),不保留整行轨迹,避免一次性 hydrate 大量 express_trace // 行导致内存溢出(导出全量 / 批量回填会传入上千运单,每单又有几十条轨迹)。 $signUnixByTrackingId = []; $orderToTrackingIds = []; $tnToTrackingIds = []; $consumeTracking = static function ($tracking) use (&$signUnixByTrackingId, &$orderToTrackingIds, &$tnToTrackingIds): void { $tid = (int) ($tracking->id ?? 0); if ($tid <= 0) { return; } 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) ($tracking->tracking_number ?? '')); if ($tn !== '') { $tnToTrackingIds[$tn][$tid] = true; } }; // 小批量流式处理,每批结束后大对象即可回收,峰值内存只与单批运单的轨迹量相关 foreach (array_chunk($orderIds, 200) as $chunk) { $byOrderId = ExpressTracking::with(['traces' => static function ($query): void { $query->order('trace_time_stamp', 'desc'); }]) ->whereIn('order_id', $chunk) ->whereNull('delete_time') ->select(); foreach ($byOrderId as $tracking) { $consumeTracking($tracking); } unset($byOrderId); } $trackingNumList = array_keys($trackingNums); if ($trackingNumList !== []) { foreach (array_chunk($trackingNumList, 200) as $chunk) { $byTn = ExpressTracking::with(['traces' => static function ($query): void { $query->order('trace_time_stamp', 'desc'); }]) ->whereRaw('TRIM(`tracking_number`) IN (' . implode(',', array_fill(0, count($chunk), '?')) . ')', $chunk) ->whereNull('delete_time') ->select(); foreach ($byTn as $tracking) { $consumeTracking($tracking); } unset($byTn); } } $out = []; foreach ($orderIds as $oid) { $oid = (int) $oid; $candidateIds = []; foreach (array_keys($orderToTrackingIds[$oid] ?? []) as $tid) { $candidateIds[(int) $tid] = true; } $tn = $tnByOrder[$oid] ?? ''; if ($tn !== '') { foreach (array_keys($tnToTrackingIds[$tn] ?? []) as $tid) { $candidateIds[(int) $tid] = true; } } $best = 0; foreach (array_keys($candidateIds) as $tid) { $best = max($best, (int) ($signUnixByTrackingId[(int) $tid] ?? 0)); } if ($best > 0) { $out[$oid] = $best; } } 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]; } }