490 lines
19 KiB
PHP
490 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\pharmacy;
|
|
|
|
use DomainException;
|
|
use think\facade\Db;
|
|
use think\facade\Config;
|
|
|
|
final class PharmacySubmissionClaimService
|
|
{
|
|
/** @return array<string,mixed> */
|
|
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<string,mixed> $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<string,mixed>|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<string,mixed> */
|
|
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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $claim @return array<string,mixed> */
|
|
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<string,mixed> $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<string,mixed> */
|
|
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];
|
|
}
|
|
}
|