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
+15
View File
@@ -528,6 +528,21 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params }) return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
} }
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
}
/** 人工核对甘草不确定提交结果。 */
export function prescriptionOrderConfirmGancaoSubmission(params: {
id: number
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
remote_order_no?: string
note: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */ /** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: { export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number id: number
@@ -2,7 +2,7 @@
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '' }}</span> <span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '' }}</span>
<el-select <el-select
v-else v-else
:model-value="modelValue" :model-value="selectedMedicineId"
class="medicine-name-select w-full" class="medicine-name-select w-full"
filterable filterable
remote remote
@@ -14,7 +14,14 @@
@visible-change="onVisibleChange" @visible-change="onVisibleChange"
@update:model-value="onUpdate" @update:model-value="onUpdate"
> >
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" /> <el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
<div class="medicine-name-option">
<span>{{ item.name }}</span>
<span class="medicine-name-option__meta">
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
</span>
</div>
</el-option>
</el-select> </el-select>
</template> </template>
@@ -24,6 +31,7 @@ import { medicineLists } from '@/api/medicine'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string modelValue: string
medicineId?: number | null
disabled?: boolean disabled?: boolean
}>(), }>(),
{ disabled: false } { disabled: false }
@@ -31,18 +39,30 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string] 'update:modelValue': [value: string]
'update:medicineId': [value: number | undefined]
}>() }>()
const loading = ref(false) const loading = ref(false)
const options = ref<{ id: number; name: string }[]>([]) type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
const options = ref<MedicineOption[]>([])
const selectedMedicineId = computed<number | ''>(() => {
const id = Number(props.medicineId)
if (Number.isInteger(id) && id > 0) {
return id
}
return (props.modelValue || '').trim() ? 0 : ''
})
function ensureCurrentInOptions() { function ensureCurrentInOptions() {
const v = (props.modelValue || '').trim() const v = (props.modelValue || '').trim()
if (!v) { if (!v) {
return return
} }
if (!options.value.some((o) => o.name === v)) { const id = Number(props.medicineId)
options.value = [{ id: 0, name: v }, ...options.value] const currentId = Number.isInteger(id) && id > 0 ? id : 0
if (!options.value.some((o) => o.id === currentId)) {
options.value = [{ id: currentId, name: v }, ...options.value]
} }
} }
@@ -56,7 +76,7 @@ const remoteMethod = async (query: string) => {
page_size: 100, page_size: 100,
status: 1 status: 1
}) })
options.value = res.lists || [] options.value = (res.lists || []) as MedicineOption[]
ensureCurrentInOptions() ensureCurrentInOptions()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -72,13 +92,19 @@ const onVisibleChange = (open: boolean) => {
} }
} }
const onUpdate = (val: string) => { const onUpdate = (val: number | string) => {
emit('update:modelValue', val || '') const id = Number(val)
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
emit('update:modelValue', selected?.name || '')
emit('update:medicineId', selected?.id)
} }
watch( watch(
() => props.modelValue, () => [props.modelValue, props.medicineId],
() => { () => {
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
emit('update:medicineId', undefined)
}
ensureCurrentInOptions() ensureCurrentInOptions()
}, },
{ immediate: true } { immediate: true }
@@ -93,4 +119,23 @@ watch(
.medicine-name-select.w-full { .medicine-name-select.w-full {
width: 100%; width: 100%;
} }
.medicine-name-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
align-items: center;
gap: 16px;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.medicine-name-option__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: right;
}
</style> </style>
@@ -139,7 +139,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }" :class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
> >
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div> <div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" /> <MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<div <div
v-if="isDuplicateHerbName(row.index)" v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1" class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -176,7 +181,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }" :class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
> >
<div class="herb-editor-card__title">辅方</div> <div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" /> <MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<div <div
v-if="isDuplicateHerbName(row.index)" v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1" class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -1045,6 +1055,7 @@ import { Search } from '@element-plus/icons-vue'
type FormulaType = '主方' | '辅方' type FormulaType = '主方' | '辅方'
interface Herb { interface Herb {
medicine_id?: number
name: string name: string
dosage: number dosage: number
formula_type?: FormulaType formula_type?: FormulaType
@@ -1071,6 +1082,10 @@ function normalizeHerbRow(raw: any): Herb {
dosage: Number(raw?.dosage) || 0, dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type) formula_type: normalizeFormulaType(raw?.formula_type)
} }
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) { if (raw?.locked === true || raw?.locked === 1) {
row.locked = true row.locked = true
} }
@@ -1578,8 +1593,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
return herbs return herbs
} }
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测 */ /** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测 */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> { async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim() const q = rawName.trim()
if (!q) return null if (!q) return null
try { try {
@@ -1590,8 +1605,10 @@ async function resolveHerbNameFromLibrary(rawName: string): Promise<string | nul
status: 1 status: 1
}) })
const lists = (res.lists || []) as { id: number; name: string }[] const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q) const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch { } catch {
return null return null
} }
@@ -1612,12 +1629,12 @@ async function handlePasteRecipeImport() {
const resolved: Herb[] = [] const resolved: Herb[] = []
const skippedNames: string[] = [] const skippedNames: string[] = []
for (const row of parsed) { for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name) const medicine = await resolveHerbFromLibrary(row.name)
if (!name) { if (!medicine) {
skippedNames.push(row.name.trim()) skippedNames.push(row.name.trim())
continue continue
} }
resolved.push({ name, dosage: row.dosage, formula_type: '主方' }) resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
} }
const skippedUnique = [...new Set(skippedNames.filter(Boolean))] const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) { if (resolved.length === 0) {
@@ -0,0 +1,129 @@
<template>
<template v-if="needsReconcile">
<el-button
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
type="danger"
size="small"
plain
@click="openDialog"
>核对甘草提交</el-button
>
<el-dialog
v-model="visible"
title="人工核对甘草提交"
width="min(92vw, 520px)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-alert
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px" @submit.prevent="submit">
<el-form-item label="核对结果" required>
<el-radio-group v-model="form.resolution">
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.resolution === 'CONFIRM_SUCCESS'"
label="甘草单号"
required
>
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
</el-form-item>
<el-form-item label="核对依据" required>
<el-input
v-model="form.note"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="例如:核对时间、甘草后台查询条件及结果"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit"
>确认并记录</el-button
>
</template>
</el-dialog>
</template>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
const emit = defineEmits<{ resolved: [] }>()
const needsReconcile = computed(() => {
const target = String(props.order?.pharmacy_claim_target || '')
.trim()
.toLowerCase()
const status = String(props.order?.pharmacy_claim_status || '')
.trim()
.toUpperCase()
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
const expiredPending =
status === 'PENDING' &&
leaseExpiresAt > 0 &&
leaseExpiresAt <= Math.floor(Date.now() / 1000)
return (
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
)
})
const visible = ref(false)
const submitting = ref(false)
const form = reactive({
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
remote_order_no: '',
note: ''
})
function openDialog() {
form.resolution = 'CONFIRM_SUCCESS'
form.remote_order_no = ''
form.note = ''
visible.value = true
}
async function submit() {
const id = Number(props.order?.id)
if (!id) return
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
feedback.msgError('请填写甘草药方单号')
return
}
if (!form.note.trim()) {
feedback.msgError('请填写甘草后台核对依据')
return
}
submitting.value = true
try {
await prescriptionOrderConfirmGancaoSubmission({
id,
resolution: form.resolution,
remote_order_no:
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
note: form.note.trim()
})
feedback.msgSuccess('甘草提交核对已记录')
visible.value = false
emit('resolved')
} catch {
// Request interceptor presents the server-side reconciliation error.
} finally {
submitting.value = false
}
}
</script>
@@ -30,6 +30,13 @@
round round
class="ml-2" class="ml-2"
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag> >甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
<el-tag
v-if="detailData?.ej_pharmacy_order_no"
type="warning"
effect="plain"
round
class="ml-2"
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
<!-- 完整版发货类型切换等 --> <!-- 完整版发货类型切换等 -->
<slot v-if="detailData" name="header-extra" :detail="detailData" /> <slot v-if="detailData" name="header-extra" :detail="detailData" />
</div> </div>
@@ -11,6 +11,45 @@ export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */ /** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6] export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
export function formatTime(v: unknown) { export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—' if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) { if (typeof v === 'number' && v > 1e9 && v < 1e11) {
@@ -134,6 +173,8 @@ export function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核', revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息', patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数', patch_rx_usage: '服用参数',
update_amount: '修改订单金额', update_amount: '修改订单金额',
@@ -696,7 +696,12 @@
<el-table-column label="序号" type="index" width="60" /> <el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220"> <el-table-column label="药材名称" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" /> <MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="剂量(克)" min-width="120"> <el-table-column label="剂量(克)" min-width="120">
@@ -732,7 +737,12 @@
<el-table-column label="序号" type="index" width="60" /> <el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220"> <el-table-column label="药材名称" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" /> <MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="剂量(克)" min-width="120"> <el-table-column label="剂量(克)" min-width="120">
@@ -1294,6 +1304,14 @@
<div v-show="createOrderStep === 1" class="create-order-step-panel"> <div v-show="createOrderStep === 1" class="create-order-step-panel">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="24">
<el-form-item label="发货药房" prop="ship_mode">
<el-radio-group v-model="createOrderForm.ship_mode">
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button label="direct">洛阳药房</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8"> <el-col :xs="24" :sm="8">
<el-form-item label="复诊"> <el-form-item label="复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" /> <el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
@@ -1709,7 +1727,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue')) const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方' type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean } type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type AuxUsageForm = { type AuxUsageForm = {
dosage_amount?: number dosage_amount?: number
@@ -1814,6 +1832,10 @@ function normalizeHerbRow(raw: any): HerbRow {
dosage: Number(raw?.dosage) || 0, dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type) formula_type: normalizeFormulaType(raw?.formula_type)
} }
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) { if (raw?.locked === true || raw?.locked === 1) {
row.locked = true row.locked = true
} }
@@ -2006,6 +2028,7 @@ const createOrderForm = reactive({
service_package: [] as string[], service_package: [] as string[],
express_company: 'auto', express_company: 'auto',
tracking_number: '', tracking_number: '',
ship_mode: 'gancao' as 'gancao' | 'direct',
fee_type: 3, fee_type: 3,
amount: 0, amount: 0,
internal_cost: undefined as number | undefined, internal_cost: undefined as number | undefined,
@@ -2252,6 +2275,7 @@ function resetCreateOrderForm() {
createOrderForm.service_package = [] createOrderForm.service_package = []
createOrderForm.express_company = 'auto' createOrderForm.express_company = 'auto'
createOrderForm.tracking_number = '' createOrderForm.tracking_number = ''
createOrderForm.ship_mode = 'gancao'
createOrderForm.fee_type = 3 createOrderForm.fee_type = 3
createOrderForm.amount = 0 createOrderForm.amount = 0
createOrderForm.internal_cost = undefined createOrderForm.internal_cost = undefined
@@ -2425,6 +2449,7 @@ async function submitCreateOrderFromPrescription() {
: '', : '',
express_company: createOrderForm.express_company || 'auto', express_company: createOrderForm.express_company || 'auto',
tracking_number: createOrderForm.tracking_number || '', tracking_number: createOrderForm.tracking_number || '',
ship_mode: createOrderForm.ship_mode,
fee_type: createOrderForm.fee_type, fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount, amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || '', remark_extra: createOrderForm.remark_extra || '',
@@ -3601,7 +3626,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
return herbs return herbs
} }
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> { async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim() const q = rawName.trim()
if (!q) return null if (!q) return null
try { try {
@@ -3612,8 +3637,10 @@ async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string
status: 1 status: 1
}) })
const lists = (res.lists || []) as { id: number; name: string }[] const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q) const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch { } catch {
return null return null
} }
@@ -3634,12 +3661,12 @@ async function handlePasteRecipeImport() {
const resolved: HerbRow[] = [] const resolved: HerbRow[] = []
const skippedNames: string[] = [] const skippedNames: string[] = []
for (const row of parsed) { for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name) const medicine = await resolvePasteHerbFromLibrary(row.name)
if (!name) { if (!medicine) {
skippedNames.push(row.name.trim()) skippedNames.push(row.name.trim())
continue continue
} }
resolved.push({ name, dosage: row.dosage, formula_type: '主方' }) resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
} }
const skippedUnique = [...new Set(skippedNames.filter(Boolean))] const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) { if (resolved.length === 0) {
@@ -157,7 +157,11 @@
<el-table-column label="序号" type="index" width="60" /> <el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220"> <el-table-column label="药材名称" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" /> <MedicineNameSelect
v-model="row.name"
v-model:medicine-id="row.medicine_id"
:disabled="editMode === 'view'"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="剂量(克)" min-width="150"> <el-table-column label="剂量(克)" min-width="150">
@@ -257,7 +261,7 @@ const editForm = reactive({
id: 0, id: 0,
prescription_name: '', prescription_name: '',
formula_type: '主方', formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>, herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
is_public: 0, is_public: 0,
disable_edit: 0 disable_edit: 0
}) })
@@ -373,9 +373,9 @@
<el-tag <el-tag
size="small" size="small"
effect="plain" effect="plain"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'" :type="supplyModeTagType(row)"
> >
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }} {{ supplyModeLabel(row) }}
</el-tag> </el-tag>
<span class="text-gray-400">#{{ row.id }}</span> <span class="text-gray-400">#{{ row.id }}</span>
</div> </div>
@@ -594,13 +594,13 @@
@click="confirmWithdraw(row)" @click="confirmWithdraw(row)"
>撤回</el-button> >撤回</el-button>
<el-button <el-button
v-if="canUploadGancaoRow(row)" v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']" v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
type="warning" type="warning"
link link
:loading="gancaoSubmitId === row.id" :loading="pharmacySubmitId === row.id"
@click="confirmSubmitGancaoRecipel(row)" @click="confirmUploadToPharmacy(row)"
>上传药</el-button> >上传药</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -638,7 +638,7 @@
<el-radio-button label="gancao">甘草药房</el-radio-button> <el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button <el-radio-button
label="direct" label="direct"
:disabled="isShipModeLockedToGancao(detail)" :disabled="isShipModeLocked(detail)"
>洛阳药房</el-radio-button> >洛阳药房</el-radio-button>
</el-radio-group> </el-radio-group>
<el-tag <el-tag
@@ -650,6 +650,19 @@
</div> </div>
</template> </template>
<template #header-actions="{ detail }"> <template #header-actions="{ detail }">
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button
v-if="canUploadPharmacyRow(detail)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
type="warning"
size="small"
plain
:loading="pharmacySubmitId === detail.id"
@click="confirmUploadToPharmacy(detail)"
>上传药房</el-button>
<el-button <el-button
v-if="canRxAudit(detail)" v-if="canRxAudit(detail)"
v-perms="['tcm.prescriptionOrder/auditPrescription']" v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -2205,6 +2218,7 @@ import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue' import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue' import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue' import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { import {
TCM_ASSISTANT_ROLE_ID, TCM_ASSISTANT_ROLE_ID,
PRESCRIPTION_AUDIT_ROLE_IDS, PRESCRIPTION_AUDIT_ROLE_IDS,
@@ -2231,7 +2245,10 @@ import {
type ServicePackageOption, type ServicePackageOption,
normalizeServicePackageOptions, normalizeServicePackageOptions,
parseServicePackageValues, parseServicePackageValues,
mergeServicePackageSelectOptions mergeServicePackageSelectOptions,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
} from './components/prescription-order-utils' } from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter' import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import { import {
@@ -2256,7 +2273,7 @@ import {
prescriptionOrderBatchAssignAssistant, prescriptionOrderBatchAssignAssistant,
prescriptionOrderPatchPrescriptionPatient, prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder, prescriptionOrderLinkPayOrder,
prescriptionOrderSubmitGancaoRecipel, prescriptionOrderUploadToPharmacy,
prescriptionOrderPreviewGancaoRecipel, prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail, prescriptionDetail,
prescriptionLibraryLists, prescriptionLibraryLists,
@@ -2576,12 +2593,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('') const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [ const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'self' }, { label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '甘草', value: 'gancao' as const }, { label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const } { label: '自营', value: 'self' as const }
] ]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') { function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
queryParams.supply_mode = value queryParams.supply_mode = value
resetPage() resetPage()
} }
@@ -2615,8 +2633,8 @@ const queryParams = reactive({
fulfillment_status: '' as number | '', fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '', prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '', payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */ /** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'self', supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */ /** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0', service_channel: '' as '' | '0',
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */ /** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
@@ -3071,14 +3089,7 @@ function canEditRow(row: {
return false return false
} }
const gcNo = String(row.gancao_reciperl_order_no || '').trim() if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
// 甘草已提交:仅允许在待双审/履约中/已发货/进行中下修改快递(已签收 6 不可改单号;后端 edit 亦拦截)
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑 // 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
return fs === 1 || fs === 2 return fs === 1 || fs === 2
@@ -3106,6 +3117,7 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number payment_slip_audit_status?: number
fulfillment_status?: number fulfillment_status?: number
}) { }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status) const rxStatus = Number(row.prescription_audit_status)
@@ -3128,6 +3140,7 @@ function canRevokePayAudit(row: {
} }
function canWithdrawRow(row: { fulfillment_status?: number }) { function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
// 只有「待双审通过」可撤回,已发货后不可撤回 // 只有「待双审通过」可撤回,已发货后不可撤回
return Number(row.fulfillment_status) === 1 return Number(row.fulfillment_status) === 1
} }
@@ -3152,13 +3165,13 @@ function shipModeLabel(v: unknown): string {
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房' return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
} }
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) { function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
return String(row.gancao_reciperl_order_no || '').trim() !== '' return isRemoteSnapshotLocked(row as Record<string, unknown>)
} }
function canEditShipMode(row: { fulfillment_status?: number }) { function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4 return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
} }
const shipModeSaving = ref(false) const shipModeSaving = ref(false)
@@ -3200,6 +3213,11 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
} }
} }
async function handleGancaoSubmissionResolved() {
await refreshCurrentPrescriptionOrderDetail()
getLists()
}
function canAddPayOrderRow(row: { function canAddPayOrderRow(row: {
fulfillment_status?: number fulfillment_status?: number
amount?: number | string amount?: number | string
@@ -3230,14 +3248,27 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5 return Number(row.fulfillment_status) === 5
} }
function canUploadGancaoRow(row: { function canUploadPharmacyRow(row: {
prescription_audit_status?: number prescription_audit_status?: number
fulfillment_status?: number
ship_mode?: string
gancao_reciperl_order_no?: string gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) { }) {
// 处方审核已通过(1) 且没有甘草订单号时才显示 if (row.can_upload_pharmacy === false) return false
const rxStatus = Number(row.prescription_audit_status) const rxStatus = Number(row.prescription_audit_status)
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim() const fs = Number(row.fulfillment_status)
return rxStatus === 1 && gancaoOrderNo === '' const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
if (normalizeShipMode(row.ship_mode) === 'direct') {
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
&& String(row.gancao_reciperl_order_no || '').trim() === ''
}
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
} }
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ─── // ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
@@ -3863,7 +3894,7 @@ async function confirmWithdraw(row: { id: number }) {
} }
} }
const gancaoSubmitId = ref(0) const pharmacySubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false) const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false) const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null) const gancaoPreviewData = ref<any>(null)
@@ -4002,18 +4033,24 @@ async function testGancaoPreviewFromDetail() {
} }
} }
async function confirmSubmitGancaoRecipel(row: { id: number }) { async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html 。确定继续?', `将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
'上传甘草药方', '上传药房',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' } { type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
) )
gancaoSubmitId.value = row.id pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id }) const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
const d = res?.data ?? res const d = res?.data ?? res
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : '' const no = d?.pharmacy_order_no != null
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功') ? String(d.pharmacy_order_no)
: d?.recipel_order_no != null
? String(d.recipel_order_no)
: ''
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
getLists() getLists()
await detailDrawerRef.value?.refreshIfCurrent(row.id) await detailDrawerRef.value?.refreshIfCurrent(row.id)
} catch (e: any) { } catch (e: any) {
@@ -4021,7 +4058,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
/* 拦截器已提示 */ /* 拦截器已提示 */
} }
} finally { } finally {
gancaoSubmitId.value = 0 pharmacySubmitId.value = 0
} }
} }
@@ -365,8 +365,8 @@
<el-tag <el-tag
size="small" size="small"
effect="plain" effect="plain"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'" :type="supplyModeTagType(row)"
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag> >{{ supplyModeLabel(row) }}</el-tag>
<span class="po-card__id">#{{ row.id }}</span> <span class="po-card__id">#{{ row.id }}</span>
</div> </div>
</div> </div>
@@ -500,7 +500,11 @@
<el-dropdown-item v-if="canRefundRow(row)" command="refund"> <el-dropdown-item v-if="canRefundRow(row)" command="refund">
<span class="text-red-500">退款</span> <span class="text-red-500">退款</span>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item> <el-dropdown-item
v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
command="uploadPharmacy"
>上传药房</el-dropdown-item>
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided> <el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
<span class="text-red-500">撤回订单</span> <span class="text-red-500">撤回订单</span>
</el-dropdown-item> </el-dropdown-item>
@@ -544,6 +548,10 @@
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag> >甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div> </div>
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2"> <div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
<gancao-submission-reconcile-button
:order="detailData"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button <el-button
v-if="canRxAudit(detailData)" v-if="canRxAudit(detailData)"
v-perms="['tcm.prescriptionOrder/auditPrescription']" v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -1885,10 +1893,12 @@
/> />
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip"> <el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
<el-form-item label="发货方式"> <el-form-item label="发货方式">
<el-radio-group v-model="shipForm.ship_mode"> <el-tag
<el-radio label="gancao">甘草药方发</el-radio> :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
<el-radio label="direct">药房直发</el-radio> effect="plain"
</el-radio-group> size="default"
>{{ shipDialogModeDisplay }}</el-tag>
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部发货类型中设置此处不可修改</span>
</el-form-item> </el-form-item>
<el-form-item label="承运商"> <el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full"> <el-select v-model="shipForm.express_company" class="w-full">
@@ -2697,6 +2707,7 @@ import {
User User
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue' import ListTimeFilter from '@/components/list-time-filter/index.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter' import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import { import {
prescriptionOrderAuditPayment, prescriptionOrderAuditPayment,
@@ -2719,7 +2730,7 @@ import {
prescriptionOrderPatchPrescriptionUsage, prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder, prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion, prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel, prescriptionOrderUploadToPharmacy,
prescriptionOrderPreviewGancaoRecipel, prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail, prescriptionDetail,
getDoctors, getDoctors,
@@ -2733,7 +2744,10 @@ import {
mergeServicePackageSelectOptions, mergeServicePackageSelectOptions,
formatServicePackageLabels, formatServicePackageLabels,
normalizeSlipAuxUsageForm, normalizeSlipAuxUsageForm,
prescriptionHasAuxFormula prescriptionHasAuxFormula,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
} from './components/prescription-order-utils' } from './components/prescription-order-utils'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf' import { jsPDF } from 'jspdf'
@@ -2984,12 +2998,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('') const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [ const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'self' }, { label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '甘草', value: 'gancao' as const }, { label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const } { label: '自营', value: 'self' as const }
] ]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') { function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
queryParams.supply_mode = value queryParams.supply_mode = value
resetPage() resetPage()
} }
@@ -3023,8 +3038,8 @@ const queryParams = reactive({
fulfillment_status: '' as number | '', fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '', prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '', payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */ /** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'self', supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */ /** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
audit_admin_id: '' as number | '' audit_admin_id: '' as number | ''
}) })
@@ -3459,13 +3474,7 @@ function canEditRow(row: {
return false return false
} }
const gcNo = String(row.gancao_reciperl_order_no || '').trim() if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
return fs === 1 || fs === 2 return fs === 1 || fs === 2
} }
@@ -3492,6 +3501,7 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number payment_slip_audit_status?: number
fulfillment_status?: number fulfillment_status?: number
}) { }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status) const rxStatus = Number(row.prescription_audit_status)
@@ -3514,6 +3524,7 @@ function canRevokePayAudit(row: {
} }
function canWithdrawRow(row: { fulfillment_status?: number }) { function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
// //
return Number(row.fulfillment_status) === 1 return Number(row.fulfillment_status) === 1
} }
@@ -3544,14 +3555,26 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5 return Number(row.fulfillment_status) === 5
} }
function canUploadGancaoRow(row: { function canUploadPharmacyRow(row: {
prescription_audit_status?: number prescription_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) { }) {
// (1) if (row.can_upload_pharmacy === false) return false
const rxStatus = Number(row.prescription_audit_status) const rxStatus = Number(row.prescription_audit_status)
const fulfillmentStatus = Number(row.fulfillment_status)
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim() const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
return rxStatus === 1 && gancaoOrderNo === '' const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
return rxStatus === 1
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
&& gancaoOrderNo === ''
&& (ejOrderNo === '' || ejRejected)
} }
function orderStatusText(s: number | undefined) { function orderStatusText(s: number | undefined) {
@@ -3589,7 +3612,7 @@ const h5ActiveFilterCount = computed(() => {
// H5: // H5:
function hasMoreCardActions(row: Record<string, any>) { function hasMoreCardActions(row: Record<string, any>) {
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row) return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
} }
// H5: // H5:
@@ -3597,7 +3620,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
if (cmd === 'addPayOrder') openAddPayOrder(row as any) if (cmd === 'addPayOrder') openAddPayOrder(row as any)
else if (cmd === 'complete') confirmComplete(row as any) else if (cmd === 'complete') confirmComplete(row as any)
else if (cmd === 'refund') openRefundOrder(row as any) else if (cmd === 'refund') openRefundOrder(row as any)
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any) else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
else if (cmd === 'withdraw') confirmWithdraw(row as any) else if (cmd === 'withdraw') confirmWithdraw(row as any)
} }
@@ -3929,6 +3952,8 @@ function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核', revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', update_amount: '修改订单金额',
complete: '完成订单', complete: '完成订单',
@@ -4721,7 +4746,7 @@ async function confirmWithdraw(row: { id: number }) {
} }
} }
const gancaoSubmitId = ref(0) const pharmacySubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false) const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false) const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null) const gancaoPreviewData = ref<any>(null)
@@ -4813,18 +4838,19 @@ async function testGancaoPreviewFromDetail() {
} }
} }
async function confirmSubmitGancaoRecipel(row: { id: number }) { async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
try { try {
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
await ElMessageBox.confirm( await ElMessageBox.confirm(
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?', `确认将本单关联处方上传至${target}`,
'上传甘草药方', '上传药房',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' } { type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
) )
gancaoSubmitId.value = row.id pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id }) const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
const d = res?.data ?? res const d = res?.data ?? res
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : '' const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功') feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
getLists() getLists()
if (detailVisible.value && Number(detailData.value?.id) === row.id) { if (detailVisible.value && Number(detailData.value?.id) === row.id) {
try { try {
@@ -4840,7 +4866,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
/* 拦截器已提示 */ /* 拦截器已提示 */
} }
} finally { } finally {
gancaoSubmitId.value = 0 pharmacySubmitId.value = 0
} }
} }
@@ -4943,6 +4969,10 @@ const shipForm = reactive({
tracking_number: '' tracking_number: ''
}) })
const shipDialogModeDisplay = computed(() =>
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
)
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) { function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
shipRowId.value = row.id shipRowId.value = row.id
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao' shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
@@ -4982,6 +5012,13 @@ async function submitShip() {
} }
} }
async function handleGancaoSubmissionResolved() {
getLists()
if (detailData.value?.id) {
await openDetail(Number(detailData.value.id))
}
}
const completeOrderStatusOptions = [ const completeOrderStatusOptions = [
{ value: 3, label: '已完成' }, { value: 3, label: '已完成' },
{ value: 7, label: '进行中' }, { value: 7, label: '进行中' },
@@ -131,7 +131,7 @@
<el-table-column label="序号" type="index" width="60" /> <el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220"> <el-table-column label="药材名称" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<MedicineNameSelect v-model="row.name" /> <MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="剂量(克)" min-width="120"> <el-table-column label="剂量(克)" min-width="120">
@@ -321,7 +321,7 @@ const formData = reactive({
pulse: '', pulse: '',
pulse_condition: '', pulse_condition: '',
clinical_diagnosis: '', clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>, herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
dose_count: 7, dose_count: 7,
dose_unit: '剂', dose_unit: '剂',
usage_days: 7, usage_days: 7,
@@ -161,6 +161,27 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已保存', $result); 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 { try {
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel'); $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) { if ($result === false) {
$error = PrescriptionOrderLogic::getError(); $error = PrescriptionOrderLogic::getError();
@@ -465,7 +486,7 @@ class PrescriptionOrderController extends BaseAdminController
return $this->fail('返回数据格式错误'); return $this->fail('返回数据格式错误');
} }
return $this->success('甘草药方上传成功', $result); return $this->success('药方上传成功', $result);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\think\facade\Log::error('submitGancaoRecipel exception', [ \think\facade\Log::error('submitGancaoRecipel exception', [
'message' => $e->getMessage(), '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,不提交订单) * 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
* 用于在编辑订单时测试价格和配置 * 用于在编辑订单时测试价格和配置
@@ -17,6 +17,7 @@ declare (strict_types=1);
namespace app\adminapi\http\middleware; namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic; use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\common\{ use app\common\{
cache\AdminAuthCache, cache\AdminAuthCache,
service\JsonService service\JsonService
@@ -74,7 +75,8 @@ class AuthMiddleware
$allUri = $this->formatUrl($adminAuthCache->getAllUri()); $allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证 // 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri)) { if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
return $next($request); return $next($request);
} }
@@ -109,6 +111,10 @@ class AuthMiddleware
*/ */
private function matchPermissionAlias(string $accessUri, array $adminUris): bool 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) if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
&& in_array($accessUri, [ && in_array($accessUri, [
'tcm.diagnosistodo/lists', 'tcm.diagnosistodo/lists',
@@ -24,6 +24,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder; use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking; use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService; use app\common\service\gancao\GancaoScmRecipelService;
use app\common\service\pharmacy\EjPharmacyClient;
use think\facade\Config; use think\facade\Config;
use think\facade\Db; use think\facade\Db;
use think\db\Query; 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 private function applySupplyModeFilter($query): void
{ {
@@ -343,13 +344,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($mode === '') { if ($mode === '') {
return; return;
} }
if ($mode === 'direct') {
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
return;
}
if ($mode === 'gancao') { 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; return;
} }
if ($mode === 'self') { 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; return;
} }
@@ -706,6 +718,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$assistantNames = $assistantIds !== [] $assistantNames = $assistantIds !== []
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id') ? \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) { foreach ($lists as &$item) {
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo); PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($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_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0; $item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin; $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['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
$item, $item,
$this->adminId, $this->adminId,
$this->adminInfo, $this->adminInfo,
$assistantByDiag $assistantByDiag
); );
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
$item,
$this->adminId,
$this->adminInfo,
$assistantByDiag
);
// 添加创建人姓名 // 添加创建人姓名
$creatorId = (int) ($item['creator_id'] ?? 0); $creatorId = (int) ($item['creator_id'] ?? 0);
@@ -766,6 +804,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$base = [ $base = [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(), 'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(), 'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
'stats_order_amount' => $s['order_amount'], 'stats_order_amount' => $s['order_amount'],
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'], 'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
'stats_order_amount_cancelled' => $s['order_amount_cancelled'], 'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
@@ -838,7 +877,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_agency_collect' => '代收金额', 'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号', 'export_tracking_number' => '快递单号',
'export_sign_time' => '签收日期', 'export_sign_time' => '签收日期',
'export_supply_mode' => '甘草还是自营', 'export_supply_mode' => '供货方式',
'export_gancao_prescription_cost' => '处方成本', 'export_gancao_prescription_cost' => '处方成本',
'export_first_visit_assistant' => '初诊医助', 'export_first_visit_assistant' => '初诊医助',
'export_rx_audit_time' => '审核时间', 'export_rx_audit_time' => '审核时间',
@@ -7,7 +7,9 @@ namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache; use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic; use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole; use app\common\model\auth\AdminRole;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\PrescriptionLibrary; use app\common\model\tcm\PrescriptionLibrary;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use think\facade\Config; use think\facade\Config;
/** /**
@@ -15,6 +17,18 @@ use think\facade\Config;
*/ */
class PrescriptionLibraryLogic extends BaseLogic 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 是否可管理全部处方(超级管理员 或 配置中的管理员角色) * @notes 是否可管理全部处方(超级管理员 或 配置中的管理员角色)
*/ */
@@ -94,7 +108,10 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据 // 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) { 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); $model = PrescriptionLibrary::create($params);
@@ -130,7 +147,10 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据 // 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) { 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); $model->save($params);
@@ -6,10 +6,13 @@ namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin; use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment; 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\Prescription;
use app\common\model\tcm\PrescriptionOrder; use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\Diagnosis; use app\common\model\tcm\Diagnosis;
use app\common\service\wechat\WechatWorkAppMessageService; 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 app\adminapi\logic\auth\AuthLogic;
use think\facade\Config; use think\facade\Config;
use think\facade\Log; use think\facade\Log;
@@ -159,6 +162,18 @@ class PrescriptionLogic
return $ts ? date('Y-m-d', $ts) : date('Y-m-d'); 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 字段结构一致) * @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
*/ */
@@ -255,13 +270,12 @@ class PrescriptionLogic
self::setError('请添加中药'); self::setError('请添加中药');
return null; return null;
} }
try {
foreach ($herbs as $h) { $herbs = self::normalizeHerbIdentities($herbs);
if (empty($h['name'])) { } catch (\DomainException $exception) {
self::setError('中药名称不能为空'); self::setError($exception->getMessage());
return null; return null;
} }
}
$sn = self::generateSn(); $sn = self::generateSn();
while (Prescription::where('sn', $sn)->find()) { while (Prescription::where('sn', $sn)->find()) {
@@ -335,6 +349,23 @@ class PrescriptionLogic
* 编辑处方 * 编辑处方
*/ */
public static function edit(array $params, int $adminId): bool 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 { try {
$prescription = Prescription::find($params['id']); $prescription = Prescription::find($params['id']);
@@ -342,6 +373,11 @@ class PrescriptionLogic
self::setError('处方不存在'); self::setError('处方不存在');
return false; 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) { if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
@@ -381,12 +417,7 @@ class PrescriptionLogic
return false; return false;
} }
foreach ($herbs as $h) { $herbs = self::normalizeHerbIdentities($herbs);
if (empty($h['name'])) {
self::setError('中药名称不能为空');
return false;
}
}
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id); $newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date); $newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
@@ -474,6 +505,29 @@ class PrescriptionLogic
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段 * 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
*/ */
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool 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 = ''; self::$error = '';
$prescription = Prescription::find($rxId); $prescription = Prescription::find($rxId);
@@ -482,6 +536,11 @@ class PrescriptionLogic
return false; return false;
} }
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
if ($snapshotLockError !== null) {
self::setError($snapshotLockError);
return false;
}
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) { if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
self::setError('无权限修改此处方'); self::setError('无权限修改此处方');
@@ -578,6 +637,22 @@ class PrescriptionLogic
* 删除处方 * 删除处方
*/ */
public static function delete(int $id): bool 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 { try {
$prescription = Prescription::find($id); $prescription = Prescription::find($id);
@@ -1027,6 +1102,22 @@ class PrescriptionLogic
* 作废处方 * 作废处方
*/ */
public static function void(int $id, int $adminId, string $adminName): bool 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); $row = Prescription::find($id);
if (!$row) { if (!$row) {
@@ -17,11 +17,25 @@ use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder; use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\dict\DictData; use app\common\model\dict\DictData;
use app\common\model\doctor\Appointment; use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\auth\Admin; 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\DataScope\DataScopeService;
use app\common\service\ExpressTrackService; use app\common\service\ExpressTrackService;
use app\common\service\ExpressTrackingService; use app\common\service\ExpressTrackingService;
use app\common\service\gancao\GancaoScmRecipelService; 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\db\Query;
use think\facade\Config; use think\facade\Config;
use think\facade\Db; use think\facade\Db;
@@ -41,6 +55,42 @@ class PrescriptionOrderLogic
return self::$error; 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 仍拼不出路径 * 使用 Db 直查 zyt_dept:避免 Dept 软删除全局作用域导致有 dept_id 仍拼不出路径
@@ -972,6 +1022,7 @@ class PrescriptionOrderLogic
$order->service_package = (string) ($params['service_package'] ?? ''); $order->service_package = (string) ($params['service_package'] ?? '');
$order->tracking_number = (string) ($params['tracking_number'] ?? ''); $order->tracking_number = (string) ($params['tracking_number'] ?? '');
$order->express_company = self::normalizeExpressCompany($params['express_company'] ?? 'auto'); $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->fee_type = (int) $params['fee_type'];
$order->amount = round((float) $params['amount'], 2); $order->amount = round((float) $params['amount'], 2);
$order->internal_cost = $internalCost; $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; return $arr;
} }
@@ -1165,6 +1227,33 @@ class PrescriptionOrderLogic
string $phone, string $phone,
int $adminId, int $adminId,
array $adminInfo 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 { ): bool {
self::$error = ''; self::$error = '';
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find(); $order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
@@ -1178,6 +1267,9 @@ class PrescriptionOrderLogic
return false; return false;
} }
if (!self::assertRemoteSnapshotMutable($order)) {
return false;
}
$rxId = (int) ($order->prescription_id ?? 0); $rxId = (int) ($order->prescription_id ?? 0);
if ($rxId <= 0) { if ($rxId <= 0) {
self::setError('该订单未关联处方'); self::setError('该订单未关联处方');
@@ -1605,6 +1697,23 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false * @return array<string,mixed>|false
*/ */
public static function edit(array $params, int $adminId, array $adminInfo) 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 = ''; self::$error = '';
$id = (int) $params['id']; $id = (int) $params['id'];
@@ -1640,11 +1749,8 @@ class PrescriptionOrderLogic
return false; return false;
} }
// 已成功提交甘草 SCM:仅允许更新快递单号与承运商(与甘草侧地址/药方等仍以取消流程为准) if (!self::assertRemoteSnapshotMutable($order)) {
$gcOrderNo = trim((string) $order->gancao_reciperl_order_no); return false;
$gcSubmitTime = (int) $order->gancao_submit_time;
if ($gcOrderNo !== '' || $gcSubmitTime > 0) {
return self::editGancaoLogisticsOnly($order, $params, $adminId, $adminInfo);
} }
$medDays = $params['medication_days'] ?? null; $medDays = $params['medication_days'] ?? null;
@@ -1827,6 +1933,23 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false * @return array<string,mixed>|false
*/ */
public static function ship(int $id, string $expressCompany, string $trackingNumber, string $shipMode, int $adminId, array $adminInfo) 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 = ''; self::$error = '';
@@ -1854,12 +1977,11 @@ class PrescriptionOrderLogic
return false; return false;
} }
$shipMode = self::normalizeShipMode($shipMode); $shipMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
$shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发'; $shipModeText = $shipMode === 'direct' ? '药房直发' : '甘草药方发';
$order->express_company = self::normalizeExpressCompany($expressCompany); $order->express_company = self::normalizeExpressCompany($expressCompany);
$order->tracking_number = $trackingNumber; $order->tracking_number = $trackingNumber;
$order->ship_mode = $shipMode;
// 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态 // 仅履约中(2)时才推进到已发货(5),已发货(5)则只更新快递信息保持状态
$isFirstShip = false; $isFirstShip = false;
if ((int) $order->fulfillment_status === 2) { if ((int) $order->fulfillment_status === 2) {
@@ -1915,6 +2037,22 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false * @return array<string,mixed>|false
*/ */
public static function withdraw(int $id, int $adminId, array $adminInfo) 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 = ''; self::$error = '';
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find(); $order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
@@ -2194,6 +2332,22 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false * @return array<string,mixed>|false
*/ */
public static function revokeRxAudit(int $id, int $adminId, array $adminInfo) 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 = ''; self::$error = '';
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find(); $order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
@@ -4025,8 +4179,7 @@ class PrescriptionOrderLogic
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? ''); $item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0; $signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : ''; $item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== ''; $item['export_supply_mode'] = PharmacySupplyMode::label(PharmacySupplyMode::resolve($item));
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出) // 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
if (self::canViewInternalCost($adminInfo)) { if (self::canViewInternalCost($adminInfo)) {
$rawCost = $item['internal_cost'] ?? null; $rawCost = $item['internal_cost'] ?? null;
@@ -4303,6 +4456,12 @@ class PrescriptionOrderLogic
if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') { if (trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '') {
return false; return false;
} }
if (trim((string) ($item['ej_pharmacy_order_no'] ?? '')) !== '') {
return false;
}
if (self::pharmacyClaimBlocksUpload($item, 'gancao')) {
return false;
}
if (self::canSeeAllPrescriptionOrders($adminInfo)) { if (self::canSeeAllPrescriptionOrders($adminInfo)) {
return true; return true;
} }
@@ -4321,12 +4480,339 @@ class PrescriptionOrderLogic
return $aid === $adminId && $adminId > 0; 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)。 * 将当前业务订单关联处方提交至甘草药管家(CTM_PREVIEW → CTM_SUBMIT_RECIPEL)。
* *
* @return array<string,mixed>|false 成功返回甘草 result 主要字段 * @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 = ''; self::$error = '';
@@ -4455,7 +4941,7 @@ class PrescriptionOrderLogic
'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE) 'payload' => json_encode(GancaoScmRecipelService::maskSensitiveData($submitPayload), JSON_UNESCAPED_UNICODE)
]); ]);
return false; throw new PharmacyReconciliationRequiredException(self::$error);
} }
$subBody = $subRet['body'] ?? []; $subBody = $subRet['body'] ?? [];
@@ -4470,27 +4956,16 @@ class PrescriptionOrderLogic
} }
$gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? ''); $gcNo = (string) (($subBody['result'] ?? [])['recipel_order_no'] ?? '');
if ($gcNo === '') { if ($gcNo === '') {
self::$error = '甘草返回缺少 recipel_order_no'; self::$error = '甘草下单可能已成功,但返回缺少 recipel_order_no,必须对账后再操作';
throw new PharmacyReconciliationRequiredException(self::$error);
return false;
} }
$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'] ?? []; $fee = $subBody['result']['fee'] ?? [];
$appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id); $appNo = $submitPayload['app_order_no'] ?? ('PO' . (string) $order->id);
return [ return [
'pharmacy' => 'gancao',
'recipel_order_no' => $gcNo, 'recipel_order_no' => $gcNo,
'remote_order_no' => $gcNo,
'app_order_no' => $appNo, 'app_order_no' => $appNo,
'fee' => is_array($fee) ? $fee : [], 'fee' => is_array($fee) ? $fee : [],
]; ];
@@ -4627,6 +5102,22 @@ class PrescriptionOrderLogic
* @return array<string,mixed>|false * @return array<string,mixed>|false
*/ */
public static function setShipMode(int $id, string $shipMode, int $adminId, array $adminInfo) 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 = ''; self::$error = '';
@@ -4641,6 +5132,9 @@ class PrescriptionOrderLogic
return false; return false;
} }
if (!self::assertRemoteSnapshotMutable($order)) {
return false;
}
$fs = (int) $order->fulfillment_status; $fs = (int) $order->fulfillment_status;
if ($fs === 3 || $fs === 4) { if ($fs === 3 || $fs === 4) {
self::$error = '已完成或已取消的订单不可修改发货类型'; self::$error = '已完成或已取消的订单不可修改发货类型';
@@ -4654,6 +5148,10 @@ class PrescriptionOrderLogic
return false; return false;
} }
if (trim((string) ($order->ej_pharmacy_order_no ?? '')) !== '') {
self::$error = '已上传洛阳药房,不可修改发货类型';
return false;
}
$oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao')); $oldMode = self::normalizeShipMode((string) ($order->ship_mode ?? 'gancao'));
if ($oldMode === $shipMode) { if ($oldMode === $shipMode) {
@@ -4888,6 +5386,23 @@ class PrescriptionOrderLogic
* @param array<string, mixed> $params * @param array<string, mixed> $params
*/ */
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool 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 = ''; self::$error = '';
$prescriptionOrderId = (int) ($params['id'] ?? 0); $prescriptionOrderId = (int) ($params['id'] ?? 0);
@@ -4902,6 +5417,9 @@ class PrescriptionOrderLogic
return false; return false;
} }
if (!self::assertRemoteSnapshotMutable($order)) {
return false;
}
if ((int) $order->fulfillment_status === 4) { if ((int) $order->fulfillment_status === 4) {
self::setError('已取消的订单不可修改'); self::setError('已取消的订单不可修改');
@@ -23,6 +23,9 @@ class PrescriptionOrderValidate extends BaseValidate
'tracking_number' => 'max:80', 'tracking_number' => 'max:80',
'express_company' => 'max:20', 'express_company' => 'max:20',
'ship_mode' => 'in:gancao,direct', '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', 'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
'amount' => 'require|float', 'amount' => 'require|float',
'order_type' => 'require|in:1,2,3,4,5,6,7,8', 'order_type' => 'require|in:1,2,3,4,5,6,7,8',
@@ -64,7 +67,7 @@ class PrescriptionOrderValidate extends BaseValidate
'create' => [ 'create' => [
'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address', 'prescription_id', 'diagnosis_id', 'recipient_name', 'recipient_phone', 'shipping_address',
'is_follow_up', 'prev_staff', 'service_channel', 'service_package', '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'], 'detail' => ['id'],
'edit' => [ 'edit' => [
@@ -87,11 +90,13 @@ class PrescriptionOrderValidate extends BaseValidate
'complete' => ['id', 'fulfillment_status'], 'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason', 'refund_amount'], 'refund' => ['id', 'reason', 'refund_amount'],
'submitGancaoRecipel' => ['id'], 'submitGancaoRecipel' => ['id'],
'uploadToPharmacy' => ['id'],
'previewGancaoRecipel' => ['id'], 'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'], 'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'], 'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
'updateAmount' => ['id', 'amount'], 'updateAmount' => ['id', 'amount'],
'setShipMode' => ['id', 'ship_mode'], 'setShipMode' => ['id', 'ship_mode'],
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
]; ];
public function updateAmount(): PrescriptionOrderValidate 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;
}
}
+2 -1
View File
@@ -8,4 +8,5 @@ use think\facade\Route;
// @see https://doc.thinkphp.cn/v8_0/multi_app_model.html // @see https://doc.thinkphp.cn/v8_0/multi_app_model.html
// 企业微信「客户联系」事件回调:GET 验签(echostr)、POST 收事件 // 企业微信「客户联系」事件回调: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' => '系统·物流自动同步', 'express_auto_update' => '系统·物流自动同步',
'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步', 'gancao_route', 'gancao_route_sync' => '系统·甘草路由同步',
'gancao_callback' => '系统·甘草回调', 'gancao_callback' => '系统·甘草回调',
'ej_pharmacy_callback' => '系统·洛阳药房回调',
'shipped_fulfillment_reconcile' => '系统·已发货履约核对', 'shipped_fulfillment_reconcile' => '系统·已发货履约核对',
default => '系统·订单已发货/签收', default => '系统·订单已发货/签收',
}; };
@@ -14,11 +14,45 @@ final class EjPharmacyShipmentPolicy
} }
/** @param array<string,mixed> $payload */ /** @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)) { if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
return 5; return 5;
} }
if (self::isCompletedEvent($payload) && in_array($currentStatus, [1, 2, 5], true)) {
return 3;
}
return $currentStatus; return $currentStatus;
} }
@@ -19,7 +19,7 @@ final class PharmacySubmissionClaimService
string $operatorName string $operatorName
): array { ): array {
self::assertTarget($target); self::assertTarget($target);
$revision = max($revision, 1); $revision = max($revision, 0);
return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array { return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array {
$order = Db::name('tcm_prescription_order') $order = Db::name('tcm_prescription_order')
@@ -31,6 +31,22 @@ final class PharmacySubmissionClaimService
throw new DomainException('订单不存在'); 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')); $expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao'));
if ($expectedTarget !== $target) { if ($expectedTarget !== $target) {
throw new DomainException('发货药房已变更,请刷新后重试'); throw new DomainException('发货药房已变更,请刷新后重试');
@@ -68,8 +84,10 @@ final class PharmacySubmissionClaimService
} }
if (self::hasAnyRemoteOrder($order)) { if (self::hasAnyRemoteOrder($order)) {
if (!self::isRejectedEjOrder($order, $target)) {
throw new DomainException('该订单已存在远程药房单号,不可重复提交'); throw new DomainException('该订单已存在远程药房单号,不可重复提交');
} }
}
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
$now = time(); $now = time();
@@ -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_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'),
'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'), 'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'),
'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1), 'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1),
// Preserve the business status from before the EJ upload.
'ej_pharmacy_previous_fulfillment_status' => (int) ($order['ej_pharmacy_previous_fulfillment_status'] ?? 0) > 0
? (int) $order['ej_pharmacy_previous_fulfillment_status']
: (int) ($order['fulfillment_status'] ?? 0),
] ]
: [ : [
'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32), 'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32),
@@ -260,11 +285,12 @@ final class PharmacySubmissionClaimService
} }
/** @return array<string,mixed>|null */ /** @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') $claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId) ->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(); ->find();
return is_array($claim) ? $claim : null; return is_array($claim) ? $claim : null;
@@ -364,6 +390,28 @@ final class PharmacySubmissionClaimService
|| trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== ''; || trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
} }
/** @param array<string,mixed> $order */
private static function isRejectedEjOrder(array $order, string $target): bool
{
if ($target !== 'direct') {
return false;
}
return strtoupper(trim((string) ($order['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|| strtoupper(trim((string) ($order['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
}
/** @param array<string,mixed> $order */
private static function nextRevisionForOrder(array $order, string $target): int
{
$latest = (int) Db::name('pharmacy_submission_claim')
->where('prescription_order_id', (int) $order['id'])
->max('source_revision');
if (self::isRejectedEjOrder($order, $target)) {
return max($latest + 1, 1);
}
return max($latest, 1);
}
private static function targetForShipMode(string $shipMode): string private static function targetForShipMode(string $shipMode): string
{ {
return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao'; return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao';
@@ -0,0 +1,9 @@
-- Keep Luoyang pharmacy workflow logs consistent with Gancao's production-flow format.
-- Only workflow summaries are changed; status/rejection logs keep their semicolon layout.
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '', ' | ')
WHERE `action` = 'ej_pharmacy_callback'
AND (
`summary` LIKE '洛阳药房回传:订单药房流转制作中;%'
OR `summary` LIKE '洛阳药房回传:订单已完成;流程:%'
);
@@ -0,0 +1,15 @@
-- Preserve the ZYT fulfillment status before an EJ pharmacy submission.
-- EJ review rejection restores this value; it is not customer refusal.
SET @schema_name := DATABASE();
SET @ddl := IF(
EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'zyt_tcm_prescription_order'
AND COLUMN_NAME = 'ej_pharmacy_previous_fulfillment_status'
),
'SELECT 1',
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_previous_fulfillment_status` tinyint unsigned NULL DEFAULT NULL COMMENT ''Fulfillment status before EJ pharmacy submission'' AFTER `ej_pharmacy_status_version`'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,72 @@
-- Translate callback operation logs that were stored with EJ enum values.
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '洛阳药房回传:WORKFLOW_STEP_COMPLETED', '洛阳药房回传:流程节点完成')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '洛阳药房回传:REVIEW_APPROVED', '洛阳药房回传:审核通过')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '洛阳药房回传:REVIEW_REJECTED', '洛阳药房回传:审核驳回')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '洛阳药房回传:INVENTORY_SHORTAGE', '洛阳药房回传:库存不足')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '洛阳药房回传:ORDER_CREATED', '洛阳药房回传:订单已创建')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:PENDING_REVIEW', '远端状态:待审核')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:READY_FOR_PRODUCTION', '远端状态:待生产')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:IN_PRODUCTION', '远端状态:生产中')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:PACKAGED', '远端状态:已包装')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:SHIPPED', '远端状态:已发货')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:REJECTED', '远端状态:已驳回')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '远端状态:STOCK_SHORTAGE', '远端状态:库存不足')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `admin_name` = REPLACE(`admin_name`, '洛阳药房:admin', '洛阳药房:管理员')
WHERE `action` = 'ej_pharmacy_callback';
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = REPLACE(`summary`, '操作人:admin', '操作人:管理员')
WHERE `action` = 'ej_pharmacy_callback';
-- Match Gancao's production-flow presentation for existing EJ workflow logs.
UPDATE `zyt_tcm_prescription_order_log`
SET `summary` = CONCAT(
'洛阳药房回传:订单药房流转制作中 | 流程:',
SUBSTRING_INDEX(SUBSTRING_INDEX(`summary`, ';节点:', -1), '', 1),
' | 药房:洛阳药房',
CASE
WHEN `summary` LIKE '%操作人:管理员%' THEN ' | 操作人:管理员'
WHEN `summary` LIKE '%操作人:%' THEN CONCAT(' | 操作人:', SUBSTRING_INDEX(SUBSTRING_INDEX(`summary`, ';操作人:', -1), '', 1))
ELSE ''
END
),
`admin_name` = '洛阳药房系统'
WHERE `action` = 'ej_pharmacy_callback'
AND `summary` LIKE '洛阳药房回传:流程节点完成;节点:%';
@@ -234,6 +234,45 @@ $assertSame(
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']), EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']),
'non-shipment callbacks must not change local fulfillment' 'non-shipment callbacks must not change local fulfillment'
); );
$assertSame(
true,
EjPharmacyShipmentPolicy::isCompletedEvent(['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]),
'a final EJ workflow callback must be recognized as order completion'
);
$assertSame(
3,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'WORKFLOW_STEP_COMPLETED', 'status' => 'COMPLETED', 'final' => true]
),
'a final EJ workflow callback must advance ZYT fulfillment to completed'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED'],
2
),
'EJ review rejection must restore the fulfillment status captured before upload'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
2,
['event_type' => 'INVENTORY_SHORTAGE', 'status' => 'STOCK_SHORTAGE'],
2
),
'EJ inventory shortage must not become a ZYT customer refusal'
);
$assertSame(
2,
EjPharmacyShipmentPolicy::nextFulfillmentStatus(
9,
['event_type' => 'REVIEW_REJECTED', 'status' => 'REJECTED']
),
'legacy EJ rejection rows already marked as ZYT refusal must reopen to uploadable fulfillment'
);
foreach ([1, 2] as $pendingFulfillment) { foreach ([1, 2] as $pendingFulfillment) {
$assertSame( $assertSame(
5, 5,
@@ -350,7 +389,7 @@ $assertSame(
&& str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"), && str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"),
'callback failure persistence must use a conditional status CAS that protects PROCESSED' 'callback failure persistence must use a conditional status CAS that protects PROCESSED'
); );
$versionGateStart = strpos($controllerSource, 'if ($incomingVersion >'); $versionGateStart = strpos($controllerSource, 'if ($versionAdvanced)');
$versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart); $versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart);
$versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart); $versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart);
$assertSame( $assertSame(
@@ -375,5 +414,21 @@ $assertSame(
&& str_contains($controllerSource, "'order_type' => 'prescription_history'"), && str_contains($controllerSource, "'order_type' => 'prescription_history'"),
'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts' 'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts'
); );
$assertSame(
true,
str_contains($controllerSource, "\$log->action = 'ej_pharmacy_callback'")
&& str_contains($controllerSource, '操作人:')
&& str_contains($controllerSource, '版本已处理,保留回传日志'),
'every unique EJ callback must be written to the prescription order operation timeline with operator and version context'
);
$assertSame(
true,
str_contains($controllerSource, '订单药房流转制作中')
&& str_contains($controllerSource, '订单已完成')
&& str_contains($controllerSource, '流程:')
&& str_contains($controllerSource, '药房:洛阳药房')
&& str_contains($controllerSource, "implode(\$isWorkflowStep ? ' | ' : '', \$summaryParts)"),
'EJ workflow callback logs must use the same production-flow presentation as Gancao callbacks'
);
echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n"; echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n";