feat: integrate Luoyang pharmacy workflow

This commit is contained in:
2026-07-22 18:00:17 +08:00
parent 31312ae975
commit e1fe818dce
27 changed files with 1925 additions and 163 deletions
@@ -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_modegancao | self,空表示不限
* 入参 supply_modegancao | 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