callbackBody(); if (!$this->isAuthenticCallback($body)) { return $this->callbackResponse(['code' => 401, 'message' => 'invalid signature'], 401); } try { $payload = $this->decodePayload($body); $payload = array_replace($payload, $this->normalizeLogistics($payload)); $result = $this->callbackWorkflow()->handle($payload); $httpStatus = (int) ($result['http_status'] ?? 500); $message = (string) ($result['message'] ?? 'callback processing failed'); if ($httpStatus >= 500) { $this->logCallbackFailure($message); } return $this->callbackResponse([ 'code' => $httpStatus === 200 ? 0 : $httpStatus, 'message' => $message, 'data' => ['duplicate' => (bool) ($result['duplicate'] ?? false)], ], $httpStatus); } catch (JsonException $exception) { return $this->callbackResponse(['code' => 400, 'message' => 'invalid JSON payload'], 400); } catch (InvalidArgumentException|DomainException $exception) { return $this->callbackResponse(['code' => 422, 'message' => $exception->getMessage()], 422); } catch (Throwable $exception) { $this->logCallbackFailure($exception->getMessage()); return $this->callbackResponse(['code' => 500, 'message' => $exception->getMessage()], 500); } } /** @param array $payload */ protected function callbackResponse(array $payload, int $httpStatus) { return json($payload, $httpStatus); } protected function logCallbackFailure(string $message): void { Log::error('ej pharmacy callback failed', ['error' => $message]); } protected function callbackBody(): string { return (string) $this->request->getContent(); } protected function isAuthenticCallback(string $body): bool { $timestamp = trim((string) $this->request->header('x-timestamp')); $nonce = trim((string) $this->request->header('x-nonce')); $signature = trim((string) $this->request->header('x-signature')); $secret = trim((string) Config::get('ej_pharmacy.callback_secret', '')); $canonical = EjPharmacySignature::canonical('POST', '/api/ej-pharmacy/webhook', $timestamp, $nonce, $body); return $secret !== '' && ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300 && $nonce !== '' && EjPharmacySignature::verify($secret, $canonical, $signature); } /** @return array */ protected function decodePayload(string $body): array { $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR); if (!is_array($payload)) { throw new InvalidArgumentException('payload must be an object'); } foreach (['event_id', 'pharmacy_order_no', 'source_order_no'] as $field) { if (trim((string) ($payload[$field] ?? '')) === '') { throw new InvalidArgumentException($field . ' is required'); } } return $payload; } protected function callbackWorkflow(): EjPharmacyCallbackWorkflow { $loadInbox = static function (string $eventId): ?array { $inbox = EjPharmacyCallbackInbox::where('event_id', $eventId)->find(); return $inbox ? $inbox->toArray() : null; }; return new EjPharmacyCallbackWorkflow( $loadInbox, static function (array $payload): array { $inbox = EjPharmacyCallbackInbox::create([ 'event_id' => trim((string) $payload['event_id']), 'pharmacy_order_no' => trim((string) $payload['pharmacy_order_no']), 'source_order_no' => trim((string) $payload['source_order_no']), 'event_type' => (string) ($payload['event_type'] ?? ''), 'status_version' => (int) ($payload['status_version'] ?? 0), 'payload' => json_encode( $payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ), 'process_status' => 'PENDING', ]); return $inbox->toArray(); }, $loadInbox, fn (array $inbox, array $payload): array => $this->processCallback($inbox, $payload), static function (array $inbox, string $error): void { $inboxId = (int) ($inbox['id'] ?? 0); $updated = EjPharmacyCallbackFailureTransition::apply( $inboxId, $error, static fn (int $id, array $values, string $protectedStatus): bool => EjPharmacyCallbackInbox::where('id', $id) ->where('process_status', '<>', $protectedStatus) ->update($values) === 1 ); if ($updated) { return; } $currentStatus = EjPharmacyCallbackInbox::where('id', $inboxId)->value('process_status'); if ($currentStatus === null) { throw new RuntimeException('callback inbox could not be marked failed'); } }, static fn (Throwable $exception): bool => (string) $exception->getCode() === '23000' || str_contains($exception->getMessage(), '1062') || str_contains(strtolower($exception->getMessage()), 'duplicate') ); } /** @param array $inbox @param array $payload @return array */ private function processCallback(array $inbox, array $payload): array { $logistics = $payload; $pharmacyOrderNo = trim((string) $payload['pharmacy_order_no']); $sourceOrderNo = trim((string) $payload['source_order_no']); return Db::transaction(function () use ($inbox, $payload, $logistics, $pharmacyOrderNo, $sourceOrderNo): array { $inboxModel = EjPharmacyCallbackInbox::where('id', (int) ($inbox['id'] ?? 0))->lock(true)->find(); if (!$inboxModel) { throw new RuntimeException('callback inbox does not exist'); } if (strtoupper((string) $inboxModel->process_status) === 'PROCESSED') { return $inboxModel->toArray(); } $order = PrescriptionOrder::where('ej_pharmacy_order_no', $pharmacyOrderNo) ->where('order_no', $sourceOrderNo) ->whereNull('delete_time') ->lock(true) ->find(); if (!$order) { throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立,请稍后重试'); } $incomingVersion = (int) ($payload['status_version'] ?? 0); $previousFulfillmentStatus = (int) ($order->fulfillment_status ?? 0); $rollbackFulfillmentStatus = (int) ($order->ej_pharmacy_previous_fulfillment_status ?? 0); $versionAdvanced = $incomingVersion > (int) ($order->ej_pharmacy_status_version ?? 0); $nextFulfillmentStatus = $previousFulfillmentStatus; if ($versionAdvanced) { $nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus( $previousFulfillmentStatus, $payload, $rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null ); $orderValues = [ 'ej_pharmacy_status' => (string) ($payload['status'] ?? $order->ej_pharmacy_status), 'ej_pharmacy_review_status' => (string) ($payload['review_status'] ?? $order->ej_pharmacy_review_status), 'ej_pharmacy_status_version' => $incomingVersion, 'ej_pharmacy_current_step' => mb_substr((string) ($payload['step_name'] ?? ''), 0, 100), 'ej_pharmacy_remark' => mb_substr((string) ($payload['remark'] ?? ''), 0, 500), 'express_company' => $logistics['express_company'] !== '' ? $logistics['express_company'] : (string) $order->express_company, 'tracking_number' => $logistics['tracking_number'] !== '' ? $logistics['tracking_number'] : (string) $order->tracking_number, ]; if ($nextFulfillmentStatus !== (int) ($order->fulfillment_status ?? 0)) { $orderValues['fulfillment_status'] = $nextFulfillmentStatus; } $order->save($orderValues); $tracking = self::syncLogistics($order, $payload, $logistics); if (EjPharmacyShipmentPolicy::isShippedEvent($payload) && (int) ($order->fulfillment_status ?? 0) === 5) { ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [ 'tracking_id' => $tracking ? (int) $tracking->id : 0, 'tracking_number' => (string) ($order->tracking_number ?? ''), 'source' => 'ej_pharmacy_callback', ]); } } if (!$versionAdvanced) { $nextFulfillmentStatus = EjPharmacyShipmentPolicy::nextFulfillmentStatus( $previousFulfillmentStatus, $payload, $rollbackFulfillmentStatus > 0 ? $rollbackFulfillmentStatus : null ); } // Each unique callback is an auditable order operation, including // callbacks that arrive out of order and are ignored by the // status-version gate. This keeps EJ review/production nodes // visible in the same timeline as the upload operation. $eventType = strtoupper(trim((string) ($payload['event_type'] ?? ''))); $stepName = trim((string) ($payload['step_name'] ?? '')); $operatorName = trim((string) ($payload['operator_name'] ?? '')); $remoteStatus = trim((string) ($payload['status'] ?? '')); $displayOperatorName = self::callbackOperatorLabel($operatorName); $isWorkflowStep = $eventType === 'WORKFLOW_STEP_COMPLETED' && $stepName !== ''; if ($isWorkflowStep) { // Keep the same readable production-flow format as Gancao: // flow name and pharmacy are the primary information. $summaryParts = [ '洛阳药房回传:' . (EjPharmacyShipmentPolicy::isCompletedEvent($payload) ? '订单已完成' : '订单药房流转制作中'), '流程:' . $stepName, '药房:洛阳药房', ]; if ($displayOperatorName !== '') { $summaryParts[] = '操作人:' . $displayOperatorName; } } else { $summaryParts = ['洛阳药房回传:' . self::callbackEventLabel($eventType)]; if ($operatorName !== '') { $summaryParts[] = '操作人:' . $displayOperatorName; } if ($remoteStatus !== '') { $summaryParts[] = '远端状态:' . self::callbackStatusLabel($remoteStatus); } $summaryParts[] = '履约状态:' . self::fulfillmentStatusLabel($previousFulfillmentStatus) . ' → ' . self::fulfillmentStatusLabel($nextFulfillmentStatus); if (!$versionAdvanced) { $summaryParts[] = '(版本已处理,保留回传日志)'; } } $log = new PrescriptionOrderLog(); $log->prescription_order_id = (int) $order->id; $log->admin_id = 0; $log->admin_name = mb_substr( $isWorkflowStep ? '洛阳药房系统' : ($operatorName !== '' ? '洛阳药房:' . $displayOperatorName : '洛阳药房ERP'), 0, 64 ); $log->action = 'ej_pharmacy_callback'; // Match the Gancao production-flow presentation so both pharmacy // timelines use the same field separator. $log->summary = mb_substr(implode($isWorkflowStep ? ' | ' : ';', $summaryParts), 0, 500); $log->create_time = time(); $log->save(); $inboxModel->save(['process_status' => 'PROCESSED', 'processed_time' => time(), 'error_message' => '']); return $inboxModel->toArray(); }); } private static function fulfillmentStatusLabel(int $status): string { return [ 1 => '待双审通过', 2 => '待发货/发货与履约', 3 => '已完成', 4 => '已取消', 5 => '已发货', 6 => '已签收', 7 => '进行中', 8 => '暂不制药', 9 => '拒收', 10 => '退款', 11 => '保留药方', 12 => '制药缓发', ][$status] ?? ('状态' . $status); } private static function callbackEventLabel(string $eventType): string { return [ 'ORDER_CREATED' => '订单已创建', 'REVIEW_APPROVED' => '审核通过', 'REVIEW_REJECTED' => '审核驳回', 'INVENTORY_SHORTAGE' => '库存不足', 'WORKFLOW_STEP_COMPLETED' => '流程节点完成', 'ORDER_SHIPPED' => '订单已发货', 'ORDER_UPDATED' => '订单状态更新', ][$eventType] ?? ($eventType !== '' ? $eventType : '状态更新'); } private static function callbackStatusLabel(string $status): string { return [ 'PENDING_REVIEW' => '待审核', 'READY_FOR_PRODUCTION' => '待生产', 'IN_PRODUCTION' => '生产中', 'PACKAGED' => '已包装', 'SHIPPED' => '已发货', 'COMPLETED' => '已完成', 'REJECTED' => '已驳回', 'STOCK_SHORTAGE' => '库存不足', 'CANCELLED' => '已取消', ][$status] ?? $status; } private static function callbackOperatorLabel(string $operatorName): string { return [ 'admin' => '管理员', 'admin_user' => '管理员', 'system' => '系统', ][$operatorName] ?? $operatorName; } /** @param array $payload @return array{express_company:string,tracking_number:string} */ private function normalizeLogistics(array $payload): array { return [ 'express_company' => PharmacyLogisticsValue::normalize( $payload['express_company'] ?? '', 32, '快递公司' ), 'tracking_number' => PharmacyLogisticsValue::normalize( $payload['tracking_number'] ?? '', 100, '运单号' ), ]; } /** @param array $payload */ private static function syncLogistics(PrescriptionOrder $order, array $payload, array $logistics): ?ExpressTracking { $trackingNumber = $logistics['tracking_number']; if ($trackingNumber === '') { return null; } $tracking = ExpressTracking::where('order_id', (int) $order->id) ->where('order_type', 'prescription') ->whereNull('delete_time') ->order('id', 'desc') ->lock(true) ->find(); $matching = null; if (!$tracking || trim((string) $tracking->tracking_number) !== $trackingNumber) { $matching = ExpressTracking::where('tracking_number', $trackingNumber) ->whereNull('delete_time') ->lock(true) ->find(); } $decision = EjPharmacyTrackingPolicy::select( $tracking ? $tracking->toArray() : null, $matching ? $matching->toArray() : null, (int) $order->id, $trackingNumber ); $now = time(); if ($decision['archive_current']) { ExpressTracking::where('order_id', (int) $order->id) ->where('order_type', 'prescription') ->whereNull('delete_time') ->update([ 'order_type' => 'prescription_history', 'auto_update' => 0, 'next_update_time' => 0, 'update_time' => $now, ]); } if ($decision['action'] === 'REUSE_MATCHING') { $tracking = $matching; } elseif ($decision['action'] === 'CREATE') { $tracking = new ExpressTracking(); $tracking->create_time = $now; } $trackingState = trim((string) ($payload['logistics_state'] ?? '')); $tracking->save([ 'order_id' => (int) $order->id, 'order_type' => 'prescription', 'tracking_number' => $trackingNumber, 'express_company' => $logistics['express_company'] !== '' ? $logistics['express_company'] : 'auto', 'express_company_name' => mb_substr((string) ($payload['express_company_name'] ?? ''), 0, 100), 'recipient_phone' => mb_substr((string) $order->recipient_phone, 0, 20), 'recipient_name' => mb_substr((string) $order->recipient_name, 0, 100), 'recipient_address' => mb_substr((string) $order->shipping_address, 0, 500), 'current_state' => $trackingState !== '' ? $trackingState : (string) ($tracking->current_state ?: '0'), 'current_state_text' => mb_substr( (string) ($payload['logistics_state_text'] ?? $tracking->current_state_text ?? '在途'), 0, 50 ), 'data_source' => 'ej_pharmacy', 'auto_update' => 1, 'next_update_time' => $now + 1800, 'update_time' => $now, ]); $traces = is_array($payload['logistics_traces'] ?? null) ? $payload['logistics_traces'] : []; foreach ($traces as $trace) { if (!is_array($trace)) { continue; } $context = mb_substr((string) ($trace['context'] ?? ''), 0, 1000); $timestamp = (int) ($trace['timestamp'] ?? 0); if ($context === '' || ExpressTrace::where('tracking_id', (int) $tracking->id) ->where('trace_time_stamp', $timestamp) ->where('trace_context', $context) ->count() > 0) { continue; } ExpressTrace::create([ 'tracking_id' => (int) $tracking->id, 'tracking_number' => $trackingNumber, 'trace_time' => (string) ($trace['time'] ?? ''), 'trace_time_stamp' => $timestamp, 'trace_context' => $context, 'status' => (string) ($trace['status'] ?? ''), 'status_code' => (string) ($trace['status_code'] ?? ''), 'location' => (string) ($trace['location'] ?? ''), 'create_time' => time(), ]); } return $tracking; } }