feat: add zyt medicine bootstrap
This commit is contained in:
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
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 = ""
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -64,4 +76,4 @@ LOGISTICS_KUAIDI100_KEY =
|
|||||||
; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify
|
; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify
|
||||||
; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院
|
; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院
|
||||||
; GANCAO_SCM_DF_ID = 101
|
; GANCAO_SCM_DF_ID = 101
|
||||||
; GANCAO_SCM_EXPRESS_TYPE = general
|
; GANCAO_SCM_EXPRESS_TYPE = general
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,134 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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('恩济药房返回了无效 JSON,HTTP ' . $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,25 @@
|
|||||||
|
<?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 nextFulfillmentStatus(int $currentStatus, array $payload): int
|
||||||
|
{
|
||||||
|
if (self::isShippedEvent($payload) && in_array($currentStatus, [1, 2], true)) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,441 @@
|
|||||||
|
<?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, 1);
|
||||||
|
|
||||||
|
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('订单不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)) {
|
||||||
|
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 + ['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),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'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 = 1): ?array
|
||||||
|
{
|
||||||
|
$claim = Db::name('pharmacy_submission_claim')
|
||||||
|
->where('prescription_order_id', $orderId)
|
||||||
|
->where('source_revision', max($revision, 1))
|
||||||
|
->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'] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?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', '')),
|
||||||
|
'connect_timeout' => max((int) env('EJ_PHARMACY_CONNECT_TIMEOUT', 5), 1),
|
||||||
|
'request_timeout' => max((int) env('EJ_PHARMACY_REQUEST_TIMEOUT', 30), 5),
|
||||||
|
];
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
-- zyt projection and integration state for the Luoyang pharmacy ERP.
|
||||||
|
-- Existing-order DDL is guarded because this migration may be resumed after a partial deployment.
|
||||||
|
|
||||||
|
SET @schema_name := DATABASE();
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_order_no'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_order_no` varchar(40) NULL DEFAULT NULL COMMENT ''Remote ej ERP order number'' AFTER `ship_mode`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_submit_time'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_submit_time` int unsigned NOT NULL DEFAULT 0 AFTER `ej_pharmacy_order_no`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_status'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_status` varchar(32) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_submit_time`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_review_status'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_review_status` varchar(24) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_status`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_status_version'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_status_version` bigint unsigned NOT NULL DEFAULT 0 AFTER `ej_pharmacy_review_status`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_current_step'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_current_step` varchar(100) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_status_version`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND COLUMN_NAME = 'ej_pharmacy_remark'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD COLUMN `ej_pharmacy_remark` varchar(500) NOT NULL DEFAULT '''' AFTER `ej_pharmacy_current_step`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Repair a partial deployment that created the order number as NOT NULL DEFAULT ''.
|
||||||
|
ALTER TABLE `zyt_tcm_prescription_order`
|
||||||
|
MODIFY COLUMN `ej_pharmacy_order_no` varchar(40) NULL DEFAULT NULL COMMENT 'Remote ej ERP order number';
|
||||||
|
UPDATE `zyt_tcm_prescription_order` SET `ej_pharmacy_order_no` = NULL
|
||||||
|
WHERE `ej_pharmacy_order_no` = '';
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND INDEX_NAME = 'uk_ej_pharmacy_order_no'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD UNIQUE KEY `uk_ej_pharmacy_order_no` (`ej_pharmacy_order_no`)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_tcm_prescription_order'
|
||||||
|
AND INDEX_NAME = 'idx_ej_pharmacy_status'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_tcm_prescription_order` ADD KEY `idx_ej_pharmacy_status` (`ej_pharmacy_status`,`ej_pharmacy_status_version`)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- EJ callbacks may carry longer Unicode carrier codes and tracking numbers than the legacy order projection.
|
||||||
|
ALTER TABLE `zyt_tcm_prescription_order`
|
||||||
|
MODIFY COLUMN `express_company` varchar(32) NOT NULL DEFAULT '' COMMENT 'Carrier code',
|
||||||
|
MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number';
|
||||||
|
ALTER TABLE `zyt_express_tracking`
|
||||||
|
MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number';
|
||||||
|
ALTER TABLE `zyt_express_trace`
|
||||||
|
MODIFY COLUMN `tracking_number` varchar(100) NOT NULL DEFAULT '' COMMENT 'Tracking number';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_catalog` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`medicine_code` varchar(32) NOT NULL,
|
||||||
|
`name` varchar(120) NOT NULL,
|
||||||
|
`brand` varchar(120) NOT NULL DEFAULT '',
|
||||||
|
`unit` varchar(24) NOT NULL,
|
||||||
|
`settlement_price` decimal(12,4) NOT NULL DEFAULT 0.0000,
|
||||||
|
`retail_price` decimal(12,4) NOT NULL DEFAULT 0.0000,
|
||||||
|
`status` tinyint unsigned NOT NULL DEFAULT 1,
|
||||||
|
`catalog_version` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`remote_deleted` tinyint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_medicine_code` (`medicine_code`),
|
||||||
|
KEY `idx_catalog_version` (`catalog_version`),
|
||||||
|
KEY `idx_name_status` (`name`,`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_ej_medicine_mapping` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`local_medicine_id` bigint unsigned NOT NULL,
|
||||||
|
`medicine_code` varchar(32) NOT NULL,
|
||||||
|
`status` tinyint unsigned NOT NULL DEFAULT 1,
|
||||||
|
`operator_id` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_name` varchar(80) NOT NULL DEFAULT '',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`delete_time` int unsigned DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_local_medicine` (`local_medicine_id`),
|
||||||
|
UNIQUE KEY `uk_medicine_code` (`medicine_code`),
|
||||||
|
KEY `idx_medicine_status` (`medicine_code`,`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_ej_medicine_mapping'
|
||||||
|
AND INDEX_NAME = 'uk_medicine_code'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_ej_medicine_mapping` ADD UNIQUE KEY `uk_medicine_code` (`medicine_code`)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_submission` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`prescription_order_id` bigint unsigned NOT NULL,
|
||||||
|
`source_revision` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`request_hash` char(64) NOT NULL,
|
||||||
|
`request_id` varchar(64) NOT NULL DEFAULT '',
|
||||||
|
`remote_order_no` varchar(40) NOT NULL DEFAULT '',
|
||||||
|
`status` varchar(24) NOT NULL DEFAULT 'PENDING',
|
||||||
|
`http_status` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`response_summary` json DEFAULT NULL,
|
||||||
|
`error_message` varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
`operator_id` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_name` varchar(80) NOT NULL DEFAULT '',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_order_revision` (`prescription_order_id`,`source_revision`),
|
||||||
|
KEY `idx_status_time` (`status`,`update_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`prescription_order_id` bigint unsigned NOT NULL,
|
||||||
|
`source_revision` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`target` varchar(16) NOT NULL,
|
||||||
|
`status` varchar(24) NOT NULL DEFAULT 'PENDING',
|
||||||
|
`claim_token` char(32) NOT NULL,
|
||||||
|
`idempotency_key` char(64) NOT NULL,
|
||||||
|
`remote_order_no` varchar(64) NOT NULL DEFAULT '',
|
||||||
|
`request_id` varchar(64) NOT NULL DEFAULT '',
|
||||||
|
`error_message` varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
`operator_id` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_name` varchar(80) NOT NULL DEFAULT '',
|
||||||
|
`claimed_at` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`lease_expires_at` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`completed_at` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`failed_at` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_order_revision` (`prescription_order_id`,`source_revision`),
|
||||||
|
UNIQUE KEY `uk_claim_token` (`claim_token`),
|
||||||
|
KEY `idx_status_time` (`status`,`update_time`),
|
||||||
|
KEY `idx_target_remote` (`target`,`remote_order_no`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `zyt_pharmacy_submission_claim`
|
||||||
|
MODIFY COLUMN `status` varchar(24) NOT NULL DEFAULT 'PENDING';
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @schema_name AND TABLE_NAME = 'zyt_pharmacy_submission_claim'
|
||||||
|
AND COLUMN_NAME = 'lease_expires_at'
|
||||||
|
),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE `zyt_pharmacy_submission_claim` ADD COLUMN `lease_expires_at` int unsigned NOT NULL DEFAULT 0 AFTER `claimed_at`'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
UPDATE `zyt_pharmacy_submission_claim`
|
||||||
|
SET `lease_expires_at` = `claimed_at` + 300
|
||||||
|
WHERE `status` = 'PENDING' AND `lease_expires_at` = 0 AND `claimed_at` > 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_pharmacy_submission_claim_audit` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`claim_id` bigint unsigned NOT NULL,
|
||||||
|
`prescription_order_id` bigint unsigned NOT NULL,
|
||||||
|
`source_revision` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`target` varchar(16) NOT NULL,
|
||||||
|
`action` varchar(32) NOT NULL,
|
||||||
|
`from_status` varchar(24) NOT NULL,
|
||||||
|
`to_status` varchar(24) NOT NULL,
|
||||||
|
`remote_order_no` varchar(64) NOT NULL DEFAULT '',
|
||||||
|
`note` varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
`operator_id` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`operator_name` varchar(80) NOT NULL DEFAULT '',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_claim_time` (`claim_id`,`create_time`),
|
||||||
|
KEY `idx_order_time` (`prescription_order_id`,`create_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_callback_inbox` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`event_id` char(36) NOT NULL,
|
||||||
|
`pharmacy_order_no` varchar(40) NOT NULL,
|
||||||
|
`source_order_no` varchar(64) NOT NULL,
|
||||||
|
`event_type` varchar(48) NOT NULL,
|
||||||
|
`status_version` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`payload` json NOT NULL,
|
||||||
|
`process_status` varchar(24) NOT NULL DEFAULT 'PENDING',
|
||||||
|
`processed_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`error_message` varchar(1000) NOT NULL DEFAULT '',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_event_id` (`event_id`),
|
||||||
|
KEY `idx_process_status` (`process_status`,`id`),
|
||||||
|
KEY `idx_order_version` (`pharmacy_order_no`,`status_version`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `zyt_ej_pharmacy_sync_state` (
|
||||||
|
`id` tinyint unsigned NOT NULL,
|
||||||
|
`cursor` bigint unsigned NOT NULL DEFAULT 0,
|
||||||
|
`last_success_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`last_failure_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`last_error_summary` varchar(500) NOT NULL DEFAULT '',
|
||||||
|
`lock_token` char(32) NOT NULL DEFAULT '',
|
||||||
|
`lock_expires_at` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO `zyt_ej_pharmacy_sync_state` (`id`, `cursor`, `create_time`, `update_time`)
|
||||||
|
VALUES (1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())
|
||||||
|
ON DUPLICATE KEY UPDATE `id` = VALUES(`id`);
|
||||||
|
|
||||||
|
-- Page sits beside the existing doctor medicine library; API nodes make every endpoint visible to AuthMiddleware.
|
||||||
|
SET @doctor_medicine_menu_id := (
|
||||||
|
SELECT `id`
|
||||||
|
FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'doctor.medicine/lists'
|
||||||
|
OR (`component` = 'doctor/medicine' AND `type` = 'C')
|
||||||
|
ORDER BY (`perms` = 'doctor.medicine/lists') DESC, `id` ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
SET @doctor_medicine_parent_id := (
|
||||||
|
SELECT `pid` FROM `zyt_system_menu` WHERE `id` = @doctor_medicine_menu_id LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@doctor_medicine_parent_id, 'C', '洛阳药房药材映射', '', 0,
|
||||||
|
'pharmacy.medicineMapping/lists', 'pharmacy/medicine-mapping', 'pharmacy/medicine_mapping/index',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @doctor_medicine_parent_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'pharmacy.medicineMapping/lists'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @medicine_mapping_menu_id := (
|
||||||
|
SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'pharmacy.medicineMapping/lists' LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@medicine_mapping_menu_id, 'A', `permission_node`.`name`, '', `permission_node`.`sort`,
|
||||||
|
`permission_node`.`perms`, '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM (
|
||||||
|
SELECT '查看同步状态' AS `name`, 10 AS `sort`, 'pharmacy.medicineMapping/status' AS `perms`
|
||||||
|
UNION ALL SELECT '检索远端药材', 20, 'pharmacy.medicineMapping/catalogOptions'
|
||||||
|
UNION ALL SELECT '同步远端目录', 30, 'pharmacy.medicineMapping/sync'
|
||||||
|
UNION ALL SELECT '保存药材映射', 40, 'pharmacy.medicineMapping/save'
|
||||||
|
UNION ALL SELECT '解除药材映射', 50, 'pharmacy.medicineMapping/unlink'
|
||||||
|
) AS `permission_node`
|
||||||
|
WHERE @medicine_mapping_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu` AS `existing_menu`
|
||||||
|
WHERE `existing_menu`.`perms` = `permission_node`.`perms`
|
||||||
|
);
|
||||||
|
|
||||||
|
-- New clients use this explicit unified-upload permission. The historical
|
||||||
|
-- submitGancaoRecipel node remains valid and its controller still routes by ship_mode.
|
||||||
|
SET @prescription_order_menu_id := (
|
||||||
|
SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/lists' LIMIT 1
|
||||||
|
);
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@prescription_order_menu_id, 'A', '上传药房', '', 10,
|
||||||
|
'tcm.prescriptionOrder/uploadToPharmacy', '', '', '', '', 0, 1, 0,
|
||||||
|
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @prescription_order_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/uploadToPharmacy'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Historical route retained as a controlled compatibility alias for the same pharmacy-upload capability.
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@prescription_order_menu_id, 'A', '上传药房(兼容入口)', '', 11,
|
||||||
|
'tcm.prescriptionOrder/submitGancaoRecipel', '', '', '', '', 0, 1, 0,
|
||||||
|
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @prescription_order_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/submitGancaoRecipel'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Manual reconciliation can finalize an uncertain remote result or release it for retry,
|
||||||
|
-- so it requires a separate high-risk permission from ordinary pharmacy upload.
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
@prescription_order_menu_id, 'A', '人工核对甘草提交', '', 12,
|
||||||
|
'tcm.prescriptionOrder/confirmGancaoSubmission', '', '', '', '', 0, 1, 0,
|
||||||
|
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @prescription_order_menu_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'tcm.prescriptionOrder/confirmGancaoSubmission'
|
||||||
|
);
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use app\adminapi\http\middleware\AuthMiddleware;
|
||||||
|
use app\api\controller\EjPharmacyCallbackController;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyCallbackRetryException;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyCallbackFailureTransition;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyCallbackWorkflow;
|
||||||
|
use app\common\service\pharmacy\PharmacyLogisticsValue;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyShipmentPolicy;
|
||||||
|
use app\common\service\pharmacy\EjPharmacyTrackingPolicy;
|
||||||
|
$passed = 0;
|
||||||
|
|
||||||
|
$assertSame = static function (mixed $expected, mixed $actual, string $message) use (&$passed): void {
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
throw new \RuntimeException(sprintf(
|
||||||
|
"%s\nExpected: %s\nActual: %s",
|
||||||
|
$message,
|
||||||
|
var_export($expected, true),
|
||||||
|
var_export($actual, true)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
++$passed;
|
||||||
|
};
|
||||||
|
|
||||||
|
$responseStatus = static fn ($response): int => $response->getCode();
|
||||||
|
|
||||||
|
final class CallbackContractResponse
|
||||||
|
{
|
||||||
|
public function __construct(private readonly int $status)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): int
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$validPayload = [
|
||||||
|
'event_id' => 'evt-integration-1',
|
||||||
|
'pharmacy_order_no' => 'EJ-1001',
|
||||||
|
'source_order_no' => 'PO-1001',
|
||||||
|
];
|
||||||
|
|
||||||
|
$makeController = static function (string $body, EjPharmacyCallbackWorkflow $workflow): EjPharmacyCallbackController {
|
||||||
|
return new class($body, $workflow) extends EjPharmacyCallbackController {
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $testBody,
|
||||||
|
private readonly EjPharmacyCallbackWorkflow $testWorkflow
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function callbackBody(): string
|
||||||
|
{
|
||||||
|
return $this->testBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isAuthenticCallback(string $body): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function callbackWorkflow(): EjPharmacyCallbackWorkflow
|
||||||
|
{
|
||||||
|
return $this->testWorkflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function callbackResponse(array $payload, int $httpStatus)
|
||||||
|
{
|
||||||
|
return new CallbackContractResponse($httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logCallbackFailure(string $message): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$processedCalls = 0;
|
||||||
|
$processedWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): array => ['id' => 1, 'process_status' => 'PROCESSED'],
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static function () use (&$processedCalls): array {
|
||||||
|
++$processedCalls;
|
||||||
|
return ['process_status' => 'PROCESSED'];
|
||||||
|
},
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$processedResponse = $makeController(
|
||||||
|
json_encode($validPayload, JSON_THROW_ON_ERROR),
|
||||||
|
$processedWorkflow
|
||||||
|
)->webhook();
|
||||||
|
$assertSame(200, $responseStatus($processedResponse), 'processed callbacks must return HTTP 200');
|
||||||
|
$assertSame(0, $processedCalls, 'only a PROCESSED inbox may return immediate success');
|
||||||
|
|
||||||
|
$pendingCalls = 0;
|
||||||
|
$pendingWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): array => ['id' => 2, 'process_status' => 'PENDING'],
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static function () use (&$pendingCalls): array {
|
||||||
|
++$pendingCalls;
|
||||||
|
return ['process_status' => 'PROCESSED'];
|
||||||
|
},
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$pendingResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $pendingWorkflow)->webhook();
|
||||||
|
$assertSame(200, $responseStatus($pendingResponse), 'a successfully retried PENDING inbox must return HTTP 200');
|
||||||
|
$assertSame(1, $pendingCalls, 'a PENDING inbox must retry business processing');
|
||||||
|
|
||||||
|
$failedCalls = 0;
|
||||||
|
$failedWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): array => ['id' => 3, 'process_status' => 'FAILED'],
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static function () use (&$failedCalls): array {
|
||||||
|
++$failedCalls;
|
||||||
|
return ['process_status' => 'PROCESSED'];
|
||||||
|
},
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$failedResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $failedWorkflow)->webhook();
|
||||||
|
$assertSame(200, $responseStatus($failedResponse), 'a successfully retried FAILED inbox must return HTTP 200');
|
||||||
|
$assertSame(1, $failedCalls, 'a FAILED inbox must retry business processing');
|
||||||
|
|
||||||
|
$retryWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): array => ['id' => 4, 'process_status' => 'PENDING'],
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static function (): never {
|
||||||
|
throw new EjPharmacyCallbackRetryException('业务订单关联尚未建立');
|
||||||
|
},
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$retryResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $retryWorkflow)->webhook();
|
||||||
|
$assertSame(503, $responseStatus($retryResponse), 'a missing order association must ask EJ to retry');
|
||||||
|
|
||||||
|
$runtimeWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static function (): never {
|
||||||
|
throw new \RuntimeException('database unavailable');
|
||||||
|
},
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static fn (): array => ['process_status' => 'PROCESSED'],
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$runtimeResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $runtimeWorkflow)->webhook();
|
||||||
|
$assertSame(500, $responseStatus($runtimeResponse), 'database and unknown failures must return HTTP 500');
|
||||||
|
|
||||||
|
$unusedWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static fn (): array => ['process_status' => 'PROCESSED'],
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$malformedResponse = $makeController('{', $unusedWorkflow)->webhook();
|
||||||
|
$assertSame(400, $responseStatus($malformedResponse), 'malformed callback JSON must return HTTP 400');
|
||||||
|
|
||||||
|
$missingFieldResponse = $makeController(
|
||||||
|
json_encode(['event_id' => 'evt-missing'], JSON_THROW_ON_ERROR),
|
||||||
|
$unusedWorkflow
|
||||||
|
)->webhook();
|
||||||
|
$assertSame(422, $responseStatus($missingFieldResponse), 'business-invalid callback JSON must return HTTP 422');
|
||||||
|
|
||||||
|
$duplicateReloads = 0;
|
||||||
|
$duplicateProcesses = 0;
|
||||||
|
$duplicateWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static function (): never {
|
||||||
|
throw new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry');
|
||||||
|
},
|
||||||
|
static function () use (&$duplicateReloads): array {
|
||||||
|
++$duplicateReloads;
|
||||||
|
return ['id' => 5, 'process_status' => 'PENDING'];
|
||||||
|
},
|
||||||
|
static function () use (&$duplicateProcesses): array {
|
||||||
|
++$duplicateProcesses;
|
||||||
|
return ['process_status' => 'PROCESSED'];
|
||||||
|
},
|
||||||
|
static function (): void {},
|
||||||
|
static fn (\Throwable $exception): bool => str_contains($exception->getMessage(), '1062')
|
||||||
|
);
|
||||||
|
$duplicateResponse = $makeController(json_encode($validPayload, JSON_THROW_ON_ERROR), $duplicateWorkflow)->webhook();
|
||||||
|
$assertSame(200, $responseStatus($duplicateResponse), 'a duplicate inbox insert race must be reloaded and processed');
|
||||||
|
$assertSame(1, $duplicateReloads, 'a duplicate inbox insert must reload the winning row once');
|
||||||
|
$assertSame(1, $duplicateProcesses, 'a reloaded PENDING duplicate must be processed once');
|
||||||
|
|
||||||
|
$callbackRaceState = 'PENDING';
|
||||||
|
$callbackRaceUpdated = EjPharmacyCallbackFailureTransition::apply(
|
||||||
|
7,
|
||||||
|
'late failure after concurrent success',
|
||||||
|
static function (int $inboxId, array $values, string $protectedStatus) use (&$callbackRaceState): bool {
|
||||||
|
$callbackRaceState = 'PROCESSED';
|
||||||
|
if ($callbackRaceState === $protectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$callbackRaceState = (string) $values['process_status'];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$assertSame(false, $callbackRaceUpdated, 'failure CAS must report no update after concurrent callback success');
|
||||||
|
$assertSame('PROCESSED', $callbackRaceState, 'concurrent callback success must never be overwritten as FAILED');
|
||||||
|
|
||||||
|
$assertSame(
|
||||||
|
'SF',
|
||||||
|
PharmacyLogisticsValue::normalize("\u{200B}\u{00A0}SF\u{3000}\u{FEFF}", 32, '快递公司'),
|
||||||
|
'logistics values must normalize Unicode edge whitespace'
|
||||||
|
);
|
||||||
|
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_SHIPPED', 'status' => 'PROCESSING']),
|
||||||
|
'ORDER_SHIPPED event type must trigger local shipment fulfillment'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'SHIPPED']),
|
||||||
|
'SHIPPED remote status must trigger local shipment fulfillment'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
false,
|
||||||
|
EjPharmacyShipmentPolicy::isShippedEvent(['event_type' => 'ORDER_UPDATED', 'status' => 'PROCESSING']),
|
||||||
|
'non-shipment callbacks must not change local fulfillment'
|
||||||
|
);
|
||||||
|
foreach ([1, 2] as $pendingFulfillment) {
|
||||||
|
$assertSame(
|
||||||
|
5,
|
||||||
|
EjPharmacyShipmentPolicy::nextFulfillmentStatus($pendingFulfillment, ['event_type' => 'ORDER_SHIPPED']),
|
||||||
|
"shipment callback must advance fulfillment {$pendingFulfillment} to shipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
foreach ([3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as $protectedFulfillment) {
|
||||||
|
$assertSame(
|
||||||
|
$protectedFulfillment,
|
||||||
|
EjPharmacyShipmentPolicy::nextFulfillmentStatus($protectedFulfillment, ['status' => 'SHIPPED']),
|
||||||
|
"shipment callback must not regress/overwrite protected fulfillment {$protectedFulfillment}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sameTrackingDecision = EjPharmacyTrackingPolicy::select(
|
||||||
|
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||||||
|
null,
|
||||||
|
7,
|
||||||
|
'SF-OLD'
|
||||||
|
);
|
||||||
|
$assertSame('REUSE_CURRENT', $sameTrackingDecision['action'], 'same-number callbacks must reuse the active order tracking');
|
||||||
|
$replacementDecision = EjPharmacyTrackingPolicy::select(
|
||||||
|
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||||||
|
null,
|
||||||
|
7,
|
||||||
|
'SF-NEW'
|
||||||
|
);
|
||||||
|
$assertSame('CREATE', $replacementDecision['action'], 'a replacement number must get a fresh tracking row');
|
||||||
|
$assertSame(true, $replacementDecision['archive_current'], 'replacing a number must archive the old active tracking');
|
||||||
|
$historicalDecision = EjPharmacyTrackingPolicy::select(
|
||||||
|
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-CURRENT'],
|
||||||
|
['id' => 8, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||||||
|
7,
|
||||||
|
'SF-OLD'
|
||||||
|
);
|
||||||
|
$assertSame('REUSE_MATCHING', $historicalDecision['action'], 'a same-order historical number may be promoted without duplicating its traces');
|
||||||
|
try {
|
||||||
|
EjPharmacyTrackingPolicy::select(
|
||||||
|
['id' => 10, 'order_id' => 7, 'tracking_number' => 'SF-OLD'],
|
||||||
|
['id' => 99, 'order_id' => 8, 'tracking_number' => 'SF-NEW'],
|
||||||
|
7,
|
||||||
|
'SF-NEW'
|
||||||
|
);
|
||||||
|
throw new RuntimeException('cross-order tracking ownership conflict unexpectedly accepted');
|
||||||
|
} catch (DomainException $exception) {
|
||||||
|
$assertSame(true, str_contains($exception->getMessage(), '其他订单'), 'cross-order tracking numbers must be rejected without rebinding');
|
||||||
|
}
|
||||||
|
|
||||||
|
$overlongWorkflowCalls = 0;
|
||||||
|
$overlongWorkflow = new EjPharmacyCallbackWorkflow(
|
||||||
|
static function () use (&$overlongWorkflowCalls): array {
|
||||||
|
++$overlongWorkflowCalls;
|
||||||
|
return ['id' => 6, 'process_status' => 'PENDING'];
|
||||||
|
},
|
||||||
|
static fn (): array => [],
|
||||||
|
static fn (): ?array => null,
|
||||||
|
static fn (): array => ['process_status' => 'PROCESSED'],
|
||||||
|
static function (): void {},
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$overlongResponse = $makeController(
|
||||||
|
json_encode($validPayload + ['tracking_number' => str_repeat('运', 101)], JSON_THROW_ON_ERROR),
|
||||||
|
$overlongWorkflow
|
||||||
|
)->webhook();
|
||||||
|
$assertSame(422, $responseStatus($overlongResponse), 'overlong logistics values must return HTTP 422');
|
||||||
|
$assertSame(0, $overlongWorkflowCalls, 'logistics validation must finish before any inbox/workflow read or write');
|
||||||
|
|
||||||
|
$middleware = new AuthMiddleware();
|
||||||
|
$aliasMethod = new ReflectionMethod($middleware, 'matchPermissionAlias');
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/uploadtopharmacy', ['tcm.prescriptionorder/submitgancaorecipel']),
|
||||||
|
'the historical permission must grant the unified upload URI'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/submitgancaorecipel', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||||||
|
'the unified permission must grant the historical upload URI'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
false,
|
||||||
|
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/export', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||||||
|
'the upload alias must not expand to unrelated URIs'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
false,
|
||||||
|
$aliasMethod->invoke($middleware, 'tcm.prescriptionorder/confirmgancaosubmission', ['tcm.prescriptionorder/uploadtopharmacy']),
|
||||||
|
'ordinary pharmacy upload permission must not grant manual Gancao reconciliation'
|
||||||
|
);
|
||||||
|
|
||||||
|
$controllerSource = (string) file_get_contents(
|
||||||
|
dirname(__DIR__, 2) . '/app/api/controller/EjPharmacyCallbackController.php'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
2,
|
||||||
|
substr_count($controllerSource, 'PharmacyLogisticsValue::normalize('),
|
||||||
|
'carrier and tracking values must each be normalized exactly once before persistence'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
0,
|
||||||
|
substr_count($controllerSource, "(string) (\$payload['tracking_number']"),
|
||||||
|
'order, tracking, and trace writes must not consume the raw tracking number'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
strpos($controllerSource, '$this->normalizeLogistics($payload)')
|
||||||
|
< strpos($controllerSource, '$this->callbackWorkflow()->handle($payload)'),
|
||||||
|
'callback logistics must be normalized before the workflow can write its inbox row'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
str_contains($controllerSource, 'EjPharmacyCallbackFailureTransition::apply(')
|
||||||
|
&& str_contains($controllerSource, "where('process_status', '<>', \$protectedStatus)"),
|
||||||
|
'callback failure persistence must use a conditional status CAS that protects PROCESSED'
|
||||||
|
);
|
||||||
|
$versionGateStart = strpos($controllerSource, 'if ($incomingVersion >');
|
||||||
|
$versionGateEnd = strpos($controllerSource, '$inboxModel->save', $versionGateStart);
|
||||||
|
$versionGatedSource = substr($controllerSource, $versionGateStart, $versionGateEnd - $versionGateStart);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
str_contains($versionGatedSource, 'EjPharmacyShipmentPolicy::nextFulfillmentStatus('),
|
||||||
|
'shipment fulfillment advancement must occur inside the callback version gate transaction'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
str_contains($versionGatedSource, 'self::syncLogistics('),
|
||||||
|
'ExpressTracking synchronization must remain inside the callback version gate'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
str_contains($versionGatedSource, 'ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder('),
|
||||||
|
'EJ shipment callbacks must reuse the existing shipped-order assistant release linkage'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
true,
|
||||||
|
str_contains($controllerSource, "where('order_id', (int) \$order->id)")
|
||||||
|
&& str_contains($controllerSource, 'EjPharmacyTrackingPolicy::select(')
|
||||||
|
&& str_contains($controllerSource, "'order_type' => 'prescription_history'"),
|
||||||
|
'EJ callbacks must isolate replacement numbers and reject cross-order tracking ownership conflicts'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "zyt pharmacy callback/auth integration tests passed: {$passed}\n";
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createLatestRequestGuard } from '../../../admin/src/views/pharmacy/medicine_mapping/latest-request.mjs'
|
||||||
|
|
||||||
|
const list = createLatestRequestGuard()
|
||||||
|
const firstList = list.next({ page_no: 1, local_name: 'A' })
|
||||||
|
const secondList = list.next({ page_no: 2, local_name: 'B' })
|
||||||
|
assert.deepEqual(firstList.snapshot, { page_no: 1, local_name: 'A' })
|
||||||
|
assert.equal(list.isLatest(firstList), false)
|
||||||
|
assert.equal(list.isLatest(secondList), true)
|
||||||
|
|
||||||
|
const catalog = createLatestRequestGuard()
|
||||||
|
const rowA = catalog.next({ localMedicineId: 1, keyword: 'A' })
|
||||||
|
catalog.invalidate()
|
||||||
|
const rowB = catalog.next({ localMedicineId: 2, keyword: 'B' })
|
||||||
|
assert.equal(catalog.isLatest(rowA), false)
|
||||||
|
assert.equal(catalog.isLatest(rowB), true)
|
||||||
|
assert.deepEqual(rowB.snapshot, { localMedicineId: 2, keyword: 'B' })
|
||||||
|
|
||||||
|
const statusRequests = createLatestRequestGuard()
|
||||||
|
const mountedStatus = statusRequests.next({ source: 'mount' })
|
||||||
|
const syncedStatus = statusRequests.next({ source: 'sync' })
|
||||||
|
const savedStatus = statusRequests.next({ source: 'save' })
|
||||||
|
const unlinkedStatus = statusRequests.next({ source: 'unlink' })
|
||||||
|
assert.equal(statusRequests.isLatest(mountedStatus), false)
|
||||||
|
assert.equal(statusRequests.isLatest(syncedStatus), false)
|
||||||
|
assert.equal(statusRequests.isLatest(savedStatus), false)
|
||||||
|
assert.equal(statusRequests.isLatest(unlinkedStatus), true)
|
||||||
|
list.next({ page_no: 3, local_name: 'C' })
|
||||||
|
catalog.invalidate()
|
||||||
|
assert.equal(statusRequests.isLatest(unlinkedStatus), true)
|
||||||
|
|
||||||
|
const page = readFileSync(
|
||||||
|
new URL('../../../admin/src/views/pharmacy/medicine_mapping/index.vue', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
assert.match(page, /medicineMappingLists\(ticket\.snapshot\)/)
|
||||||
|
assert.match(page, /listRequests\.isLatest\(ticket\)/)
|
||||||
|
assert.match(page, /catalogRequests\.invalidate\(\)/)
|
||||||
|
assert.match(page, /catalogRequests\.isLatest\(ticket\)/)
|
||||||
|
assert.match(page, /localMedicineId/)
|
||||||
|
assert.match(page, /const initialTicket = await searchCatalogNow\(rowSnapshot\.local_name, localMedicineId\)/)
|
||||||
|
assert.match(page, /catalogRequests\.isLatest\(initialTicket\)/)
|
||||||
|
assert.match(page, /const statusRequests = createLatestRequestGuard/)
|
||||||
|
|
||||||
|
const loadStatusStart = page.indexOf('const loadStatus = async () => {')
|
||||||
|
const loadStatusEnd = page.indexOf('\nconst search =', loadStatusStart)
|
||||||
|
assert.ok(loadStatusStart >= 0 && loadStatusEnd > loadStatusStart)
|
||||||
|
const loadStatusSource = page.slice(loadStatusStart, loadStatusEnd)
|
||||||
|
assert.match(loadStatusSource, /const ticket = statusRequests\.next\(undefined\)/)
|
||||||
|
assert.match(loadStatusSource, /const nextStatus = await medicineMappingStatus\(\)/)
|
||||||
|
assert.match(
|
||||||
|
loadStatusSource,
|
||||||
|
/if \(statusRequests\.isLatest\(ticket\)\) \{\s*status\.value = nextStatus\s*\}/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
loadStatusSource,
|
||||||
|
/finally\s*\{\s*if \(statusRequests\.isLatest\(ticket\)\) \{\s*statusLoading\.value = false\s*\}/
|
||||||
|
)
|
||||||
|
assert.equal(page.match(/loadStatus\(\)/g)?.length, 4)
|
||||||
|
|
||||||
|
console.log('zyt mapping latest-request behavior and production wiring passed: 25')
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use app\common\service\pharmacy\EjMedicineBootstrapService;
|
||||||
|
|
||||||
|
$hostname = trim((string) (getenv('ZYT_BOOTSTRAP_TEST_DB_HOST') ?: ''));
|
||||||
|
$port = (int) (getenv('ZYT_BOOTSTRAP_TEST_DB_PORT') ?: 3306);
|
||||||
|
$username = (string) (getenv('ZYT_BOOTSTRAP_TEST_DB_USER') ?: '');
|
||||||
|
$configuredDatabase = trim((string) (getenv('ZYT_BOOTSTRAP_TEST_DB_DATABASE') ?: ''));
|
||||||
|
$passwordOverride = getenv('ZYT_BOOTSTRAP_TEST_DB_PASSWORD');
|
||||||
|
$password = $passwordOverride === false ? '' : $passwordOverride;
|
||||||
|
if ($hostname === '' || $username === '') {
|
||||||
|
fwrite(STDOUT, "medicine bootstrap MySQL integration skipped: database credentials unavailable\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$databaseName = 'zyt_bootstrap_test_' . bin2hex(random_bytes(8));
|
||||||
|
$admin = null;
|
||||||
|
$pdo = null;
|
||||||
|
$temporaryTables = false;
|
||||||
|
$stage = 'connect';
|
||||||
|
try {
|
||||||
|
$admin = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $hostname, $port),
|
||||||
|
$username,
|
||||||
|
$password,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$stage = 'create_database';
|
||||||
|
try {
|
||||||
|
$admin->exec("CREATE DATABASE `{$databaseName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
if ($configuredDatabase === '' || !in_array((string) $exception->getCode(), ['42000', '1044'], true)) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
$temporaryTables = true;
|
||||||
|
$databaseName = $configuredDatabase;
|
||||||
|
}
|
||||||
|
$stage = 'test';
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $hostname, $port, $databaseName),
|
||||||
|
$username,
|
||||||
|
$password,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$tableKind = $temporaryTables ? 'CREATE TEMPORARY TABLE' : 'CREATE TABLE';
|
||||||
|
$pdo->exec("{$tableKind} projection_catalog (
|
||||||
|
medicine_code varchar(32) NOT NULL PRIMARY KEY,
|
||||||
|
local_medicine_id bigint unsigned NOT NULL,
|
||||||
|
name varchar(120) NOT NULL
|
||||||
|
) ENGINE=InnoDB");
|
||||||
|
$pdo->exec("{$tableKind} projection_mapping (
|
||||||
|
local_medicine_id bigint unsigned NOT NULL PRIMARY KEY,
|
||||||
|
medicine_code varchar(32) NOT NULL UNIQUE,
|
||||||
|
operator_id bigint unsigned NOT NULL,
|
||||||
|
operator_name varchar(80) NOT NULL
|
||||||
|
) ENGINE=InnoDB");
|
||||||
|
foreach (['submissions', 'callbacks', 'business_links'] as $table) {
|
||||||
|
$pdo->exec("{$tableKind} `{$table}` (id bigint unsigned NOT NULL PRIMARY KEY) ENGINE=InnoDB");
|
||||||
|
}
|
||||||
|
$pdo->exec("INSERT INTO projection_catalog VALUES ('OLD001', 999, '旧投影')");
|
||||||
|
$pdo->exec("INSERT INTO projection_mapping VALUES (999, 'OLD001', 8, 'old-operator')");
|
||||||
|
|
||||||
|
$transaction = static function (callable $operation) use ($pdo): array {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$result = $operation();
|
||||||
|
$pdo->commit();
|
||||||
|
return $result;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$referenceCounter = static function () use ($pdo): array {
|
||||||
|
return [
|
||||||
|
'submissions' => (int) $pdo->query('SELECT COUNT(*) FROM submissions')->fetchColumn(),
|
||||||
|
'callbacks' => (int) $pdo->query('SELECT COUNT(*) FROM callbacks')->fetchColumn(),
|
||||||
|
'business_links' => (int) $pdo->query('SELECT COUNT(*) FROM business_links')->fetchColumn(),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
$referenceLocker = static function () use ($pdo): void {
|
||||||
|
foreach (['submissions', 'callbacks', 'business_links'] as $table) {
|
||||||
|
$pdo->query("SELECT id FROM `{$table}` ORDER BY id FOR UPDATE")->fetchAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$locker = static function () use ($pdo): void {
|
||||||
|
$pdo->query('SELECT medicine_code FROM projection_catalog ORDER BY medicine_code FOR UPDATE')->fetchAll();
|
||||||
|
$pdo->query('SELECT local_medicine_id FROM projection_mapping ORDER BY local_medicine_id FOR UPDATE')->fetchAll();
|
||||||
|
};
|
||||||
|
$verifier = static function () use ($pdo): array {
|
||||||
|
return [
|
||||||
|
'catalog' => (int) $pdo->query('SELECT COUNT(*) FROM projection_catalog')->fetchColumn(),
|
||||||
|
'active_mappings' => (int) $pdo->query('SELECT COUNT(*) FROM projection_mapping')->fetchColumn(),
|
||||||
|
'unmapped' => (int) $pdo->query(
|
||||||
|
'SELECT COUNT(*) FROM projection_catalog c LEFT JOIN projection_mapping m '
|
||||||
|
. 'ON m.medicine_code = c.medicine_code WHERE m.local_medicine_id IS NULL'
|
||||||
|
)->fetchColumn(),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
$rows = [
|
||||||
|
['local_medicine_id' => 1, 'medicine_code' => 'EJ000001', 'name' => '黄芪'],
|
||||||
|
['local_medicine_id' => 2, 'medicine_code' => 'EJ000002', 'name' => '党参'],
|
||||||
|
];
|
||||||
|
$replacer = static function (array $nextRows) use ($pdo): void {
|
||||||
|
$pdo->exec('DELETE FROM projection_mapping');
|
||||||
|
$pdo->exec('DELETE FROM projection_catalog');
|
||||||
|
$catalog = $pdo->prepare(
|
||||||
|
'INSERT INTO projection_catalog (medicine_code,local_medicine_id,name) VALUES (?,?,?)'
|
||||||
|
);
|
||||||
|
$mapping = $pdo->prepare(
|
||||||
|
'INSERT INTO projection_mapping (local_medicine_id,medicine_code,operator_id,operator_name) VALUES (?,?,0,?)'
|
||||||
|
);
|
||||||
|
foreach ($nextRows as $row) {
|
||||||
|
$catalog->execute([$row['medicine_code'], $row['local_medicine_id'], $row['name']]);
|
||||||
|
$mapping->execute([$row['local_medicine_id'], $row['medicine_code'], 'system-bootstrap']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$pdo->exec('INSERT INTO submissions VALUES (1)');
|
||||||
|
try {
|
||||||
|
EjMedicineBootstrapService::replaceProjectionWith(
|
||||||
|
$rows,
|
||||||
|
$transaction,
|
||||||
|
$referenceLocker,
|
||||||
|
$referenceCounter,
|
||||||
|
$locker,
|
||||||
|
$replacer,
|
||||||
|
$verifier
|
||||||
|
);
|
||||||
|
throw new RuntimeException('nonzero MySQL reference gate unexpectedly allowed replacement');
|
||||||
|
} catch (RuntimeException $exception) {
|
||||||
|
if (!str_contains($exception->getMessage(), '业务引用')) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM submissions');
|
||||||
|
if ((string) $pdo->query('SELECT medicine_code FROM projection_catalog')->fetchColumn() !== 'OLD001') {
|
||||||
|
throw new RuntimeException('nonzero MySQL reference gate mutated the old projection');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = EjMedicineBootstrapService::replaceProjectionWith(
|
||||||
|
$rows,
|
||||||
|
$transaction,
|
||||||
|
$referenceLocker,
|
||||||
|
$referenceCounter,
|
||||||
|
$locker,
|
||||||
|
$replacer,
|
||||||
|
$verifier
|
||||||
|
);
|
||||||
|
if ($result !== ['catalog' => 2, 'active_mappings' => 2, 'unmapped' => 0]) {
|
||||||
|
throw new RuntimeException('successful MySQL projection replacement did not verify exactly');
|
||||||
|
}
|
||||||
|
$operators = $pdo->query(
|
||||||
|
'SELECT CONCAT(operator_id, ":", operator_name) FROM projection_mapping ORDER BY local_medicine_id'
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if ($operators !== ['0:system-bootstrap', '0:system-bootstrap']) {
|
||||||
|
throw new RuntimeException('bootstrap mappings did not preserve the system operator identity');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec('DELETE FROM projection_mapping');
|
||||||
|
$pdo->exec('DELETE FROM projection_catalog');
|
||||||
|
$pdo->exec("INSERT INTO projection_catalog VALUES ('OLD002', 998, '回滚旧投影')");
|
||||||
|
$pdo->exec("INSERT INTO projection_mapping VALUES (998, 'OLD002', 7, 'rollback-operator')");
|
||||||
|
try {
|
||||||
|
EjMedicineBootstrapService::replaceProjectionWith(
|
||||||
|
$rows,
|
||||||
|
$transaction,
|
||||||
|
$referenceLocker,
|
||||||
|
$referenceCounter,
|
||||||
|
$locker,
|
||||||
|
static function (array $nextRows) use ($replacer): void {
|
||||||
|
$replacer($nextRows);
|
||||||
|
throw new RuntimeException('forced MySQL replacement failure');
|
||||||
|
},
|
||||||
|
$verifier
|
||||||
|
);
|
||||||
|
throw new RuntimeException('forced MySQL replacement failure unexpectedly committed');
|
||||||
|
} catch (RuntimeException $exception) {
|
||||||
|
if ($exception->getMessage() !== 'forced MySQL replacement failure') {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rolledBack = $pdo->query(
|
||||||
|
'SELECT c.medicine_code,c.local_medicine_id,c.name,m.operator_id,m.operator_name '
|
||||||
|
. 'FROM projection_catalog c JOIN projection_mapping m USING (medicine_code)'
|
||||||
|
)->fetch();
|
||||||
|
if ($rolledBack !== [
|
||||||
|
'medicine_code' => 'OLD002',
|
||||||
|
'local_medicine_id' => 998,
|
||||||
|
'name' => '回滚旧投影',
|
||||||
|
'operator_id' => 7,
|
||||||
|
'operator_name' => 'rollback-operator',
|
||||||
|
]) {
|
||||||
|
throw new RuntimeException('MySQL rollback did not restore the old projection exactly');
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, $temporaryTables
|
||||||
|
? "medicine bootstrap MySQL integration passed: temporary_tables\n"
|
||||||
|
: "medicine bootstrap MySQL integration passed: temporary_database\n");
|
||||||
|
} catch (PDOException $exception) {
|
||||||
|
if ($stage === 'test') {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, sprintf(
|
||||||
|
"medicine bootstrap MySQL integration skipped: temporary database unavailable stage=%s sqlstate=%s\n",
|
||||||
|
$stage,
|
||||||
|
(string) $exception->getCode()
|
||||||
|
));
|
||||||
|
exit(0);
|
||||||
|
} finally {
|
||||||
|
$pdo = null;
|
||||||
|
if ($admin instanceof PDO && !$temporaryTables) {
|
||||||
|
try {
|
||||||
|
$admin->exec("DROP DATABASE IF EXISTS `{$databaseName}`");
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$routeSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/api/route/app.php');
|
||||||
|
|
||||||
|
$assertTrue(
|
||||||
|
!str_contains($routeSource, 'Controller@'),
|
||||||
|
'API routes must use controller dispatch so InitMiddleware receives controller and action names'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($routeSource, "'EjPharmacyCallback/webhook'"),
|
||||||
|
'ej pharmacy webhook must use ThinkPHP controller dispatch'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($routeSource, "'QywxExternalContactCallback/notify'"),
|
||||||
|
'QYWX callback must use ThinkPHP controller dispatch'
|
||||||
|
);
|
||||||
|
|
||||||
|
$controllerSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/adminapi/controller/tcm/PrescriptionOrderController.php');
|
||||||
|
$migrationSource = (string) file_get_contents(dirname(__DIR__, 2) . '/sql/1.9.20260721/luoyang_pharmacy_erp.sql');
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($controllerSource, 'confirmGancaoSubmission'),
|
||||||
|
'admin API must expose an actionable Gancao reconciliation endpoint'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($migrationSource, '`lease_expires_at`'),
|
||||||
|
'pharmacy submission claims must persist an explicit lease expiry'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($migrationSource, 'zyt_pharmacy_submission_claim_audit'),
|
||||||
|
'manual submission reconciliation must have an append-only audit table'
|
||||||
|
);
|
||||||
|
|
||||||
|
$bootstrapItemPath = dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineBootstrapItem.php';
|
||||||
|
$bootstrapServicePath = dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineBootstrapService.php';
|
||||||
|
$bootstrapCommandPath = dirname(__DIR__, 2) . '/app/command/EjPharmacyBootstrapMedicines.php';
|
||||||
|
$assertTrue(is_file($bootstrapItemPath), 'ZYT must provide a dedicated EJ medicine bootstrap item normalizer');
|
||||||
|
$assertTrue(is_file($bootstrapServicePath), 'ZYT must provide an atomic EJ medicine bootstrap service');
|
||||||
|
$assertTrue(is_file($bootstrapCommandPath), 'ZYT must provide the ej-pharmacy:bootstrap-medicines command');
|
||||||
|
|
||||||
|
$clientSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjPharmacyClient.php');
|
||||||
|
$configSource = (string) file_get_contents(dirname(__DIR__, 2) . '/config/ej_pharmacy.php');
|
||||||
|
$consoleSource = (string) file_get_contents(dirname(__DIR__, 2) . '/config/console.php');
|
||||||
|
$syncServiceSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/common/service/pharmacy/EjMedicineCatalogSyncService.php');
|
||||||
|
$mappingLogicSource = (string) file_get_contents(dirname(__DIR__, 2) . '/app/adminapi/logic/pharmacy/MedicineMappingLogic.php');
|
||||||
|
$mappingPageSource = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/views/pharmacy/medicine_mapping/index.vue');
|
||||||
|
$mappingApiSource = (string) file_get_contents(dirname(__DIR__, 3) . '/admin/src/api/pharmacy.ts');
|
||||||
|
$exampleEnvSource = (string) file_get_contents(dirname(__DIR__, 2) . '/.example.env');
|
||||||
|
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($clientSource, 'function importMedicines(')
|
||||||
|
&& str_contains($clientSource, "'/api/openapi/v1/medicine-imports'"),
|
||||||
|
'EJ client must post structured medicine import batches through the existing HMAC transport'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($consoleSource, "'ej-pharmacy:bootstrap-medicines'")
|
||||||
|
&& str_contains((string) file_get_contents($bootstrapCommandPath), 'RESET_TEST_CATALOG'),
|
||||||
|
'bootstrap command must be registered and guard destructive replacement with the exact confirmation token'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($configSource, "'catalog_sync_enabled'")
|
||||||
|
&& str_contains($configSource, "env('EJ_PHARMACY_CATALOG_SYNC_ENABLED', false)"),
|
||||||
|
'legacy EJ catalog sync must default disabled'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($exampleEnvSource, 'EJ_PHARMACY_CATALOG_SYNC_ENABLED = false'),
|
||||||
|
'example environment must explicitly disable legacy catalog sync'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
strpos($syncServiceSource, "Config::get('ej_pharmacy.catalog_sync_enabled', false)")
|
||||||
|
< strpos($syncServiceSource, 'ensureStateRow()'),
|
||||||
|
'disabled catalog sync must fail before any synchronization state write'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($mappingLogicSource, "'sync_enabled'")
|
||||||
|
&& str_contains($mappingApiSource, 'sync_enabled: boolean'),
|
||||||
|
'mapping status must expose the legacy sync feature gate end to end'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($mappingPageSource, 'v-if="status.sync_enabled"'),
|
||||||
|
'mapping page must hide the incremental sync button when legacy sync is disabled'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($migrationSource, 'UNIQUE KEY `uk_medicine_code` (`medicine_code`)'),
|
||||||
|
'bootstrap mapping projection must reject duplicate remote medicine codes at the database boundary'
|
||||||
|
);
|
||||||
|
$bootstrapServiceSource = (string) file_get_contents($bootstrapServicePath);
|
||||||
|
$assertTrue(
|
||||||
|
strpos($bootstrapServiceSource, "Db::name('ej_pharmacy_submission')")
|
||||||
|
< strpos($bootstrapServiceSource, "'submissions' => (int) Db::name('ej_pharmacy_submission')->count()"),
|
||||||
|
'bootstrap replacement must lock reference sources before checking the zero-reference gate'
|
||||||
|
);
|
||||||
|
$assertTrue(
|
||||||
|
str_contains($bootstrapServiceSource, "Db::name('doctor_medicine')->where('id', '>=', 0)")
|
||||||
|
&& str_contains($bootstrapServiceSource, '本地药材源快照在远端导入期间发生变化'),
|
||||||
|
'bootstrap replacement must lock and revalidate the complete local medicine source snapshot'
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user