Files
zyt/server/app/common/service/ExpressTrackingService.php
T
2026-06-01 17:43:01 +08:00

1526 lines
55 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\ExpressTracking;
use app\common\model\ExpressTrace;
use app\common\model\ExpressStateLog;
use app\common\model\ExpressQueryLog;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use think\facade\Db;
use think\facade\Log;
/**
* 物流追踪服务(存储和自动更新)
*/
class ExpressTrackingService
{
/**
* 创建或更新物流追踪记录
*
* @param array $params [
* 'order_id' => 订单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 查询并落库(CLIgancao:sync-logistics -t
*
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
*/
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<string, mixed>|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<string, mixed>|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;
$tracking->sign_time = $now;
$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<string>}
*/
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<string>
* } 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<string, mixed>|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<string, mixed> $detail getDetail* / loadTrackingDetailNormalized 结果
*/
private static function effectiveSignUnixFromTrackingDetail(array $detail): int
{
$a = (int) ($detail['sign_time'] ?? 0);
$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);
}
}
}
return max($a, $b, $c, self::signedTrackingHeadFallbackUnix($detail, $stateOk));
}
/**
* 已签收但 sign_time / 轨迹时间戳均未写入时,用主表 latest_trace_time 回填(甘草等同步路径常见)。
*
* @param array<string, mixed> $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<string, mixed> $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'] ?? '');
$hay = $st . ' ' . $ctx;
foreach (['签收', '妥投', '已送达', '本人签收'] as $k) {
if (mb_stripos($hay, $k) !== false) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $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<string, mixed> $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'] ?? '');
$p = strtotime($tstr);
$ts = $p !== false ? (int) $p : 0;
if ($ts <= 0) {
continue;
}
$signedLike = self::plainContextLooksSigned($ctx);
if ($signedLike) {
$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;
}
private static function plainContextLooksSigned(string $context): bool
{
foreach (['签收', '妥投', '已送达', '本人签收', '快件已送达'] as $k) {
if (mb_stripos($context, $k) !== false) {
return true;
}
}
return false;
}
/**
* 提成结算 overview 等大批量场景:按订单批量从库表推导签收时间(不调用快递100)。
* 语义与 YejiStatsLogic 物流子查询 / effectiveSignUnixFromTrackingDetail 对齐。
*
* @param list<array{order_id: int, tracking_number?: string}> $items
*
* @return array<int, int> 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 [];
}
$trackingById = [];
$orderToTrackingIds = [];
$tnToTrackingIds = [];
$attachTrackingRow = static function (array $etRow) use (&$trackingById, &$orderToTrackingIds, &$tnToTrackingIds): void {
$tid = (int) ($etRow['id'] ?? 0);
if ($tid <= 0) {
return;
}
$trackingById[$tid] = $etRow;
$oid = (int) ($etRow['order_id'] ?? 0);
if ($oid > 0) {
$orderToTrackingIds[$oid][$tid] = true;
}
$tn = trim((string) ($etRow['tracking_number'] ?? ''));
if ($tn !== '') {
$tnToTrackingIds[$tn][$tid] = true;
}
};
foreach (array_chunk($orderIds, 800) 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) {
$row = $tracking->toArray();
$row['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
$attachTrackingRow($row);
}
}
$trackingNumList = array_keys($trackingNums);
if ($trackingNumList !== []) {
foreach (array_chunk($trackingNumList, 400) 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) {
$row = $tracking->toArray();
$row['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
$attachTrackingRow($row);
}
}
}
$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) {
$detail = $trackingById[(int) $tid] ?? null;
if (!is_array($detail)) {
continue;
}
$best = max($best, self::effectiveSignUnixFromTrackingDetail($detail));
}
if ($best > 0) {
$out[$oid] = $best;
}
}
return $out;
}
}