Compare commits

...
Author SHA1 Message Date
long 81d6a38e26 build 2026-07-28 16:29:32 +08:00
long 64d760af1a debug 2026-07-28 16:28:36 +08:00
long 9ba53b6145 debug 2026-07-28 15:15:14 +08:00
long 30cf33c546 build 2026-07-28 11:04:45 +08:00
long e1fe818dce feat: integrate Luoyang pharmacy workflow 2026-07-22 18:00:17 +08:00
long 31312ae975 feat: add zyt medicine bootstrap 2026-07-22 14:50:34 +08:00
916 changed files with 11143 additions and 2710 deletions
+87
View File
@@ -0,0 +1,87 @@
import request from '@/utils/request'
export interface MedicineMappingQuery {
page_no: number
page_size: number
local_name?: string
remote_keyword?: string
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
}
export interface MedicineMappingRow {
local_medicine_id: number
local_name: string
local_unit: string
local_status: number
mapping_id: number | null
mapping_status: number
medicine_code: string | null
remote_name: string | null
remote_brand: string | null
remote_unit: string | null
settlement_price: string | number | null
retail_price: string | number | null
catalog_version: number | null
remote_status: number | null
remote_deleted: number | null
operator_name: string | null
mapping_update_time: number | null
}
export interface CatalogOption {
medicine_code: string
name: string
brand: string
unit: string
settlement_price: string | number
retail_price: string | number
catalog_version: number
status: number
}
export interface PharmacySyncStatus {
sync_enabled: boolean
cursor: number
last_success_time: number
last_failure_time: number
last_error_summary: string
is_syncing: boolean
catalog_total: number
catalog_active: number
unmapped_local: number
}
export interface PharmacySyncResult {
pages: number
pulled: number
received: number
created: number
updated: number
unchanged: number
deactivated: number
cursor: number
}
export function medicineMappingLists(params: MedicineMappingQuery) {
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
}
export function medicineMappingStatus() {
return request.get({ url: '/pharmacy.medicineMapping/status' })
}
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
}
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
return request.post({ url: '/pharmacy.medicineMapping/save', params })
}
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
}
export function medicineCatalogSync() {
return request.post({ url: '/pharmacy.medicineMapping/sync' })
}
+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 v-if="canSelectShipMode" :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" />
@@ -1697,6 +1715,7 @@ import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue' import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue' import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
@@ -1709,7 +1728,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 +1833,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 +2029,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,
@@ -2014,6 +2038,10 @@ const createOrderForm = reactive({
pay_order_ids: [] as number[] pay_order_ids: [] as number[]
}) })
// 只有具备「设置发货类型」权限的账号才可在创建业务订单时选择洛阳药房;
// 无权限账号保持历史默认逻辑,固定走甘草药房。
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据) // 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
const regionOptions = ref([]) const regionOptions = ref([])
@@ -2252,6 +2280,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 +2454,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 +3631,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 +3642,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 +3666,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: '进行中' },
@@ -0,0 +1,686 @@
<template>
<div class="mapping-page">
<header class="page-header">
<h1>洛阳药房药材映射</h1>
<el-button
v-if="status.sync_enabled"
v-perms="['pharmacy.medicineMapping/sync']"
type="primary"
:icon="Refresh"
:loading="syncing"
@click="handleSync"
>
增量同步
</el-button>
</header>
<section class="status-strip" v-loading="statusLoading">
<div class="status-item">
<span>目录</span>
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
<small>启用 / 总数</small>
</div>
<div class="status-item warning">
<span>未映射本地药材</span>
<strong>{{ status.unmapped_local }}</strong>
<small>仅统计启用药材</small>
</div>
<div class="status-item">
<span>同步游标</span>
<strong>{{ status.cursor }}</strong>
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
</div>
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
<span>最近同步</span>
<strong>{{
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
}}</strong>
<small :title="status.last_error_summary">
{{
status.last_error_summary ||
formatTime(status.last_failure_time) ||
'无失败记录'
}}
</small>
</div>
</section>
<el-form class="filter-bar" inline @submit.prevent>
<el-form-item label="本地药材">
<el-input
v-model="query.local_name"
clearable
placeholder="名称"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="远端目录">
<el-input
v-model="query.remote_keyword"
clearable
placeholder="名称或编码"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="映射状态">
<el-select
v-model="query.mapping_status"
clearable
placeholder="全部"
style="width: 150px"
>
<el-option label="已映射" value="mapped" />
<el-option label="未映射" value="unmapped" />
<el-option label="远端失效" value="invalid" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
<el-table-column label="本地药材" min-width="190" fixed="left">
<template #default="{ row }">
<div class="medicine-name">{{ row.local_name }}</div>
<div class="subline">
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
</div>
</template>
</el-table-column>
<el-table-column label="本地状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
{{ row.local_status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="映射状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="mappingTag(row.mapping_status)" size="small">
{{ mappingText(row.mapping_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="洛阳药房目录" min-width="250">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
<div class="subline code">{{ row.medicine_code }}</div>
</template>
<template v-else>
<div class="medicine-name">-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
</template>
</el-table-column>
<el-table-column label="单位" width="80" align="center">
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
</el-table-column>
<el-table-column label="价格" width="155" align="right">
<template #default="{ row }">
<template v-if="row.mapping_status !== 0">
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="版本 / 状态" width="130" align="center">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div>v{{ row.catalog_version || 0 }}</div>
<div class="subline">
{{
row.remote_status === 1 && row.remote_deleted !== 1
? '远端启用'
: '远端停用'
}}
</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-perms="['pharmacy.medicineMapping/save']"
type="primary"
link
:icon="Link"
:disabled="row.local_status !== 1"
@click="openMapping(row)"
>
{{ row.mapping_status === 1 ? '更换' : '映射' }}
</el-button>
<el-button
v-if="row.mapping_status === 1 || row.mapping_status === 2"
v-perms="['pharmacy.medicineMapping/unlink']"
type="danger"
link
:icon="CloseBold"
@click="handleUnlink(row)"
>
解除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page_no"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadRows"
@current-change="loadRows"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
width="min(560px, calc(100vw - 32px))"
destroy-on-close
>
<div class="local-summary">
<span>本地药材</span>
<strong>{{ editingRow?.local_name }}</strong>
<small
>ID {{ editingRow?.local_medicine_id }} ·
{{ editingRow?.local_unit || '-' }}</small
>
</div>
<el-form label-position="top" class="mapping-form">
<el-form-item label="洛阳药房药材">
<el-select
v-model="selectedCode"
filterable
remote
reserve-keyword
clearable
:remote-method="searchCatalog"
:loading="catalogLoading"
placeholder="输入药材名称或编码检索"
style="width: 100%"
>
<el-option
v-for="option in catalogOptions"
:key="option.medicine_code"
:label="`${option.name} · ${option.medicine_code}`"
:value="option.medicine_code"
>
<div class="option-row">
<span>{{ option.name }}</span>
<small
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
{{ option.unit }}</small
>
</div>
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="!selectedCode"
@click="saveMapping"
>
保存映射
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="pharmacyMedicineMapping">
import { onMounted, reactive, ref } from 'vue'
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import { createLatestRequestGuard } from './latest-request.mjs'
import {
medicineCatalogOptions,
medicineCatalogSync,
medicineMappingLists,
medicineMappingSave,
medicineMappingStatus,
medicineMappingUnlink,
type CatalogOption,
type MedicineMappingQuery,
type MedicineMappingRow,
type PharmacySyncResult,
type PharmacySyncStatus
} from '@/api/pharmacy'
const emptyStatus = (): PharmacySyncStatus => ({
sync_enabled: false,
cursor: 0,
last_success_time: 0,
last_failure_time: 0,
last_error_summary: '',
is_syncing: false,
catalog_total: 0,
catalog_active: 0,
unmapped_local: 0
})
const loading = ref(false)
const statusLoading = ref(false)
const syncing = ref(false)
const saving = ref(false)
const catalogLoading = ref(false)
const rows = ref<MedicineMappingRow[]>([])
const total = ref(0)
const status = ref<PharmacySyncStatus>(emptyStatus())
const query = reactive<MedicineMappingQuery>({
page_no: 1,
page_size: 20,
local_name: '',
remote_keyword: '',
mapping_status: ''
})
const dialogVisible = ref(false)
const editingRow = ref<MedicineMappingRow | null>(null)
const selectedCode = ref('')
const catalogOptions = ref<CatalogOption[]>([])
let catalogTimer: number | undefined
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
const statusRequests = createLatestRequestGuard()
const formatPrice = (value: string | number | null | undefined) => {
const number = Number(value || 0)
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
}
const formatTime = (timestamp: number) => {
if (!timestamp) return ''
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
}
const mappingText = (value: number) => {
if (value === 1) return '已映射'
if (value === 2) return '远端失效'
return '未映射'
}
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
if (value === 1) return 'success'
if (value === 2) return 'warning'
return 'info'
}
const loadRows = async () => {
const ticket = listRequests.next({ ...query })
loading.value = true
try {
const response = await medicineMappingLists(ticket.snapshot)
if (listRequests.isLatest(ticket)) {
rows.value = response.lists || []
total.value = response.count || 0
}
} finally {
if (listRequests.isLatest(ticket)) {
loading.value = false
}
}
}
const loadStatus = async () => {
const ticket = statusRequests.next(undefined)
statusLoading.value = true
try {
const nextStatus = await medicineMappingStatus()
if (statusRequests.isLatest(ticket)) {
status.value = nextStatus
}
} finally {
if (statusRequests.isLatest(ticket)) {
statusLoading.value = false
}
}
}
const search = () => {
query.page_no = 1
void loadRows()
}
const resetFilters = () => {
query.local_name = ''
query.remote_keyword = ''
query.mapping_status = ''
search()
}
const handleSync = async () => {
syncing.value = true
try {
const result = (await medicineCatalogSync()) as PharmacySyncResult
feedback.msgSuccess(
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
)
await Promise.all([loadRows(), loadStatus()])
} finally {
syncing.value = false
}
}
const searchCatalogNow = async (
keyword: string,
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
) => {
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
catalogLoading.value = true
try {
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
if (
catalogRequests.isLatest(ticket) &&
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
) {
catalogOptions.value = options
}
} finally {
if (catalogRequests.isLatest(ticket)) {
catalogLoading.value = false
}
}
return ticket
}
const searchCatalog = (keyword: string) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
}
const openMapping = async (row: MedicineMappingRow) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
const rowSnapshot = { ...row }
const localMedicineId = Number(rowSnapshot.local_medicine_id)
editingRow.value = rowSnapshot
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
catalogOptions.value = []
dialogVisible.value = true
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
if (
!catalogRequests.isLatest(initialTicket) ||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
)
return
if (
selectedCode.value &&
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
) {
await searchCatalogNow(selectedCode.value, localMedicineId)
}
}
const saveMapping = async () => {
if (!editingRow.value || !selectedCode.value) return
saving.value = true
try {
await medicineMappingSave({
local_medicine_id: editingRow.value.local_medicine_id,
medicine_code: selectedCode.value
})
dialogVisible.value = false
await Promise.all([loadRows(), loadStatus()])
} finally {
saving.value = false
}
}
const handleUnlink = async (row: MedicineMappingRow) => {
await ElMessageBox.confirm(
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
'解除映射',
{
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消'
}
)
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
await Promise.all([loadRows(), loadStatus()])
}
onMounted(() => {
void Promise.all([loadRows(), loadStatus()])
})
</script>
<style scoped>
.mapping-page {
min-width: 0;
padding: 16px;
color: var(--el-text-color-primary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
line-height: 28px;
letter-spacing: 0;
}
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-height: 88px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
}
.status-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-right: 0;
}
.status-item span,
.status-item small {
display: block;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-item strong {
display: block;
margin: 5px 0 2px;
font-size: 18px;
line-height: 24px;
}
.status-item.warning strong {
color: var(--el-color-warning-dark-2);
}
.status-item.danger strong,
.status-item.danger small {
color: var(--el-color-danger);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0 8px;
padding: 14px 16px 0;
border: 1px solid var(--el-border-color-light);
border-bottom: 0;
background: var(--el-fill-color-extra-light);
}
.filter-bar :deep(.el-input) {
width: 190px;
}
.table-wrap {
min-width: 0;
overflow-x: auto;
}
.table-wrap :deep(.el-table) {
min-width: 1220px;
}
.medicine-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subline,
.muted {
margin-top: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
overflow-x: auto;
padding-top: 16px;
}
.local-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
padding: 12px 14px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.local-summary span,
.local-summary small {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.local-summary small {
grid-column: 2;
}
.mapping-form {
margin-top: 18px;
}
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.option-row small {
overflow: hidden;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-item:nth-child(2) {
border-right: 0;
}
.status-item:nth-child(-n + 2) {
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
@media (max-width: 600px) {
.mapping-page {
padding: 12px;
}
.page-header {
align-items: stretch;
flex-direction: column;
}
.page-header .el-button {
width: 100%;
}
.status-strip {
grid-template-columns: 1fr;
}
.status-item,
.status-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-bottom: 0;
}
.filter-bar {
display: block;
}
.filter-bar :deep(.el-form-item),
.filter-bar :deep(.el-input),
.filter-bar :deep(.el-select) {
width: 100% !important;
}
.pagination-wrap {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,12 @@
export interface LatestRequestTicket<T> {
readonly generation: number
readonly snapshot: T
}
export interface LatestRequestGuard<T> {
next(snapshot: T): LatestRequestTicket<T>
invalidate(): number
isLatest(ticket: LatestRequestTicket<T>): boolean
}
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
@@ -0,0 +1,22 @@
export function createLatestRequestGuard() {
let generation = 0
return {
next(snapshot) {
generation += 1
const stableSnapshot = Array.isArray(snapshot)
? [...snapshot]
: snapshot && typeof snapshot === 'object'
? { ...snapshot }
: snapshot
return Object.freeze({ generation, snapshot: stableSnapshot })
},
invalidate() {
generation += 1
return generation
},
isLatest(ticket) {
return ticket?.generation === generation
}
}
}
@@ -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,
+16
View File
@@ -1,5 +1,21 @@
APP_DEBUG = true APP_DEBUG = true
# 恩济药房 ERP:必须写在第一个 INI 分区之前
# 在 ej 后台创建商户后,将一次性返回的 app_key、app_secret、webhook_secret 填入下方
EJ_PHARMACY_ENABLED = false
EJ_PHARMACY_CATALOG_SYNC_ENABLED = false
EJ_PHARMACY_BASE_URL = "https://ej.example.com"
EJ_PHARMACY_APP_KEY = ""
EJ_PHARMACY_APP_SECRET = ""
EJ_PHARMACY_CALLBACK_SECRET = ""
# autoNSS cURL 自动使用 PHP OpenSSL;也可显式填写 openssl 或 curl。
EJ_PHARMACY_HTTP_TRANSPORT = "auto"
# CentOS/RHEL 7 可填写 /etc/pki/tls/certs/ca-bundle.crt;留空使用 PHP 默认 CA。
EJ_PHARMACY_CA_FILE = ""
EJ_PHARMACY_SUBMISSION_LEASE_SECONDS = 300
EJ_PHARMACY_CONNECT_TIMEOUT = 5
EJ_PHARMACY_REQUEST_TIMEOUT = 30
[APP] [APP]
DEFAULT_TIMEZONE = "Asia/Shanghai" DEFAULT_TIMEZONE = "Asia/Shanghai"
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\pharmacy;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\pharmacy\MedicineMappingLists;
use app\adminapi\logic\pharmacy\MedicineMappingLogic;
use app\adminapi\validate\pharmacy\MedicineMappingValidate;
class MedicineMappingController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new MedicineMappingLists());
}
public function status()
{
return $this->data(MedicineMappingLogic::status());
}
public function catalogOptions()
{
return $this->data(MedicineMappingLogic::catalogOptions(
(string) $this->request->get('keyword', ''),
(int) $this->request->get('limit', 30)
));
}
public function save()
{
$params = (new MedicineMappingValidate())->post()->goCheck('save');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::save($params, $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已保存', [], 1, 1);
}
public function unlink()
{
$params = (new MedicineMappingValidate())->post()->goCheck('unlink');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::unlink((int) $params['local_medicine_id'], $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已解除', [], 1, 1);
}
public function sync()
{
$result = MedicineMappingLogic::sync();
if ($result === false) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('目录同步完成', $result);
}
}
@@ -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',
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\pharmacy;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use think\facade\Db;
class MedicineMappingLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$rows = $this->query()
->field($this->fields())
->limit($this->limitOffset, $this->limitLength)
->order('l.id', 'desc')
->select()
->toArray();
foreach ($rows as &$row) {
foreach ([
'local_medicine_id', 'local_status', 'mapping_id', 'mapping_status',
'operator_id', 'mapping_update_time', 'catalog_version', 'remote_status', 'remote_deleted',
] as $field) {
if ($row[$field] !== null) {
$row[$field] = (int) $row[$field];
}
}
}
unset($row);
return $rows;
}
public function count(): int
{
return (int) $this->query()->count('l.id');
}
private function query()
{
$query = Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->leftJoin('ej_medicine_catalog c', 'c.medicine_code = m.medicine_code')
->whereNull('l.delete_time');
$localName = trim((string) ($this->params['local_name'] ?? ''));
if ($localName !== '') {
$query->where('l.name', 'like', '%' . $localName . '%');
}
$remoteKeyword = trim((string) ($this->params['remote_keyword'] ?? ''));
if ($remoteKeyword !== '') {
$query->where(function ($nested) use ($remoteKeyword): void {
$nested->where('c.name', 'like', '%' . $remoteKeyword . '%')
->whereOr('c.medicine_code', 'like', '%' . $remoteKeyword . '%');
});
}
$mappingStatus = trim((string) ($this->params['mapping_status'] ?? ''));
if ($mappingStatus === 'mapped') {
$query->whereNotNull('m.id')->where('c.status', 1)->where('c.remote_deleted', 0);
} elseif ($mappingStatus === 'unmapped') {
$query->whereNull('m.id');
} elseif ($mappingStatus === 'invalid') {
$query->whereNotNull('m.id')
->where(function ($nested): void {
$nested->whereNull('c.id')
->whereOr('c.status', '<>', 1)
->whereOr('c.remote_deleted', 1);
});
}
return $query;
}
private function fields(): string
{
return implode(',', [
'l.id AS local_medicine_id',
'l.name AS local_name',
'l.unit AS local_unit',
'l.status AS local_status',
'm.id AS mapping_id',
'm.medicine_code',
'm.operator_id',
'm.operator_name',
'm.update_time AS mapping_update_time',
'c.name AS remote_name',
'c.brand AS remote_brand',
'c.unit AS remote_unit',
'c.settlement_price',
'c.retail_price',
'c.catalog_version',
'c.status AS remote_status',
'c.remote_deleted',
"CASE WHEN m.id IS NULL THEN 0 "
. "WHEN c.id IS NULL OR c.status <> 1 OR c.remote_deleted = 1 THEN 2 ELSE 1 END AS mapping_status",
]);
}
}
@@ -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' => '审核时间',
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\pharmacy;
use app\common\logic\BaseLogic;
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
use app\common\service\pharmacy\EjMedicineMappingPolicy;
use think\facade\Db;
use think\facade\Config;
use Throwable;
class MedicineMappingLogic extends BaseLogic
{
public static function save(array $params, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($params, $operatorId, $operatorName): void {
$localId = (int) $params['local_medicine_id'];
$medicineCode = trim((string) $params['medicine_code']);
$local = Db::name('doctor_medicine')->where('id', $localId)->lock(true)->find();
$remote = Db::name('ej_medicine_catalog')->where('medicine_code', $medicineCode)->lock(true)->find();
EjMedicineMappingPolicy::assertValid($local ?: [], $remote ?: []);
$now = time();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localId)
->lock(true)
->find();
$values = [
'medicine_code' => $medicineCode,
'status' => 1,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => null,
];
if ($mapping) {
Db::name('ej_medicine_mapping')->where('id', (int) $mapping['id'])->update($values);
return;
}
Db::name('ej_medicine_mapping')->insert(array_merge($values, [
'local_medicine_id' => $localId,
'create_time' => $now,
]));
});
return true;
} catch (Throwable $exception) {
self::setError(self::isDuplicateKey($exception)
? '该本地药材映射刚被其他操作更新,请刷新后重试'
: $exception->getMessage());
return false;
}
}
public static function unlink(int $localMedicineId, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($localMedicineId, $operatorId, $operatorName): void {
$local = Db::name('doctor_medicine')->where('id', $localMedicineId)->lock(true)->find();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localMedicineId)
->lock(true)
->find();
$decision = EjMedicineMappingPolicy::unlinkDecision($local ?: [], $mapping ?: null);
if ($decision['already_unlinked']) {
return;
}
$now = time();
Db::name('ej_medicine_mapping')
->where('id', $decision['mapping_id'])
->where('local_medicine_id', $localMedicineId)
->where('status', 1)
->whereNull('delete_time')
->update([
'status' => 0,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => $now,
]);
});
return true;
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed>|false */
public static function sync()
{
self::$error = '';
try {
return EjMedicineCatalogSyncService::sync(200);
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed> */
public static function status(): array
{
$state = Db::name('ej_pharmacy_sync_state')->where('id', 1)->find() ?: [];
$catalogTotal = (int) Db::name('ej_medicine_catalog')->count();
$catalogActive = (int) Db::name('ej_medicine_catalog')
->where('status', 1)->where('remote_deleted', 0)->count();
$unmappedLocal = (int) Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->where('l.status', 1)
->whereNull('l.delete_time')
->whereNull('m.id')
->count('l.id');
return [
'sync_enabled' => (bool) Config::get('ej_pharmacy.catalog_sync_enabled', false),
'cursor' => (int) ($state['cursor'] ?? 0),
'last_success_time' => (int) ($state['last_success_time'] ?? 0),
'last_failure_time' => (int) ($state['last_failure_time'] ?? 0),
'last_error_summary' => (string) ($state['last_error_summary'] ?? ''),
'is_syncing' => !empty($state['lock_token']) && (int) ($state['lock_expires_at'] ?? 0) >= time(),
'catalog_total' => $catalogTotal,
'catalog_active' => $catalogActive,
'unmapped_local' => $unmappedLocal,
];
}
/** @return array<int,array<string,mixed>> */
public static function catalogOptions(string $keyword, int $limit = 30): array
{
$query = Db::name('ej_medicine_catalog')
->where('status', 1)
->where('remote_deleted', 0);
$keyword = trim($keyword);
if ($keyword !== '') {
$query->where(function ($nested) use ($keyword): void {
$nested->where('name', 'like', '%' . $keyword . '%')
->whereOr('medicine_code', 'like', '%' . $keyword . '%');
});
}
return $query
->field('medicine_code,name,brand,unit,settlement_price,retail_price,catalog_version,status')
->order('catalog_version', 'desc')
->limit(min(max($limit, 1), 50))
->select()
->toArray();
}
private static function isDuplicateKey(Throwable $exception): bool
{
return (string) $exception->getCode() === '23000'
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
}
}
@@ -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,12 +270,11 @@ 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();
@@ -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 仍拼不出路径
@@ -293,6 +343,21 @@ class PrescriptionOrderLogic
return false; return false;
} }
/**
* 是否允许在业务订单中选择发货药房(甘草 / 洛阳)。
* 无此权限时沿用历史逻辑,订单固定走甘草药房。
*/
public static function canSelectShipMode(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$permissions = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.prescriptionOrder/setShipMode', $permissions, true);
}
/** /**
* @param PrescriptionOrder $row * @param PrescriptionOrder $row
*/ */
@@ -972,6 +1037,9 @@ 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::canSelectShipMode($adminInfo)
? self::normalizeShipMode((string) ($params['ship_mode'] ?? 'gancao'))
: '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 +1221,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 +1244,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 +1284,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 +1714,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 +1766,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 +1950,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 +1994,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 +2054,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 +2349,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 +4196,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 +4473,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 +4497,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 +4958,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 +4973,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 +5119,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 +5149,14 @@ class PrescriptionOrderLogic
return false; return false;
} }
if (!self::canSelectShipMode($adminInfo)) {
self::$error = '无权限设置发货药房';
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 +5170,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 +5408,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 +5439,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('已取消的订单不可修改');
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\pharmacy;
use app\common\validate\BaseValidate;
class MedicineMappingValidate extends BaseValidate
{
protected $rule = [
'local_medicine_id' => 'require|integer|gt:0',
'medicine_code' => 'require|max:32',
];
protected $message = [
'local_medicine_id.require' => '本地药材ID不能为空',
'local_medicine_id.integer' => '本地药材ID格式错误',
'local_medicine_id.gt' => '本地药材ID格式错误',
'medicine_code.require' => '请选择洛阳药房药材',
'medicine_code.max' => '洛阳药房药材编码不能超过32个字符',
];
public function sceneSave(): self
{
return $this->only(['local_medicine_id', 'medicine_code']);
}
public function sceneUnlink(): self
{
return $this->only(['local_medicine_id']);
}
}
@@ -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 = [
'洛阳药房回传:订单药房流转制作中',
'流程:' . $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');
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\pharmacy\EjMedicineBootstrapService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class EjPharmacyBootstrapMedicines extends Command
{
protected function configure()
{
$this->setName('ej-pharmacy:bootstrap-medicines')
->setDescription('一次性导入并原子替换恩济药房药材目录投影')
->addOption('replace', null, Option::VALUE_NONE, '确认替换本地 EJ 药材投影')
->addOption('confirm', null, Option::VALUE_OPTIONAL, '破坏性操作确认令牌:RESET_TEST_CATALOG', '')
->addOption('batch-size', null, Option::VALUE_OPTIONAL, '远端导入批次大小(1-500', 100);
}
protected function execute(Input $input, Output $output)
{
try {
$batchSize = (int) $input->getOption('batch-size');
EjMedicineBootstrapService::assertCommandGate(
(bool) $input->getOption('replace'),
(string) $input->getOption('confirm'),
$batchSize
);
$result = EjMedicineBootstrapService::execute($batchSize);
$output->writeln(sprintf(
'bootstrap 完成 source=%d batches=%d catalog=%d active_mappings=%d unmapped=%d',
$result['source_count'],
$result['batch_count'],
$result['catalog'],
$result['active_mappings'],
$result['unmapped']
));
return 0;
} catch (\Throwable $exception) {
$output->error($exception->getMessage());
return 1;
}
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
class EjPharmacySyncCatalog extends Command
{
protected function configure()
{
$this->setName('ej-pharmacy:sync-catalog')
->setDescription('增量同步洛阳药房 ERP 药材目录')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '每页数量', 200);
}
protected function execute(Input $input, Output $output)
{
try {
$stats = EjMedicineCatalogSyncService::sync(max((int) $input->getOption('limit'), 1));
$output->writeln(sprintf(
'同步完成 pages=%d pulled=%d created=%d updated=%d deactivated=%d cursor=%d',
$stats['pages'],
$stats['pulled'],
$stats['created'],
$stats['updated'],
$stats['deactivated'],
$stats['cursor']
));
return 0;
} catch (\Throwable $exception) {
$output->error($exception->getMessage());
return 1;
}
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\common\model\pharmacy;
use app\common\model\BaseModel;
class EjMedicineCatalog extends BaseModel
{
protected $name = 'ej_medicine_catalog';
protected $autoWriteTimestamp = true;
protected $dateFormat = false;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace app\common\model\pharmacy;
use app\common\model\BaseModel;
use think\model\concern\SoftDelete;
class EjMedicineMapping extends BaseModel
{
use SoftDelete;
protected $name = 'ej_medicine_mapping';
protected $deleteTime = 'delete_time';
protected $autoWriteTimestamp = true;
protected $dateFormat = false;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\common\model\pharmacy;
use app\common\model\BaseModel;
class EjPharmacyCallbackInbox extends BaseModel
{
protected $name = 'ej_pharmacy_callback_inbox';
protected $autoWriteTimestamp = true;
protected $dateFormat = false;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\common\model\pharmacy;
use app\common\model\BaseModel;
class EjPharmacySubmission extends BaseModel
{
protected $name = 'ej_pharmacy_submission';
protected $autoWriteTimestamp = true;
protected $dateFormat = false;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\common\model\pharmacy;
use app\common\model\BaseModel;
class PharmacySubmissionClaim extends BaseModel
{
protected $name = 'pharmacy_submission_claim';
protected $autoWriteTimestamp = true;
protected $dateFormat = false;
}
@@ -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 => '系统·订单已发货/签收',
}; };
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use InvalidArgumentException;
final class EjMedicineBootstrapItem
{
/** @param array<string,mixed> $row @return array<string,mixed> */
public static function fromRow(array $row): array
{
$sourceId = self::sourceId($row['id'] ?? null);
$name = self::text($row['name'] ?? null, '药材名称', 120);
$unit = self::text($row['unit'] ?? null, '药材单位', 24);
$status = $row['status'] ?? null;
if (!in_array($status, [0, 1, '0', '1'], true)) {
throw new InvalidArgumentException("本地药材 {$sourceId} 状态必须为 0 或 1");
}
return [
'source_medicine_id' => $sourceId,
'name' => $name,
'brand' => '',
'unit' => $unit,
'settlement_price' => self::roundPrice($row['settlement_price'] ?? null),
'retail_price' => self::roundPrice($row['retail_price'] ?? null),
'status' => (int) $status,
];
}
/** @param array<int,array<string,mixed>> $rows @return list<array<string,mixed>> */
public static function fromRows(array $rows): array
{
$items = array_map([self::class, 'fromRow'], $rows);
usort($items, static fn (array $left, array $right): int => self::compareIds(
(string) $left['source_medicine_id'],
(string) $right['source_medicine_id']
));
$previousId = null;
foreach ($items as $item) {
$sourceId = (string) $item['source_medicine_id'];
if ($previousId !== null && hash_equals($previousId, $sourceId)) {
throw new InvalidArgumentException("本地药材 source_medicine_id 重复:{$sourceId}");
}
$previousId = $sourceId;
}
return $items;
}
public static function roundPrice(mixed $value): string
{
if (!is_string($value) || preg_match('/^(0|[1-9]\d*)\.(\d{1,6})$/D', $value, $matches) !== 1) {
throw new InvalidArgumentException('药材价格必须是最多六位小数的非负十进制字符串');
}
$whole = ltrim($matches[1], '0');
$whole = $whole === '' ? '0' : $whole;
$fraction = str_pad($matches[2], 6, '0');
$fourDecimals = substr($fraction, 0, 4);
if ((int) $fraction[4] < 5) {
return $whole . '.' . $fourDecimals;
}
$digits = self::addOne($whole . $fourDecimals);
if (strlen($digits) < 5) {
$digits = str_pad($digits, 5, '0', STR_PAD_LEFT);
}
return substr($digits, 0, -4) . '.' . substr($digits, -4);
}
public static function compareIds(string $left, string $right): int
{
return strlen($left) <=> strlen($right) ?: strcmp($left, $right);
}
private static function sourceId(mixed $value): string
{
if (is_int($value)) {
$value = (string) $value;
}
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
throw new InvalidArgumentException('本地药材 id 必须是正整数');
}
$value = ltrim($value, '0');
if ($value === '') {
throw new InvalidArgumentException('本地药材 id 必须是正整数');
}
return $value;
}
private static function text(mixed $value, string $field, int $maxLength): string
{
if (!is_string($value)) {
throw new InvalidArgumentException("{$field}必须是字符串");
}
$trimmed = preg_replace('/\A[\s\p{Z}\p{Cf}]+|[\s\p{Z}\p{Cf}]+\z/u', '', $value);
if (!is_string($trimmed) || $trimmed === '' || mb_strlen($trimmed) > $maxLength) {
throw new InvalidArgumentException("{$field}不能为空且不能超过 {$maxLength} 个字符");
}
return $trimmed;
}
private static function addOne(string $digits): string
{
$characters = str_split($digits);
for ($index = count($characters) - 1; $index >= 0; --$index) {
if ($characters[$index] !== '9') {
$characters[$index] = (string) ((int) $characters[$index] + 1);
return implode('', $characters);
}
$characters[$index] = '0';
}
return '1' . implode('', $characters);
}
}
@@ -0,0 +1,481 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use InvalidArgumentException;
use RuntimeException;
use think\facade\Db;
final class EjMedicineBootstrapService
{
private const SOURCE_SYSTEM = 'zyt';
private const BOOTSTRAP_RUN_ID = 'zyt-medicine-bootstrap-v1';
private const EXPECTED_MEDICINE_COUNT = 654;
public static function assertCommandGate(bool $replace, string $confirm, int $batchSize): void
{
if (!$replace) {
throw new InvalidArgumentException('必须显式提供 --replace 才能替换恩济药材投影');
}
if (!hash_equals('RESET_TEST_CATALOG', $confirm)) {
throw new InvalidArgumentException('必须提供 --confirm=RESET_TEST_CATALOG');
}
if ($batchSize < 1 || $batchSize > 500) {
throw new InvalidArgumentException('--batch-size 必须在 1 到 500 之间');
}
}
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
public static function buildBatches(array $items, int $batchSize): array
{
if ($batchSize < 1 || $batchSize > 500) {
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
}
usort($items, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
(string) ($left['source_medicine_id'] ?? ''),
(string) ($right['source_medicine_id'] ?? '')
));
$batches = [];
foreach (array_chunk($items, $batchSize) as $index => $batchItems) {
$ordinal = $index + 1;
$contentJson = json_encode(
$batchItems,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$contentIdentity = substr(hash('sha256', $contentJson), 0, 32);
$batches[] = [
'source_system' => self::SOURCE_SYSTEM,
'import_id' => sprintf('%s-%04d-%s', self::BOOTSTRAP_RUN_ID, $ordinal, $contentIdentity),
'items' => $batchItems,
];
}
return $batches;
}
/**
* @param array<string,mixed> $response
* @param array<string,mixed> $payload
* @param array<string,bool> $seenCodes
* @param array<int,bool> $seenVersions
* @return list<array{source_medicine_id:string,medicine_code:string,catalog_version:int,action:string}>
*/
public static function validateImportResponse(
array $response,
array $payload,
array &$seenCodes,
array &$seenVersions
): array {
$httpStatus = (int) ($response['http_status'] ?? 0);
$body = $response['body'] ?? null;
if (!in_array($httpStatus, [200, 201], true) || !is_array($body) || (int) ($body['code'] ?? -1) !== 0) {
$message = is_array($body) ? trim((string) ($body['message'] ?? '')) : '';
throw new RuntimeException(sprintf(
'恩济药材导入失败 HTTP %d%s',
$httpStatus,
$message === '' ? '' : '' . $message
));
}
$data = $body['data'] ?? null;
if (!is_array($data)) {
throw new RuntimeException('恩济药材导入响应缺少 data');
}
$expectedImportId = (string) ($payload['import_id'] ?? '');
if (!hash_equals($expectedImportId, (string) ($data['import_id'] ?? ''))) {
throw new RuntimeException('恩济药材导入响应 import_id 不匹配');
}
$expectedItems = $payload['items'] ?? null;
$responseItems = $data['items'] ?? null;
if (!is_array($expectedItems) || !is_array($responseItems)) {
throw new RuntimeException('恩济药材导入响应 items 无效');
}
$itemCount = count($expectedItems);
$createdCount = (int) ($data['created_count'] ?? -1);
$existingCount = (int) ($data['existing_count'] ?? -1);
if (
(int) ($data['item_count'] ?? -1) !== $itemCount
|| count($responseItems) !== $itemCount
|| $createdCount < 0
|| $existingCount < 0
|| $createdCount + $existingCount !== $itemCount
|| !is_bool($data['idempotent'] ?? null)
) {
throw new RuntimeException('恩济药材导入响应计数或幂等标记不完整');
}
$expectedSourceIds = array_map(
static fn (array $item): string => (string) ($item['source_medicine_id'] ?? ''),
$expectedItems
);
$nextSeenCodes = $seenCodes;
$nextSeenVersions = $seenVersions;
$normalized = [];
$responseSourceIds = [];
$actions = ['created' => 0, 'existing' => 0];
foreach ($responseItems as $item) {
if (!is_array($item)) {
throw new RuntimeException('恩济药材导入响应 item 必须是对象');
}
$sourceId = self::canonicalSourceId($item['source_medicine_id'] ?? null);
if (isset($responseSourceIds[$sourceId])) {
throw new RuntimeException("恩济药材导入响应 source_medicine_id 重复:{$sourceId}");
}
$responseSourceIds[$sourceId] = true;
$code = trim((string) ($item['medicine_code'] ?? ''));
if ($code === '' || mb_strlen($code) > 32 || isset($nextSeenCodes[$code])) {
throw new RuntimeException("恩济药材导入响应 medicine_code 为空、过长或重复:{$code}");
}
$version = filter_var($item['catalog_version'] ?? null, FILTER_VALIDATE_INT);
if ($version === false || $version < 1 || isset($nextSeenVersions[$version])) {
throw new RuntimeException('恩济药材导入响应 catalog_version 缺失或重复');
}
$action = (string) ($item['action'] ?? '');
if (!array_key_exists($action, $actions)) {
throw new RuntimeException('恩济药材导入响应 action 无效');
}
++$actions[$action];
$nextSeenCodes[$code] = true;
$nextSeenVersions[$version] = true;
$normalized[] = [
'source_medicine_id' => $sourceId,
'medicine_code' => $code,
'catalog_version' => $version,
'action' => $action,
];
}
usort($normalized, static fn (array $left, array $right): int => EjMedicineBootstrapItem::compareIds(
$left['source_medicine_id'],
$right['source_medicine_id']
));
sort($expectedSourceIds, SORT_NATURAL);
$actualSourceIds = array_column($normalized, 'source_medicine_id');
sort($actualSourceIds, SORT_NATURAL);
if ($expectedSourceIds !== $actualSourceIds) {
throw new RuntimeException('恩济药材导入响应 source_medicine_id 不完整或不匹配');
}
if ($actions['created'] !== $createdCount || $actions['existing'] !== $existingCount) {
throw new RuntimeException('恩济药材导入响应 action 与计数不一致');
}
if ($data['idempotent'] === true && ($createdCount !== 0 || $existingCount !== $itemCount)) {
throw new RuntimeException('恩济药材导入响应幂等标记与 action 不一致');
}
$seenCodes = $nextSeenCodes;
$seenVersions = $nextSeenVersions;
return $normalized;
}
/**
* @param null|callable():array<int,array<string,mixed>> $sourceLoader
* @param null|callable(array<string,mixed>):array<string,mixed> $importer
* @param null|callable(array<int,array<string,mixed>>):array<string,int> $projectionReplacer
* @return array{source_count:int,batch_count:int,catalog:int,active_mappings:int,unmapped:int}
*/
public static function execute(
int $batchSize = 100,
?callable $sourceLoader = null,
?callable $importer = null,
?callable $projectionReplacer = null
): array {
if ($batchSize < 1 || $batchSize > 500) {
throw new InvalidArgumentException('药材导入批次大小必须在 1 到 500 之间');
}
$sourceRows = $sourceLoader === null ? self::loadSourceRows() : $sourceLoader();
$items = EjMedicineBootstrapItem::fromRows($sourceRows);
if (count($items) !== self::EXPECTED_MEDICINE_COUNT) {
throw new RuntimeException(sprintf(
'药材 bootstrap 要求恰好 %d 条启用且未删除的本地药材,当前为 %d 条',
self::EXPECTED_MEDICINE_COUNT,
count($items)
));
}
$batches = self::buildBatches($items, $batchSize);
if ($importer === null) {
if (!EjPharmacyClient::isConfigured()) {
throw new RuntimeException('恩济药房接口未启用或配置不完整');
}
$client = new EjPharmacyClient();
$importer = static fn (array $payload): array => $client->importMedicines($payload);
}
$sourceById = [];
foreach ($items as $item) {
$sourceById[(string) $item['source_medicine_id']] = $item;
}
$seenCodes = [];
$seenVersions = [];
$projectionRows = [];
foreach ($batches as $payload) {
$responseItems = self::validateImportResponse(
$importer($payload),
$payload,
$seenCodes,
$seenVersions
);
foreach ($responseItems as $responseItem) {
$sourceId = $responseItem['source_medicine_id'];
$source = $sourceById[$sourceId];
$projectionRows[] = [
'local_medicine_id' => (int) $sourceId,
'medicine_code' => $responseItem['medicine_code'],
'name' => $source['name'],
'brand' => '',
'unit' => $source['unit'],
'settlement_price' => $source['settlement_price'],
'retail_price' => $source['retail_price'],
'status' => $source['status'],
'catalog_version' => $responseItem['catalog_version'],
];
}
}
usort($projectionRows, static fn (array $left, array $right): int => $left['local_medicine_id'] <=> $right['local_medicine_id']);
$verification = $projectionReplacer === null
? self::replaceProjection($projectionRows)
: $projectionReplacer($projectionRows);
self::assertProjectionVerification($verification, self::EXPECTED_MEDICINE_COUNT);
return [
'source_count' => count($items),
'batch_count' => count($batches),
'catalog' => (int) $verification['catalog'],
'active_mappings' => (int) $verification['active_mappings'],
'unmapped' => (int) $verification['unmapped'],
];
}
/**
* @param array<int,array<string,mixed>> $projectionRows
* @param callable(callable():array<string,int>):array<string,int> $transaction
* @param callable():void $referenceLocker
* @param callable():array<string,int> $referenceCounter
* @param callable():void $projectionLocker
* @param callable(array<int,array<string,mixed>>):void $replacer
* @param callable():array<string,int> $verifier
* @return array<string,int>
*/
public static function replaceProjectionWith(
array $projectionRows,
callable $transaction,
callable $referenceLocker,
callable $referenceCounter,
callable $projectionLocker,
callable $replacer,
callable $verifier
): array {
return $transaction(static function () use (
$projectionRows,
$referenceLocker,
$referenceCounter,
$projectionLocker,
$replacer,
$verifier
): array {
$referenceLocker();
$references = $referenceCounter();
foreach (['submissions', 'callbacks', 'business_links'] as $key) {
if ((int) ($references[$key] ?? -1) !== 0) {
throw new RuntimeException('恩济药材投影已有提交、回调或业务引用,禁止 bootstrap 替换');
}
}
$projectionLocker();
$replacer($projectionRows);
$verification = $verifier();
self::assertProjectionVerification($verification, count($projectionRows));
return $verification;
});
}
/** @return array<int,array<string,mixed>> */
private static function loadSourceRows(): array
{
return Db::name('doctor_medicine')
->field('id,name,unit,settlement_price,retail_price,status')
->where('status', 1)
->whereNull('delete_time')
->order('id', 'asc')
->select()
->toArray();
}
/** @param array<int,array<string,mixed>> $projectionRows @return array<string,int> */
private static function replaceProjection(array $projectionRows): array
{
return self::replaceProjectionWith(
$projectionRows,
static fn (callable $operation): array => Db::transaction($operation),
static function (): void {
Db::name('ej_pharmacy_submission')
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
Db::name('ej_pharmacy_callback_inbox')
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
Db::name('pharmacy_submission_claim')
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
Db::name('tcm_prescription_order')
->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
},
static function (): array {
$directClaims = (int) Db::name('pharmacy_submission_claim')
->where('target', 'direct')
->count();
$linkedOrders = (int) Db::name('tcm_prescription_order')
->where(function ($query): void {
$query->whereNotNull('ej_pharmacy_order_no')
->whereOr('ej_pharmacy_submit_time', '>', 0)
->whereOr('ej_pharmacy_status', '<>', '')
->whereOr('ej_pharmacy_status_version', '>', 0);
})
->count();
return [
'submissions' => (int) Db::name('ej_pharmacy_submission')->count(),
'callbacks' => (int) Db::name('ej_pharmacy_callback_inbox')->count(),
'business_links' => $directClaims + $linkedOrders,
];
},
static function () use ($projectionRows): void {
Db::name('ej_pharmacy_sync_state')->where('id', 1)->lock(true)->find();
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
Db::name('doctor_medicine')->where('id', '>=', 0)->order('id', 'asc')->lock(true)->column('id');
$lockedRows = Db::name('doctor_medicine')
->field('id,name,unit,settlement_price,retail_price,status')
->where('status', 1)
->whereNull('delete_time')
->order('id', 'asc')
->select()
->toArray();
$lockedItems = EjMedicineBootstrapItem::fromRows($lockedRows);
$expectedItems = array_map(static fn (array $row): array => [
'source_medicine_id' => (string) $row['local_medicine_id'],
'name' => (string) $row['name'],
'brand' => '',
'unit' => (string) $row['unit'],
'settlement_price' => (string) $row['settlement_price'],
'retail_price' => (string) $row['retail_price'],
'status' => (int) $row['status'],
], $projectionRows);
if ($lockedItems !== $expectedItems) {
throw new RuntimeException('本地药材源快照在远端导入期间发生变化,已拒绝替换投影');
}
},
static function (array $rows): void {
Db::name('ej_medicine_mapping')->where('id', '>=', 0)->delete();
Db::name('ej_medicine_catalog')->where('id', '>=', 0)->delete();
$now = time();
$catalogRows = [];
$mappingRows = [];
foreach ($rows as $row) {
$catalogRows[] = [
'medicine_code' => $row['medicine_code'],
'name' => $row['name'],
'brand' => '',
'unit' => $row['unit'],
'settlement_price' => $row['settlement_price'],
'retail_price' => $row['retail_price'],
'status' => $row['status'],
'catalog_version' => $row['catalog_version'],
'remote_deleted' => 0,
'create_time' => $now,
'update_time' => $now,
];
$mappingRows[] = [
'local_medicine_id' => $row['local_medicine_id'],
'medicine_code' => $row['medicine_code'],
'status' => 1,
'operator_id' => 0,
'operator_name' => 'system-bootstrap',
'create_time' => $now,
'update_time' => $now,
'delete_time' => null,
];
}
foreach (array_chunk($catalogRows, 500) as $chunk) {
Db::name('ej_medicine_catalog')->insertAll($chunk);
}
foreach (array_chunk($mappingRows, 500) as $chunk) {
Db::name('ej_medicine_mapping')->insertAll($chunk);
}
$stateValues = [
'cursor' => 0,
'last_success_time' => $now,
'last_failure_time' => 0,
'last_error_summary' => '',
'lock_token' => '',
'lock_expires_at' => 0,
'update_time' => $now,
];
$updated = Db::name('ej_pharmacy_sync_state')->where('id', 1)->update($stateValues);
if ($updated === 0 && !Db::name('ej_pharmacy_sync_state')->where('id', 1)->find()) {
Db::name('ej_pharmacy_sync_state')->insert($stateValues + ['id' => 1, 'create_time' => $now]);
}
},
static function () use ($projectionRows): array {
$expectedLocalIds = array_map(
static fn (array $row): int => (int) $row['local_medicine_id'],
$projectionRows
);
$actualLocalIds = array_map(
'intval',
Db::name('ej_medicine_mapping')
->where('status', 1)
->whereNull('delete_time')
->order('local_medicine_id', 'asc')
->column('local_medicine_id')
);
if ($expectedLocalIds !== $actualLocalIds) {
throw new RuntimeException('恩济药材 bootstrap 映射未精确覆盖全部本地药材 id');
}
return [
'catalog' => (int) Db::name('ej_medicine_catalog')->count(),
'active_mappings' => count($actualLocalIds),
'unmapped' => (int) Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->where('l.status', 1)
->whereNull('l.delete_time')
->whereNull('m.id')
->count('l.id'),
];
}
);
}
/** @param array<string,mixed> $verification */
private static function assertProjectionVerification(array $verification, int $expected): void
{
if (
(int) ($verification['catalog'] ?? -1) !== $expected
|| (int) ($verification['active_mappings'] ?? -1) !== $expected
|| (int) ($verification['unmapped'] ?? -1) !== 0
) {
throw new RuntimeException(sprintf(
'恩济药材 bootstrap 最终验证失败:catalog=%d active_mappings=%d unmapped=%d expected=%d',
(int) ($verification['catalog'] ?? -1),
(int) ($verification['active_mappings'] ?? -1),
(int) ($verification['unmapped'] ?? -1),
$expected
));
}
}
private static function canonicalSourceId(mixed $value): string
{
if (is_int($value)) {
$value = (string) $value;
}
if (!is_string($value) || preg_match('/^\d+$/D', $value) !== 1) {
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
}
$value = ltrim($value, '0');
if ($value === '') {
throw new RuntimeException('恩济药材导入响应 source_medicine_id 无效');
}
return $value;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use RuntimeException;
final class EjMedicineCatalogSyncPolicy
{
/** @return array{items:array<int,array<string,mixed>>,next_cursor:int,has_more:bool} */
public static function parsePage(array $response, int $cursor): array
{
$body = is_array($response['body'] ?? null) ? $response['body'] : [];
$httpStatus = (int) ($response['http_status'] ?? 0);
if ($httpStatus < 200 || $httpStatus >= 300 || (int) ($body['code'] ?? -1) !== 0) {
$message = trim((string) ($body['message'] ?? ''));
throw new RuntimeException($message !== '' ? $message : '洛阳药房药材目录同步失败');
}
$data = is_array($body['data'] ?? null) ? $body['data'] : [];
$items = is_array($data['items'] ?? null) ? array_values(array_filter(
$data['items'],
static fn ($item): bool => is_array($item)
)) : [];
$nextCursor = max(0, (int) ($data['next_cursor'] ?? $cursor));
$hasMore = !empty($data['has_more']);
if ($hasMore && $nextCursor <= $cursor) {
throw new RuntimeException('洛阳药房药材目录游标未推进,已停止同步');
}
return ['items' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore];
}
/**
* @param array<string,mixed>|null $existing
* @param array<string,mixed> $remote
* @return array{action:string,values:array<string,mixed>,deactivated:int}
*/
public static function merge(?array $existing, array $remote): array
{
$code = trim((string) ($remote['medicine_code'] ?? ''));
if ($code === '') {
throw new RuntimeException('洛阳药房药材目录包含空 medicine_code');
}
$deleted = !empty($remote['deleted']) || !empty($remote['remote_deleted']);
$values = [
'medicine_code' => $code,
'name' => trim((string) ($remote['name'] ?? '')),
'brand' => trim((string) ($remote['brand'] ?? '')),
'unit' => trim((string) ($remote['unit'] ?? '')),
'settlement_price' => self::decimal($remote['settlement_price'] ?? 0),
'retail_price' => self::decimal($remote['retail_price'] ?? 0),
'status' => $deleted ? 0 : (int) ($remote['status'] ?? 0),
'catalog_version' => max(0, (int) ($remote['catalog_version'] ?? 0)),
'remote_deleted' => $deleted ? 1 : 0,
];
if ($existing === null) {
return [
'action' => 'created',
'values' => $values,
'deactivated' => $values['status'] === 0 ? 1 : 0,
];
}
$existingComparable = [
'medicine_code' => trim((string) ($existing['medicine_code'] ?? '')),
'name' => trim((string) ($existing['name'] ?? '')),
'brand' => trim((string) ($existing['brand'] ?? '')),
'unit' => trim((string) ($existing['unit'] ?? '')),
'settlement_price' => self::decimal($existing['settlement_price'] ?? 0),
'retail_price' => self::decimal($existing['retail_price'] ?? 0),
'status' => (int) ($existing['status'] ?? 0),
'catalog_version' => max(0, (int) ($existing['catalog_version'] ?? 0)),
'remote_deleted' => (int) ($existing['remote_deleted'] ?? 0),
];
$deactivated = $existingComparable['status'] === 1 && $values['status'] === 0 ? 1 : 0;
return [
'action' => $existingComparable === $values ? 'unchanged' : 'updated',
'values' => $values,
'deactivated' => $deactivated,
];
}
private static function decimal(mixed $value): string
{
return number_format(max(0.0, (float) $value), 4, '.', '');
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use app\common\model\pharmacy\EjMedicineCatalog;
use RuntimeException;
use think\facade\Db;
use think\facade\Config;
use Throwable;
final class EjMedicineCatalogSyncService
{
private const STATE_ID = 1;
private const LOCK_TTL = 600;
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
public static function sync(int $limit = 200): array
{
if (!(bool) Config::get('ej_pharmacy.catalog_sync_enabled', false)) {
throw new RuntimeException('恩济药房增量目录同步已关闭,请使用一次性 bootstrap 命令初始化药材目录');
}
if (!EjPharmacyClient::isConfigured()) {
throw new RuntimeException('洛阳药房接口未启用或配置不完整');
}
self::ensureStateRow();
$token = bin2hex(random_bytes(16));
$client = new EjPharmacyClient();
$workflow = new EjMedicineCatalogSyncWorkflow(
static fn (): bool => self::acquireLock($token),
static fn () => self::releaseLock($token),
static fn (): int => (int) (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->value('cursor') ?? 0),
static fn (int $cursor, int $pageLimit): array => $client->medicines($cursor, $pageLimit),
static fn (array $items, int $nextCursor): array => self::mergePage($items, $nextCursor, $token),
static function (int $cursor) use ($token): void {
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
->where('lock_token', $token)->update([
'cursor' => $cursor,
'last_success_time' => time(),
'last_error_summary' => '',
'update_time' => time(),
]);
},
static function (int $cursor, string $error) use ($token): void {
Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)
->where('lock_token', $token)->update([
'cursor' => $cursor,
'last_failure_time' => time(),
'last_error_summary' => $error,
'update_time' => time(),
]);
}
);
return $workflow->sync($limit);
}
private static function ensureStateRow(): void
{
if (Db::name('ej_pharmacy_sync_state')->where('id', self::STATE_ID)->find()) {
return;
}
try {
Db::name('ej_pharmacy_sync_state')->insert([
'id' => self::STATE_ID,
'cursor' => 0,
'lock_token' => '',
'lock_expires_at' => 0,
'create_time' => time(),
'update_time' => time(),
]);
} catch (Throwable $exception) {
if (!self::isDuplicateKey($exception)) {
throw $exception;
}
}
}
private static function acquireLock(string $token): bool
{
$now = time();
$updated = Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where(function ($query) use ($now): void {
$query->where('lock_token', '')->whereOr('lock_expires_at', '<', $now);
})
->update([
'lock_token' => $token,
'lock_expires_at' => $now + self::LOCK_TTL,
'update_time' => $now,
]);
return $updated === 1;
}
private static function releaseLock(string $token): void
{
Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->where('lock_token', $token)
->update(['lock_token' => '', 'lock_expires_at' => 0, 'update_time' => time()]);
}
/** @return array{created:int,updated:int,unchanged:int,deactivated:int} */
private static function mergePage(array $items, int $nextCursor, string $token): array
{
return Db::transaction(function () use ($items, $nextCursor, $token): array {
$stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'deactivated' => 0];
foreach ($items as $item) {
$code = trim((string) ($item['medicine_code'] ?? ''));
$model = $code === '' ? null : EjMedicineCatalog::where('medicine_code', $code)->lock(true)->find();
$result = EjMedicineCatalogSyncPolicy::merge($model ? $model->toArray() : null, $item);
$values = $result['values'];
if ($result['action'] === 'created') {
EjMedicineCatalog::create($values);
} elseif ($result['action'] === 'updated' && $model) {
unset($values['medicine_code']);
$model->save($values);
}
++$stats[$result['action']];
$stats['deactivated'] += $result['deactivated'];
if ($result['deactivated'] === 1) {
$now = time();
Db::name('ej_medicine_mapping')
->where('medicine_code', $code)
->where('status', 1)
->update(['status' => 0, 'delete_time' => $now, 'update_time' => $now]);
}
}
$state = Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->lock(true)
->find();
if (!$state || !hash_equals((string) $state['lock_token'], $token)) {
throw new RuntimeException('洛阳药房目录同步锁已失效,请重试');
}
Db::name('ej_pharmacy_sync_state')
->where('id', self::STATE_ID)
->update([
'cursor' => $nextCursor,
'lock_expires_at' => time() + self::LOCK_TTL,
'update_time' => time(),
]);
return $stats;
});
}
private static function isDuplicateKey(Throwable $exception): bool
{
return (string) $exception->getCode() === '23000'
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
use RuntimeException;
use Throwable;
final class EjMedicineCatalogSyncWorkflow
{
private $acquireLock;
private $releaseLock;
private $loadCursor;
private $fetchPage;
private $mergePage;
private $markSuccess;
private $markFailure;
public function __construct(
callable $acquireLock,
callable $releaseLock,
callable $loadCursor,
callable $fetchPage,
callable $mergePage,
callable $markSuccess,
callable $markFailure
) {
$this->acquireLock = $acquireLock;
$this->releaseLock = $releaseLock;
$this->loadCursor = $loadCursor;
$this->fetchPage = $fetchPage;
$this->mergePage = $mergePage;
$this->markSuccess = $markSuccess;
$this->markFailure = $markFailure;
}
/** @return array{pages:int,pulled:int,received:int,created:int,updated:int,unchanged:int,deactivated:int,cursor:int} */
public function sync(int $limit = 200): array
{
if (!(bool) ($this->acquireLock)()) {
throw new DomainException('洛阳药房目录正在同步,请稍后重试');
}
$cursor = 0;
$stats = [
'pages' => 0,
'pulled' => 0,
'received' => 0,
'created' => 0,
'updated' => 0,
'unchanged' => 0,
'deactivated' => 0,
'cursor' => $cursor,
];
try {
$cursor = max(0, (int) ($this->loadCursor)());
$stats['cursor'] = $cursor;
for ($page = 0; $page < 1000; ++$page) {
$parsed = EjMedicineCatalogSyncPolicy::parsePage(
($this->fetchPage)($cursor, min(max($limit, 1), 500)),
$cursor
);
$merged = ($this->mergePage)($parsed['items'], $parsed['next_cursor']);
++$stats['pages'];
$pulled = count($parsed['items']);
$stats['pulled'] += $pulled;
$stats['received'] += $pulled;
foreach (['created', 'updated', 'unchanged', 'deactivated'] as $key) {
$stats[$key] += (int) ($merged[$key] ?? 0);
}
$cursor = $parsed['next_cursor'];
$stats['cursor'] = $cursor;
if (!$parsed['has_more']) {
($this->markSuccess)($cursor, $stats);
return $stats;
}
}
throw new RuntimeException('洛阳药房药材目录分页超过安全上限');
} catch (Throwable $exception) {
($this->markFailure)($cursor, self::summarizeError($exception->getMessage()));
throw $exception;
} finally {
($this->releaseLock)();
}
}
public static function summarizeError(string $message): string
{
$message = preg_replace('/(app[_-]?secret|signature|token|authorization)\s*[:=]\s*[^\s,;]+/i', '$1=[redacted]', $message) ?? $message;
return mb_substr(trim($message), 0, 500);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class EjMedicineMappingPolicy
{
/** @param array<string,mixed> $local @param array<string,mixed> $remote */
public static function assertValid(array $local, array $remote): void
{
if ((int) ($local['id'] ?? 0) <= 0) {
throw new DomainException('本地药材不存在');
}
if (!empty($local['delete_time'])) {
throw new DomainException('本地药材已删除,不能建立映射');
}
if ((int) ($local['status'] ?? 0) !== 1) {
throw new DomainException('本地药材已停用,不能建立映射');
}
if (trim((string) ($remote['medicine_code'] ?? '')) === '') {
throw new DomainException('洛阳药房药材不存在');
}
if (!empty($remote['remote_deleted'])) {
throw new DomainException('洛阳药房药材已删除,不能建立映射');
}
if ((int) ($remote['status'] ?? 0) !== 1) {
throw new DomainException('洛阳药房药材已停用,不能建立映射');
}
}
/**
* @param array<string,mixed> $local
* @param array<string,mixed>|null $mapping
* @return array{mapping_id:int,already_unlinked:bool}
*/
public static function unlinkDecision(array $local, ?array $mapping): array
{
$localId = (int) ($local['id'] ?? 0);
if ($localId <= 0) {
throw new DomainException('本地药材不存在');
}
if ($mapping === null) {
return ['mapping_id' => 0, 'already_unlinked' => true];
}
if ((int) ($mapping['local_medicine_id'] ?? 0) !== $localId) {
throw new DomainException('药材映射归属不匹配');
}
$mappingId = (int) ($mapping['id'] ?? 0);
if ($mappingId <= 0) {
throw new DomainException('药材映射记录无效');
}
return [
'mapping_id' => $mappingId,
'already_unlinked' => (int) ($mapping['status'] ?? 0) !== 1 || !empty($mapping['delete_time']),
];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Closure;
use InvalidArgumentException;
final class EjPharmacyCallbackFailureTransition
{
/** @param callable(int,array<string,mixed>,string):bool $conditionalUpdate */
public static function apply(int $inboxId, string $error, callable $conditionalUpdate): bool
{
if ($inboxId <= 0) {
throw new InvalidArgumentException('callback inbox id is missing');
}
return (bool) Closure::fromCallable($conditionalUpdate)(
$inboxId,
[
'process_status' => 'FAILED',
'error_message' => mb_substr($error, 0, 1000),
'update_time' => time(),
],
'PROCESSED'
);
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use RuntimeException;
final class EjPharmacyCallbackRetryException extends RuntimeException
{
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Closure;
use DomainException;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class EjPharmacyCallbackWorkflow
{
private Closure $loadInbox;
private Closure $createInbox;
private Closure $reloadInbox;
private Closure $process;
private Closure $markFailed;
private Closure $isDuplicateKey;
public function __construct(
callable $loadInbox,
callable $createInbox,
callable $reloadInbox,
callable $process,
callable $markFailed,
callable $isDuplicateKey
) {
$this->loadInbox = Closure::fromCallable($loadInbox);
$this->createInbox = Closure::fromCallable($createInbox);
$this->reloadInbox = Closure::fromCallable($reloadInbox);
$this->process = Closure::fromCallable($process);
$this->markFailed = Closure::fromCallable($markFailed);
$this->isDuplicateKey = Closure::fromCallable($isDuplicateKey);
}
/** @param array<string,mixed> $payload @return array<string,mixed> */
public function handle(array $payload): array
{
$eventId = trim((string) ($payload['event_id'] ?? ''));
if ($eventId === '') {
return ['http_status' => 422, 'message' => 'event_id is required', 'duplicate' => false];
}
$inbox = ($this->loadInbox)($eventId);
if (is_array($inbox) && strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
}
if (!is_array($inbox)) {
try {
$inbox = ($this->createInbox)($payload);
} catch (Throwable $exception) {
if (!(bool) ($this->isDuplicateKey)($exception)) {
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
}
$inbox = ($this->reloadInbox)($eventId);
if (!is_array($inbox)) {
return ['http_status' => 500, 'message' => 'callback inbox race could not be reloaded', 'duplicate' => false];
}
if (strtoupper((string) ($inbox['process_status'] ?? '')) === 'PROCESSED') {
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => true];
}
}
}
try {
($this->process)($inbox, $payload);
return ['http_status' => 200, 'message' => 'ok', 'duplicate' => false];
} catch (EjPharmacyCallbackRetryException $exception) {
($this->markFailed)($inbox, $exception->getMessage());
return ['http_status' => 503, 'message' => $exception->getMessage(), 'duplicate' => false];
} catch (InvalidArgumentException|DomainException $exception) {
($this->markFailed)($inbox, $exception->getMessage());
return ['http_status' => 422, 'message' => $exception->getMessage(), 'duplicate' => false];
} catch (Throwable $exception) {
($this->markFailed)($inbox, $exception->getMessage());
return ['http_status' => 500, 'message' => $exception->getMessage(), 'duplicate' => false];
}
}
}
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Closure;
use RuntimeException;
use think\facade\Config;
final class EjPharmacyClient
{
private string $baseUrl;
private string $appKey;
private string $appSecret;
/** @var null|Closure(string,string,string,array<int,string>):array{http_status:int,body:array<string,mixed>,request_id:string} */
private ?Closure $transport;
public function __construct(
?string $baseUrl = null,
?string $appKey = null,
?string $appSecret = null,
?callable $transport = null
)
{
$this->baseUrl = rtrim($baseUrl ?? (string) Config::get('ej_pharmacy.base_url', ''), '/');
$this->appKey = $appKey ?? (string) Config::get('ej_pharmacy.app_key', '');
$this->appSecret = $appSecret ?? (string) Config::get('ej_pharmacy.app_secret', '');
if ($this->baseUrl === '' || $this->appKey === '' || $this->appSecret === '') {
throw new RuntimeException('恩济药房接口未配置完整');
}
$this->transport = $transport === null ? null : Closure::fromCallable($transport);
}
public static function isConfigured(): bool
{
return (bool) Config::get('ej_pharmacy.enabled', false)
&& trim((string) Config::get('ej_pharmacy.base_url', '')) !== ''
&& trim((string) Config::get('ej_pharmacy.app_key', '')) !== ''
&& trim((string) Config::get('ej_pharmacy.app_secret', '')) !== '';
}
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
public function medicines(int $after = 0, int $limit = 100): array
{
return $this->request('GET', '/api/openapi/v1/medicines', null, [
'after' => max($after, 0),
'limit' => min(max($limit, 1), 500),
]);
}
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
public function importMedicines(array $payload): array
{
return $this->request('POST', '/api/openapi/v1/medicine-imports', $payload);
}
/** @param array<string,mixed> $payload @return array{http_status:int,body:array<string,mixed>,request_id:string} */
public function createPrescriptionOrder(array $payload): array
{
return $this->request('POST', '/api/openapi/v1/prescription-orders', $payload);
}
/** @return array{http_status:int,body:array<string,mixed>,request_id:string} */
public function prescriptionOrder(string $sourceOrderNo, int $sourceRevision = 0): array
{
$query = ['source_system' => 'zyt'];
if ($sourceRevision > 0) {
$query['source_revision'] = $sourceRevision;
}
return $this->request(
'GET',
'/api/openapi/v1/prescription-orders/' . rawurlencode($sourceOrderNo),
null,
$query
);
}
/** @param array<string,mixed>|null $payload @param array<string,int|string> $query @return array{http_status:int,body:array<string,mixed>,request_id:string} */
private function request(string $method, string $path, ?array $payload = null, array $query = []): array
{
$queryString = $query === [] ? '' : http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$pathWithQuery = $path . ($queryString !== '' ? '?' . $queryString : '');
$body = $payload === null
? ''
: (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$requestId = bin2hex(random_bytes(16));
$canonical = EjPharmacySignature::canonical($method, $pathWithQuery, $timestamp, $nonce, $body);
$headers = [
'Accept: application/json',
'Content-Type: application/json; charset=utf-8',
'X-App-Key: ' . $this->appKey,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . EjPharmacySignature::sign($this->appSecret, $canonical),
'X-Request-Id: ' . $requestId,
'Expect:',
];
if ($this->transport !== null) {
return ($this->transport)(strtoupper($method), $pathWithQuery, $body, $headers);
}
$configuredTransport = strtolower((string) Config::get('ej_pharmacy.http_transport', 'auto'));
$curlSsl = strtoupper((string) ((function_exists('curl_version') ? curl_version() : [])['ssl_version'] ?? ''));
if ($configuredTransport === 'openssl'
|| ($configuredTransport === 'auto' && str_starts_with($curlSsl, 'NSS/'))
) {
return $this->requestWithOpenSsl($method, $pathWithQuery, $body, $headers, $requestId);
}
$ch = curl_init($this->baseUrl . $pathWithQuery);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => (int) Config::get('ej_pharmacy.connect_timeout', 5),
CURLOPT_TIMEOUT => (int) Config::get('ej_pharmacy.request_timeout', 30),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException('恩济药房通信失败:' . $error);
}
$decoded = json_decode((string) $raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('恩济药房返回了无效 JSONHTTP ' . $httpStatus);
}
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
}
/** @param array<int,string> $headers @return array{http_status:int,body:array<string,mixed>,request_id:string} */
private function requestWithOpenSsl(
string $method,
string $path,
string $body,
array $headers,
string $requestId
): array {
$ssl = [
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
];
$caFile = trim((string) Config::get('ej_pharmacy.ca_file', ''));
if ($caFile !== '') {
$ssl['cafile'] = $caFile;
}
$context = stream_context_create([
'http' => [
'method' => strtoupper($method),
'header' => implode("\r\n", $headers),
'content' => $body,
'ignore_errors' => true,
'timeout' => (int) Config::get('ej_pharmacy.request_timeout', 30),
'protocol_version' => 1.1,
],
'ssl' => $ssl,
]);
$raw = @file_get_contents($this->baseUrl . $path, false, $context);
if ($raw === false) {
$lastError = error_get_last();
$message = is_array($lastError) ? (string) ($lastError['message'] ?? '') : '';
throw new RuntimeException('恩济药房通信失败:' . ($message !== '' ? $message : 'OpenSSL 请求失败'));
}
$httpStatus = 0;
$responseHeaders = $http_response_header ?? [];
foreach (array_reverse($responseHeaders) as $responseHeader) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', $responseHeader, $matches)) {
$httpStatus = (int) $matches[1];
break;
}
}
$decoded = json_decode((string) $raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('恩济药房返回了无效 JSONHTTP ' . $httpStatus);
}
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
use InvalidArgumentException;
final class EjPharmacyPayload
{
/**
* @param array<string,mixed> $order
* @param array<string,mixed> $prescription
* @param array<int,string> $medicineMappings
* @return array<string,mixed>
*/
public static function build(array $order, array $prescription, array $medicineMappings, int $revision): array
{
$orderNo = trim((string) ($order['order_no'] ?? ''));
if ($orderNo === '') {
throw new InvalidArgumentException('order_no is required');
}
$herbs = $prescription['herbs'] ?? [];
if (!is_array($herbs) || $herbs === []) {
throw new InvalidArgumentException('prescription herbs are required');
}
$medicines = [];
foreach ($herbs as $herb) {
if (!is_array($herb)) {
continue;
}
$medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
$medicineCode = trim((string) ($medicineMappings[$medicineId] ?? ''));
if ($medicineId <= 0 || $medicineCode === '') {
throw new DomainException('Unmapped medicine: ' . ($name !== '' ? $name : (string) $medicineId));
}
$quantity = (float) ($herb['dose'] ?? $herb['dosage'] ?? $herb['quantity'] ?? 0);
if ($quantity <= 0) {
throw new InvalidArgumentException('Medicine quantity must be positive: ' . $name);
}
$medicines[] = [
'source_medicine_id' => (string) $medicineId,
'medicine_code' => $medicineCode,
'name' => $name,
'quantity' => number_format($quantity, 4, '.', ''),
'unit' => trim((string) ($herb['unit'] ?? '克')) ?: '克',
'usage' => trim((string) ($herb['usage'] ?? '')),
];
}
if ($medicines === []) {
throw new InvalidArgumentException('prescription herbs are required');
}
return [
'source_system' => 'zyt',
'source_order_no' => $orderNo,
'source_revision' => max($revision, 1),
'patient' => [
'source_patient_id' => (string) ($prescription['patient_id'] ?? ''),
'name' => trim((string) ($prescription['patient_name'] ?? $order['recipient_name'] ?? '')),
'id_card' => trim((string) ($prescription['id_card'] ?? '')),
'mobile' => trim((string) ($prescription['phone'] ?? $order['recipient_phone'] ?? '')),
],
'shipping' => [
'recipient_name' => trim((string) ($order['recipient_name'] ?? $prescription['patient_name'] ?? '')),
'recipient_mobile' => trim((string) ($order['recipient_phone'] ?? $prescription['phone'] ?? '')),
'province' => trim((string) ($order['shipping_province'] ?? '')),
'city' => trim((string) ($order['shipping_city'] ?? '')),
'district' => trim((string) ($order['shipping_district'] ?? '')),
'address' => trim((string) ($order['shipping_address'] ?? '')),
],
'prescription' => [
'source_prescription_id' => (string) ($prescription['id'] ?? ''),
'diagnosis' => trim((string) (
$prescription['clinical_diagnosis']
?? $prescription['diagnosis']
?? $prescription['diagnosis_name']
?? ''
)),
'processing_type' => trim((string) ($prescription['processing_type'] ?? 'decoction')) ?: 'decoction',
'dose_count' => max((int) ($order['dose_count'] ?? $prescription['dose_count'] ?? 1), 1),
'doctor' => [
'source_doctor_id' => (string) ($prescription['creator_id'] ?? $prescription['doctor_id'] ?? ''),
'name' => trim((string) ($prescription['doctor_name'] ?? '')),
],
'doctor_signature' => is_array($prescription['doctor_signature'] ?? null)
? $prescription['doctor_signature']
: [],
'medicines' => $medicines,
'instructions' => trim((string) (
$prescription['usage_instruction']
?? $prescription['instructions']
?? $prescription['advice']
?? ''
)),
],
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
final class EjPharmacyShipmentPolicy
{
/** @param array<string,mixed> $payload */
public static function isShippedEvent(array $payload): bool
{
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'ORDER_SHIPPED'
|| strtoupper(trim((string) ($payload['status'] ?? ''))) === 'SHIPPED';
}
/** @param array<string,mixed> $payload */
public static function isWorkflowStepEvent(array $payload): bool
{
return strtoupper(trim((string) ($payload['event_type'] ?? ''))) === 'WORKFLOW_STEP_COMPLETED';
}
/** @param array<string,mixed> $payload */
public static function isCompletedEvent(array $payload): bool
{
// A workflow node (including the final “ship” node) is only a
// pharmacy-process update. It must not close the ZYT business order.
return !self::isWorkflowStepEvent($payload)
&& strtoupper(trim((string) ($payload['status'] ?? ''))) === 'COMPLETED';
}
/** @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::isWorkflowStepEvent($payload)) {
// EJ workflow callbacks are informational nodes. Keep the ZYT
// fulfillment status unchanged; only explicit shipment/completion
// events may advance it.
return $currentStatus;
}
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
return 5;
}
if (self::isCompletedEvent($payload) && in_array($currentStatus, [1, 2, 5], true)) {
return 3;
}
return $currentStatus;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
final class EjPharmacySignature
{
public static function canonical(string $method, string $path, string $timestamp, string $nonce, string $body): string
{
return implode("\n", [
strtoupper(trim($method)),
$path,
trim($timestamp),
trim($nonce),
hash('sha256', $body),
]);
}
public static function sign(string $secret, string $canonical): string
{
return hash_hmac('sha256', $canonical, $secret);
}
public static function verify(string $secret, string $canonical, string $signature): bool
{
return $secret !== '' && $signature !== '' && hash_equals(self::sign($secret, $canonical), strtolower(trim($signature)));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class EjPharmacyTrackingPolicy
{
/**
* @param array<string,mixed>|null $current Active tracking for this order.
* @param array<string,mixed>|null $matching Tracking already owning the incoming number.
* @return array{action:string,archive_current:bool}
*/
public static function select(?array $current, ?array $matching, int $orderId, string $trackingNumber): array
{
$trackingNumber = trim($trackingNumber);
if ($current !== null && trim((string) ($current['tracking_number'] ?? '')) === $trackingNumber) {
return ['action' => 'REUSE_CURRENT', 'archive_current' => false];
}
if ($matching !== null) {
$ownerOrderId = (int) ($matching['order_id'] ?? 0);
if ($ownerOrderId !== 0 && $ownerOrderId !== $orderId) {
throw new DomainException('该运单号已关联其他订单,禁止重新绑定');
}
return ['action' => 'REUSE_MATCHING', 'archive_current' => $current !== null];
}
return ['action' => 'CREATE', 'archive_current' => $current !== null];
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Closure;
use DomainException;
use think\facade\Db;
final class LockedPharmacySnapshotMutation
{
/**
* @param callable():array<string,mixed> $lockOrder
* @param callable(array<string,mixed>):?array<string,mixed> $lockClaim
* @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation
*/
public static function run(
callable $lockOrder,
callable $lockClaim,
callable $mutation,
bool $assertMutable = true
): mixed {
$order = Closure::fromCallable($lockOrder)();
if ($order === []) {
throw new DomainException('订单不存在');
}
$claim = Closure::fromCallable($lockClaim)($order);
if ($assertMutable) {
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim);
}
$result = Closure::fromCallable($mutation)($order, $claim);
if ($result === false) {
throw new DomainException('受保护变更未完成,事务已回滚');
}
return $result;
}
/** @param callable(array<string,mixed>,?array<string,mixed>):mixed $mutation */
public static function execute(
int $orderId,
callable $mutation,
bool $assertMutable = true,
int $revision = 1
): mixed {
return Db::transaction(static fn (): mixed => self::run(
static fn (): array => (array) (Db::name('tcm_prescription_order')
->where('id', $orderId)
->whereNull('delete_time')
->lock(true)
->find() ?: []),
static fn (): ?array => Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', max($revision, 1))
->lock(true)
->find() ?: null,
$mutation,
$assertMutable
));
}
/** @param callable(array<int,array<string,mixed>>):mixed $mutation */
public static function executeForPrescription(int $prescriptionId, callable $mutation): mixed
{
return Db::transaction(static function () use ($prescriptionId, $mutation): mixed {
$orders = Db::name('tcm_prescription_order')
->where('prescription_id', $prescriptionId)
->whereNull('delete_time')
->order('id', 'asc')
->lock(true)
->select()
->toArray();
foreach ($orders as $order) {
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', (int) $order['id'])
->where('source_revision', 1)
->lock(true)
->find();
PharmacyRemoteSnapshotPolicy::assertMutable($order, $claim ?: null);
}
$result = Closure::fromCallable($mutation)($orders);
if ($result === false) {
throw new DomainException('受保护变更未完成,事务已回滚');
}
return $result;
});
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class PharmacyHerbIdentityResolver
{
/**
* @param array<int,array<string,mixed>> $herbs
* @param callable(array<int,int>):array<int,array<string,mixed>> $loadByIds
* @param callable(array<int,string>):array<int,array<string,mixed>> $loadByNames
* @return array<int,array<string,mixed>>
*/
public static function resolve(array $herbs, callable $loadByIds, callable $loadByNames): array
{
$ids = [];
$names = [];
foreach ($herbs as $herb) {
if (!is_array($herb)) {
continue;
}
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
continue;
}
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
if ($name !== '') {
$names[] = $name;
}
}
$byId = [];
foreach ($ids === [] ? [] : $loadByIds(array_values(array_unique($ids))) as $row) {
if (self::isActive($row)) {
$byId[(int) $row['id']] = $row;
}
}
$byName = [];
foreach ($names === [] ? [] : $loadByNames(array_values(array_unique($names))) as $row) {
if (!self::isActive($row)) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name !== '') {
$byName[$name][] = $row;
}
}
$resolved = [];
foreach ($herbs as $herb) {
if (!is_array($herb)) {
continue;
}
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
$id = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
if ($id > 0) {
$row = $byId[$id] ?? null;
if (!is_array($row)) {
throw new DomainException('药材“' . ($name !== '' ? $name : (string) $id) . '”对应的本地药材不存在或已停用');
}
} else {
if ($name === '') {
throw new DomainException('药材名称不能为空');
}
$candidates = $byName[$name] ?? [];
if (count($candidates) === 0) {
throw new DomainException('药材“' . $name . '”未在本地药材库中找到');
}
if (count($candidates) !== 1) {
throw new DomainException('药材“' . $name . '”存在多个同名记录,请重新选择具体药材');
}
$row = $candidates[0];
$id = (int) $row['id'];
}
$herb['medicine_id'] = $id;
$herb['name'] = trim((string) ($row['name'] ?? $name));
unset($herb['id'], $herb['title'], $herb['local_medicine_id']);
$resolved[] = $herb;
}
if ($resolved === []) {
throw new DomainException('处方药材不能为空');
}
return $resolved;
}
/** @param array<string,mixed> $row */
private static function isActive(array $row): bool
{
return (int) ($row['id'] ?? 0) > 0
&& (int) ($row['status'] ?? 0) === 1
&& ($row['delete_time'] ?? null) === null;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use InvalidArgumentException;
final class PharmacyLogisticsValue
{
public static function normalize(mixed $value, int $maxLength, string $label): string
{
$normalized = preg_replace(
'/^[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+|[\s\p{Z}\x{200B}\x{2060}\x{FEFF}]+$/u',
'',
(string) $value
);
if ($normalized === null) {
throw new InvalidArgumentException($label . '格式无效');
}
if (mb_strlen($normalized) > $maxLength) {
throw new InvalidArgumentException($label . '长度不能超过' . $maxLength . '个字符');
}
return $normalized;
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use RuntimeException;
final class PharmacyReconciliationRequiredException extends RuntimeException
{
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Throwable;
final class PharmacyRemoteOutcomeClassifier
{
public static function isConfirmedEjNoCreateHttpStatus(int $httpStatus): bool
{
return in_array($httpStatus, [400, 401, 403, 404, 405, 415, 422], true);
}
public static function isConfirmedNoCreate(Throwable $exception): bool
{
return $exception instanceof PharmacyRemoteRejectedException;
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use RuntimeException;
/** The pharmacy explicitly confirmed that no remote order was created. */
final class PharmacyRemoteRejectedException extends RuntimeException
{
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class PharmacyRemoteSnapshotPolicy
{
/**
* @param array<string,mixed> $order
* @param array<string,mixed>|null $claim
*/
public static function isLocked(array $order, ?array $claim): bool
{
if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') {
return true;
}
if (trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '') {
return true;
}
if ((int) ($order['gancao_submit_time'] ?? 0) > 0 || (int) ($order['ej_pharmacy_submit_time'] ?? 0) > 0) {
return true;
}
return is_array($claim) && in_array(
strtoupper((string) ($claim['status'] ?? '')),
['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'],
true
);
}
/**
* @param array<string,mixed> $order
* @param array<string,mixed>|null $claim
*/
public static function assertMutable(array $order, ?array $claim): void
{
if (self::isLocked($order, $claim)) {
throw new DomainException('订单已提交药房,患者、地址、处方与发货药房快照不可修改;请先完成远端取消确认,取消后创建新版本');
}
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class PharmacySubmissionClaimPolicy
{
/** @param array<string,mixed> $claim @return array<string,mixed> */
public static function existingDecision(array $claim, string $requestedTarget, ?int $now = null): array
{
$target = (string) ($claim['target'] ?? '');
$status = strtoupper(trim((string) ($claim['status'] ?? '')));
if ($status === 'SUCCESS') {
if ($target !== $requestedTarget) {
throw new DomainException('该订单已上传其他药房');
}
return ['action' => 'IDEMPOTENT'] + $claim;
}
if ($status === 'PENDING') {
$leaseExpiresAt = (int) ($claim['lease_expires_at'] ?? 0);
if ($leaseExpiresAt > 0 && $leaseExpiresAt <= ($now ?? time())) {
return [
'action' => $target === 'gancao' ? 'RECONCILE' : 'RETRY',
'lease_expired' => true,
] + $claim;
}
throw new DomainException('该订单正在上传药房,请勿重复提交');
}
if (in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) {
if ($target !== $requestedTarget) {
throw new DomainException('该订单远端结果待对账,禁止切换药房');
}
return ['action' => 'RECONCILE'] + $claim;
}
if ($status === 'FAILED') {
return ['action' => 'RETRY'] + $claim;
}
throw new DomainException('药房提交状态异常,请先对账处理');
}
}
@@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
use think\facade\Db;
use think\facade\Config;
final class PharmacySubmissionClaimService
{
/** @return array<string,mixed> */
public static function acquire(
int $orderId,
int $revision,
string $target,
int $operatorId,
string $operatorName
): array {
self::assertTarget($target);
$revision = max($revision, 0);
return Db::transaction(function () use ($orderId, $revision, $target, $operatorId, $operatorName): array {
$order = Db::name('tcm_prescription_order')
->where('id', $orderId)
->whereNull('delete_time')
->lock(true)
->find();
if (!$order) {
throw new DomainException('订单不存在');
}
if ($revision <= 0) {
$revision = self::nextRevisionForOrder($order, $target);
}
// Rows handled by the old integration may already be marked as
// ZYT "拒收". Reopen them to the uploadable fulfillment state
// before creating the next EJ revision.
if ($target === 'direct'
&& self::isRejectedEjOrder($order, $target)
&& (int) ($order['fulfillment_status'] ?? 0) === 9) {
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
'fulfillment_status' => 2,
]);
$order['fulfillment_status'] = 2;
}
$expectedTarget = self::targetForShipMode((string) ($order['ship_mode'] ?? 'gancao'));
if ($expectedTarget !== $target) {
throw new DomainException('发货药房已变更,请刷新后重试');
}
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', $revision)
->lock(true)
->find();
if ($claim) {
$decision = PharmacySubmissionClaimPolicy::existingDecision($claim, $target);
if ($decision['action'] === 'IDEMPOTENT') {
return [
'target' => $target,
'token' => (string) $claim['claim_token'],
'status' => 'SUCCESS',
'idempotent' => true,
'result' => self::idempotentResult($target, (string) ($claim['remote_order_no'] ?? '')),
];
}
if ($decision['action'] === 'RECONCILE') {
if (!empty($decision['lease_expired'])) {
$claim = self::expirePendingGancaoClaim($claim, $operatorId, $operatorName);
}
return [
'target' => $target,
'token' => (string) $claim['claim_token'],
'status' => (string) $claim['status'],
'idempotency_key' => (string) $claim['idempotency_key'],
'idempotent' => false,
'reconcile' => true,
];
}
}
if (self::hasAnyRemoteOrder($order)) {
if (!self::isRejectedEjOrder($order, $target)) {
throw new DomainException('该订单已存在远程药房单号,不可重复提交');
}
}
$token = bin2hex(random_bytes(16));
$now = time();
$leaseSeconds = max(30, (int) Config::get('ej_pharmacy.submission_lease_seconds', 300));
$values = [
'target' => $target,
'status' => 'PENDING',
'claim_token' => $token,
'idempotency_key' => hash('sha256', $orderId . ':' . $revision . ':' . $target),
'remote_order_no' => '',
'request_id' => '',
'error_message' => '',
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'claimed_at' => $now,
'lease_expires_at' => $now + $leaseSeconds,
'completed_at' => 0,
'failed_at' => 0,
'update_time' => $now,
];
if ($claim) {
Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])->update($values);
} else {
Db::name('pharmacy_submission_claim')->insert($values + [
'prescription_order_id' => $orderId,
'source_revision' => $revision,
'create_time' => $now,
]);
}
return $values + [
'source_revision' => $revision,
'idempotent' => false,
];
});
}
/** @param array<string,mixed> $result */
public static function markSuccess(
int $orderId,
int $revision,
string $target,
string $token,
array $result
): bool {
self::assertTarget($target);
$remoteOrderNo = trim((string) (
$result['remote_order_no']
?? $result['pharmacy_order_no']
?? $result['recipel_order_no']
?? ''
));
if ($remoteOrderNo === '') {
throw new DomainException('药房返回缺少远程订单号');
}
return Db::transaction(function () use ($orderId, $revision, $target, $token, $result, $remoteOrderNo): bool {
$order = Db::name('tcm_prescription_order')
->where('id', $orderId)
->whereNull('delete_time')
->lock(true)
->find();
if (!$order || self::hasConflictingRemoteOrder($order, $target)) {
return false;
}
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', max($revision, 1))
->where('target', $target)
->where('claim_token', $token)
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
->lock(true)
->find();
if (!$claim) {
return false;
}
$now = time();
$claimUpdated = Db::name('pharmacy_submission_claim')
->where('id', (int) $claim['id'])
->where('target', $target)
->where('claim_token', $token)
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
->update([
'status' => 'SUCCESS',
'remote_order_no' => mb_substr($remoteOrderNo, 0, 64),
'request_id' => mb_substr((string) ($result['request_id'] ?? ''), 0, 64),
'error_message' => '',
'completed_at' => $now,
'failed_at' => 0,
'lease_expires_at' => 0,
'update_time' => $now,
]);
if ($claimUpdated !== 1) {
return false;
}
$orderValues = $target === 'direct'
? [
'ej_pharmacy_order_no' => mb_substr($remoteOrderNo, 0, 40),
'ej_pharmacy_submit_time' => $now,
'ej_pharmacy_status' => (string) ($result['status'] ?? 'PENDING_REVIEW'),
'ej_pharmacy_review_status' => (string) ($result['review_status'] ?? 'PENDING'),
'ej_pharmacy_status_version' => (int) ($result['status_version'] ?? 1),
// Preserve the business status from before the EJ upload.
'ej_pharmacy_previous_fulfillment_status' => (int) ($order['ej_pharmacy_previous_fulfillment_status'] ?? 0) > 0
? (int) $order['ej_pharmacy_previous_fulfillment_status']
: (int) ($order['fulfillment_status'] ?? 0),
]
: [
'gancao_reciperl_order_no' => mb_substr($remoteOrderNo, 0, 32),
'gancao_submit_time' => $now,
];
Db::name('tcm_prescription_order')->where('id', $orderId)->update($orderValues);
return true;
});
}
public static function markFailure(
int $orderId,
int $revision,
string $target,
string $token,
string $error
): bool {
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
$order = Db::name('tcm_prescription_order')
->where('id', $orderId)
->whereNull('delete_time')
->lock(true)
->find();
if (!$order) {
return false;
}
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', max($revision, 1))
->where('target', $target)
->where('claim_token', $token)
->where('status', 'PENDING')
->lock(true)
->find();
if (!$claim) {
return false;
}
$now = time();
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
->where('status', 'PENDING')
->update([
'status' => 'FAILED',
'error_message' => mb_substr($error, 0, 1000),
'failed_at' => $now,
'lease_expires_at' => 0,
'update_time' => $now,
]) === 1;
});
}
public static function markReconcile(
int $orderId,
int $revision,
string $target,
string $token,
string $error
): bool {
return Db::transaction(function () use ($orderId, $revision, $target, $token, $error): bool {
Db::name('tcm_prescription_order')
->where('id', $orderId)
->lock(true)
->find();
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', max($revision, 1))
->where('target', $target)
->where('claim_token', $token)
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
->lock(true)
->find();
if (!$claim) {
return false;
}
return Db::name('pharmacy_submission_claim')->where('id', (int) $claim['id'])
->whereIn('status', ['PENDING', 'PENDING_RECONCILE'])
->update([
'status' => 'PENDING_RECONCILE',
'error_message' => mb_substr($error, 0, 1000),
'failed_at' => 0,
'lease_expires_at' => 0,
'update_time' => time(),
]) === 1;
});
}
/** @return array<string,mixed>|null */
public static function claimForOrder(int $orderId, int $revision = 0): ?array
{
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->when($revision > 0, static fn ($query) => $query->where('source_revision', $revision))
->order('source_revision', 'desc')
->find();
return is_array($claim) ? $claim : null;
}
/** @return array<string,mixed> */
public static function resolveGancao(
int $orderId,
int $revision,
string $resolution,
string $remoteOrderNo,
string $note,
int $operatorId,
string $operatorName
): array {
return Db::transaction(function () use (
$orderId,
$revision,
$resolution,
$remoteOrderNo,
$note,
$operatorId,
$operatorName
): array {
$order = Db::name('tcm_prescription_order')
->where('id', $orderId)
->whereNull('delete_time')
->lock(true)
->find();
if (!$order) {
throw new DomainException('订单不存在');
}
$claim = Db::name('pharmacy_submission_claim')
->where('prescription_order_id', $orderId)
->where('source_revision', max($revision, 1))
->lock(true)
->find();
if (!$claim) {
throw new DomainException('未找到待核对的甘草提交');
}
$resolved = PharmacySubmissionReconciliationPolicy::resolve(
$claim,
$resolution,
$remoteOrderNo,
$note
);
if ($resolved['status'] === 'SUCCESS' && self::hasConflictingRemoteOrder($order, 'gancao')) {
throw new DomainException('订单已存在洛阳药房单号,不能确认甘草成功');
}
$now = time();
$updated = Db::name('pharmacy_submission_claim')
->where('id', (int) $claim['id'])
->where('claim_token', (string) $claim['claim_token'])
->whereIn('status', ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE'])
->update([
'status' => $resolved['status'],
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
'error_message' => mb_substr($resolved['note'], 0, 1000),
'lease_expires_at' => 0,
'completed_at' => $resolved['status'] === 'SUCCESS' ? $now : 0,
'failed_at' => $resolved['status'] === 'FAILED' ? $now : 0,
'update_time' => $now,
]);
if ($updated !== 1) {
throw new DomainException('提交状态已变化,请刷新后重新核对');
}
if ($resolved['status'] === 'SUCCESS') {
Db::name('tcm_prescription_order')->where('id', $orderId)->update([
'gancao_reciperl_order_no' => mb_substr($resolved['remote_order_no'], 0, 32),
'gancao_submit_time' => $now,
]);
}
Db::name('pharmacy_submission_claim_audit')->insert([
'claim_id' => (int) $claim['id'],
'prescription_order_id' => $orderId,
'source_revision' => max($revision, 1),
'target' => 'gancao',
'action' => strtoupper(trim($resolution)),
'from_status' => strtoupper((string) $claim['status']),
'to_status' => $resolved['status'],
'remote_order_no' => mb_substr($resolved['remote_order_no'], 0, 64),
'note' => mb_substr($resolved['note'], 0, 1000),
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'create_time' => $now,
]);
return $resolved + ['claim_id' => (int) $claim['id']];
});
}
/** @param array<string,mixed> $order */
public static function hasAnyRemoteOrder(array $order): bool
{
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== ''
|| trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
}
/** @param array<string,mixed> $order */
private static function isRejectedEjOrder(array $order, string $target): bool
{
if ($target !== 'direct') {
return false;
}
return strtoupper(trim((string) ($order['ej_pharmacy_status'] ?? ''))) === 'REJECTED'
|| strtoupper(trim((string) ($order['ej_pharmacy_review_status'] ?? ''))) === 'REJECTED';
}
/** @param array<string,mixed> $order */
private static function nextRevisionForOrder(array $order, string $target): int
{
$latest = (int) Db::name('pharmacy_submission_claim')
->where('prescription_order_id', (int) $order['id'])
->max('source_revision');
if (self::isRejectedEjOrder($order, $target)) {
return max($latest + 1, 1);
}
return max($latest, 1);
}
private static function targetForShipMode(string $shipMode): string
{
return strtolower(trim($shipMode)) === 'direct' ? 'direct' : 'gancao';
}
/** @param array<string,mixed> $claim @return array<string,mixed> */
private static function expirePendingGancaoClaim(array $claim, int $operatorId, string $operatorName): array
{
$now = time();
$newToken = bin2hex(random_bytes(16));
$updated = Db::name('pharmacy_submission_claim')
->where('id', (int) $claim['id'])
->where('status', 'PENDING')
->where('claim_token', (string) $claim['claim_token'])
->update([
'status' => 'PENDING_RECONCILE',
'claim_token' => $newToken,
'error_message' => '提交租约已超时,甘草远端结果不确定,须人工核对',
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'lease_expires_at' => 0,
'update_time' => $now,
]);
if ($updated !== 1) {
throw new DomainException('提交租约状态已变化,请刷新后重试');
}
Db::name('pharmacy_submission_claim_audit')->insert([
'claim_id' => (int) $claim['id'],
'prescription_order_id' => (int) $claim['prescription_order_id'],
'source_revision' => (int) $claim['source_revision'],
'target' => 'gancao',
'action' => 'LEASE_EXPIRED',
'from_status' => 'PENDING',
'to_status' => 'PENDING_RECONCILE',
'remote_order_no' => '',
'note' => '租约超时后轮换 claim token,禁止自动重提',
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'create_time' => $now,
]);
return array_replace($claim, [
'status' => 'PENDING_RECONCILE',
'claim_token' => $newToken,
'lease_expires_at' => 0,
]);
}
private static function assertTarget(string $target): void
{
if (!in_array($target, ['gancao', 'direct'], true)) {
throw new DomainException('不支持的药房目标');
}
}
/** @param array<string,mixed> $order */
private static function hasConflictingRemoteOrder(array $order, string $target): bool
{
if ($target === 'direct') {
return trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '';
}
return trim((string) ($order['ej_pharmacy_order_no'] ?? '')) !== '';
}
/** @return array<string,mixed> */
private static function idempotentResult(string $target, string $remoteOrderNo): array
{
if ($target === 'direct') {
return ['pharmacy' => 'ej', 'pharmacy_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
}
return ['pharmacy' => 'gancao', 'recipel_order_no' => $remoteOrderNo, 'remote_order_no' => $remoteOrderNo];
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use Closure;
use DomainException;
use InvalidArgumentException;
use Throwable;
final class PharmacySubmissionClaimWorkflow
{
private Closure $acquireClaim;
private Closure $invokeRemote;
private Closure $markSuccess;
private Closure $markFailure;
private Closure $markReconcile;
public function __construct(
callable $acquireClaim,
callable $invokeRemote,
callable $markSuccess,
callable $markFailure,
callable $markReconcile
) {
$this->acquireClaim = Closure::fromCallable($acquireClaim);
$this->invokeRemote = Closure::fromCallable($invokeRemote);
$this->markSuccess = Closure::fromCallable($markSuccess);
$this->markFailure = Closure::fromCallable($markFailure);
$this->markReconcile = Closure::fromCallable($markReconcile);
}
/** @return array<string,mixed> */
public function execute(string $target): array
{
if (!in_array($target, ['gancao', 'direct'], true)) {
throw new InvalidArgumentException('Unsupported pharmacy target');
}
$claim = ($this->acquireClaim)($target);
if (!empty($claim['idempotent'])) {
$result = is_array($claim['result'] ?? null) ? $claim['result'] : [];
return $result + ['target' => $target, 'idempotent' => true];
}
$token = trim((string) ($claim['token'] ?? $claim['claim_token'] ?? ''));
if ($token === '') {
throw new DomainException('药房提交凭证缺失');
}
$claimStatus = strtoupper(trim((string) ($claim['status'] ?? '')));
if ($target === 'gancao' && (
!empty($claim['reconcile'])
|| in_array($claimStatus, ['UNKNOWN', 'PENDING_RECONCILE'], true)
)) {
throw new PharmacyReconciliationRequiredException(
'甘草药房远端结果待核对,当前禁止重提;请等待人工或后续对账'
);
}
try {
$result = ($this->invokeRemote)($target, $token, $claim);
if (!is_array($result)) {
throw new DomainException('药房返回数据格式错误');
}
} catch (Throwable $exception) {
if (PharmacyRemoteOutcomeClassifier::isConfirmedNoCreate($exception)) {
($this->markFailure)($target, $token, $exception->getMessage(), $claim);
} else {
($this->markReconcile)($target, $token, $exception->getMessage(), $claim);
}
throw $exception;
}
try {
$finalized = (bool) ($this->markSuccess)($target, $token, $result, $claim);
} catch (Throwable $exception) {
($this->markReconcile)($target, $token, '远端成功但本地回写异常:' . $exception->getMessage(), $claim);
throw new PharmacyReconciliationRequiredException(
'远端可能已创建订单,本地回写失败,必须对账后再操作',
0,
$exception
);
}
if (!$finalized) {
($this->markReconcile)($target, $token, '远端成功但本地提交凭证无法完成', $claim);
throw new PharmacyReconciliationRequiredException('远端已返回成功,本地回写未完成,必须对账后再操作');
}
return $result + ['target' => $target, 'idempotent' => false];
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
final class PharmacySubmissionReconciliationPolicy
{
/** @param array<string,mixed> $claim @return array{status:string,remote_order_no:string,note:string} */
public static function resolve(
array $claim,
string $resolution,
string $remoteOrderNo,
string $note,
?int $now = null
): array
{
if (strtolower(trim((string) ($claim['target'] ?? ''))) !== 'gancao') {
throw new DomainException('仅甘草药房不确定提交支持人工确认');
}
$status = strtoupper(trim((string) ($claim['status'] ?? '')));
$expiredPending = $status === 'PENDING'
&& (int) ($claim['lease_expires_at'] ?? 0) > 0
&& (int) $claim['lease_expires_at'] <= ($now ?? time());
if (!$expiredPending && !in_array($status, ['UNKNOWN', 'PENDING_RECONCILE'], true)) {
throw new DomainException('当前提交状态无需人工确认');
}
$resolution = strtoupper(trim($resolution));
if (!in_array($resolution, ['CONFIRM_SUCCESS', 'CONFIRM_NOT_CREATED'], true)) {
throw new DomainException('不支持的人工确认结果');
}
$note = trim($note);
if ($note === '') {
throw new DomainException('请填写甘草后台核对依据');
}
$remoteOrderNo = trim($remoteOrderNo);
if ($resolution === 'CONFIRM_SUCCESS' && $remoteOrderNo === '') {
throw new DomainException('确认成功时必须填写甘草药方单号');
}
return [
'status' => $resolution === 'CONFIRM_SUCCESS' ? 'SUCCESS' : 'FAILED',
'remote_order_no' => $resolution === 'CONFIRM_SUCCESS' ? $remoteOrderNo : '',
'note' => $note,
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
final class PharmacySupplyMode
{
/** @param array<string,mixed> $order */
public static function resolve(array $order): string
{
if (strtolower(trim((string) ($order['ship_mode'] ?? ''))) === 'direct') {
return 'direct';
}
if (trim((string) ($order['gancao_reciperl_order_no'] ?? '')) !== '') {
return 'gancao';
}
return 'self';
}
public static function label(string $mode): string
{
return match (strtolower(trim($mode))) {
'direct' => '洛阳直发',
'gancao' => '甘草',
default => '自营',
};
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
final class PharmacyUploadPermissionAlias
{
public const LEGACY_URI = 'tcm.prescriptionorder/submitgancaorecipel';
public const CANONICAL_URI = 'tcm.prescriptionorder/uploadtopharmacy';
public const RECONCILE_URI = 'tcm.prescriptionorder/confirmgancaosubmission';
public static function canonicalUri(string $uri): string
{
$uri = strtolower($uri);
return in_array($uri, [self::LEGACY_URI, self::CANONICAL_URI], true)
? self::CANONICAL_URI
: $uri;
}
public static function isControlled(string $uri): bool
{
return in_array(strtolower($uri), [self::LEGACY_URI, self::CANONICAL_URI, self::RECONCILE_URI], true);
}
/** @param array<int,string> $adminUris */
public static function allows(string $accessUri, array $adminUris): bool
{
$canonicalAccess = self::canonicalUri($accessUri);
foreach ($adminUris as $uri) {
if (self::canonicalUri((string) $uri) === $canonicalAccess) {
return true;
}
}
return false;
}
}
+2
View File
@@ -32,6 +32,8 @@ return [
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive', 'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST // 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute', 'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
'ej-pharmacy:bootstrap-medicines' => 'app\\command\\EjPharmacyBootstrapMedicines',
// 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW // 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW
'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost', 'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost',
// 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP) // 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
return [
'submission_lease_seconds' => (int) env('EJ_PHARMACY_SUBMISSION_LEASE_SECONDS', 300),
'enabled' => filter_var(env('EJ_PHARMACY_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'catalog_sync_enabled' => filter_var(
env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false),
FILTER_VALIDATE_BOOLEAN
),
'base_url' => rtrim(trim((string) env('EJ_PHARMACY_BASE_URL', '')), '/'),
'app_key' => trim((string) env('EJ_PHARMACY_APP_KEY', '')),
'app_secret' => trim((string) env('EJ_PHARMACY_APP_SECRET', '')),
'callback_secret' => trim((string) env('EJ_PHARMACY_CALLBACK_SECRET', '')),
// RHEL/CentOS 7 ships cURL with NSS, which rejects some otherwise valid
// server certificate chains. `auto` selects the OpenSSL stream transport
// for NSS builds while keeping cURL for OpenSSL builds.
'http_transport' => strtolower(trim((string) env('EJ_PHARMACY_HTTP_TRANSPORT', 'auto'))),
'ca_file' => trim((string) env('EJ_PHARMACY_CA_FILE', '')),
'connect_timeout' => max((int) env('EJ_PHARMACY_CONNECT_TIMEOUT', 5), 1),
'request_timeout' => max((int) env('EJ_PHARMACY_REQUEST_TIMEOUT', 30), 5),
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import t from"./error-Pzo5SbGa.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-9dA1PEvN.js";import"./index-BWzN7QIb.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
@@ -1 +0,0 @@
import r from"./error-D7g_31ON.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DiYdpaIG.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -0,0 +1 @@
import e from"./error-Pzo5SbGa.js";import{o,q as r,r as t,v as s}from"./.pnpm-9dA1PEvN.js";import"./index-BWzN7QIb.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
@@ -1 +0,0 @@
import o from"./error-D7g_31ON.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DiYdpaIG.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
@@ -1 +0,0 @@
function a(e){"@babel/helpers - typeof";return a=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},a(e)}function c(e,t,r,n,f,y,i){try{var u=e[y](i),o=u.value}catch(l){return void r(l)}u.done?t(o):Promise.resolve(o).then(n,f)}function p(e){return function(){var t=this,r=arguments;return new Promise(function(n,f){var y=e.apply(t,r);function i(o){c(y,n,f,i,u,"next",o)}function u(o){c(y,n,f,i,u,"throw",o)}i(void 0)})}}function b(e,t){if(a(e)!="object"||!e)return e;var r=e[Symbol.toPrimitive];if(r!==void 0){var n=r.call(e,t);if(a(n)!="object")return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return(t==="string"?String:Number)(e)}function m(e){var t=b(e,"string");return a(t)=="symbol"?t:t+""}function s(e,t,r){return(t=m(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}export{a as _,p as a,s as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
@@ -1 +0,0 @@
@@ -1 +0,0 @@
@@ -1 +0,0 @@
import{H as u}from"../highlight.js-Bxt7hFFy.js";import{f as c,i as g,w as s,A as n}from"../@vue/runtime-core-C6bnekPw.js";import{n as h}from"../@vue/reactivity-DiY1c2vO.js";var i=c({props:{code:{type:String,required:!0},language:{type:String,default:""},autodetect:{type:Boolean,default:!0},ignoreIllegals:{type:Boolean,default:!0}},setup:function(e){var t=h(e.language);s((function(){return e.language}),(function(a){t.value=a}));var r=n((function(){return e.autodetect||!t.value})),o=n((function(){return!r.value&&!u.getLanguage(t.value)}));return{className:n((function(){return o.value?"":"hljs "+t.value})),highlightedCode:n((function(){var a;if(o.value)return console.warn('The language "'+t.value+'" you specified could not be found.'),e.code.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;");if(r.value){var l=u.highlightAuto(e.code);return t.value=(a=l.language)!==null&&a!==void 0?a:"",l.value}return(l=u.highlight(e.code,{language:t.value,ignoreIllegals:e.ignoreIllegals})).value}))}},render:function(){return g("pre",{},[g("code",{class:this.className,innerHTML:this.highlightedCode})])}}),v={install:function(e){e.component("highlightjs",i)},component:i};export{v as o};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import"./uikit-base-component-vue3-YgTqL4da.js";import{A as r}from"../tuikit-atomicx-vue3-Dln8Zi6e.js";import{f as s,ak as c,I as l,aq as m,G as u,at as i,H as p,A as d}from"../@vue/runtime-core-C6bnekPw.js";import{y as v}from"../@vue/reactivity-DiY1c2vO.js";import"./chat-uikit-engine-zx802ozq.js";const _=(t,o)=>{const a=t.__vccOpts||t;for(const[e,n]of o)a[e]=n;return a},f={key:0,class:"chat"},h=s({name:"Chat",__name:"Chat",props:{PlaceholderEmpty:{default:null}},setup(t){const{activeConversation:o}=r(),a=d(()=>{var e;return!((e=o.value)!=null&&e.conversationID)});return(e,n)=>v(o)?(c(),l("div",f,[m(e.$slots,"default",{},void 0,!0)])):a.value&&t.PlaceholderEmpty?(c(),u(i(t.PlaceholderEmpty),{key:1})):p("",!0)}}),A=_(h,[["__scopeId","data-v-1c9c77cd"]]);typeof window<"u"&&(window.__CHAT_ATOMICX_VUE3__={name:"@tencentcloud/chat-uikit-vue3",version:"4.5.4"},console.log("[@tencentcloud/chat-uikit-vue3] v4.5.4"));export{A as E};
@@ -1 +0,0 @@
.chat[data-v-1c9c77cd]{display:flex;flex-direction:column;min-width:0}.uikit-chat-header[data-v-a0c42ddc]{padding:14px 10px;height:64px;display:flex;justify-content:center;background-color:var(--bg-color-operate)}.uikit-chat-header__container[data-v-a0c42ddc]{padding:0 10px;flex-direction:row;align-items:center;justify-content:space-between}.uikit-chat-header__left[data-v-a0c42ddc]{flex:1 1 auto;display:flex;flex-direction:row;align-items:center}.uikit-chat-header__avatar[data-v-a0c42ddc]{margin-right:12px}.uikit-chat-header__info[data-v-a0c42ddc]{flex:1;display:flex;flex-direction:column;justify-content:center}.uikit-chat-header__title[data-v-a0c42ddc]{display:block;margin:0;font-size:16px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-color-primary)}.uikit-chat-header__typing-indicator[data-v-a0c42ddc]{font-size:12px;color:var(--text-color-secondary)}.uikit-chat-header__live[data-v-a0c42ddc]{margin-top:4px;font-size:12px;color:var(--text-color-secondary)}/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}:root{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,*:after,*:before{box-sizing:border-box}ul,li{list-style:none;padding:0;margin:0}picture,img,video,canvas,svg{display:block;max-width:100%}img{max-width:100%;height:auto;vertical-align:middle;image-rendering:-webkit-optimize-contrast;aspect-ratio:attr(width)/attr(height);display:inline-block;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}img:not([src],[srcset]){visibility:hidden}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More