first commit
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user