feat: add zyt medicine bootstrap

This commit is contained in:
2026-07-22 14:50:34 +08:00
parent 8a6890767e
commit 31312ae975
51 changed files with 6567 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
import request from '@/utils/request'
export interface MedicineMappingQuery {
page_no: number
page_size: number
local_name?: string
remote_keyword?: string
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
}
export interface MedicineMappingRow {
local_medicine_id: number
local_name: string
local_unit: string
local_status: number
mapping_id: number | null
mapping_status: number
medicine_code: string | null
remote_name: string | null
remote_brand: string | null
remote_unit: string | null
settlement_price: string | number | null
retail_price: string | number | null
catalog_version: number | null
remote_status: number | null
remote_deleted: number | null
operator_name: string | null
mapping_update_time: number | null
}
export interface CatalogOption {
medicine_code: string
name: string
brand: string
unit: string
settlement_price: string | number
retail_price: string | number
catalog_version: number
status: number
}
export interface PharmacySyncStatus {
sync_enabled: boolean
cursor: number
last_success_time: number
last_failure_time: number
last_error_summary: string
is_syncing: boolean
catalog_total: number
catalog_active: number
unmapped_local: number
}
export interface PharmacySyncResult {
pages: number
pulled: number
received: number
created: number
updated: number
unchanged: number
deactivated: number
cursor: number
}
export function medicineMappingLists(params: MedicineMappingQuery) {
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
}
export function medicineMappingStatus() {
return request.get({ url: '/pharmacy.medicineMapping/status' })
}
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
}
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
return request.post({ url: '/pharmacy.medicineMapping/save', params })
}
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
}
export function medicineCatalogSync() {
return request.post({ url: '/pharmacy.medicineMapping/sync' })
}
@@ -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
}
}
}
+12
View File
@@ -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"
@@ -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('恩济药房返回了无效 JSONHTTP ' . $httpStatus);
}
return ['http_status' => $httpStatus, 'body' => $decoded, 'request_id' => $requestId];
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\common\service\pharmacy;
use DomainException;
use InvalidArgumentException;
final class EjPharmacyPayload
{
/**
* @param array<string,mixed> $order
* @param array<string,mixed> $prescription
* @param array<int,string> $medicineMappings
* @return array<string,mixed>
*/
public static function build(array $order, array $prescription, array $medicineMappings, int $revision): array
{
$orderNo = trim((string) ($order['order_no'] ?? ''));
if ($orderNo === '') {
throw new InvalidArgumentException('order_no is required');
}
$herbs = $prescription['herbs'] ?? [];
if (!is_array($herbs) || $herbs === []) {
throw new InvalidArgumentException('prescription herbs are required');
}
$medicines = [];
foreach ($herbs as $herb) {
if (!is_array($herb)) {
continue;
}
$medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0);
$name = trim((string) ($herb['name'] ?? $herb['title'] ?? ''));
$medicineCode = trim((string) ($medicineMappings[$medicineId] ?? ''));
if ($medicineId <= 0 || $medicineCode === '') {
throw new DomainException('Unmapped medicine: ' . ($name !== '' ? $name : (string) $medicineId));
}
$quantity = (float) ($herb['dose'] ?? $herb['dosage'] ?? $herb['quantity'] ?? 0);
if ($quantity <= 0) {
throw new InvalidArgumentException('Medicine quantity must be positive: ' . $name);
}
$medicines[] = [
'source_medicine_id' => (string) $medicineId,
'medicine_code' => $medicineCode,
'name' => $name,
'quantity' => number_format($quantity, 4, '.', ''),
'unit' => trim((string) ($herb['unit'] ?? '克')) ?: '克',
'usage' => trim((string) ($herb['usage'] ?? '')),
];
}
if ($medicines === []) {
throw new InvalidArgumentException('prescription herbs are required');
}
return [
'source_system' => 'zyt',
'source_order_no' => $orderNo,
'source_revision' => max($revision, 1),
'patient' => [
'source_patient_id' => (string) ($prescription['patient_id'] ?? ''),
'name' => trim((string) ($prescription['patient_name'] ?? $order['recipient_name'] ?? '')),
'id_card' => trim((string) ($prescription['id_card'] ?? '')),
'mobile' => trim((string) ($prescription['phone'] ?? $order['recipient_phone'] ?? '')),
],
'shipping' => [
'recipient_name' => trim((string) ($order['recipient_name'] ?? $prescription['patient_name'] ?? '')),
'recipient_mobile' => trim((string) ($order['recipient_phone'] ?? $prescription['phone'] ?? '')),
'province' => trim((string) ($order['shipping_province'] ?? '')),
'city' => trim((string) ($order['shipping_city'] ?? '')),
'district' => trim((string) ($order['shipping_district'] ?? '')),
'address' => trim((string) ($order['shipping_address'] ?? '')),
],
'prescription' => [
'source_prescription_id' => (string) ($prescription['id'] ?? ''),
'diagnosis' => trim((string) (
$prescription['clinical_diagnosis']
?? $prescription['diagnosis']
?? $prescription['diagnosis_name']
?? ''
)),
'processing_type' => trim((string) ($prescription['processing_type'] ?? 'decoction')) ?: 'decoction',
'dose_count' => max((int) ($order['dose_count'] ?? $prescription['dose_count'] ?? 1), 1),
'doctor' => [
'source_doctor_id' => (string) ($prescription['creator_id'] ?? $prescription['doctor_id'] ?? ''),
'name' => trim((string) ($prescription['doctor_name'] ?? '')),
],
'doctor_signature' => is_array($prescription['doctor_signature'] ?? null)
? $prescription['doctor_signature']
: [],
'medicines' => $medicines,
'instructions' => trim((string) (
$prescription['usage_instruction']
?? $prescription['instructions']
?? $prescription['advice']
?? ''
)),
],
];
}
}
@@ -0,0 +1,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;
}
}
+2
View File
@@ -32,6 +32,8 @@ return [
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive', 'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST // 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute', 'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
'ej-pharmacy:bootstrap-medicines' => 'app\\command\\EjPharmacyBootstrapMedicines',
// 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW // 历史 internal_cost:批量甘草预报价回填(CTM_PREVIEW
'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost', 'tcm:backfill-internal-cost' => 'app\\command\\TcmBackfillPrescriptionOrderInternalCost',
// 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP) // 批量回填业务订单签收时间到物流库(导出读库即可,不再逐单 HTTP)
+18
View File
@@ -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) {
}
}
}
+98
View File
@@ -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