feat: integrate Luoyang pharmacy workflow
This commit is contained in:
@@ -161,6 +161,27 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('已保存', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工核对甘草不确定提交:确认远端成功或确认未创建。
|
||||
*/
|
||||
public function confirmGancaoSubmission()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('confirmGancaoSubmission');
|
||||
$result = PrescriptionOrderLogic::confirmGancaoSubmission(
|
||||
(int) $params['id'],
|
||||
(string) $params['resolution'],
|
||||
(string) ($params['remote_order_no'] ?? ''),
|
||||
(string) $params['note'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('甘草提交核对已记录', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方的患者姓名与手机号(订单详情场景)
|
||||
*/
|
||||
@@ -451,7 +472,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
{
|
||||
try {
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
|
||||
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
|
||||
if ($result === false) {
|
||||
$error = PrescriptionOrderLogic::getError();
|
||||
@@ -465,7 +486,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->fail('返回数据格式错误');
|
||||
}
|
||||
|
||||
return $this->success('甘草药方上传成功', $result);
|
||||
return $this->success('药方上传成功', $result);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::error('submitGancaoRecipel exception', [
|
||||
'message' => $e->getMessage(),
|
||||
@@ -477,6 +498,19 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified pharmacy upload. The order ship_mode decides the target.
|
||||
*/
|
||||
public function uploadToPharmacy()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
|
||||
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
return $this->success('药方上传成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
|
||||
* 用于在编辑订单时测试价格和配置
|
||||
|
||||
@@ -17,6 +17,7 @@ declare (strict_types=1);
|
||||
namespace app\adminapi\http\middleware;
|
||||
|
||||
use app\adminapi\logic\LoginLogic;
|
||||
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
|
||||
use app\common\{
|
||||
cache\AdminAuthCache,
|
||||
service\JsonService
|
||||
@@ -74,7 +75,8 @@ class AuthMiddleware
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
|
||||
// 判断该当前访问的uri是否存在,不存在无需验证
|
||||
if (!in_array($accessUri, $allUri)) {
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -109,6 +111,10 @@ class AuthMiddleware
|
||||
*/
|
||||
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
|
||||
{
|
||||
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
|
||||
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
|
||||
}
|
||||
|
||||
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
|
||||
&& in_array($accessUri, [
|
||||
'tcm.diagnosistodo/lists',
|
||||
|
||||
@@ -24,6 +24,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use app\common\service\pharmacy\EjPharmacyClient;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
use think\db\Query;
|
||||
@@ -333,9 +334,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
|
||||
* 供货方式:洛阳直发(ship_mode=direct,含尚未上传)/ 甘草(有甘草单号)/ 自营(其余)。
|
||||
*
|
||||
* 入参 supply_mode:gancao | self,空表示不限
|
||||
* 入参 supply_mode:gancao | direct | self,空表示不限
|
||||
*/
|
||||
private function applySupplyModeFilter($query): void
|
||||
{
|
||||
@@ -343,13 +344,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
if ($mode === '') {
|
||||
return;
|
||||
}
|
||||
if ($mode === 'direct') {
|
||||
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
|
||||
|
||||
return;
|
||||
}
|
||||
if ($mode === 'gancao') {
|
||||
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''");
|
||||
$query->whereRaw(
|
||||
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
|
||||
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($mode === 'self') {
|
||||
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''");
|
||||
$query->whereRaw(
|
||||
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
|
||||
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -706,6 +718,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$assistantNames = $assistantIds !== []
|
||||
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
|
||||
: [];
|
||||
$orderIdsForClaims = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'id')))));
|
||||
$claimByOrder = [];
|
||||
if ($orderIdsForClaims !== []) {
|
||||
$claimRows = Db::name('pharmacy_submission_claim')
|
||||
->whereIn('prescription_order_id', $orderIdsForClaims)
|
||||
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
|
||||
->order('source_revision', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($claimRows as $claimRow) {
|
||||
$claimId = (int) $claimRow['prescription_order_id'];
|
||||
if (!isset($claimByOrder[$claimId])) {
|
||||
$claimByOrder[$claimId] = $claimRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($lists as &$item) {
|
||||
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
|
||||
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
|
||||
@@ -713,12 +741,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
|
||||
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
|
||||
$item['deposit_min_amount'] = $depMin;
|
||||
$claim = $claimByOrder[$pid] ?? [];
|
||||
$item['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$item['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$item['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
|
||||
$item,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
|
||||
$item,
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$assistantByDiag
|
||||
);
|
||||
|
||||
// 添加创建人姓名
|
||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||
@@ -766,6 +804,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$base = [
|
||||
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
|
||||
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
|
||||
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
|
||||
'stats_order_amount' => $s['order_amount'],
|
||||
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
|
||||
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
|
||||
@@ -838,7 +877,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'export_agency_collect' => '代收金额',
|
||||
'export_tracking_number' => '快递单号',
|
||||
'export_sign_time' => '签收日期',
|
||||
'export_supply_mode' => '甘草还是自营',
|
||||
'export_supply_mode' => '供货方式',
|
||||
'export_gancao_prescription_cost' => '处方成本',
|
||||
'export_first_visit_assistant' => '初诊医助',
|
||||
'export_rx_audit_time' => '审核时间',
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace app\adminapi\logic\tcm;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\tcm\PrescriptionLibrary;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,18 @@ use think\facade\Config;
|
||||
*/
|
||||
class PrescriptionLibraryLogic extends BaseLogic
|
||||
{
|
||||
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
|
||||
private static function normalizeHerbIdentities(array $herbs): array
|
||||
{
|
||||
return PharmacyHerbIdentityResolver::resolve(
|
||||
$herbs,
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
|
||||
*/
|
||||
@@ -94,7 +108,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
$params['herbs'] = json_encode(
|
||||
self::normalizeHerbIdentities($params['herbs']),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
$model = PrescriptionLibrary::create($params);
|
||||
@@ -130,7 +147,10 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
$params['herbs'] = json_encode(
|
||||
self::normalizeHerbIdentities($params['herbs']),
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
$model->save($params);
|
||||
|
||||
@@ -6,10 +6,13 @@ namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
@@ -159,6 +162,18 @@ class PrescriptionLogic
|
||||
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
|
||||
private static function normalizeHerbIdentities(array $herbs): array
|
||||
{
|
||||
return PharmacyHerbIdentityResolver::resolve(
|
||||
$herbs,
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
|
||||
*/
|
||||
@@ -255,12 +270,11 @@ class PrescriptionLogic
|
||||
self::setError('请添加中药');
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($herbs as $h) {
|
||||
if (empty($h['name'])) {
|
||||
self::setError('中药名称不能为空');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$herbs = self::normalizeHerbIdentities($herbs);
|
||||
} catch (\DomainException $exception) {
|
||||
self::setError($exception->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
$sn = self::generateSn();
|
||||
@@ -335,6 +349,23 @@ class PrescriptionLogic
|
||||
* 编辑处方
|
||||
*/
|
||||
public static function edit(array $params, int $adminId): bool
|
||||
{
|
||||
$prescriptionId = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$prescriptionId,
|
||||
static fn (): bool => self::editLocked($params, $adminId)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function editLocked(array $params, int $adminId): bool
|
||||
{
|
||||
try {
|
||||
$prescription = Prescription::find($params['id']);
|
||||
@@ -342,6 +373,11 @@ class PrescriptionLogic
|
||||
self::setError('处方不存在');
|
||||
return false;
|
||||
}
|
||||
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
|
||||
if ($snapshotLockError !== null) {
|
||||
self::setError($snapshotLockError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已通过且未作废:不可编辑
|
||||
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
|
||||
@@ -381,12 +417,7 @@ class PrescriptionLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($herbs as $h) {
|
||||
if (empty($h['name'])) {
|
||||
self::setError('中药名称不能为空');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$herbs = self::normalizeHerbIdentities($herbs);
|
||||
|
||||
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
|
||||
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
|
||||
@@ -474,6 +505,29 @@ class PrescriptionLogic
|
||||
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
|
||||
*/
|
||||
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$rxId,
|
||||
static fn (): bool => self::patchPatientContactLocked(
|
||||
$rxId,
|
||||
$patientName,
|
||||
$phone,
|
||||
$gender,
|
||||
$adminId,
|
||||
$adminInfo
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPatientContactLocked(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$prescription = Prescription::find($rxId);
|
||||
@@ -482,6 +536,11 @@ class PrescriptionLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
|
||||
if ($snapshotLockError !== null) {
|
||||
self::setError($snapshotLockError);
|
||||
return false;
|
||||
}
|
||||
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
|
||||
self::setError('无权限修改此处方');
|
||||
|
||||
@@ -578,6 +637,22 @@ class PrescriptionLogic
|
||||
* 删除处方
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$id,
|
||||
static fn (): bool => self::deleteLocked($id)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function deleteLocked(int $id): bool
|
||||
{
|
||||
try {
|
||||
$prescription = Prescription::find($id);
|
||||
@@ -1027,6 +1102,22 @@ class PrescriptionLogic
|
||||
* 作废处方
|
||||
*/
|
||||
public static function void(int $id, int $adminId, string $adminName): bool
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
|
||||
$id,
|
||||
static fn (): bool => self::voidLocked($id, $adminId, $adminName)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function voidLocked(int $id, int $adminId, string $adminName): bool
|
||||
{
|
||||
$row = Prescription::find($id);
|
||||
if (!$row) {
|
||||
|
||||
@@ -17,11 +17,25 @@ use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\dict\DictData;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\doctor\Medicine as DoctorMedicine;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\pharmacy\EjMedicineMapping;
|
||||
use app\common\model\pharmacy\EjPharmacySubmission;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\ExpressTrackService;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use app\common\service\pharmacy\EjPharmacyClient;
|
||||
use app\common\service\pharmacy\EjPharmacyPayload;
|
||||
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
|
||||
use app\common\service\pharmacy\PharmacyRemoteOutcomeClassifier;
|
||||
use app\common\service\pharmacy\PharmacyRemoteSnapshotPolicy;
|
||||
use app\common\service\pharmacy\PharmacyRemoteRejectedException;
|
||||
use app\common\service\pharmacy\PharmacyReconciliationRequiredException;
|
||||
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimService;
|
||||
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
|
||||
use app\common\service\pharmacy\PharmacySupplyMode;
|
||||
use think\db\Query;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
@@ -41,6 +55,42 @@ class PrescriptionOrderLogic
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
private static function assertRemoteSnapshotMutable(PrescriptionOrder $order): bool
|
||||
{
|
||||
try {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable(
|
||||
$order->toArray(),
|
||||
PharmacySubmissionClaimService::claimForOrder((int) $order->id)
|
||||
);
|
||||
return true;
|
||||
} catch (\DomainException $exception) {
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function remoteSnapshotLockErrorForPrescription(int $prescriptionId): ?string
|
||||
{
|
||||
if ($prescriptionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$orders = PrescriptionOrder::where('prescription_id', $prescriptionId)
|
||||
->whereNull('delete_time')
|
||||
->select();
|
||||
foreach ($orders as $order) {
|
||||
try {
|
||||
PharmacyRemoteSnapshotPolicy::assertMutable(
|
||||
$order->toArray(),
|
||||
PharmacySubmissionClaimService::claimForOrder((int) $order->id)
|
||||
);
|
||||
} catch (\DomainException $exception) {
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门自底向上链式名称,含父级(如:总部 / 华东 / 上海门诊)
|
||||
* 使用 Db 直查 zyt_dept:避免 Dept 软删除全局作用域导致有 dept_id 仍拼不出路径
|
||||
@@ -972,6 +1022,7 @@ class PrescriptionOrderLogic
|
||||
$order->service_package = (string) ($params['service_package'] ?? '');
|
||||
$order->tracking_number = (string) ($params['tracking_number'] ?? '');
|
||||
$order->express_company = self::normalizeExpressCompany($params['express_company'] ?? 'auto');
|
||||
$order->ship_mode = self::normalizeShipMode((string) ($params['ship_mode'] ?? 'gancao'));
|
||||
$order->fee_type = (int) $params['fee_type'];
|
||||
$order->amount = round((float) $params['amount'], 2);
|
||||
$order->internal_cost = $internalCost;
|
||||
@@ -1153,6 +1204,17 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
$claim = PharmacySubmissionClaimService::claimForOrder($id);
|
||||
$arr['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
|
||||
$arr['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
|
||||
$arr['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
|
||||
$arr['can_upload_pharmacy'] = self::canUploadToPharmacy(
|
||||
$arr,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$diagIdForMeta > 0 ? [$diagIdForMeta => (int) ($arr['assistant_id'] ?? 0)] : []
|
||||
);
|
||||
|
||||
return $arr;
|
||||
}
|
||||
|
||||
@@ -1165,6 +1227,33 @@ class PrescriptionOrderLogic
|
||||
string $phone,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionPatientLocked(
|
||||
$prescriptionOrderId,
|
||||
$patientName,
|
||||
$phone,
|
||||
$adminId,
|
||||
$adminInfo
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPrescriptionPatientLocked(
|
||||
int $prescriptionOrderId,
|
||||
string $patientName,
|
||||
string $phone,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): bool {
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||
@@ -1178,6 +1267,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
@@ -1605,6 +1697,23 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function edit(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
$id = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::editLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function editLocked(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
@@ -1640,11 +1749,8 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
// 已成功提交甘草 SCM:仅允许更新快递单号与承运商(与甘草侧地址/药方等仍以取消流程为准)
|
||||
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no);
|
||||
$gcSubmitTime = (int) $order->gancao_submit_time;
|
||||
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
|
||||
return self::editGancaoLogisticsOnly($order, $params, $adminId, $adminInfo);
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$medDays = $params['medication_days'] ?? null;
|
||||
@@ -1827,6 +1933,23 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function ship(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::shipLocked($id, $expressCompany, $trackingNumber, $shipMode, $adminId, $adminInfo),
|
||||
false
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function shipLocked(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
|
||||
@@ -1854,12 +1977,11 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$shipMode = self::normalizeShipMode($shipMode);
|
||||
$shipMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
|
||||
|
||||
$order->express_company = self::normalizeExpressCompany($expressCompany);
|
||||
$order->tracking_number = $trackingNumber;
|
||||
$order->ship_mode = $shipMode;
|
||||
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
|
||||
$isFirstShip = false;
|
||||
if ((int) $order->fulfillment_status === 2) {
|
||||
@@ -1915,6 +2037,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function withdraw(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function withdrawLocked(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
@@ -2194,6 +2332,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function revokeRxAudit(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::revokeRxAuditLocked($id, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function revokeRxAuditLocked(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
@@ -4025,8 +4179,7 @@ class PrescriptionOrderLogic
|
||||
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
|
||||
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
|
||||
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
|
||||
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '';
|
||||
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
|
||||
$item['export_supply_mode'] = PharmacySupplyMode::label(PharmacySupplyMode::resolve($item));
|
||||
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
|
||||
if (self::canViewInternalCost($adminInfo)) {
|
||||
$rawCost = $item['internal_cost'] ?? null;
|
||||
@@ -4303,6 +4456,12 @@ class PrescriptionOrderLogic
|
||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (self::pharmacyClaimBlocksUpload($item, 'gancao')) {
|
||||
return false;
|
||||
}
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4321,12 +4480,339 @@ class PrescriptionOrderLogic
|
||||
return $aid === $adminId && $adminId > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified upload eligibility for Gancao and Luoyang pharmacy.
|
||||
* A Luoyang order rejected by EJ may be submitted again with a new
|
||||
* source revision; other successful submissions remain one-shot.
|
||||
*
|
||||
* @param array<string,mixed> $item
|
||||
* @param array<int,int|string> $assistantByDiag
|
||||
*/
|
||||
public static function canUploadToPharmacy(array $item, int $adminId, array $adminInfo, array $assistantByDiag = []): bool
|
||||
{
|
||||
$mode = self::normalizeShipMode((string) ($item['ship_mode'] ?? 'gancao'));
|
||||
if ($mode === 'gancao') {
|
||||
return self::canUploadGancaoRecipel($item, $adminId, $adminInfo, $assistantByDiag);
|
||||
}
|
||||
if (!EjPharmacyClient::isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
if ((int) ($item['prescription_audit_status'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$fulfillmentStatus = (int) ($item['fulfillment_status'] ?? 0);
|
||||
if (in_array($fulfillmentStatus, [3, 4, 8, 9, 10, 11, 12], true)
|
||||
&& !($fulfillmentStatus === 9 && self::isEjRejectedState($item))) {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== ''
|
||||
&& !self::isEjRejectedState($item)) {
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
|
||||
return false;
|
||||
}
|
||||
if (self::pharmacyClaimBlocksUpload($item, 'direct')) {
|
||||
return false;
|
||||
}
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo) || (int) ($item['creator_id'] ?? 0) === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
self::canViewOrdersForOwnPrescription($adminInfo)
|
||||
&& self::isPrescriptionOrderPrescriber((int) ($item['prescription_id'] ?? 0), $adminId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
$diagnosisId = (int) ($item['diagnosis_id'] ?? 0);
|
||||
return $adminId > 0 && (int) ($assistantByDiag[$diagnosisId] ?? 0) === $adminId;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $item */
|
||||
private static function pharmacyClaimBlocksUpload(array $item, string $mode): bool
|
||||
{
|
||||
if ($mode === 'direct' && self::isEjRejectedState($item)) {
|
||||
return false;
|
||||
}
|
||||
$status = strtoupper(trim((string) ($item['pharmacy_claim_status'] ?? '')));
|
||||
if (!in_array($status, ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'], true)) {
|
||||
return false;
|
||||
}
|
||||
$target = strtolower(trim((string) ($item['pharmacy_claim_target'] ?? '')));
|
||||
$leaseExpiresAt = (int) ($item['pharmacy_claim_lease_expires_at'] ?? 0);
|
||||
if ($status === 'PENDING'
|
||||
&& $mode === 'direct'
|
||||
&& $target === 'direct'
|
||||
&& $leaseExpiresAt > 0
|
||||
&& $leaseExpiresAt <= time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $item */
|
||||
private static function isEjRejectedState(array $item): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($item['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($item['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads to the pharmacy selected on the business order.
|
||||
*
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function uploadToPharmacy(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::$error = '订单不存在';
|
||||
return false;
|
||||
}
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作';
|
||||
return false;
|
||||
}
|
||||
$target = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao')) === 'direct'
|
||||
? 'direct'
|
||||
: 'gancao';
|
||||
$assistantByDiag = Diagnosis::where('id', (int) $order->diagnosis_id)
|
||||
->whereNull('delete_time')
|
||||
->column('assistant_id', 'id');
|
||||
if (!self::canUploadToPharmacy($order->toArray(), $adminId, $adminInfo, $assistantByDiag)) {
|
||||
self::$error = '须处方审核通过且订单未结束后方可上传药房;洛阳药房审核驳回的订单可重新上传';
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$workflow = new PharmacySubmissionClaimWorkflow(
|
||||
static fn (string $claimTarget): array => PharmacySubmissionClaimService::acquire(
|
||||
$id,
|
||||
0,
|
||||
$claimTarget,
|
||||
$adminId,
|
||||
(string) ($adminInfo['name'] ?? '')
|
||||
),
|
||||
static function (string $claimTarget, string $token, array $claim) use ($id, $adminId, $adminInfo): array {
|
||||
$canonicalOrder = self::canonicalPharmacyOrder($id);
|
||||
if ($claimTarget === 'gancao') {
|
||||
$result = self::submitGancaoRemote((int) $canonicalOrder->id, $adminId, $adminInfo);
|
||||
} else {
|
||||
$result = self::uploadEjRemote($canonicalOrder, $adminId, $adminInfo, $claim);
|
||||
}
|
||||
if ($result === false) {
|
||||
$error = self::$error !== '' ? self::$error : '药房上传失败';
|
||||
throw new PharmacyRemoteRejectedException($error);
|
||||
}
|
||||
return $result;
|
||||
},
|
||||
static fn (string $claimTarget, string $token, array $result, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markSuccess(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$result
|
||||
),
|
||||
static fn (string $claimTarget, string $token, string $error, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markFailure(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$error
|
||||
),
|
||||
static fn (string $claimTarget, string $token, string $error, array $claim): bool =>
|
||||
PharmacySubmissionClaimService::markReconcile(
|
||||
$id,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1),
|
||||
$claimTarget,
|
||||
$token,
|
||||
$error
|
||||
)
|
||||
);
|
||||
$result = $workflow->execute($target);
|
||||
$remoteOrderNo = (string) (
|
||||
$result['remote_order_no']
|
||||
?? $result['pharmacy_order_no']
|
||||
?? $result['recipel_order_no']
|
||||
?? ''
|
||||
);
|
||||
self::writeLog(
|
||||
$id,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$target === 'direct' ? 'ej_pharmacy_submit' : 'gancao_submit',
|
||||
'上传' . ($target === 'direct' ? '洛阳药房' : '甘草药房') . '成功 ' . $remoteOrderNo
|
||||
);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('upload pharmacy failed', ['order_id' => $id, 'target' => $target, 'error' => $exception->getMessage()]);
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
public static function confirmGancaoSubmission(
|
||||
int $id,
|
||||
string $resolution,
|
||||
string $remoteOrderNo,
|
||||
string $note,
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
) {
|
||||
self::$error = '';
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
self::$error = '订单不存在';
|
||||
return false;
|
||||
}
|
||||
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作';
|
||||
return false;
|
||||
}
|
||||
$operatorName = trim((string) ($adminInfo['name'] ?? $adminInfo['nickname'] ?? ''));
|
||||
try {
|
||||
$result = PharmacySubmissionClaimService::resolveGancao(
|
||||
$id,
|
||||
1,
|
||||
$resolution,
|
||||
$remoteOrderNo,
|
||||
$note,
|
||||
$adminId,
|
||||
$operatorName
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
self::$error = $exception->getMessage();
|
||||
return false;
|
||||
}
|
||||
$summary = $result['status'] === 'SUCCESS'
|
||||
? '人工核对甘草提交:确认成功,药方单号 ' . $result['remote_order_no']
|
||||
: '人工核对甘草提交:确认未创建,可重新上传';
|
||||
self::writeLog($id, $adminId, $adminInfo, 'gancao_submission_reconcile', $summary . ';依据:' . trim($note));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function canonicalPharmacyOrder(int $id): PrescriptionOrder
|
||||
{
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
throw new \DomainException('订单不存在,药房提交状态需要人工对账');
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|false */
|
||||
private static function uploadEjRemote(PrescriptionOrder $order, int $adminId, array $adminInfo, array $claim)
|
||||
{
|
||||
try {
|
||||
$prescription = PrescriptionLogic::detail((int) $order->prescription_id, $adminId, $adminInfo);
|
||||
if ($prescription === null) {
|
||||
throw new \DomainException(PrescriptionLogic::getError() ?: '处方不存在');
|
||||
}
|
||||
|
||||
$herbs = PharmacyHerbIdentityResolver::resolve(
|
||||
is_array($prescription['herbs'] ?? null) ? $prescription['herbs'] : [],
|
||||
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
|
||||
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
|
||||
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
|
||||
);
|
||||
$localMedicineIds = array_values(array_unique(array_map(
|
||||
static fn (array $herb): int => (int) $herb['medicine_id'],
|
||||
$herbs
|
||||
)));
|
||||
$mapping = EjMedicineMapping::whereIn('local_medicine_id', $localMedicineIds)
|
||||
->where('status', 1)
|
||||
->whereNull('delete_time')
|
||||
->column('medicine_code', 'local_medicine_id');
|
||||
|
||||
$prescription['herbs'] = $herbs;
|
||||
$signature = trim((string) ($prescription['doctor_signature'] ?? ''));
|
||||
$prescription['doctor_signature'] = $signature === '' ? [] : [
|
||||
'url' => $signature,
|
||||
'signed_at' => (int) ($prescription['audit_time'] ?? 0) > 0
|
||||
? date(DATE_ATOM, (int) $prescription['audit_time'])
|
||||
: '',
|
||||
];
|
||||
$prescription['processing_type'] = (int) ($prescription['need_decoction'] ?? 1) === 1
|
||||
? 'decoction'
|
||||
: 'dispensing';
|
||||
$payload = EjPharmacyPayload::build(
|
||||
$order->toArray(),
|
||||
$prescription,
|
||||
$mapping,
|
||||
max((int) ($claim['source_revision'] ?? 1), 1)
|
||||
);
|
||||
$client = new EjPharmacyClient();
|
||||
} catch (\Throwable $exception) {
|
||||
throw new PharmacyRemoteRejectedException($exception->getMessage(), 0, $exception);
|
||||
}
|
||||
if (!empty($claim['reconcile'])) {
|
||||
$query = $client->prescriptionOrder((string) $order->order_no, 1);
|
||||
$queryBody = is_array($query['body'] ?? null) ? $query['body'] : [];
|
||||
$queryData = is_array($queryBody['data'] ?? null) ? $queryBody['data'] : [];
|
||||
$queryRemoteNo = trim((string) ($queryData['pharmacy_order_no'] ?? ''));
|
||||
if ((int) ($query['http_status'] ?? 0) === 200 && (int) ($queryBody['code'] ?? -1) === 0 && $queryRemoteNo !== '') {
|
||||
return $queryData + [
|
||||
'pharmacy' => 'ej',
|
||||
'pharmacy_order_no' => $queryRemoteNo,
|
||||
'remote_order_no' => $queryRemoteNo,
|
||||
'request_id' => (string) ($query['request_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
if ((int) ($query['http_status'] ?? 0) !== 404) {
|
||||
throw new \RuntimeException('洛阳药房对账查询失败,保留待对账状态');
|
||||
}
|
||||
}
|
||||
$response = $client->createPrescriptionOrder($payload);
|
||||
$body = $response['body'];
|
||||
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
|
||||
$remoteOrderNo = trim((string) ($data['pharmacy_order_no'] ?? ''));
|
||||
$httpStatus = (int) ($response['http_status'] ?? 0);
|
||||
if ($httpStatus >= 500) {
|
||||
throw new \RuntimeException(trim((string) ($body['message'] ?? '洛阳药房服务异常')));
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300) {
|
||||
$message = trim((string) ($body['message'] ?? '洛阳药房下单响应异常'));
|
||||
if (PharmacyRemoteOutcomeClassifier::isConfirmedEjNoCreateHttpStatus($httpStatus)) {
|
||||
throw new PharmacyRemoteRejectedException($message);
|
||||
}
|
||||
throw new \RuntimeException($message . ',远端结果不确定,需要对账');
|
||||
}
|
||||
if ((int) ($body['code'] ?? -1) !== 0) {
|
||||
throw new PharmacyRemoteRejectedException(trim((string) ($body['message'] ?? '洛阳药房明确拒绝下单')));
|
||||
}
|
||||
if ($remoteOrderNo === '') {
|
||||
throw new \RuntimeException('洛阳药房返回成功但缺少远程订单号,需要对账');
|
||||
}
|
||||
|
||||
return $data + [
|
||||
'pharmacy' => 'ej',
|
||||
'pharmacy_order_no' => $remoteOrderNo,
|
||||
'remote_order_no' => $remoteOrderNo,
|
||||
'request_id' => (string) ($response['request_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** Controlled compatibility alias; ship_mode still selects the pharmacy target. */
|
||||
public static function submitGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
return self::uploadToPharmacy($id, $adminId, $adminInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前业务订单关联处方提交至甘草药管家(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)。
|
||||
*
|
||||
* @return array<string,mixed>|false 成功返回甘草 result 主要字段
|
||||
*/
|
||||
public static function submitGancaoRecipel(int $id, int $adminId, array $adminInfo)
|
||||
private static function submitGancaoRemote(int $id, int $adminId, array $adminInfo)
|
||||
{
|
||||
|
||||
self::$error = '';
|
||||
@@ -4455,7 +4941,7 @@ class PrescriptionOrderLogic
|
||||
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
|
||||
]);
|
||||
|
||||
return false;
|
||||
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||
}
|
||||
$subBody = $subRet['body'] ?? [];
|
||||
|
||||
@@ -4470,27 +4956,16 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
|
||||
if ($gcNo === '') {
|
||||
self::$error = '甘草返回缺少 recipel_order_no';
|
||||
|
||||
return false;
|
||||
self::$error = '甘草下单可能已成功,但返回缺少 recipel_order_no,必须对账后再操作';
|
||||
throw new PharmacyReconciliationRequiredException(self::$error);
|
||||
}
|
||||
$order->gancao_reciperl_order_no = mb_substr($gcNo, 0, 32);
|
||||
$order->gancao_submit_time = time();
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = '本地保存甘草单号失败:' . $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'gancao_submit', '上传甘草药方成功 ' . $gcNo);
|
||||
|
||||
$fee = $subBody['result']['fee'] ?? [];
|
||||
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
|
||||
|
||||
return [
|
||||
'pharmacy' => 'gancao',
|
||||
'recipel_order_no' => $gcNo,
|
||||
'remote_order_no' => $gcNo,
|
||||
'app_order_no' => $appNo,
|
||||
'fee' => is_array($fee) ? $fee : [],
|
||||
];
|
||||
@@ -4627,6 +5102,22 @@ class PrescriptionOrderLogic
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function setShipMode(int $id, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::setError('');
|
||||
try {
|
||||
return LockedPharmacySnapshotMutation::execute(
|
||||
$id,
|
||||
static fn () => self::setShipModeLocked($id, $shipMode, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function setShipModeLocked(int $id, string $shipMode, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
|
||||
@@ -4641,6 +5132,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
$fs = (int) $order->fulfillment_status;
|
||||
if ($fs === 3 || $fs === 4) {
|
||||
self::$error = '已完成或已取消的订单不可修改发货类型';
|
||||
@@ -4654,6 +5148,10 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (trim((string) ($order->ej_pharmacy_order_no ?? '')) !== '') {
|
||||
self::$error = '已上传洛阳药房,不可修改发货类型';
|
||||
return false;
|
||||
}
|
||||
|
||||
$oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
|
||||
if ($oldMode === $shipMode) {
|
||||
@@ -4888,6 +5386,23 @@ class PrescriptionOrderLogic
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||
self::setError('');
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
self::setError($exception->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function patchPrescriptionUsageLocked(array $params, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
self::$error = '';
|
||||
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||
@@ -4902,6 +5417,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'tracking_number' => 'max:80',
|
||||
'express_company' => 'max:20',
|
||||
'ship_mode' => 'in:gancao,direct',
|
||||
'resolution' => 'require|in:CONFIRM_SUCCESS,CONFIRM_NOT_CREATED',
|
||||
'remote_order_no' => 'max:64',
|
||||
'note' => 'require|max:1000',
|
||||
'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
'amount' => 'require|float',
|
||||
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
@@ -64,7 +67,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'create' => [
|
||||
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
|
||||
'is_follow_up', 'prev_staff', 'service_channel', 'service_package',
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
'tracking_number', 'express_company', 'ship_mode', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
],
|
||||
'detail' => ['id'],
|
||||
'edit' => [
|
||||
@@ -87,11 +90,13 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'refund' => ['id', 'reason', 'refund_amount'],
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'uploadToPharmacy' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
'setShipMode' => ['id', 'ship_mode'],
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
];
|
||||
|
||||
public function updateAmount(): PrescriptionOrderValidate
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ use think\facade\Route;
|
||||
// @see https://doc.thinkphp.cn/v8_0/multi_app_model.html
|
||||
|
||||
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallbackController@notify', 'GET|POST');
|
||||
Route::rule('qywx/external-contact/notify', 'QywxExternalContactCallback/notify', 'GET|POST');
|
||||
Route::post('ej-pharmacy/webhook', 'EjPharmacyCallback/webhook');
|
||||
|
||||
@@ -684,6 +684,7 @@ class ExpressTrackingService
|
||||
'express_auto_update' => '系统·物流自动同步',
|
||||
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
|
||||
'gancao_callback' => '系统·甘草回调',
|
||||
'ej_pharmacy_callback' => '系统·洛阳药房回调',
|
||||
'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
|
||||
default => '系统·订单已发货/签收',
|
||||
};
|
||||
|
||||
@@ -14,11 +14,45 @@ final class EjPharmacyShipmentPolicy
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function nextFulfillmentStatus(int $currentStatus, array $payload): int
|
||||
public static function isCompletedEvent(array $payload): bool
|
||||
{
|
||||
return strtoupper(trim((string) ($payload['status'] ?? ''))) === 'COMPLETED'
|
||||
|| (strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'WORKFLOW_STEP_COMPLETED'
|
||||
&& (bool) ($payload['final'] ?? false));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function isRejectedEvent(array $payload): bool
|
||||
{
|
||||
return in_array(strtoupper(trim((string) ($payload['event_type'] ?? ''))), [
|
||||
'REVIEW_REJECTED',
|
||||
'INVENTORY_SHORTAGE',
|
||||
], true)
|
||||
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'REJECTED'
|
||||
|| strtoupper(trim((string) ($payload['review_status'] ?? ''))) === 'REJECTED';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function nextFulfillmentStatus(
|
||||
int $currentStatus,
|
||||
array $payload,
|
||||
?int $rollbackStatus = null
|
||||
): int
|
||||
{
|
||||
if (self::isRejectedEvent($payload)) {
|
||||
// EJ rejection means the pharmacy did not accept the submission;
|
||||
// it must not turn the ZYT business order into a customer refusal.
|
||||
// Restore the status captured immediately before the submission.
|
||||
return $rollbackStatus !== null && $rollbackStatus > 0
|
||||
? $rollbackStatus
|
||||
: ($currentStatus === 9 ? 2 : $currentStatus);
|
||||
}
|
||||
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
|
||||
return 5;
|
||||
}
|
||||
if (self::isCompletedEvent($payload) && in_array($currentStatus, [1, 2, 5], true)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return $currentStatus;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ final class PharmacySubmissionClaimService
|
||||
string $operatorName
|
||||
): array {
|
||||
self::assertTarget($target);
|
||||
$revision = max($revision, 1);
|
||||
$revision = max($revision, 0);
|
||||
|
||||
return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array {
|
||||
$order = Db::name('tcm_prescription_order')
|
||||
@@ -31,6 +31,22 @@ final class PharmacySubmissionClaimService
|
||||
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('发货药房已变更,请刷新后重试');
|
||||
@@ -68,7 +84,9 @@ final class PharmacySubmissionClaimService
|
||||
}
|
||||
|
||||
if (self::hasAnyRemoteOrder($order)) {
|
||||
throw new DomainException('该订单已存在远程药房单号,不可重复提交');
|
||||
if (!self::isRejectedEjOrder($order, $target)) {
|
||||
throw new DomainException('该订单已存在远程药房单号,不可重复提交');
|
||||
}
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
@@ -100,7 +118,10 @@ final class PharmacySubmissionClaimService
|
||||
]);
|
||||
}
|
||||
|
||||
return $values + ['idempotent' => false];
|
||||
return $values + [
|
||||
'source_revision' => $revision,
|
||||
'idempotent' => false,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,6 +192,10 @@ final class PharmacySubmissionClaimService
|
||||
'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),
|
||||
@@ -260,11 +285,12 @@ final class PharmacySubmissionClaimService
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function claimForOrder(int $orderId, int $revision = 1): ?array
|
||||
public static function claimForOrder(int $orderId, int $revision = 0): ?array
|
||||
{
|
||||
$claim = Db::name('pharmacy_submission_claim')
|
||||
->where('prescription_order_id', $orderId)
|
||||
->where('source_revision', max($revision, 1))
|
||||
->when($revision > 0, static fn ($query) => $query->where('source_revision', $revision))
|
||||
->order('source_revision', 'desc')
|
||||
->find();
|
||||
|
||||
return is_array($claim) ? $claim : null;
|
||||
@@ -364,6 +390,28 @@ final class PharmacySubmissionClaimService
|
||||
|| 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';
|
||||
|
||||
Reference in New Issue
Block a user