456 lines
20 KiB
PHP
456 lines
20 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\controller;
|
||
|
||
use DomainException;
|
||
use app\common\model\ExpressTrace;
|
||
use app\common\model\ExpressTracking;
|
||
use app\common\model\pharmacy\EjPharmacyCallbackInbox;
|
||
use app\common\model\tcm\PrescriptionOrder;
|
||
use app\common\model\tcm\PrescriptionOrderLog;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
|
||
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
|
||
use app\common\service\pharmacy\EjPharmacySignature;
|
||
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
|
||
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
|
||
use app\common\service\pharmacy\PharmacyLogisticsValue;
|
||
use app\common\service\ExpressTrackingService;
|
||
use InvalidArgumentException;
|
||
use JsonException;
|
||
use RuntimeException;
|
||
use think\facade\Config;
|
||
use think\facade\Db;
|
||
use think\facade\Log;
|
||
use Throwable;
|
||
|
||
class EjPharmacyCallbackController extends BaseApiController
|
||
{
|
||
public array $notNeedLogin = ['webhook'];
|
||
|
||
public function webhook()
|
||
{
|
||
$body = $this->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<string,mixed> $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<string,mixed> */
|
||
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<string,mixed> $inbox @param array<string,mixed> $payload @return array<string,mixed> */
|
||
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<string,mixed> $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<string,mixed> $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;
|
||
}
|
||
}
|