*/ public static function acquire( int $orderId, int $revision, string $target, int $operatorId, string $operatorName ): array { self::assertTarget($target); $revision = max($revision, 0); return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array { $order = Db::name('tcm_prescription_order') ->where('id', $orderId) ->whereNull('delete_time') ->lock(true) ->find(); if (!$order) { throw new DomainException('订单不存在'); } if ($revision <= 0) { $revision = self::nextRevisionForOrder($order, $target); } // Rows handled by the old integration may already be marked as // ZYT "拒收". Reopen them to the uploadable fulfillment state // before creating the next EJ revision. if ($target === 'direct' && self::isRejectedEjOrder($order, $target) && (int) ($order['fulfillment_status'] ?? 0) === 9) { Db::name('tcm_prescription_order')->where('id', $orderId)->update([ 'fulfillment_status' => 2, ]); $order['fulfillment_status'] = 2; } $expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao')); if ($expectedTarget !== $target) { throw new DomainException('发货药房已变更,请刷新后重试'); } $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->where('source_revision', $revision) ->lock(true) ->find(); if ($claim) { $decision = PharmacySubmissionClaimPolicy::existingDecision($claim, $target); if ($decision['action'] === 'IDEMPOTENT') { return [ 'target' => $target, 'token' => (string) $claim['claim_token'], 'status' => 'SUCCESS', 'idempotent' => true, 'result' => self::idempotentResult($target, (string) ($claim['remote_order_no'] ?? '')), ]; } if ($decision['action'] === 'RECONCILE') { if (!empty($decision['lease_expired'])) { $claim = self::expirePendingGancaoClaim($claim, $operatorId, $operatorName); } return [ 'target' => $target, 'token' => (string) $claim['claim_token'], 'status' => (string) $claim['status'], 'idempotency_key' => (string) $claim['idempotency_key'], 'idempotent' => false, 'reconcile' => true, ]; } } if (self::hasAnyRemoteOrder($order)) { if (!self::isRejectedEjOrder($order, $target)) { throw new DomainException('该订单已存在远程药房单号,不可重复提交'); } } $token = bin2hex(random_bytes(16)); $now = time(); $leaseSeconds = max(30, (int) Config::get('ej_pharmacy.submission_lease_seconds', 300)); $values = [ 'target' => $target, 'status' => 'PENDING', 'claim_token' => $token, 'idempotency_key' => hash('sha256', $orderId . ':' . $revision . ':' . $target), 'remote_order_no' => '', 'request_id' => '', 'error_message' => '', 'operator_id' => $operatorId, 'operator_name' => mb_substr(trim($operatorName), 0, 80), 'claimed_at' => $now, 'lease_expires_at' => $now + $leaseSeconds, 'completed_at' => 0, 'failed_at' => 0, 'update_time' => $now, ]; if ($claim) { Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])->update($values); } else { Db::name('pharmacy_submission_claim')->insert($values + [ 'prescription_order_id' => $orderId, 'source_revision' => $revision, 'create_time' => $now, ]); } return $values + [ 'source_revision' => $revision, 'idempotent' => false, ]; }); } /** @param array $result */ public static function markSuccess( int $orderId, int $revision, string $target, string $token, array $result ): bool { self::assertTarget($target); $remoteOrderNo = trim((string) ( $result['remote_order_no'] ?? $result['pharmacy_order_no'] ?? $result['recipel_order_no'] ?? '' )); if ($remoteOrderNo === '') { throw new DomainException('药房返回缺少远程订单号'); } return Db::transaction(function () use ($orderId, $revision, $target, $token, $result, $remoteOrderNo): bool { $order = Db::name('tcm_prescription_order') ->where('id', $orderId) ->whereNull('delete_time') ->lock(true) ->find(); if (!$order || self::hasConflictingRemoteOrder($order, $target)) { return false; } $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->where('source_revision', max($revision, 1)) ->where('target', $target) ->where('claim_token', $token) ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) ->lock(true) ->find(); if (!$claim) { return false; } $now = time(); $claimUpdated = Db::name('pharmacy_submission_claim') ->where('id', (int) $claim['id']) ->where('target', $target) ->where('claim_token', $token) ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) ->update([ 'status' => 'SUCCESS', 'remote_order_no' => mb_substr($remoteOrderNo, 0, 64), 'request_id' => mb_substr((string) ($result['request_id'] ?? ''), 0, 64), 'error_message' => '', 'completed_at' => $now, 'failed_at' => 0, 'lease_expires_at' => 0, 'update_time' => $now, ]); if ($claimUpdated !== 1) { return false; } $orderValues = $target === 'direct' ? [ 'ej_pharmacy_order_no' => mb_substr($remoteOrderNo, 0, 40), 'ej_pharmacy_submit_time' => $now, 'ej_pharmacy_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'), 'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'), 'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1), // Preserve the business status from before the EJ upload. 'ej_pharmacy_previous_fulfillment_status' => (int) ($order['ej_pharmacy_previous_fulfillment_status'] ?? 0) > 0 ? (int) $order['ej_pharmacy_previous_fulfillment_status'] : (int) ($order['fulfillment_status'] ?? 0), ] : [ 'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32), 'gancao_submit_time' => $now, ]; Db::name('tcm_prescription_order')->where('id', $orderId)->update($orderValues); return true; }); } public static function markFailure( int $orderId, int $revision, string $target, string $token, string $error ): bool { return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool { $order = Db::name('tcm_prescription_order') ->where('id', $orderId) ->whereNull('delete_time') ->lock(true) ->find(); if (!$order) { return false; } $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->where('source_revision', max($revision, 1)) ->where('target', $target) ->where('claim_token', $token) ->where('status', 'PENDING') ->lock(true) ->find(); if (!$claim) { return false; } $now = time(); return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id']) ->where('status', 'PENDING') ->update([ 'status' => 'FAILED', 'error_message' => mb_substr($error, 0, 1000), 'failed_at' => $now, 'lease_expires_at' => 0, 'update_time' => $now, ]) === 1; }); } public static function markReconcile( int $orderId, int $revision, string $target, string $token, string $error ): bool { return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool { Db::name('tcm_prescription_order') ->where('id', $orderId) ->lock(true) ->find(); $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->where('source_revision', max($revision, 1)) ->where('target', $target) ->where('claim_token', $token) ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) ->lock(true) ->find(); if (!$claim) { return false; } return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id']) ->whereIn('status', ['PENDING', 'PENDING_RECONCILE']) ->update([ 'status' => 'PENDING_RECONCILE', 'error_message' => mb_substr($error, 0, 1000), 'failed_at' => 0, 'lease_expires_at' => 0, 'update_time' => time(), ]) === 1; }); } /** @return array|null */ public static function claimForOrder(int $orderId, int $revision = 0): ?array { $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->when($revision > 0, static fn ($query) => $query->where('source_revision', $revision)) ->order('source_revision', 'desc') ->find(); return is_array($claim) ? $claim : null; } /** @return array */ public static function resolveGancao( int $orderId, int $revision, string $resolution, string $remoteOrderNo, string $note, int $operatorId, string $operatorName ): array { return Db::transaction(function () use ( $orderId, $revision, $resolution, $remoteOrderNo, $note, $operatorId, $operatorName ): array { $order = Db::name('tcm_prescription_order') ->where('id', $orderId) ->whereNull('delete_time') ->lock(true) ->find(); if (!$order) { throw new DomainException('订单不存在'); } $claim = Db::name('pharmacy_submission_claim') ->where('prescription_order_id', $orderId) ->where('source_revision', max($revision, 1)) ->lock(true) ->find(); if (!$claim) { throw new DomainException('未找到待核对的甘草提交'); } $resolved = PharmacySubmissionReconciliationPolicy::resolve( $claim, $resolution, $remoteOrderNo, $note ); if ($resolved['status'] === 'SUCCESS' && self::hasConflictingRemoteOrder($order, 'gancao')) { throw new DomainException('订单已存在洛阳药房单号,不能确认甘草成功'); } $now = time(); $updated = Db::name('pharmacy_submission_claim') ->where('id', (int) $claim['id']) ->where('claim_token', (string) $claim['claim_token']) ->whereIn('status', ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE']) ->update([ 'status' => $resolved['status'], 'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64), 'error_message' => mb_substr($resolved['note'], 0, 1000), 'lease_expires_at' => 0, 'completed_at' => $resolved['status'] === 'SUCCESS' ? $now : 0, 'failed_at' => $resolved['status'] === 'FAILED' ? $now : 0, 'update_time' => $now, ]); if ($updated !== 1) { throw new DomainException('提交状态已变化,请刷新后重新核对'); } if ($resolved['status'] === 'SUCCESS') { Db::name('tcm_prescription_order')->where('id', $orderId)->update([ 'gancao_reciperl_order_no' => mb_substr($resolved['remote_order_no'], 0, 32), 'gancao_submit_time' => $now, ]); } Db::name('pharmacy_submission_claim_audit')->insert([ 'claim_id' => (int) $claim['id'], 'prescription_order_id' => $orderId, 'source_revision' => max($revision, 1), 'target' => 'gancao', 'action' => strtoupper(trim($resolution)), 'from_status' => strtoupper((string) $claim['status']), 'to_status' => $resolved['status'], 'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64), 'note' => mb_substr($resolved['note'], 0, 1000), 'operator_id' => $operatorId, 'operator_name' => mb_substr(trim($operatorName), 0, 80), 'create_time' => $now, ]); return $resolved + ['claim_id' => (int) $claim['id']]; }); } /** @param array $order */ public static function hasAnyRemoteOrder(array $order): bool { return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '' || trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== ''; } /** @param array $order */ private static function isRejectedEjOrder(array $order, string $target): bool { if ($target !== 'direct') { return false; } return strtoupper(trim((string) ($order['ej_pharmacy_status'] ?? ''))) === 'REJECTED' || strtoupper(trim((string) ($order['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED'; } /** @param array $order */ private static function nextRevisionForOrder(array $order, string $target): int { $latest = (int) Db::name('pharmacy_submission_claim') ->where('prescription_order_id', (int) $order['id']) ->max('source_revision'); if (self::isRejectedEjOrder($order, $target)) { return max($latest + 1, 1); } return max($latest, 1); } private static function targetForShipMode(string $shipMode): string { return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao'; } /** @param array $claim @return array */ private static function expirePendingGancaoClaim(array $claim, int $operatorId, string $operatorName): array { $now = time(); $newToken = bin2hex(random_bytes(16)); $updated = Db::name('pharmacy_submission_claim') ->where('id', (int) $claim['id']) ->where('status', 'PENDING') ->where('claim_token', (string) $claim['claim_token']) ->update([ 'status' => 'PENDING_RECONCILE', 'claim_token' => $newToken, 'error_message' => '提交租约已超时,甘草远端结果不确定,须人工核对', 'operator_id' => $operatorId, 'operator_name' => mb_substr(trim($operatorName), 0, 80), 'lease_expires_at' => 0, 'update_time' => $now, ]); if ($updated !== 1) { throw new DomainException('提交租约状态已变化,请刷新后重试'); } Db::name('pharmacy_submission_claim_audit')->insert([ 'claim_id' => (int) $claim['id'], 'prescription_order_id' => (int) $claim['prescription_order_id'], 'source_revision' => (int) $claim['source_revision'], 'target' => 'gancao', 'action' => 'LEASE_EXPIRED', 'from_status' => 'PENDING', 'to_status' => 'PENDING_RECONCILE', 'remote_order_no' => '', 'note' => '租约超时后轮换 claim token,禁止自动重提', 'operator_id' => $operatorId, 'operator_name' => mb_substr(trim($operatorName), 0, 80), 'create_time' => $now, ]); return array_replace($claim, [ 'status' => 'PENDING_RECONCILE', 'claim_token' => $newToken, 'lease_expires_at' => 0, ]); } private static function assertTarget(string $target): void { if (!in_array($target, ['gancao', 'direct'], true)) { throw new DomainException('不支持的药房目标'); } } /** @param array $order */ private static function hasConflictingRemoteOrder(array $order, string $target): bool { if ($target === 'direct') { return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== ''; } return trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== ''; } /** @return array */ private static function idempotentResult(string $target, string $remoteOrderNo): array { if ($target === 'direct') { return ['pharmacy' => 'ej', 'pharmacy_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo]; } return ['pharmacy' => 'gancao', 'recipel_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo]; } }