'系统审核中', 11 => '系统审核通过', 110 => '订单药房流转制作中', 20 => '物流中', 30 => '完成', 90 => '拦截', 91 => '主动撤单', 92 => '驳回', ]; /** * 物流商名称 → express_company 编码映射 */ private const EXPRESS_MAP = [ '顺丰' => 'sf', '京东' => 'jd', '极兔' => 'jt', '圆通' => 'yt', '中通' => 'zt', '韵达' => 'yd', '申通' => 'st', '邮政' => 'yz', 'EMS' => 'ems', ]; /** * 甘草订单状态回调入口 */ public function orderStatus(): Response { $rawBody = (string) file_get_contents('php://input'); $headers = $this->request->header(); $accessAppkey = (string) $this->pickHeader($headers, ['access-appkey', 'accessappkey', 'x-access-appkey']); $accessNonce = (string) $this->pickHeader($headers, ['access-nonce', 'accessnonce', 'x-access-nonce']); $accessTimestamp = (string) $this->pickHeader($headers, ['access-timestamp', 'accesstimestamp', 'x-access-timestamp']); $accessSign = (string) $this->pickHeader($headers, ['access-sign', 'accesssign', 'x-access-sign']); Log::info(sprintf( 'Gancao callback received | appkey=%s | nonce=%s | ts=%s | sign=%s | body=%s | headers=%s', $accessAppkey !== '' ? $accessAppkey : '(empty)', $accessNonce !== '' ? $accessNonce : '(empty)', $accessTimestamp !== '' ? $accessTimestamp : '(empty)', $accessSign !== '' ? $accessSign : '(empty)', $rawBody, json_encode($headers, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) )); try { if (!$this->verifySign($accessAppkey, $accessNonce, $accessTimestamp, $accessSign, $rawBody)) { Log::warning('Gancao callback sign verification failed'); return $this->ok(); } $data = json_decode($rawBody, true); if (!is_array($data)) { Log::error('Gancao callback invalid json', ['body' => $rawBody]); return $this->ok(); } $this->handleCallback($data); } catch (\Throwable $e) { Log::error('Gancao callback exception', [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ]); } return $this->ok(); } /* ------------------------------------------------------------------ */ /* 签名验证 */ /* ------------------------------------------------------------------ */ /** * 兼容多种 header key 命名(ThinkPHP 默认都会统一成小写-连字符,但不同反向代理/php-fpm 下可能变体) * * @param array> $headers * @param array $candidates 按优先级排列的 header key */ private function pickHeader(array $headers, array $candidates): string { foreach ($candidates as $key) { if (!isset($headers[$key])) { continue; } $v = $headers[$key]; if (is_array($v)) { $v = reset($v); } $v = trim((string) $v); if ($v !== '') { return $v; } } return ''; } /** * md5(access-appkey + secret-key + access-nonce + access-timestamp + $sBody) * * 注意:回调签名使用的是「回调通知账号」—— callback_appkey / callback_secret, * 与下单使用的 biz_ak / biz_sk 是不同的两套凭证。 */ private function verifySign(string $appkey, string $nonce, string $timestamp, string $sign, string $body): bool { $config = Config::get('gancao_scm', []); $cfgAppkey = (string) ($config['callback_appkey'] ?? ''); $secretKey = (string) ($config['callback_secret'] ?? ''); if ($appkey === '' || $sign === '') { Log::warning(sprintf( 'Gancao callback missing header | appkey=%s | sign=%s', $appkey !== '' ? $appkey : '(empty)', $sign !== '' ? $sign : '(empty)' )); return false; } if ($appkey !== $cfgAppkey) { Log::warning(sprintf( 'Gancao callback appkey mismatch | received=%s | expected(config.callback_appkey)=%s', $appkey, $cfgAppkey !== '' ? $cfgAppkey : '(empty, check GANCAO_SCM_CALLBACK_APPKEY in .env)' )); return false; } $expected = md5($appkey . $secretKey . $nonce . $timestamp . $body); if (!hash_equals($expected, $sign)) { Log::warning(sprintf( 'Gancao callback sign mismatch | received=%s | expected=%s | nonce=%s | ts=%s', $sign, $expected, $nonce, $timestamp )); return false; } return true; } /* ------------------------------------------------------------------ */ /* 回调数据处理 */ /* ------------------------------------------------------------------ */ private function handleCallback(array $data): void { $recipelOrderNo = (string) ($data['recipel_order_no'] ?? ''); $appOrderNo = (string) ($data['app_order_no'] ?? ''); $state = (int) ($data['state'] ?? 0); $ext = is_array($data['ext'] ?? null) ? $data['ext'] : []; if ($recipelOrderNo === '' && $appOrderNo === '') { Log::warning('Gancao callback missing order no', ['data' => $data]); return; } $order = $this->findOrder($recipelOrderNo, $appOrderNo); if (!$order) { Log::warning('Gancao callback order not found', compact('recipelOrderNo', 'appOrderNo')); return; } $this->updateOrderStatus($order, $state, $ext); $this->writeCallbackLog($order, $state, $ext); Log::info('Gancao callback processed', [ 'order_id' => $order->id, 'recipel_order_no' => $recipelOrderNo, 'state' => $state, 'ext' => $ext, ]); } /** * 通过甘草处方单号或应用商订单号查找本地订单 */ private function findOrder(string $recipelOrderNo, string $appOrderNo): ?PrescriptionOrder { if ($recipelOrderNo !== '') { $order = PrescriptionOrder::where('gancao_reciperl_order_no', $recipelOrderNo) ->whereNull('delete_time') ->find(); if ($order) { return $order; } } if ($appOrderNo !== '') { return PrescriptionOrder::where('order_no', $appOrderNo) ->whereNull('delete_time') ->find() ?: null; } return null; } /* ------------------------------------------------------------------ */ /* 订单状态更新 */ /* ------------------------------------------------------------------ */ /** * state 说明: * 10 系统审核中 * 11 系统审核通过 * 110 订单药房流转制作中(ext: flow_name, supplier) * 20 物流中(ext: shipping_name, nu, supplier) * 30 完成 - 终态。fulfillment 见 resolveFulfilmentOnGancaoState30(与 zyt_order 已付/关联合计对比业务订单 amount) * 90 拦截 - 可恢复 * 91 主动撤单 - 终态(退费) * 92 驳回 - 终态(无法制作并退费) */ /** * 甘草 state=30:返回 fulfillment_status 3=已完成 或 6=已签收 * 1) 已支付金额(zyt_order.status=2 的 amount 合计)与业务订单 amount 一致 → 3 * 2) 否则已关联订单金额合计(全部关联单 amount)与业务订单 amount 一致 → 3 * 3) 否则 → 6(含:已支付与总金额不一致且关联合计也不一致) * 无关联 zyt_order:仅甘草完成则 3 */ private function resolveFulfilmentOnGancaoState30(PrescriptionOrder $order): int { $poId = (int) $order->id; if ($poId <= 0) { return 3; } $payIds = PrescriptionOrderPayOrder::where('prescription_order_id', $poId) ->column('pay_order_id'); $payIds = array_values(array_filter( array_map('intval', is_array($payIds) ? $payIds : []), static fn (int $id): bool => $id > 0 )); if ($payIds === []) { return 3; } $orderAmt = round((float) ($order->amount ?? 0), 2); $sumAll = round( (float) Order::whereIn('id', $payIds)->whereNull('delete_time')->sum('amount'), 2 ); $sumPaid = round( (float) Order::whereIn('id', $payIds) ->whereNull('delete_time') ->where('status', 2) ->sum('amount'), 2 ); if (abs($sumPaid - $orderAmt) <= 0.02) { return 3; } if (abs($sumAll - $orderAmt) <= 0.02) { return 3; } Log::warning('Gancao 完成回调:已支付(status=2)与关联合计均未与业务订单金额对齐,标已签收(6)', [ 'prescription_order_id' => $poId, 'order_no' => (string) ($order->order_no ?? ''), 'tcm_order_amount' => $orderAmt, 'sum_paid_status2' => $sumPaid, 'sum_linked_all' => $sumAll, 'linked_pay_order_ids' => $payIds, ]); return 6; } private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void { $order->gancao_order_state = $state; switch ($state) { case 10: case 11: break; case 110: $this->handleProduction($order, $ext); break; case 20: $this->handleShipping($order, $ext); break; case 30: $this->handleShipping($order, $ext); if ((int) $order->fulfillment_status !== 4) { $order->fulfillment_status = $this->resolveFulfilmentOnGancaoState30($order); } break; case 90: $order->gancao_remark = '甘草订单被拦截(可恢复)'; break; case 91: if ((int) $order->fulfillment_status !== 3) { $order->fulfillment_status = 4; // 已取消 } $order->gancao_remark = '甘草主动撤单(已退费)'; break; case 92: if ((int) $order->fulfillment_status !== 3) { $order->fulfillment_status = 4; // 已取消 } $order->gancao_remark = '甘草驳回(无法制作并退费)'; break; } $savedOk = false; try { $order->save(); $savedOk = true; } catch (\Throwable $e) { Log::error('Gancao callback save failed', [ 'order_id' => $order->id, 'error' => $e->getMessage(), ]); } if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) { ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [ 'tracking_number' => (string) ($order->tracking_number ?? ''), 'source' => 'gancao_callback', ]); } } /** * state=110:药房流转制作中 */ private function handleProduction(PrescriptionOrder $order, array $ext): void { $flowName = (string) ($ext['flow_name'] ?? ''); $supplier = (string) ($ext['supplier'] ?? ''); if ($flowName !== '') { $order->gancao_flow_name = mb_substr($flowName, 0, 100); } if ($supplier !== '') { $order->gancao_supplier = mb_substr($supplier, 0, 100); } $fs = (int) $order->fulfillment_status; if ($fs === 2 && (str_contains($flowName, '发货') || str_contains($flowName, '寄出'))) { $order->fulfillment_status = 5; // 已发货 } } /** * state=20/30:物流中 / 已完成 — 回写快递单号与快递公司 */ private function handleShipping(PrescriptionOrder $order, array $ext): void { $shippingName = (string) ($ext['shipping_name'] ?? ''); $nu = (string) ($ext['nu'] ?? ''); $supplier = (string) ($ext['supplier'] ?? ''); if ($nu !== '') { $order->tracking_number = mb_substr($nu, 0, 80); } if ($shippingName !== '') { $order->gancao_shipping_name = mb_substr($shippingName, 0, 50); $order->express_company = $this->resolveExpressCode($shippingName); } if ($supplier !== '') { $order->gancao_supplier = mb_substr($supplier, 0, 100); } $fs = (int) $order->fulfillment_status; if (in_array($fs, [1, 2], true)) { $order->fulfillment_status = 5; // 已发货 } } /** * 将甘草返回的物流商名称解析为系统内 express_company 短码 */ private function resolveExpressCode(string $shippingName): string { foreach (self::EXPRESS_MAP as $keyword => $code) { if (str_contains($shippingName, $keyword)) { return $code; } } return 'auto'; } /* ------------------------------------------------------------------ */ /* 操作日志 */ /* ------------------------------------------------------------------ */ private function writeCallbackLog(PrescriptionOrder $order, int $state, array $ext): void { $stateName = self::STATE_MAP[$state] ?? "未知状态({$state})"; $summary = "甘草回调:{$stateName}"; if (isset($ext['flow_name'])) { $summary .= " | 流程:{$ext['flow_name']}"; } if (isset($ext['supplier'])) { $summary .= " | 药房:{$ext['supplier']}"; } if (isset($ext['shipping_name'])) { $summary .= " | 物流:{$ext['shipping_name']}"; } if (isset($ext['nu'])) { $summary .= " | 单号:{$ext['nu']}"; } try { $log = new PrescriptionOrderLog(); $log->prescription_order_id = (int) $order->id; $log->admin_id = 0; $log->admin_name = '甘草系统'; $log->action = 'gancao_callback'; $log->summary = mb_substr($summary, 0, 500); $log->create_time = time(); $log->save(); } catch (\Throwable $e) { Log::warning('Gancao callback log write failed', ['error' => $e->getMessage()]); } } /* ------------------------------------------------------------------ */ /* 响应 */ /* ------------------------------------------------------------------ */ private function ok(): Response { return response('ok', 200, [], 'html'); } }