1412 lines
51 KiB
PHP
Executable File
1412 lines
51 KiB
PHP
Executable File
<?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 查询并落库(CLI:gancao: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)) {
|
||
$fromDb = self::effectiveSignUnixFromTrackingDetail($detail);
|
||
if ($fromDb > 0) {
|
||
$memo[$cacheKey] = $fromDb;
|
||
|
||
return $fromDb;
|
||
}
|
||
}
|
||
|
||
if (!$queryKuaidiWhenDbUnusable) {
|
||
$memo[$cacheKey] = 0;
|
||
|
||
return 0;
|
||
}
|
||
|
||
$ec = strtolower(trim($expressCompany));
|
||
if ($ec === '') {
|
||
$ec = 'auto';
|
||
}
|
||
$api = ExpressTrackService::query($ec, $num, $recipientPhone, '');
|
||
$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);
|
||
}
|
||
|
||
/**
|
||
* @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;
|
||
}
|
||
}
|