diff --git a/admin/scripts/verify-appointment-type.cjs b/admin/scripts/verify-appointment-type.cjs new file mode 100644 index 000000000..53e31c5e1 --- /dev/null +++ b/admin/scripts/verify-appointment-type.cjs @@ -0,0 +1,65 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc') + +const root = path.resolve(__dirname, '..') +const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8') +const utils = ts.transpileModule(read('utils/appointment-type.ts'), { + compilerOptions: { module: ts.ModuleKind.CommonJS } +}).outputText +const exportsObject = {} +vm.runInNewContext(utils, { exports: exportsObject }) +for (const [value, expected] of [ + ['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'], + [null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'], + ['phone', '电话问诊'], ['other', '未知'] +]) { + assert.equal(exportsObject.appointmentTypeDescription(value), expected) +} + +const callers = [ + 'views/tcm/appointment/list.vue', + 'views/tcm/appointment/list_h5.vue', + 'views/patient/reception/index.vue' +] +for (const caller of callers) { + assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/) +} +const chat = read('components/chat-dialog/index.vue') +assert.match(chat, /appointmentType\.value = data\.appointmentType/) +assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/) +const form = read('views/tcm/diagnosis/appointment.vue') +assert.match(form, /图文问诊<\/el-radio>/) +assert.match(form, /appointmentType: 'video'/) +assert.match(form, /form\.appointmentType = 'video'/) +assert.match(form, /appointment_type: form\.appointmentType/) + +async function main() { + const components = [ + ...callers, 'components/chat-dialog/index.vue', + 'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue' + ] + for (const filename of components) { + const { descriptor, errors } = parse(read(filename), { filename }) + assert.deepEqual(errors, [], `${filename} parses`) + const script = compileScript(descriptor, { id: filename }) + const template = compileTemplate({ + source: descriptor.template.content, + filename, id: filename, + compilerOptions: { bindingMetadata: script.bindings } + }) + assert.deepEqual(template.errors, [], `${filename} template compiles`) + for (const style of descriptor.styles) { + const result = await compileStyleAsync({ + source: style.content, filename: path.join(root, 'src', filename), + id: filename, scoped: style.scoped, preprocessLang: style.lang + }) + assert.deepEqual(result.errors, [], `${filename} styles compile`) + } + } + console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`) +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) \ No newline at end of file diff --git a/admin/src/api/first_visit.ts b/admin/src/api/first_visit.ts index 9660aa68d..fcbbb5566 100644 --- a/admin/src/api/first_visit.ts +++ b/admin/src/api/first_visit.ts @@ -313,6 +313,7 @@ export interface WecomPromotionBatchUpdatePoolResult { success: boolean sync_error?: string sync_queued?: boolean + sync_status?: WecomPromotionMemberSyncStatus member_matched?: number member_updated?: number error?: string @@ -357,9 +358,28 @@ export function wecomPromotionCheckApiPermission() { return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' }) } -export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) { - return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 }) -} +export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) { + return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 }) +} + +export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked' + +export interface WecomPromotionMemberSyncResult { + pool_id: number + sync_status: WecomPromotionMemberSyncStatus + sync_error: string + sync_queued: boolean + range_userids: string[] + range_department_ids: string[] +} + +export function wecomPromotionSyncMemberRange(params: { pool_id: number }) { + return request.post({ + url: '/firstvisit.wecomPromotion/syncMemberRange', + params, + timeout: 120000 + }, { ignoreCancelToken: true, isOpenRetry: false }) +} export function wecomPromotionRemoteLinkDetail(params: { id: number }) { return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params }) diff --git a/admin/src/api/tcm.ts b/admin/src/api/tcm.ts index 4b5b9d99b..795e588ad 100644 --- a/admin/src/api/tcm.ts +++ b/admin/src/api/tcm.ts @@ -424,6 +424,11 @@ export function prescriptionOrderEdit(params: Record) { return request.post({ url: '/tcm.prescriptionOrder/edit', params }) } +/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */ +export function prescriptionOrderEditTime(params: { id: number; create_time: string }) { + return request.post({ url: '/tcm.prescriptionOrder/editTime', params }) +} + /** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */ export function prescriptionOrderDdcode(params: { id: number diff --git a/admin/src/components/chat-dialog/index.vue b/admin/src/components/chat-dialog/index.vue index ed1a2f2c1..23278540d 100644 --- a/admin/src/components/chat-dialog/index.vue +++ b/admin/src/components/chat-dialog/index.vue @@ -11,7 +11,10 @@ class="chat-window-header" @mousedown="onHeaderMouseDown" > - 与 {{ patientName }} 通讯 +
+ 与 {{ patientName }} 通讯 + {{ appointmentTypeLabel }} +
@@ -140,6 +143,7 @@ import { import { CallLocalRecorder } from '@/utils/call-local-recorder' import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot' import feedback from '@/utils/feedback' +import { appointmentTypeDescription } from '@/utils/appointment-type' import { formatTUICallUserError, getTUICallPackageArrearsMessage, @@ -180,6 +184,8 @@ const visible = ref(false) const isReady = ref(false) const error = ref('') const patientName = ref('') +const appointmentType = ref('video') +const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value)) const patientId = ref(null) const diagnosisId = ref(null) const loadingText = ref('正在初始化...') @@ -1010,13 +1016,14 @@ watch(() => activeConversation.value, async (newConversation) => { } }) -const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => { +const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => { visible.value = true isMinimized.value = false posX.value = 100 posY.value = 80 resetCallKitPositionToLeft() patientName.value = data.patientName + appointmentType.value = data.appointmentType patientId.value = data.patientId diagnosisId.value = data.diagnosisId || null error.value = '' @@ -1426,6 +1433,18 @@ defineExpose({ open }) justify-content: space-between; flex-shrink: 0; + .chat-window-heading { + display: flex; + align-items: center; + min-width: 0; + gap: 4px; + } + + .chat-appointment-type { + flex-shrink: 0; + margin-right: 8px; + } + .chat-window-title { font-size: 16px; font-weight: 500; diff --git a/admin/src/utils/appointment-type.ts b/admin/src/utils/appointment-type.ts new file mode 100644 index 000000000..33a5f212a --- /dev/null +++ b/admin/src/utils/appointment-type.ts @@ -0,0 +1,8 @@ +/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */ +export function appointmentTypeDescription(value?: string | null): string { + if (value == null || value.trim() === '') return '视频问诊' + if (value === 'text') return '图文问诊' + if (value === 'video') return '视频问诊' + if (value === 'phone') return '电话问诊' + return '未知' +} diff --git a/admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue b/admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue new file mode 100644 index 000000000..fce362dd5 --- /dev/null +++ b/admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue @@ -0,0 +1,93 @@ + + + diff --git a/admin/src/views/consumer/prescription/components/prescription-order-utils.ts b/admin/src/views/consumer/prescription/components/prescription-order-utils.ts index e2ca64c0c..18495b041 100644 --- a/admin/src/views/consumer/prescription/components/prescription-order-utils.ts +++ b/admin/src/views/consumer/prescription/components/prescription-order-utils.ts @@ -178,7 +178,8 @@ export function logActionText(act: string) { ej_pharmacy_callback: '洛阳药房状态', patch_rx_patient: '处方患者信息', patch_rx_usage: '服用参数', - update_amount: '修改订单金额', + update_amount: '修改订单金额', + edit_time: '修改创建时间', complete: '完成订单', refund: '退款', manual_log: '手工备注', diff --git a/admin/src/views/consumer/prescription/guahao.vue b/admin/src/views/consumer/prescription/guahao.vue index acf3f5695..d2d70effb 100644 --- a/admin/src/views/consumer/prescription/guahao.vue +++ b/admin/src/views/consumer/prescription/guahao.vue @@ -182,7 +182,6 @@ - diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index b95095c50..180ccfed0 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue' import MedicineNameSelect from '@/components/medicine-name-select/index.vue' import PrescriptionSlip from '@/components/prescription-slip/index.vue' import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue' -import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue' +import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue' import { useRouter } from 'vue-router' import html2canvas from 'html2canvas' import jsPDF from 'jspdf' @@ -3882,7 +3882,18 @@ const handleDelete = async (id: number) => { getLists() } -onMounted(async () => { +// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。 +let refreshOnReturn = false +onDeactivated(() => { + refreshOnReturn = true +}) +onActivated(() => { + if (!refreshOnReturn) return + refreshOnReturn = false + getLists() +}) + +onMounted(async () => { await loadRoleOptions() await loadRegionData() await loadServicePackageOptions() diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index 4e5bab1d1..1737e2d01 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -550,7 +550,13 @@ type="primary" link @click="openEdit(row)" - >编辑 + >编辑 + 修改创建时间 {{ shipModeLabel(detail.ship_mode) }}
- -') + }) + } + }, vue()], + server: { host: '127.0.0.1', port: 5586, strictPort: true, open: false } +}) +await server.listen() +console.log('Isolated fixture: http://127.0.0.1:5586/__time-check') +for (const event of ['SIGINT', 'SIGTERM']) process.on(event, async () => { await server.close(); process.exit(0) }) diff --git a/artifacts/prescription-enhancements/typecheck-triage.json b/artifacts/prescription-enhancements/typecheck-triage.json new file mode 100644 index 000000000..94f9b498b --- /dev/null +++ b/artifacts/prescription-enhancements/typecheck-triage.json @@ -0,0 +1,5 @@ +{ + "errors": 57, + "errorsOnChangedLines": [], + "note": "Reported diagnostic locations are checked against the diff; this is not a separate baseline typecheck run." +} \ No newline at end of file diff --git a/artifacts/prescription-enhancements/typecheck.log b/artifacts/prescription-enhancements/typecheck.log new file mode 100644 index 000000000..6594e87d8 --- /dev/null +++ b/artifacts/prescription-enhancements/typecheck.log @@ -0,0 +1,91 @@ +src/components/chat-dialog/ChatMessageItem.vue(29,7): error TS7022: 'attrs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. +src/components/editor/index.vue(29,44): error TS7016: Could not find a declaration file for module '@wangeditor/editor-for-vue'. 'D:/web/zyt/admin/node_modules/.pnpm/@wangeditor+editor-for-vue@_d49ef1161b4f4b880c450fdbfe3a0001/node_modules/@wangeditor/editor-for-vue/dist/index.esm.js' implicitly has an 'any' type. + There are types at 'D:/web/zyt/admin/node_modules/@wangeditor/editor-for-vue/dist/src/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@wangeditor/editor-for-vue' library may need to update its package.json or typings. +src/components/link/mini-program.vue(11,30): error TS7006: Parameter 'value' implicitly has an 'any' type. +src/components/link/mini-program.vue(22,30): error TS7006: Parameter 'value' implicitly has an 'any' type. +src/components/link/mini-program.vue(33,30): error TS7006: Parameter 'value' implicitly has an 'any' type. +src/components/link/mini-program.vue(48,31): error TS7006: Parameter 'value' implicitly has an 'any' type. +src/utils/call-local-recorder.ts(335,26): error TS2339: Property 'captureStream' does not exist on type 'HTMLVideoElement'. +src/views/asset/user/index.vue(230,20): error TS2339: Property 'remark' does not exist on type 'never'. +src/views/consumer/prescription/index.vue(1952,9): error TS2322: Type '{ name: any; code: any; children: any; }[]' is not assignable to type 'never[]'. + Type '{ name: any; code: any; children: any; }' is not assignable to type 'never'. +src/views/consumer/prescription/index.vue(1958,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'. +src/views/consumer/prescription/index.vue(1986,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'. +src/views/consumer/prescription/index.vue(2001,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'. +src/views/consumer/prescription/index.vue(2016,13): error TS2322: Type '{ name: string; children: { name: string; children: { name: string; }[]; }[]; }' is not assignable to type 'never'. +src/views/consumer/prescription/index.vue(2256,32): error TS2304: Cannot find name 'searchPatientsAPI'. +src/views/consumer/prescription/order_list.vue(1939,68): error TS7006: Parameter 'r' implicitly has an 'any' type. +src/views/consumer/prescription/order_list.vue(2379,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'. + Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'. +src/views/consumer/prescription/order_list.vue(3775,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'. + Type 'string' is not assignable to type 'number'. +src/views/consumer/prescription/order_list_h5.vue(497,96): error TS7006: Parameter 'cmd' implicitly has an 'any' type. +src/views/consumer/prescription/order_list_h5.vue(566,47): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(576,58): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(586,47): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(596,59): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(611,46): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown; }'. +src/views/consumer/prescription/order_list_h5.vue(621,53): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; diagnosis_id?: number | undefined; pay_order_ids?: number[] | undefined; }'. +src/views/consumer/prescription/order_list_h5.vue(631,53): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(641,53): error TS2345: Argument of type 'Record' is not assignable to parameter of type '{ id: number; }'. + Property 'id' is missing in type 'Record' but required in type '{ id: number; }'. +src/views/consumer/prescription/order_list_h5.vue(2509,68): error TS7006: Parameter 'r' implicitly has an 'any' type. +src/views/consumer/prescription/order_list_h5.vue(2848,9): error TS2322: Type '{ value: any; label: any; code: any; children: any; }[]' is not assignable to type 'never[]'. + Type '{ value: any; label: any; code: any; children: any; }' is not assignable to type 'never'. +src/views/consumer/prescription/order_list_h5.vue(4694,13): error TS2322: Type 'string | number | undefined' is not assignable to type 'number | undefined'. + Type 'string' is not assignable to type 'number'. +src/views/decoration/component/tabbar/pc/attr.vue(10,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'. + Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'. +src/views/decoration/component/tabbar/pc/attr.vue(13,18): error TS2345: Argument of type '{ modelValue: any; }' is not assignable to parameter of type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'. + Property 'itemData' is missing in type '{ modelValue: any; }' but required in type '{ readonly modelValue: any[]; readonly max?: number | undefined; readonly min?: number | undefined; readonly itemData: any; readonly "onUpdate:modelValue"?: ((value: any) => any) | undefined; }'. +src/views/decoration/component/widgets/middle-banner/content.vue(6,33): error TS2339: Property 'height' does not exist on type '{}'. +src/views/doctor/dept-tongji.vue(131,31): error TS7006: Parameter 'depts' implicitly has an 'any' type. +src/views/doctor/dept-tongji.vue(132,17): error TS7034: Variable 'result' implicitly has type 'any[]' in some locations where its type cannot be determined. +src/views/doctor/dept-tongji.vue(133,27): error TS7006: Parameter 'dept' implicitly has an 'any' type. +src/views/doctor/dept-tongji.vue(136,30): error TS7005: Variable 'result' implicitly has an 'any[]' type. +src/views/doctor/dept-tongji.vue(139,20): error TS7005: Variable 'result' implicitly has an 'any[]' type. +src/views/doctor/tongji.vue(230,48): error TS2769: No overload matches this call. + Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error. + Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'. + Types of parameters 'total' and 'previousValue' are incompatible. + Type 'unknown' is not assignable to type 'number'. + Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error. + Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'. + Types of parameters 'count' and 'currentValue' are incompatible. + Type 'unknown' is not assignable to type 'number'. +src/views/doctor/tongji.vue(236,51): error TS2769: No overload matches this call. + Overload 1 of 3, '(callbackfn: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown, initialValue: unknown): unknown', gave the following error. + Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown'. + Types of parameters 'total' and 'previousValue' are incompatible. + Type 'unknown' is not assignable to type 'number'. + Overload 2 of 3, '(callbackfn: (previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number, initialValue: number): number', gave the following error. + Argument of type '(total: number, count: number) => number' is not assignable to parameter of type '(previousValue: number, currentValue: unknown, currentIndex: number, array: unknown[]) => number'. + Types of parameters 'count' and 'currentValue' are incompatible. + Type 'unknown' is not assignable to type 'number'. +src/views/first_visit/my_patients/components/OrderActionHost.vue(13,28): error TS7006: Parameter 'command' implicitly has an 'any' type. +src/views/first_visit/my_patients/components/OrderPanel.vue(202,40): error TS7006: Parameter 'command' implicitly has an 'any' type. +src/views/first_visit/wecom_promotion/index.vue(97,43): error TS7006: Parameter 'checked' implicitly has an 'any' type. +src/views/first_visit/wecom_promotion/index.vue(183,184): error TS7006: Parameter 'value' implicitly has an 'any' type. +src/views/order/index.vue(446,33): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap. +src/views/order/index.vue(1542,13): error TS2367: This comparison appears to be unintentional because the types '"supplement"' and '"normal"' have no overlap. +src/views/tcm/appointment/list.vue(263,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type. +src/views/tcm/diagnosis/add.vue(744,49): error TS2349: This expression is not callable. + Type 'String' has no call signatures. +src/views/tcm/diagnosis/add.vue(772,45): error TS2349: This expression is not callable. + Type 'String' has no call signatures. +src/views/tcm/diagnosis/components/BloodRecordList.vue(416,5): error TS2322: Type 'string' is not assignable to type 'number'. +src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(145,54): error TS2554: Expected 0 arguments, but got 1. +src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(146,50): error TS2554: Expected 0 arguments, but got 1. +src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(147,47): error TS2554: Expected 0 arguments, but got 1. +src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(148,47): error TS2554: Expected 0 arguments, but got 1. +src/views/tcm/diagnosis/components/RecordingVideoPlayer.vue(154,26): error TS2554: Expected 0 arguments, but got 1. +src/views/tcm/diagnosis/index.vue(379,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type. +src/views/tcm/follow/index.vue(239,69): error TS7006: Parameter 'cmd' implicitly has an 'any' type. +src/views/workbench/index.vue(541,18): error TS7006: Parameter 'd' implicitly has an 'any' type. diff --git a/artifacts/prescription-enhancements/vue-compile.json b/artifacts/prescription-enhancements/vue-compile.json new file mode 100644 index 000000000..1ea2209cf --- /dev/null +++ b/artifacts/prescription-enhancements/vue-compile.json @@ -0,0 +1,42 @@ +[ + { + "file": "admin/src/components/chat-dialog/index.vue", + "errors": [] + }, + { + "file": "admin/src/views/consumer/prescription/guahao.vue", + "errors": [] + }, + { + "file": "admin/src/views/consumer/prescription/index.vue", + "errors": [] + }, + { + "file": "admin/src/views/consumer/prescription/order_list.vue", + "errors": [] + }, + { + "file": "admin/src/views/consumer/prescription/order_list_h5.vue", + "errors": [] + }, + { + "file": "admin/src/views/patient/reception/index.vue", + "errors": [] + }, + { + "file": "admin/src/views/tcm/appointment/list.vue", + "errors": [] + }, + { + "file": "admin/src/views/tcm/appointment/list_h5.vue", + "errors": [] + }, + { + "file": "admin/src/views/tcm/diagnosis/appointment.vue", + "errors": [] + }, + { + "file": "admin/src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue", + "errors": [] + } +] \ No newline at end of file diff --git a/artifacts/wecom-member-sync/README.md b/artifacts/wecom-member-sync/README.md new file mode 100644 index 000000000..a23c769a6 --- /dev/null +++ b/artifacts/wecom-member-sync/README.md @@ -0,0 +1,29 @@ +# 企业微信成员范围同步修复 + +## 原因与处理 + +截图中的“待移出”来自本地禁用状态与上次企微范围不一致。批量修改原来只写入同步队列,依赖后台分钟任务;单独保存忽略了同步器的 `noop`/未完成状态。另有两个一致性缺陷:同步期间配置版本变化后仍返回 synced;周期重算可能将超时租约或尚未应用的版本直接视为完成。 + +本次修改: + +- 新增 POST `/firstvisit.wecomPromotion/syncMemberRange`,只向当前有权限操作的方案推送完整可用成员名单,再回读确认。不使用会导入其他链接的 `syncRemoteLinks`。 +- 批量保存后由页面最多 2 并发调用专用同步接口,等待每个方案结果,不以后台任务启动为本次修改立即生效的前提。 +- 单独保存、批量保存、手动同步均返回明确的 `synced` / `pending` / `failed` / `blocked` 状态。保存本地成功与远端确认分开显示。 +- 已下线成员从推送名单排除,部门范围清空;回读仍含旧成员或部门时判失败,不能标同步成功。 +- 同步完成时检查租约、令牌、配置版本。过期工作不能覆盖新确认快照;变更未完成时继续排队。 +- 同样的开关状态再次批量保存仍会重算,以修复之前留下的待同步任务。 +- 页面新增“同步成员范围”按钮和批量失败结果重试,并展示当前计划、上次确认的远端范围与具体同步错误。 + +## 部署与现有方案 + +本次修改源代码,未部署,未读取业务数据库或调用真实企业微信修改接口。不新增表结构或权限菜单,沿用获客助手页面和方案操作范围权限;需将 admin 构建及 server 修改一起部署。已有队列表仍按原项目要求安装。 + +部署后,对已经出现“待移出”的方案点击“同步成员范围”;只有显示“企微成员范围已确认同步”,才能认为当前计划已经由企业微信回读确认。批量重新保存也会主动同步各方案。后台分钟任务仍用于上限、有效期和失败补偿。 + +## 验证记录 + +见 [前端验证](frontend.md) 与 [隔离数据库验证](backend-tests.md)。所有测试使用虚构数据,未向真实企业微信发送请求。 + +生产构建通过(Vite,2 分 4 秒),输出到任务临时目录,未覆盖线上目录。完整输出保留在 `build.log`;仍有既有的大体积分包警告。前端 7 项回归与后端 24 个场景、52 个断言均通过。全量类型检查仍有其他模块的已有报错,详情见前端验证记录。 + +浏览器隔离验证已通过:使用实际 Vue 页面和本地 API 替身,初始仅“测试许可”上线、“测试旧医助”仍在远端快照中。手动同步成功后,页面显示仅许可在企微范围,旧医助变为“未在企微范围”;模拟接口失败后,页面保留旧医助“待移出(仍在企微)”,展示具体错误和“重试同步”。验证页及临时服务已关闭。复现入口为 `ui-preview.mjs`,使用本机回环地址和虚构数据。 diff --git a/artifacts/wecom-member-sync/backend-test-output.log b/artifacts/wecom-member-sync/backend-test-output.log new file mode 100644 index 000000000..5d7f2efe3 --- /dev/null +++ b/artifacts/wecom-member-sync/backend-test-output.log @@ -0,0 +1,31 @@ +PASS only XuKe is sent and both remote range dimensions are verified +PASS GET mismatch rejects old user and remains retryable +PASS GET mismatch rejects department expansion and remains retryable +PASS transport failure preserves last confirmed snapshot and retry +PASS active sync lease cannot be reported as synced or stolen +PASS version change during GET reports pending and resyncs +PASS lease expiry during GET cannot commit success +PASS superseded lease cannot overwrite newer worker snapshot +PASS reconcile keeps expired running lease pending despite matching cache +PASS reconcile keeps unconfirmed version pending despite matching cache +PASS reconcile removes cached departments even when users match +PASS explicit retry repairs old pending without changing member switches +PASS explicit retry repairs old failed backoff without changing member switches +PASS explicit retry active lease returns pending without false success +PASS explicit retry API failure remains failed with saved local state +PASS no eligible member is blocked and never sends empty official range +PASS explicit retry rejects pools outside operator scope +PASS batch repeated offline selection requeues the unsynced remote range +PASS batch disables both old assistants and queue confirms XuKe only +PASS single offline with active worker reports pending then retries successfully +PASS single and batch preserve at least one eligible assistant +PASS shared operator can retry its own assigned pool +PASS retry never revives deleting pool +PASS retry never revives delete failed pool +{ + "passed": 24, + "failed": 0, + "checks": 52, + "failures": [] +} +Disposable test database dropped. diff --git a/artifacts/wecom-member-sync/backend-tests.md b/artifacts/wecom-member-sync/backend-tests.md new file mode 100644 index 000000000..dbead1315 --- /dev/null +++ b/artifacts/wecom-member-sync/backend-tests.md @@ -0,0 +1,31 @@ +# 企微成员范围同步回归验证 + +2026-09-09 验证通过:**24 个场景,52 个断言,0 失败**。 + +- 测试:`server/tests/WecomPromotionMemberSyncTest.php` +- 启动脚本:`artifacts/wecom-member-sync/run-backend-tests.ps1` +- 完整输出:`artifacts/wecom-member-sync/backend-test-output.log` +- 环境:PHP 8.2.9、独立 MySQL 5.7.26、真实 Think ORM 与事务。 + +运行命令(仓库根目录): + +```powershell +& ./artifacts/wecom-member-sync/run-backend-tests.ps1 +``` + +启动脚本使用随机空闲回环端口、`--no-defaults` 和独立临时 datadir;测试另建随机 `wecom_member_test_*` 数据库。测试仅加载 vendor、框架辅助函数、未初始化的 `think\App` 及显式测试 DB 配置,不读取业务数据库配置或 `.env`。测试使用部署 SQL 中的真实建表定义,API fake 不访问企微。 + +验证覆盖: + +1. 仅许可 `XuKe` 启用时,update 精确发送 `user_list=[XuKe]` 和 `department_list=[]`,随后 GET 校验并保存真实返回范围。 +2. GET 仍含旧医助或非空部门时失败,保留失败原因、原已确认版本和重试计划;本地下线状态保持不变。 +3. 接口异常保留原已确认远端快照及重试退避。 +4. 活跃租约不会被抢占或误报已同步;GET 期间版本变化、租约过期或被新工作接管均返回 pending;旧工作不能覆盖新快照。 +5. 重算不会把过期租约、未应用版本或残留部门误置为已同步。 +6. 显式重试可修复旧 pending、失败退避以及开关未变化的状态,并准确返回 synced / pending / failed / blocked。 +7. 实际批量下线两个旧医助后,真实队列服务仅同步 XuKe;重复选择已下线医助仍会重新排队。 +8. 实际单独下线遇到活跃工作时返回 pending,过期后可重试完成。 +9. 单独和批量下线都保持至少一名可用医助;空可用范围不会向企微发送空列表。 +10. 已分配共享操作人可重试自己的方案;无关账号被拒绝;删除中或删除失败的方案不会被同步操作复活。 + +最终运行已删除测试数据库、关闭临时 MySQL 进程并移除临时 datadir。未访问线上企微,也未验证真实租户接口权限或实际远端数据;该报告证明本地同步与状态机回归通过。 diff --git a/artifacts/wecom-member-sync/build.log b/artifacts/wecom-member-sync/build.log new file mode 100644 index 000000000..4022986b8 --- /dev/null +++ b/artifacts/wecom-member-sync/build.log @@ -0,0 +1,494 @@ +vite v6.4.2 building for production... + + WARN +(!) outDir D:\web\zyt\artifacts\wecom-member-sync\admin-build is not inside project root and will not be emptied. +Use --emptyOutDir to override. + + +transforming... +✓ 4060 modules transformed. + + WARN node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js (3199:26): Use of eval in "node_modules/.pnpm/tcplayer.js@5.3.4-beta.32/node_modules/tcplayer.js/dist/tcplayer.v5.3.4.min.js" is strongly discouraged as it poses security risks and may cause issues with minification. + +rendering chunks... +computing gzip size... +../artifacts/wecom-member-sync/admin-build/assets/default_avatar-C6VB7PGm.png 6.09 kB +../artifacts/wecom-member-sync/admin-build/assets/no_perms-jDxcYpYC.png 14.62 kB +../artifacts/wecom-member-sync/admin-build/index.html 31.87 kB │ gzip: 16.16 kB +../artifacts/wecom-member-sync/admin-build/assets/login_bg-BkIjQ0FB.png 59.27 kB +../artifacts/wecom-member-sync/admin-build/assets/red3-NOuWP8DK.png 105.00 kB +../artifacts/wecom-member-sync/admin-build/assets/pink3-BxZ4Y6CS.png 108.36 kB +../artifacts/wecom-member-sync/admin-build/assets/blue3-D3K9OqGO.png 108.98 kB +../artifacts/wecom-member-sync/admin-build/assets/yellow3-C_qd9cqN.png 109.27 kB +../artifacts/wecom-member-sync/admin-build/assets/green3-CPorIQiC.png 109.99 kB +../artifacts/wecom-member-sync/admin-build/assets/purple3-BGd0LxTa.png 110.15 kB +../artifacts/wecom-member-sync/admin-build/assets/my_topbg-BiU0PleK.png 142.47 kB +../artifacts/wecom-member-sync/admin-build/assets/red2-Dw8p71sP.png 750.50 kB +../artifacts/wecom-member-sync/admin-build/assets/purple2-C0tQkldV.png 752.33 kB +../artifacts/wecom-member-sync/admin-build/assets/green2-C-VRKLSN.png 756.86 kB +../artifacts/wecom-member-sync/admin-build/assets/yellow2-B-WtqITJ.png 762.86 kB +../artifacts/wecom-member-sync/admin-build/assets/pink2-BpEp33zy.png 806.48 kB +../artifacts/wecom-member-sync/admin-build/assets/blue2-CRQPdLZd.png 806.81 kB +../artifacts/wecom-member-sync/admin-build/assets/red1-C6Y3UuNB.png 1,660.63 kB +../artifacts/wecom-member-sync/admin-build/assets/yellow1-Ebw0T5sw.png 1,662.23 kB +../artifacts/wecom-member-sync/admin-build/assets/pink1-BWNZrP7C.png 1,668.56 kB +../artifacts/wecom-member-sync/admin-build/assets/blue1-gLOo1H0w.png 1,676.54 kB +../artifacts/wecom-member-sync/admin-build/assets/purple1-BpMq9FWz.png 1,680.01 kB +../artifacts/wecom-member-sync/admin-build/assets/green1-h1zqes95.png 1,688.20 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CmFE2aZQ.css 0.04 kB │ gzip: 0.06 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CjSHFu-R.css 0.04 kB │ gzip: 0.06 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Oppei429.css 0.04 kB │ gzip: 0.06 kB +../artifacts/wecom-member-sync/admin-build/assets/CaseRecordList-Cl-c3q7S.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/DietRecordList-Cc-xUr2B.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/roster-CqNlv_rj.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/BloodRecordList-DhVK-Y_e.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/index-B3imPWFk.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/list-BluNBZln.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/ExerciseRecordList-DGtgtzou.css 0.05 kB │ gzip: 0.07 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-CObVTcPU.css 0.09 kB │ gzip: 0.10 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-D-D8bVPM.css 0.09 kB │ gzip: 0.10 kB +../artifacts/wecom-member-sync/admin-build/assets/pc_details-nlJo1_D0.css 0.09 kB │ gzip: 0.10 kB +../artifacts/wecom-member-sync/admin-build/assets/AppointmentRecordPanel-DC22GDgn.css 0.09 kB │ gzip: 0.10 kB +../artifacts/wecom-member-sync/admin-build/assets/theme-picker-BsELUxM9.css 0.11 kB │ gzip: 0.09 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Cl-9UIip.css 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/AssignLogPanel-DkQMoEkX.css 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/content-DXAsZ7EV.css 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/content-DaxRy47P.css 0.14 kB │ gzip: 0.14 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-CAJZpU7c.css 0.14 kB │ gzip: 0.12 kB +../artifacts/wecom-member-sync/admin-build/assets/content-DxNvUNZR.css 0.15 kB │ gzip: 0.15 kB +../artifacts/wecom-member-sync/admin-build/assets/useListTimeFilter-DI6SIumd.css 0.16 kB │ gzip: 0.14 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-C1NtFi7N.css 0.16 kB │ gzip: 0.11 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Bd3rHJ5J.css 0.18 kB │ gzip: 0.15 kB +../artifacts/wecom-member-sync/admin-build/assets/change-password-DRfsLJ26.css 0.19 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Dy_alH9u.css 0.19 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/decoration-img-C5XvHl9_.css 0.19 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/account_cost-fbCWUO9k.css 0.20 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/index-57RdOFNo.css 0.23 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/error-Cz3CexuM.css 0.24 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-D4atl_7z.css 0.25 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-lNortPsv.css 0.27 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/setting-CxqqGetv.css 0.27 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DX96drV3.css 0.28 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-DYFusokT.css 0.29 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/OrderActionHost-DiqybThz.css 0.31 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bk2rMrqw.css 0.32 kB │ gzip: 0.20 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D0f1Mn00.css 0.33 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/prescription-drawer-BjwiqvPc.css 0.35 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/bind-work-wechat-BX1gqwq1.css 0.35 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DhR2yeiO.css 0.39 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DoFDMZdP.css 0.40 kB │ gzip: 0.23 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DBNvK3ZK.css 0.45 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientOrderList-D8-uh2iu.css 0.45 kB │ gzip: 0.25 kB +../artifacts/wecom-member-sync/admin-build/assets/DiagnosisTodoList-D0SBmTHG.css 0.46 kB │ gzip: 0.23 kB +../artifacts/wecom-member-sync/admin-build/assets/CallRecordPanel-TsjZtTO8.css 0.47 kB │ gzip: 0.23 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C_LxAEkS.css 0.48 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/login-Bt4SvQsz.css 0.52 kB │ gzip: 0.28 kB +../artifacts/wecom-member-sync/admin-build/assets/content-sMbqkta2.css 0.55 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/RecordingPlaybackBlock-B3KYFgvg.css 0.58 kB │ gzip: 0.26 kB +../artifacts/wecom-member-sync/admin-build/assets/medicine-DO6x6zrS.css 0.58 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/preview-pc-BRQDo0AR.css 0.67 kB │ gzip: 0.36 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BzrSkGWL.css 0.67 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/tabbar-DJsOahKR.css 0.69 kB │ gzip: 0.31 kB +../artifacts/wecom-member-sync/admin-build/assets/qywx-Dym8iFe0.css 0.74 kB │ gzip: 0.37 kB +../artifacts/wecom-member-sync/admin-build/assets/AssistantWatchCallDialog-BIPZRgaH.css 0.76 kB │ gzip: 0.38 kB +../artifacts/wecom-member-sync/admin-build/assets/picker-JBYDNsl5.css 0.84 kB │ gzip: 0.32 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-phone-CctIyraX.css 0.94 kB │ gzip: 0.34 kB +../artifacts/wecom-member-sync/admin-build/assets/preview-C7oaKmYo.css 0.98 kB │ gzip: 0.41 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bidrbn2Z.css 1.16 kB │ gzip: 0.51 kB +../artifacts/wecom-member-sync/admin-build/assets/TrackingNoteTimeline-B9T8pFso.css 1.18 kB │ gzip: 0.47 kB +../artifacts/wecom-member-sync/admin-build/assets/RecordingVideoPlayer-4DegHAwm.css 1.19 kB │ gzip: 0.53 kB +../artifacts/wecom-member-sync/admin-build/assets/index-55HtuLpn.css 1.21 kB │ gzip: 0.55 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientInfoCard-n6_UPo9e.css 1.32 kB │ gzip: 0.55 kB +../artifacts/wecom-member-sync/admin-build/assets/TrackingMatrix-zyxtXYPb.css 1.32 kB │ gzip: 0.44 kB +../artifacts/wecom-member-sync/admin-build/assets/SendPanel-DaGPWlQG.css 1.39 kB │ gzip: 0.50 kB +../artifacts/wecom-member-sync/admin-build/assets/picker-XZbUgFct.css 1.57 kB │ gzip: 0.50 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderDetailDrawer-B4rggqa1.css 1.73 kB │ gzip: 0.71 kB +../artifacts/wecom-member-sync/admin-build/assets/ImChatRecordPanel-aX8zpWRQ.css 1.74 kB │ gzip: 0.59 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BYOmK9R5.css 1.93 kB │ gzip: 0.58 kB +../artifacts/wecom-member-sync/admin-build/assets/index-OgnT0gUo.css 1.98 kB │ gzip: 0.56 kB +../artifacts/wecom-member-sync/admin-build/assets/MessageBubble-BwtdPM6K.css 2.04 kB │ gzip: 0.60 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BmDW9vcs.css 2.44 kB │ gzip: 0.86 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientCaseCard-C3Mob6yG.css 2.49 kB │ gzip: 0.81 kB +../artifacts/wecom-member-sync/admin-build/assets/tongji-C0VnzFxy.css 2.49 kB │ gzip: 0.77 kB +../artifacts/wecom-member-sync/admin-build/assets/dept-tongji-ky7rOYoi.css 2.67 kB │ gzip: 0.72 kB +../artifacts/wecom-member-sync/admin-build/assets/readonly-CdP3eZN0.css 2.69 kB │ gzip: 0.92 kB +../artifacts/wecom-member-sync/admin-build/assets/NoteTimeline-I2FSxX4s.css 2.74 kB │ gzip: 0.84 kB +../artifacts/wecom-member-sync/admin-build/assets/patient-call-k8V9hV2G.css 2.85 kB │ gzip: 0.91 kB +../artifacts/wecom-member-sync/admin-build/assets/DailyMatrix-DCZnxI8i.css 2.87 kB │ gzip: 0.85 kB +../artifacts/wecom-member-sync/admin-build/assets/PaibanPanel-aqm8azGc.css 3.35 kB │ gzip: 0.88 kB +../artifacts/wecom-member-sync/admin-build/assets/paiban-BTUHWpUu.css 3.35 kB │ gzip: 0.88 kB +../artifacts/wecom-member-sync/admin-build/assets/appointment-gzuOW_Wp.css 3.43 kB │ gzip: 0.93 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiBatchGenerateDialog-CKkykmdr.css 3.45 kB │ gzip: 0.86 kB +../artifacts/wecom-member-sync/admin-build/assets/h5-BSFcFSny.css 3.52 kB │ gzip: 0.99 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bk7b_sL2.css 3.57 kB │ gzip: 0.96 kB +../artifacts/wecom-member-sync/admin-build/assets/add-ByXRFm8M.css 3.60 kB │ gzip: 0.87 kB +../artifacts/wecom-member-sync/admin-build/assets/PromotionAutomationForm-BMhNCJa-.css 3.64 kB │ gzip: 1.03 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-B6IOi2bJ.css 3.78 kB │ gzip: 1.08 kB +../artifacts/wecom-member-sync/admin-build/assets/WelcomeMessageEditor-DD5mXWP2.css 4.41 kB │ gzip: 1.23 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiReportDialog-CV6AuHV3.css 4.46 kB │ gzip: 1.11 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CMsGEvq_.css 5.19 kB │ gzip: 1.18 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-node-ChUPkaxq.css 5.47 kB │ gzip: 1.29 kB +../artifacts/wecom-member-sync/admin-build/assets/OrderPanel-BzElZPEq.css 5.67 kB │ gzip: 1.46 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DYEnq1RY.css 6.11 kB │ gzip: 1.70 kB +../artifacts/wecom-member-sync/admin-build/assets/list-q8oaox07.css 6.51 kB │ gzip: 1.45 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DtDaTXOB.css 7.01 kB │ gzip: 1.69 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-card-naMqyghz.css 7.09 kB │ gzip: 1.53 kB +../artifacts/wecom-member-sync/admin-build/assets/index-AnyUcdTT.css 7.21 kB │ gzip: 1.86 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BqGStdXW.css 7.95 kB │ gzip: 2.30 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bw7BJ7QR.css 8.03 kB │ gzip: 1.99 kB +../artifacts/wecom-member-sync/admin-build/assets/progress-99NT9ysL.css 8.14 kB │ gzip: 1.91 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Cmf8QBsF.css 8.75 kB │ gzip: 1.78 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CSIcxqFk.css 8.85 kB │ gzip: 2.21 kB +../artifacts/wecom-member-sync/admin-build/assets/index-B_6Taw4k.css 8.97 kB │ gzip: 1.91 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-DOd7Lhrw.css 9.47 kB │ gzip: 2.05 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Dnn9ddtX.css 9.49 kB │ gzip: 2.20 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D9Td5Y2a.css 9.52 kB │ gzip: 2.10 kB +../artifacts/wecom-member-sync/admin-build/assets/ProgressPanel-C_aSqDJh.css 10.10 kB │ gzip: 2.10 kB +../artifacts/wecom-member-sync/admin-build/assets/index-nUsN4URL.css 10.57 kB │ gzip: 2.04 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DV5JwFnL.css 11.95 kB │ gzip: 2.61 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Dc4ifSiV.css 13.65 kB │ gzip: 3.05 kB +../artifacts/wecom-member-sync/admin-build/assets/commission-settlement-Gy2UsUPr.css 15.55 kB │ gzip: 3.19 kB +../artifacts/wecom-member-sync/admin-build/assets/list_h5-jFsYJrxI.css 17.68 kB │ gzip: 3.23 kB +../artifacts/wecom-member-sync/admin-build/assets/order_list-CMCIYVRT.css 18.15 kB │ gzip: 3.76 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C6H7fmYO.css 18.86 kB │ gzip: 3.83 kB +../artifacts/wecom-member-sync/admin-build/assets/WecomFloatingWidgetBuilder-DRWt4rvh.css 19.20 kB │ gzip: 4.00 kB +../artifacts/wecom-member-sync/admin-build/assets/index_h5-CWq8J0dT.css 19.54 kB │ gzip: 3.51 kB +../artifacts/wecom-member-sync/admin-build/assets/yeji-7-gSVFmd.css 25.24 kB │ gzip: 4.60 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BSycZlsS.css 45.91 kB │ gzip: 9.91 kB +../artifacts/wecom-member-sync/admin-build/assets/order_list_h5-DB3qJv9G.css 48.13 kB │ gzip: 7.68 kB +../artifacts/wecom-member-sync/admin-build/assets/.pnpm-B3v8nGpq.css 723.50 kB │ gzip: 100.49 kB +../artifacts/wecom-member-sync/admin-build/assets/getExposeType-BhVtb25-.js 0.07 kB │ gzip: 0.09 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-qqeREw9s.js 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-DcOELR-K.js 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-tHefvLXa.js 0.13 kB │ gzip: 0.13 kB +../artifacts/wecom-member-sync/admin-build/assets/MediaSourceSelect-8A82adC4.js 0.14 kB │ gzip: 0.14 kB +../artifacts/wecom-member-sync/admin-build/assets/code-preview-DRmC7RiQ.js 0.16 kB │ gzip: 0.15 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-X53VrVRm.js 0.18 kB │ gzip: 0.16 kB +../artifacts/wecom-member-sync/admin-build/assets/refund-log-B9It8rlX.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/account-adjust-qMgPFEex.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Bl11rHMa.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/content-ne8NsUKb.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Dms_y08P.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/content-tYml8wxW.js 0.19 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/perm-Cs7Dcdwt.js 0.20 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/diabetes-discovery-display-B_wmGXQJ.js 0.20 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderTimeDialog-Dqm-s38p.js 0.20 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/weapp-z86WO93x.js 0.20 kB │ gzip: 0.15 kB +../artifacts/wecom-member-sync/admin-build/assets/GancaoSubmissionReconcileButton-IPCTzvIw.js 0.21 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Ddgk3Vgk.js 0.21 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-CE55pahX.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-AvD9VAin.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-CboBRRXJ.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-DjOH93eC.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-C4ieP-Hx.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-Dxe0CJRt.js 0.21 kB │ gzip: 0.17 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DG0CrN4I.js 0.22 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-awcvFaxy.js 0.22 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-DvEPH3sF.js 0.22 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-_sZJZZlo.js 0.22 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/useLockFn-DZaCbVGv.js 0.22 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-DbL-VfLn.js 0.23 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/auth-CzTX0mcP.js 0.24 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-BVUVZ4z1.js 0.25 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/relations-add-Bbfd_-Kp.js 0.25 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/mobile-style-DS1gdFbp.js 0.26 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-XEIomOE9.js 0.27 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/cost-edit-BOiGaL17.js 0.30 kB │ gzip: 0.20 kB +../artifacts/wecom-member-sync/admin-build/assets/yeji-edit-F8OmljrO.js 0.30 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/404-cLkY5rts.js 0.31 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/data-table-BybaVEqV.js 0.31 kB │ gzip: 0.20 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-edit-CNLd_xiS.js 0.33 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/medicine-DHDpGXRz.js 0.35 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/consumer-FZiu13hZ.js 0.35 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/user-CVW3IWKF.js 0.37 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/role-BMscz-0E.js 0.39 kB │ gzip: 0.18 kB +../artifacts/wecom-member-sync/admin-build/assets/useDictOptions-B6OsldKP.js 0.42 kB │ gzip: 0.32 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D4MbHviX.js 0.42 kB │ gzip: 0.32 kB +../artifacts/wecom-member-sync/admin-build/assets/admin-BG9sUQT0.js 0.42 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Czz9V8bo.js 0.44 kB │ gzip: 0.36 kB +../artifacts/wecom-member-sync/admin-build/assets/pay-CLNugi0F.js 0.45 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/department-CNIAiYg7.js 0.46 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-p4ALi4Sf.js 0.46 kB │ gzip: 0.19 kB +../artifacts/wecom-member-sync/admin-build/assets/content-uGlbGuas.js 0.47 kB │ gzip: 0.37 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-CWDxq6pQ.js 0.48 kB │ gzip: 0.25 kB +../artifacts/wecom-member-sync/admin-build/assets/post-C0FRzdXA.js 0.49 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/overflow-Bh-ERwlc.js 0.49 kB │ gzip: 0.37 kB +../artifacts/wecom-member-sync/admin-build/assets/decoration-CvKJtp9z.js 0.50 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/message-B01Piuuj.js 0.50 kB │ gzip: 0.21 kB +../artifacts/wecom-member-sync/admin-build/assets/403-WJpkLdIc.js 0.52 kB │ gzip: 0.44 kB +../artifacts/wecom-member-sync/admin-build/assets/footer.vue_vue_type_script_setup_true_lang-CJd2gVU1.js 0.53 kB │ gzip: 0.38 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-Bj_piHuB.js 0.54 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-DwQmZN66.js 0.54 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/add-nav-CxIe0DSN.js 0.54 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/content-C8KmKRfj.js 0.54 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-set-C2QaR06b.js 0.54 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-CxOIVUbW.js 0.55 kB │ gzip: 0.26 kB +../artifacts/wecom-member-sync/admin-build/assets/patient-CAccvuDU.js 0.56 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/fans-ymq1y7mi.js 0.60 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-CRgmMBI6.js 0.60 kB │ gzip: 0.28 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-C65vISyn.js 0.61 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-DepTsRMB.js 0.61 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/diag-display-DCz_VAqj.js 0.61 kB │ gzip: 0.39 kB +../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-BOyQbtb7.js 0.61 kB │ gzip: 0.41 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bgu1hGId.js 0.63 kB │ gzip: 0.43 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-BRVMaOaW.js 0.63 kB │ gzip: 0.32 kB +../artifacts/wecom-member-sync/admin-build/assets/link-D9GAHWRN.js 0.64 kB │ gzip: 0.44 kB +../artifacts/wecom-member-sync/admin-build/assets/asset-MS9grbtE.js 0.67 kB │ gzip: 0.22 kB +../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-D2f7dQtt.js 0.69 kB │ gzip: 0.45 kB +../artifacts/wecom-member-sync/admin-build/assets/useMediaSourceOptions-B-KC30lN.js 0.70 kB │ gzip: 0.42 kB +../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-CZ5ik_hN.js 0.70 kB │ gzip: 0.45 kB +../artifacts/wecom-member-sync/admin-build/assets/website-B8vbukz_.js 0.74 kB │ gzip: 0.24 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-_vTr8Gju.js 0.76 kB │ gzip: 0.49 kB +../artifacts/wecom-member-sync/admin-build/assets/usePaging-oaNM6A9T.js 0.77 kB │ gzip: 0.47 kB +../artifacts/wecom-member-sync/admin-build/assets/self_input_stats-DN-gl7Ot.js 0.79 kB │ gzip: 0.27 kB +../artifacts/wecom-member-sync/admin-build/assets/decoration-img-D9zjI4VN.js 0.81 kB │ gzip: 0.50 kB +../artifacts/wecom-member-sync/admin-build/assets/dict-BeF70n9_.js 0.81 kB │ gzip: 0.25 kB +../artifacts/wecom-member-sync/admin-build/assets/code-IeXe1BXO.js 0.82 kB │ gzip: 0.25 kB +../artifacts/wecom-member-sync/admin-build/assets/blood-thresholds-CJ1fqMjh.js 0.83 kB │ gzip: 0.34 kB +../artifacts/wecom-member-sync/admin-build/assets/content-DhfW0oMX.js 0.84 kB │ gzip: 0.55 kB +../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-B7uTRM6j.js 0.86 kB │ gzip: 0.55 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-kAZgL5aN.js 0.86 kB │ gzip: 0.52 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-CDLrQsa9.js 0.88 kB │ gzip: 0.51 kB +../artifacts/wecom-member-sync/admin-build/assets/error-DqXjSnDQ.js 0.89 kB │ gzip: 0.60 kB +../artifacts/wecom-member-sync/admin-build/assets/theme-picker-CJcYdGjg.js 0.92 kB │ gzip: 0.59 kB +../artifacts/wecom-member-sync/admin-build/assets/wecomOauthPostMessage-C_e6VGrO.js 0.95 kB │ gzip: 0.53 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-Cmgm_Z5P.js 0.98 kB │ gzip: 0.53 kB +../artifacts/wecom-member-sync/admin-build/assets/wx_oa-BX1EksXU.js 1.04 kB │ gzip: 0.29 kB +../artifacts/wecom-member-sync/admin-build/assets/article-C_uwRm-g.js 1.07 kB │ gzip: 0.26 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-setting.vue_vue_type_script_setup_true_lang-C1N5GjJr.js 1.08 kB │ gzip: 0.61 kB +../artifacts/wecom-member-sync/admin-build/assets/rich_text-DHfEzg2F.js 1.10 kB │ gzip: 0.58 kB +../artifacts/wecom-member-sync/admin-build/assets/cache-DwXb7UJT.js 1.13 kB │ gzip: 0.69 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DCOziDmg.js 1.21 kB │ gzip: 0.70 kB +../artifacts/wecom-member-sync/admin-build/assets/popover_input-pPM8UqwF.js 1.25 kB │ gzip: 0.59 kB +../artifacts/wecom-member-sync/admin-build/assets/finance-aNuyRBoJ.js 1.30 kB │ gzip: 0.37 kB +../artifacts/wecom-member-sync/admin-build/assets/pc-Bg3ZB7sY.js 1.31 kB │ gzip: 0.78 kB +../artifacts/wecom-member-sync/admin-build/assets/doctor-CK2s5tr0.js 1.32 kB │ gzip: 0.37 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-CRAXblPZ.js 1.33 kB │ gzip: 0.78 kB +../artifacts/wecom-member-sync/admin-build/assets/content-BYP2_Tvt.js 1.33 kB │ gzip: 0.72 kB +../artifacts/wecom-member-sync/admin-build/assets/content-CbdFdpr9.js 1.37 kB │ gzip: 0.70 kB +../artifacts/wecom-member-sync/admin-build/assets/content-DNecD5Ej.js 1.38 kB │ gzip: 0.79 kB +../artifacts/wecom-member-sync/admin-build/assets/file-Bp3s6tGU.js 1.41 kB │ gzip: 0.64 kB +../artifacts/wecom-member-sync/admin-build/assets/upload-B7edCrM6.js 1.41 kB │ gzip: 0.64 kB +../artifacts/wecom-member-sync/admin-build/assets/content-Btz_MAcB.js 1.42 kB │ gzip: 0.79 kB +../artifacts/wecom-member-sync/admin-build/assets/code-preview.vue_vue_type_script_setup_true_lang-uoGImbA_.js 1.44 kB │ gzip: 0.85 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Eef1y49A.js 1.44 kB │ gzip: 0.85 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_style_index_0_lang-YvOGf2LN.js 1.46 kB │ gzip: 0.79 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-phone-BSc3cQZO.js 1.47 kB │ gzip: 0.79 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-C5crH6QC.js 1.50 kB │ gzip: 0.76 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-D0XeSt7x.js 1.55 kB │ gzip: 0.87 kB +../artifacts/wecom-member-sync/admin-build/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-xuHF5G6T.js 1.58 kB │ gzip: 0.80 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientInfoCard-DS1nmIqs.js 1.60 kB │ gzip: 0.82 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CzFCY2cx.js 1.62 kB │ gzip: 0.71 kB +../artifacts/wecom-member-sync/admin-build/assets/style-Dr3deMtC.js 1.62 kB │ gzip: 0.88 kB +../artifacts/wecom-member-sync/admin-build/assets/refund-log.vue_vue_type_script_setup_true_lang-MUi35Imc.js 1.66 kB │ gzip: 0.87 kB +../artifacts/wecom-member-sync/admin-build/assets/statistics-CJjYO2rr.js 1.69 kB │ gzip: 1.02 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-I0pt-6bF.js 1.71 kB │ gzip: 0.81 kB +../artifacts/wecom-member-sync/admin-build/assets/order-BUKOtASo.js 1.74 kB │ gzip: 0.44 kB +../artifacts/wecom-member-sync/admin-build/assets/environment-DWwJCNO7.js 1.74 kB │ gzip: 0.74 kB +../artifacts/wecom-member-sync/admin-build/assets/setup-DiCR4FTt.js 1.77 kB │ gzip: 1.00 kB +../artifacts/wecom-member-sync/admin-build/assets/useMenuOa-BkNkTb8E.js 1.82 kB │ gzip: 0.85 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-setting-u7gF0hf4.js 1.88 kB │ gzip: 0.53 kB +../artifacts/wecom-member-sync/admin-build/assets/index-d48aBo9A.js 1.90 kB │ gzip: 0.99 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-QYQSHa-u.js 1.94 kB │ gzip: 1.07 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C2cPGRvp.js 2.03 kB │ gzip: 0.98 kB +../artifacts/wecom-member-sync/admin-build/assets/index-bKgIS70c.js 2.04 kB │ gzip: 1.14 kB +../artifacts/wecom-member-sync/admin-build/assets/TrackingNoteTimeline-xYwE1xTb.js 2.08 kB │ gzip: 1.17 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Xkc1_OtQ.js 2.09 kB │ gzip: 1.17 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-B6HGq-9K.js 2.09 kB │ gzip: 0.95 kB +../artifacts/wecom-member-sync/admin-build/assets/open_setting-9okEzl7R.js 2.10 kB │ gzip: 1.12 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bgd9wlhP.js 2.12 kB │ gzip: 1.23 kB +../artifacts/wecom-member-sync/admin-build/assets/account-adjust.vue_vue_type_script_setup_true_lang-D-3zXgHH.js 2.16 kB │ gzip: 1.12 kB +../artifacts/wecom-member-sync/admin-build/assets/filing-CyXPFn3v.js 2.17 kB │ gzip: 1.17 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Cj99LS1q.js 2.18 kB │ gzip: 1.28 kB +../artifacts/wecom-member-sync/admin-build/assets/icon-Bns0PfYA.js 2.23 kB │ gzip: 0.82 kB +../artifacts/wecom-member-sync/admin-build/assets/content.vue_vue_type_script_setup_true_lang-DhnZ5uUj.js 2.24 kB │ gzip: 1.16 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BOJvKXiZ.js 2.28 kB │ gzip: 1.16 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BStMM2L3.js 2.29 kB │ gzip: 1.09 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BBhGStE3.js 2.37 kB │ gzip: 1.15 kB +../artifacts/wecom-member-sync/admin-build/assets/RecordingPlaybackBlock-DqgFGONg.js 2.40 kB │ gzip: 1.19 kB +../artifacts/wecom-member-sync/admin-build/assets/auth.vue_vue_type_script_setup_true_lang-gsN33aVY.js 2.40 kB │ gzip: 1.33 kB +../artifacts/wecom-member-sync/admin-build/assets/stats-DnnjDn0w.js 2.45 kB │ gzip: 0.58 kB +../artifacts/wecom-member-sync/admin-build/assets/data-table.vue_vue_type_script_setup_true_lang--ailqOCz.js 2.51 kB │ gzip: 1.27 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-attr-CUvkQdHO.js 2.51 kB │ gzip: 1.25 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-C5GzP6lG.js 2.52 kB │ gzip: 1.21 kB +../artifacts/wecom-member-sync/admin-build/assets/h5-DSNR1Mm7.js 2.56 kB │ gzip: 1.22 kB +../artifacts/wecom-member-sync/admin-build/assets/change-password-ta6h_6OO.js 2.57 kB │ gzip: 1.35 kB +../artifacts/wecom-member-sync/admin-build/assets/im-business-message-parse-kN-fWnvR.js 2.60 kB │ gzip: 1.28 kB +../artifacts/wecom-member-sync/admin-build/assets/add-nav.vue_vue_type_script_setup_true_lang-CFknNh-L.js 2.61 kB │ gzip: 1.26 kB +../artifacts/wecom-member-sync/admin-build/assets/tabbar-C88gPo2h.js 2.66 kB │ gzip: 1.33 kB +../artifacts/wecom-member-sync/admin-build/assets/file-CMovQJ90.js 2.73 kB │ gzip: 1.06 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-RehJztW-.js 2.74 kB │ gzip: 1.25 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-Cpki_pAM.js 2.77 kB │ gzip: 1.53 kB +../artifacts/wecom-member-sync/admin-build/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-C-68I4vC.js 2.77 kB │ gzip: 1.08 kB +../artifacts/wecom-member-sync/admin-build/assets/CaseRecordList-8zdwttyu.js 2.80 kB │ gzip: 1.53 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CLLhcmpE.js 2.81 kB │ gzip: 1.06 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DiASvNNV.js 2.85 kB │ gzip: 1.34 kB +../artifacts/wecom-member-sync/admin-build/assets/follow_reply-CSorma7I.js 2.85 kB │ gzip: 1.49 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Bffw7YqJ.js 2.86 kB │ gzip: 1.44 kB +../artifacts/wecom-member-sync/admin-build/assets/cost-edit.vue_vue_type_script_setup_true_lang-Cmvbgg8a.js 2.86 kB │ gzip: 1.35 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-CtES0DRg.js 2.89 kB │ gzip: 1.41 kB +../artifacts/wecom-member-sync/admin-build/assets/default_reply-CscRdIJ0.js 2.91 kB │ gzip: 1.54 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-0LKRFKuR.js 2.96 kB │ gzip: 1.13 kB +../artifacts/wecom-member-sync/admin-build/assets/protocol-ZLC8fa4K.js 2.98 kB │ gzip: 1.10 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-B-G1Tu-E.js 2.99 kB │ gzip: 1.45 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-node-CBylWf1i.js 3.03 kB │ gzip: 1.25 kB +../artifacts/wecom-member-sync/admin-build/assets/relations-add.vue_vue_type_script_setup_true_lang-aR2rU0ed.js 3.04 kB │ gzip: 1.27 kB +../artifacts/wecom-member-sync/admin-build/assets/picker.vue_vue_type_script_setup_true_lang-BfUAGTj-.js 3.05 kB │ gzip: 1.50 kB +../artifacts/wecom-member-sync/admin-build/assets/AssignLogPanel-bKGiDzvs.js 3.06 kB │ gzip: 1.48 kB +../artifacts/wecom-member-sync/admin-build/assets/keyword_reply-CsSFhqK2.js 3.11 kB │ gzip: 1.60 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BpbTpEr6.js 3.12 kB │ gzip: 1.52 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C7CPelVN.js 3.21 kB │ gzip: 1.53 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-Ci8Hxg9H.js 3.25 kB │ gzip: 1.47 kB +../artifacts/wecom-member-sync/admin-build/assets/menu-set.vue_vue_type_script_setup_true_lang-DIhktTQ1.js 3.26 kB │ gzip: 1.40 kB +../artifacts/wecom-member-sync/admin-build/assets/pc_details-DLsyGZVY.js 3.39 kB │ gzip: 1.49 kB +../artifacts/wecom-member-sync/admin-build/assets/index-EK1ZoXzo.js 3.39 kB │ gzip: 1.45 kB +../artifacts/wecom-member-sync/admin-build/assets/index-B4DqQN1y.js 3.42 kB │ gzip: 1.53 kB +../artifacts/wecom-member-sync/admin-build/assets/index-F1TVtlEt.js 3.43 kB │ gzip: 1.64 kB +../artifacts/wecom-member-sync/admin-build/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-CXsYk_L_.js 3.44 kB │ gzip: 1.70 kB +../artifacts/wecom-member-sync/admin-build/assets/balance_details-D1em5rcF.js 3.48 kB │ gzip: 1.63 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Dz-tv5pp.js 3.48 kB │ gzip: 1.64 kB +../artifacts/wecom-member-sync/admin-build/assets/index.vue_vue_type_script_setup_true_lang-BkG4Pyop.js 3.52 kB │ gzip: 1.56 kB +../artifacts/wecom-member-sync/admin-build/assets/journal-B3VHOvYL.js 3.73 kB │ gzip: 1.46 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-S15Fn-vv.js 3.73 kB │ gzip: 1.57 kB +../artifacts/wecom-member-sync/admin-build/assets/detail-BN8q6bc8.js 3.76 kB │ gzip: 1.55 kB +../artifacts/wecom-member-sync/admin-build/assets/AssistantWatchCallDialog-D6ItC7fG.js 3.78 kB │ gzip: 1.85 kB +../artifacts/wecom-member-sync/admin-build/assets/CallRecordPanel-BkgzguRi.js 3.78 kB │ gzip: 1.77 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-D8D8cWM0.js 3.91 kB │ gzip: 1.71 kB +../artifacts/wecom-member-sync/admin-build/assets/preview-pc-BUr4tqb-.js 3.98 kB │ gzip: 1.63 kB +../artifacts/wecom-member-sync/admin-build/assets/AppointmentRecordPanel-DofowFlu.js 4.02 kB │ gzip: 1.73 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-ccE_MiO_.js 4.11 kB │ gzip: 1.65 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CyvXSol2.js 4.11 kB │ gzip: 1.79 kB +../artifacts/wecom-member-sync/admin-build/assets/first_visit-BgsXe0rB.js 4.13 kB │ gzip: 0.89 kB +../artifacts/wecom-member-sync/admin-build/assets/detail-D2efPnyJ.js 4.15 kB │ gzip: 1.56 kB +../artifacts/wecom-member-sync/admin-build/assets/MessageBubble-CNAEZB2M.js 4.17 kB │ gzip: 1.79 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C1yhlKda.js 4.22 kB │ gzip: 1.92 kB +../artifacts/wecom-member-sync/admin-build/assets/useListTimeFilter-DmwzA9pt.js 4.26 kB │ gzip: 1.52 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Cf0PaHkM.js 4.37 kB │ gzip: 2.14 kB +../artifacts/wecom-member-sync/admin-build/assets/bind-work-wechat-D86sStWr.js 4.40 kB │ gzip: 2.27 kB +../artifacts/wecom-member-sync/admin-build/assets/recharge_record-CYVPA2q8.js 4.43 kB │ gzip: 1.87 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C4phnhkw.js 4.45 kB │ gzip: 1.79 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Dcp58BAx.js 4.47 kB │ gzip: 1.91 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BYfcmOIK.js 4.49 kB │ gzip: 1.96 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D7s6xUn-.js 4.49 kB │ gzip: 1.96 kB +../artifacts/wecom-member-sync/admin-build/assets/preview-C8KaKJOT.js 4.49 kB │ gzip: 1.70 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BO2nUIFI.js 4.51 kB │ gzip: 1.75 kB +../artifacts/wecom-member-sync/admin-build/assets/RecordingVideoPlayer-DG_ItJGl.js 4.53 kB │ gzip: 2.26 kB +../artifacts/wecom-member-sync/admin-build/assets/ImChatRecordPanel-DBsLrdAR.js 4.54 kB │ gzip: 2.40 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BGjkic1N.js 4.55 kB │ gzip: 1.91 kB +../artifacts/wecom-member-sync/admin-build/assets/paiban-BHVNKfBv.js 4.56 kB │ gzip: 2.13 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CCjsau_X.js 4.57 kB │ gzip: 1.33 kB +../artifacts/wecom-member-sync/admin-build/assets/login_register-BUwvSc4G.js 4.59 kB │ gzip: 2.01 kB +../artifacts/wecom-member-sync/admin-build/assets/dept-tongji-D3yDbmue.js 4.66 kB │ gzip: 2.04 kB +../artifacts/wecom-member-sync/admin-build/assets/index-diGjfvCp.js 4.76 kB │ gzip: 2.05 kB +../artifacts/wecom-member-sync/admin-build/assets/attr.vue_vue_type_script_setup_true_lang-BrN1pP87.js 4.92 kB │ gzip: 2.04 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-B9rCxYXv.js 5.04 kB │ gzip: 1.99 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-BKQXUJgT.js 5.18 kB │ gzip: 2.03 kB +../artifacts/wecom-member-sync/admin-build/assets/PaibanPanel-LklYduy4.js 5.28 kB │ gzip: 2.43 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CQBKhK32.js 5.32 kB │ gzip: 2.19 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C5quTqHH.js 5.53 kB │ gzip: 2.20 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-spsl5dpC.js 5.55 kB │ gzip: 2.56 kB +../artifacts/wecom-member-sync/admin-build/assets/yeji-edit.vue_vue_type_script_setup_true_lang-CpWA2Fm9.js 5.56 kB │ gzip: 1.81 kB +../artifacts/wecom-member-sync/admin-build/assets/mobile-style.vue_vue_type_script_setup_true_lang-Cd_o1G2a.js 5.59 kB │ gzip: 1.86 kB +../artifacts/wecom-member-sync/admin-build/assets/ExerciseRecordList-2DnzVhG1.js 5.62 kB │ gzip: 2.16 kB +../artifacts/wecom-member-sync/admin-build/assets/attr-BEKvhJVW.js 5.62 kB │ gzip: 2.24 kB +../artifacts/wecom-member-sync/admin-build/assets/tongji-DdsvLKqf.js 5.66 kB │ gzip: 2.31 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientOrderList-SLHonpPV.js 5.82 kB │ gzip: 2.84 kB +../artifacts/wecom-member-sync/admin-build/assets/DietRecordList-DXFlk9Hu.js 5.84 kB │ gzip: 2.01 kB +../artifacts/wecom-member-sync/admin-build/assets/mubiao-dept-card-BvVBrj2J.js 5.91 kB │ gzip: 2.38 kB +../artifacts/wecom-member-sync/admin-build/assets/login-DahMaDyR.js 6.03 kB │ gzip: 2.72 kB +../artifacts/wecom-member-sync/admin-build/assets/refund_record-tVmHQHkz.js 6.27 kB │ gzip: 2.33 kB +../artifacts/wecom-member-sync/admin-build/assets/DiagnosisTodoList-DL7sSeE_.js 6.34 kB │ gzip: 2.89 kB +../artifacts/wecom-member-sync/admin-build/assets/index-B3CV61hr.js 6.35 kB │ gzip: 3.20 kB +../artifacts/wecom-member-sync/admin-build/assets/information-CkVlFtW3.js 6.36 kB │ gzip: 1.80 kB +../artifacts/wecom-member-sync/admin-build/assets/NoteTimeline-DrUjWbF8.js 6.48 kB │ gzip: 2.60 kB +../artifacts/wecom-member-sync/admin-build/assets/account_cost-D0LoQOBc.js 6.56 kB │ gzip: 2.86 kB +../artifacts/wecom-member-sync/admin-build/assets/PatientCaseCard-CGXlt3-4.js 6.61 kB │ gzip: 2.34 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CBmrTp4t.js 6.61 kB │ gzip: 2.62 kB +../artifacts/wecom-member-sync/admin-build/assets/index-MiIcWS8C.js 6.77 kB │ gzip: 2.89 kB +../artifacts/wecom-member-sync/admin-build/assets/readonly-BvagAVZz.js 6.98 kB │ gzip: 2.69 kB +../artifacts/wecom-member-sync/admin-build/assets/picker-L0C-vjJL.js 7.32 kB │ gzip: 3.18 kB +../artifacts/wecom-member-sync/admin-build/assets/edit.vue_vue_type_script_setup_true_lang-B2Ih0vdW.js 7.35 kB │ gzip: 2.66 kB +../artifacts/wecom-member-sync/admin-build/assets/setting-B0hbjCIa.js 7.35 kB │ gzip: 2.93 kB +../artifacts/wecom-member-sync/admin-build/assets/weapp-DXM2HiOo.js 7.39 kB │ gzip: 2.13 kB +../artifacts/wecom-member-sync/admin-build/assets/prescription-order-utils-CXfs2Bra.js 7.62 kB │ gzip: 3.11 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Dlg6Bi8D.js 7.65 kB │ gzip: 3.13 kB +../artifacts/wecom-member-sync/admin-build/assets/config-BcLOIFO3.js 7.77 kB │ gzip: 2.59 kB +../artifacts/wecom-member-sync/admin-build/assets/TrackingMatrix-DOwGys12.js 7.84 kB │ gzip: 3.07 kB +../artifacts/wecom-member-sync/admin-build/assets/patient-call-Y8nXiKQB.js 7.85 kB │ gzip: 3.24 kB +../artifacts/wecom-member-sync/admin-build/assets/dayjs-CF5xpNFg.js 8.07 kB │ gzip: 3.45 kB +../artifacts/wecom-member-sync/admin-build/assets/tcm-DXNwz16C.js 8.99 kB │ gzip: 1.57 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-CNbGRUOL.js 9.00 kB │ gzip: 2.83 kB +../artifacts/wecom-member-sync/admin-build/assets/tim-upload-plugin-B7yxlBgj.js 9.47 kB │ gzip: 3.61 kB +../artifacts/wecom-member-sync/admin-build/assets/index-C3eHLn0f.js 9.49 kB │ gzip: 3.84 kB +../artifacts/wecom-member-sync/admin-build/assets/BloodRecordList-DDUvca7F.js 9.58 kB │ gzip: 3.33 kB +../artifacts/wecom-member-sync/admin-build/assets/medicine-C5ovRLd1.js 9.74 kB │ gzip: 3.56 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-BU-RSgIR.js 9.99 kB │ gzip: 3.55 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BaNoRHB2.js 10.06 kB │ gzip: 3.98 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DPwU-iXs.js 10.33 kB │ gzip: 3.89 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiBatchGenerateDialog-MAUu97Gq.js 10.36 kB │ gzip: 4.29 kB +../artifacts/wecom-member-sync/admin-build/assets/SendPanel-CP6ywpBj.js 10.37 kB │ gzip: 3.57 kB +../artifacts/wecom-member-sync/admin-build/assets/progress-Bdpacf-D.js 11.18 kB │ gzip: 4.59 kB +../artifacts/wecom-member-sync/admin-build/assets/list-DLWc-1Ay.js 11.18 kB │ gzip: 4.03 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DI3xU_bx.js 11.30 kB │ gzip: 4.22 kB +../artifacts/wecom-member-sync/admin-build/assets/appointment-BnHZdDEA.js 11.34 kB │ gzip: 4.24 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-D7zgLcEk.js 11.44 kB │ gzip: 3.76 kB +../artifacts/wecom-member-sync/admin-build/assets/OrderPanel-BeU2BFQj.js 11.74 kB │ gzip: 4.22 kB +../artifacts/wecom-member-sync/admin-build/assets/picker-BmsTkGwL.js 12.15 kB │ gzip: 4.49 kB +../artifacts/wecom-member-sync/admin-build/assets/ProgressPanel-ud8QcUjn.js 12.58 kB │ gzip: 4.48 kB +../artifacts/wecom-member-sync/admin-build/assets/prescription-drawer-FdfeYO6a.js 12.76 kB │ gzip: 3.88 kB +../artifacts/wecom-member-sync/admin-build/assets/index-hktqVhuc.js 13.00 kB │ gzip: 4.23 kB +../artifacts/wecom-member-sync/admin-build/assets/index-Brf9bxlj.js 13.11 kB │ gzip: 3.91 kB +../artifacts/wecom-member-sync/admin-build/assets/h5-CcXRh9Cg.js 13.83 kB │ gzip: 4.40 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CWW_NfST.js 14.26 kB │ gzip: 5.90 kB +../artifacts/wecom-member-sync/admin-build/assets/guahao-DcHTagDB.js 14.96 kB │ gzip: 4.41 kB +../artifacts/wecom-member-sync/admin-build/assets/WecomFloatingWidgetBuilder--gBah4yP.js 15.22 kB │ gzip: 6.02 kB +../artifacts/wecom-member-sync/admin-build/assets/index-6M0lWcGt.js 15.52 kB │ gzip: 6.41 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DZLpymXv.js 15.90 kB │ gzip: 5.20 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-BbgFd2kT.js 15.90 kB │ gzip: 4.70 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D0fXfjGU.js 16.34 kB │ gzip: 6.06 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DogyaUiZ.js 16.35 kB │ gzip: 5.11 kB +../artifacts/wecom-member-sync/admin-build/assets/index-D58SNEFF.js 16.60 kB │ gzip: 6.07 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionAiReportDialog-Bu6VbnFg.js 17.15 kB │ gzip: 6.47 kB +../artifacts/wecom-member-sync/admin-build/assets/index-mbKhz0ZD.js 17.84 kB │ gzip: 6.02 kB +../artifacts/wecom-member-sync/admin-build/assets/roster-BiU7KYHE.js 18.09 kB │ gzip: 5.71 kB +../artifacts/wecom-member-sync/admin-build/assets/index-KaH-WGp4.js 18.19 kB │ gzip: 6.88 kB +../artifacts/wecom-member-sync/admin-build/assets/PromotionAutomationForm-Dl6eSdrv.js 18.23 kB │ gzip: 6.37 kB +../artifacts/wecom-member-sync/admin-build/assets/index-KtwiGqvr.js 18.60 kB │ gzip: 5.23 kB +../artifacts/wecom-member-sync/admin-build/assets/WelcomeMessageEditor-lo-w_F0X.js 19.11 kB │ gzip: 6.95 kB +../artifacts/wecom-member-sync/admin-build/assets/OrderActionHost-B1_31moq.js 21.16 kB │ gzip: 6.02 kB +../artifacts/wecom-member-sync/admin-build/assets/index-B6abcro-.js 21.91 kB │ gzip: 7.87 kB +../artifacts/wecom-member-sync/admin-build/assets/add-DZ_NopBp.js 22.29 kB │ gzip: 5.36 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CPsxxVLT.js 22.76 kB │ gzip: 7.97 kB +../artifacts/wecom-member-sync/admin-build/assets/DailyMatrix-CUZg4BRS.js 24.97 kB │ gzip: 7.09 kB +../artifacts/wecom-member-sync/admin-build/assets/list_h5-BQk1KH7b.js 27.36 kB │ gzip: 9.29 kB +../artifacts/wecom-member-sync/admin-build/assets/list-CHJTYjNq.js 29.93 kB │ gzip: 9.61 kB +../artifacts/wecom-member-sync/admin-build/assets/qywx-vbiMVYqv.js 30.35 kB │ gzip: 10.01 kB +../artifacts/wecom-member-sync/admin-build/assets/commission-settlement-rcwbrTnB.js 35.83 kB │ gzip: 9.75 kB +../artifacts/wecom-member-sync/admin-build/assets/edit-DUaMYFnP.js 35.86 kB │ gzip: 9.02 kB +../artifacts/wecom-member-sync/admin-build/assets/index-BxaPQXKm.js 36.24 kB │ gzip: 12.99 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CwotNYII.js 36.70 kB │ gzip: 11.82 kB +../artifacts/wecom-member-sync/admin-build/assets/index_h5-BKLNAJoN.js 37.44 kB │ gzip: 11.46 kB +../artifacts/wecom-member-sync/admin-build/assets/index-GLDXkTd_.js 40.10 kB │ gzip: 11.87 kB +../artifacts/wecom-member-sync/admin-build/assets/PrescriptionOrderDetailDrawer-C0hb8NZr.js 48.77 kB │ gzip: 13.48 kB +../artifacts/wecom-member-sync/admin-build/assets/index-JRqlrlqw.js 50.72 kB │ gzip: 14.98 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DmecxuyG.js 55.03 kB │ gzip: 15.89 kB +../artifacts/wecom-member-sync/admin-build/assets/tim-profanity-filter-plugin-BJ7z5puq.js 55.90 kB │ gzip: 21.04 kB +../artifacts/wecom-member-sync/admin-build/assets/index-DYj8tK9D.js 67.65 kB │ gzip: 20.68 kB +../artifacts/wecom-member-sync/admin-build/assets/rtc-detect-CAvkmauD.js 74.80 kB │ gzip: 25.93 kB +../artifacts/wecom-member-sync/admin-build/assets/yeji-5JxNzYB_.js 87.40 kB │ gzip: 23.27 kB +../artifacts/wecom-member-sync/admin-build/assets/index-RIkBLGnV.js 88.42 kB │ gzip: 25.66 kB +../artifacts/wecom-member-sync/admin-build/assets/order_list-C6JoI8wj.js 114.89 kB │ gzip: 31.81 kB +../artifacts/wecom-member-sync/admin-build/assets/order_list_h5-DFg5JSxg.js 139.58 kB │ gzip: 36.82 kB +../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/chat-uikit-engine-BJz4IWsw.js 162.84 kB │ gzip: 41.72 kB +../artifacts/wecom-member-sync/admin-build/assets/index-CE4okNZ0.js 290.16 kB │ gzip: 92.01 kB +../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/chat-Bg29VzHQ.js 725.88 kB │ gzip: 178.89 kB +../artifacts/wecom-member-sync/admin-build/assets/@tencentcloud/call-engine-js-CRx4G1jN.js 2,290.42 kB │ gzip: 749.64 kB +../artifacts/wecom-member-sync/admin-build/assets/.pnpm-BHOjf1ZS.js 18,525.72 kB │ gzip: 5,519.46 kB + + WARN +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. + +✓ built in 2m 4s diff --git a/artifacts/wecom-member-sync/frontend.md b/artifacts/wecom-member-sync/frontend.md new file mode 100644 index 000000000..1a3969b60 --- /dev/null +++ b/artifacts/wecom-member-sync/frontend.md @@ -0,0 +1,45 @@ +# 企业微信获客成员范围同步:前端修复 + +## 诊断 + +- 单独开关原先只检查 `sync_error`,为空就提示“企微多人路由范围已重新计算”;该字段无法区分同步已确认、任务仍在排队、活跃租约导致未执行等情况。 +- 批量保存原先只提示后台同步,没有主动消费成功入队的方案;分钟任务未运行时本地规则可以长期与官方链接不一致。 +- `is_in_remote_range` 来自官方链接上次确认的 `range_user_json`,并非本地 enabled 状态。“待移出”代表成员仍在记录中的企微范围,确实可能继续获客。 +- 原有 `syncRemoteLinks` 是企业微信链接列表导入接口,会扫描 `listLinks/getLink` 并 upsert,本次没有将它误用为推送当前方案成员范围的入口。 + +## 最终行为 + +- 接入新的 `POST /firstvisit.wecomPromotion/syncMemberRange`,只提交指定 `pool_id`,使用 120 秒超时并关闭 HTTP 自动重试。 +- 保存方案、保存成员规则、单独切换成员,仅在后端明确返回 `sync_status: synced` 时提示企微成员范围已确认同步;旧服务端缺少该字段时保守显示尚未确认同步。 +- 批量保存成功且 `sync_queued` 的方案立即进行专用范围同步,最多并发 2 个,全部等待结束再完成交互。对话框页脚显示同步进度,未完成时禁止关闭。 +- 按方案保留本地保存结果、企微同步结果及具体错误。保存失败或同步未确认的方案保持勾选,允许重试;pending 不做无界重复请求。 +- 方案顶部提供“同步成员范围”,结果列表提供“重试同步”。权限沿用方案 `can_operate`,必须已有官方链接。 +- 页面明确展示“当前计划”和“上次企微确认”成员及确认时间,保留服务端 `last_error`;行状态改为“企微范围内”“待移出(仍在企微)”“待加入企微”“未在企微范围”。 + +## 文件 + +- `admin/src/views/first_visit/wecom_promotion/index.vue` +- `admin/src/api/first_visit.ts` +- `admin/tests/wecom-promotion-member-sync.test.cjs` + +## 验证 + +命令:`cd admin && node --test tests/wecom-promotion-member-sync.test.cjs` + +7 项通过,使用 Vue SFC 真实 setup 编译后执行,并编译模板及 SCSS;API 均为测试替身,不连接数据库或企业微信。 + +1. 本地禁用而远端仍包含的成员明确显示待移出,展示实际同步错误。 +2. 单成员切换不把空错误和计划入队误判为企微同步成功。 +3. 单成员规则保存保留企微失败原因,只接受明确 synced 确认。 +4. 方案保存 pending 时保持警告。 +5. 手动重试只调用指定方案专用接口,并检查操作权限。 +6. 批量同步最多两并发,等待全部结果;本地保存失败项不会请求企微同步。 +7. 批量保存部分失败和企微超时保留逐项错误与选择,不误报整体成功。 + +`git diff --check` 对修改文件通过。 + +全量 `npx vue-tsc --noEmit --pretty false` 已执行,退出 1。报错位于工作区其他文件(处方、聊天、录音、装修、医生统计等);本次修改的获客助手页面和 `first_visit.ts` 无报错。未为本次修复扩展修改这些独立模块。 + +## 验证边界 + +没有访问业务数据库、真实企微凭据,也没有发送消息或调用企业微信修改接口。尚未对真实已投放链接执行修复后的同步;需部署匹配的前后端后,在目标方案点击“同步成员范围”,以远端回读确认结果为准。 diff --git a/artifacts/wecom-member-sync/run-backend-tests.ps1 b/artifacts/wecom-member-sync/run-backend-tests.ps1 new file mode 100644 index 000000000..7c712b17e --- /dev/null +++ b/artifacts/wecom-member-sync/run-backend-tests.ps1 @@ -0,0 +1,65 @@ +param( + [string]$Php = 'D:/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe', + [string]$MySqlRoot = 'D:/phpstudy_pro/Extensions/MySQL5.7.26' +) + +$ErrorActionPreference = 'Stop' +$artifactRoot = [System.IO.Path]::GetFullPath($PSScriptRoot) +$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $artifactRoot '../..')) +$runRoot = [System.IO.Path]::GetFullPath((Join-Path $artifactRoot ('mysql-test-' + [guid]::NewGuid().ToString('N')))) +if (-not $runRoot.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Disposable data directory escaped its artifact directory.' +} +$dataRoot = Join-Path $runRoot 'data' +$mysqld = Join-Path $MySqlRoot 'bin/mysqld.exe' +$mysqladmin = Join-Path $MySqlRoot 'bin/mysqladmin.exe' +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) +$listener.Start() +$port = $listener.LocalEndpoint.Port +$listener.Stop() +$mysqlProcess = $null +$testExit = 1 +$previousPort = $env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT +try { + New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null + $initArguments = @('--no-defaults', '--initialize-insecure', ('--basedir=' + $MySqlRoot), ('--datadir=' + $dataRoot)) + $initProcess = Start-Process -FilePath $mysqld -ArgumentList $initArguments -WindowStyle Hidden -PassThru -Wait -RedirectStandardError (Join-Path $runRoot 'initialize.log') + if ($initProcess.ExitCode -ne 0) { throw 'Disposable MySQL initialization failed.' } + $startArguments = @('--no-defaults', ('--basedir=' + $MySqlRoot), ('--datadir=' + $dataRoot), ('--port=' + $port), '--bind-address=127.0.0.1', '--innodb-buffer-pool-size=32M') + $mysqlProcess = Start-Process -FilePath $mysqld -ArgumentList $startArguments -WindowStyle Hidden -PassThru -RedirectStandardError (Join-Path $runRoot 'server.log') + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + if ($mysqlProcess.HasExited) { throw 'Disposable MySQL exited before becoming ready.' } + $client = [System.Net.Sockets.TcpClient]::new() + try { + $client.Connect('127.0.0.1', $port) + $ready = $true + } catch { + Start-Sleep -Milliseconds 250 + } finally { + $client.Dispose() + } + if ($ready) { break } + } + if (-not $ready) { throw 'Disposable MySQL did not become ready.' } + $env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT = [string]$port + & $Php (Join-Path $workspaceRoot 'server/tests/WecomPromotionMemberSyncTest.php') 2>&1 | Tee-Object -FilePath (Join-Path $artifactRoot 'backend-test-output.log') + $testExit = $LASTEXITCODE +} finally { + $env:ZYT_WECOM_MEMBER_TEST_MYSQL_PORT = $previousPort + if ($null -ne $mysqlProcess -and -not $mysqlProcess.HasExited) { + & $mysqladmin --no-defaults --host=127.0.0.1 ('--port=' + $port) --user=root shutdown 2>$null + $mysqlProcess.WaitForExit(10000) | Out-Null + if (-not $mysqlProcess.HasExited) { + Stop-Process -Id $mysqlProcess.Id -Force + $mysqlProcess.WaitForExit(5000) | Out-Null + } + } + $resolvedRunRoot = [System.IO.Path]::GetFullPath($runRoot) + if (-not $resolvedRunRoot.StartsWith($artifactRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -or (Split-Path $resolvedRunRoot -Leaf) -notmatch '^mysql-test-[a-f0-9]{32}$') { + throw 'Refusing to remove an unverified temporary path.' + } + if (Test-Path -LiteralPath $resolvedRunRoot) { Remove-Item -LiteralPath $resolvedRunRoot -Recurse -Force } + Write-Output 'Disposable MySQL stopped and its temporary data directory removed.' +} +exit $testExit diff --git a/artifacts/wecom-member-sync/ui-preview.mjs b/artifacts/wecom-member-sync/ui-preview.mjs new file mode 100644 index 000000000..630895826 --- /dev/null +++ b/artifacts/wecom-member-sync/ui-preview.mjs @@ -0,0 +1,70 @@ +import { createServer } from '../../admin/node_modules/vite/dist/node/index.js' +import vue from '../../admin/node_modules/@vitejs/plugin-vue/dist/index.mjs' +import fs from 'node:fs' + +const root = 'D:/web/zyt/admin' +const apiNames = [...fs.readFileSync(`${root}/src/api/first_visit.ts`, 'utf8').matchAll(/export (?:async )?function (wecomPromotion\w+)\s*\(/g)].map(match => match[1]) +const members = [ + { id: 1, admin_id: 1, name: '测试许可', userid: 'test_xuke', enabled: 1, reception_available: true }, + { id: 2, admin_id: 2, name: '测试旧医助', userid: 'test_old', enabled: 0, reception_available: false } +].map(member => ({ ...member, dept_ids: [1], dept_names: ['测试部门'], display_dept_id: 1, + status: member.enabled, today_count: 0, total_count: 0, daily_limit: 0, is_in_remote_range: true, sync_status: 1 })) +const overview = { + automation_installed: false, meta: { scope_label: '隔离验证', generated_at: '' }, + config: { configured: true, ready: true, missing: [], callback_ready: true }, + summary: { configured_apps: 1, pool_count: 1, online_links: 1, today_clicks: 0 }, + member_options: members, operator_options: [], department_options: [{ id: 1, name: '测试部门' }], links: [], + pools: [{ id: 99, name: '仅许可接待测试方案', public_key: 'test-only', status: 1, member_admin_ids: [1, 2], + main_url: 'https://work.weixin.qq.com/ca/test-only', can_operate: true, can_manage_access: true, can_delete: true, + operator_admin_ids: [], member_rules: members, automation_config: { backup_member_admin_ids: [] }, + official_link: { id: 199, remote_link_id: 'test-link', range_userids: ['test_xuke', 'test_old'], range_department_ids: [], last_sync_time: 1788920000 }, + dispatch_sync: { status: 1, desired_version: 2, applied_version: 1, last_error: '' } + }] +} +const fixture = `const overview = ${JSON.stringify(overview)}; +async function invoke(name, params) { + if (name === 'wecomPromotionOverview') return structuredClone(overview); + if (name === 'wecomPromotionSyncMemberRange') { + const pool = overview.pools[0]; + if (new URLSearchParams(location.search).get('mode') === 'failure') { + pool.dispatch_sync.status = 3; pool.dispatch_sync.last_error = '模拟企微网络错误'; + pool.member_rules.forEach(row => {row.sync_status = 3; row.sync_error = '模拟企微网络错误';}); + return {pool_id:99,sync_status:'failed',sync_error:'模拟企微网络错误',sync_queued:true}; + } + pool.official_link.range_userids = ['test_xuke']; pool.official_link.last_sync_time = Math.floor(Date.now()/1000); + pool.dispatch_sync = {status:0,desired_version:2,applied_version:2,last_error:''}; + pool.member_rules.forEach(row => {row.is_in_remote_range = row.enabled === 1; row.sync_status = 0;}); + return {pool_id:99,sync_status:'synced',sync_error:'',sync_queued:false,range_userids:['test_xuke'],range_department_ids:[]}; + } + throw new Error('隔离页面未实现该操作:' + name); +} +${apiNames.map(name => `export function ${name}(params) {return invoke('${name}',params);}`).join('\n')}` + +const server = await createServer({ + configFile: false, root, base: '/', + cacheDir: 'D:/web/zyt/artifacts/wecom-member-sync/vite-cache', + optimizeDeps: { entries: [], include: ['vue', 'element-plus', '@element-plus/icons-vue'] }, + resolve: { alias: { '@': `${root}/src` } }, + plugins: [{ + name: 'member-sync-isolated-fixture', enforce: 'pre', + resolveId(id) { + if (id === '@/api/first_visit' || id.endsWith('/src/api/first_visit')) return '\0member-sync-api' + if (id === '/__member-sync-entry.js') return '\0member-sync-entry' + }, + load(id) { + if (id === '\0member-sync-api') return fixture + if (id === '\0member-sync-entry') return `import {createApp} from 'vue';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import View from '/src/views/first_visit/wecom_promotion/index.vue';createApp(View).use(ElementPlus).mount('#app');` + }, + configureServer(dev) { + dev.middlewares.use((req, res, next) => { + if (!req.url?.startsWith('/__member-sync-check')) return next() + res.setHeader('Content-Type', 'text/html;charset=utf-8') + res.end('企微成员同步隔离验证

隔离验证:虚构数据,不连接企业微信

') + }) + } + }, vue()], + server: { host: '127.0.0.1', port: 5587, strictPort: true, open: false } +}) +await server.listen() +console.log('Isolated UI: http://127.0.0.1:5587/__member-sync-check') +for (const event of ['SIGINT', 'SIGTERM']) process.on(event, async () => { await server.close(); process.exit(0) }) diff --git a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php index ab6034180..0584cf232 100644 --- a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php +++ b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php @@ -180,6 +180,24 @@ class WecomPromotionController extends BaseAdminController return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission())); } + /** 推送当前方案的可用成员,不导入其他官方链接。 */ + public function syncMemberRange() + { + if (!$this->hasPagePermission()) { + return $this->fail('权限不足'); + } + if (!$this->request->isPost()) { + return $this->fail('请使用 POST 同步成员范围'); + } + $poolId = (int) $this->request->post('pool_id', 0); + + return $this->run(fn () => $this->data(WecomPromotionLogic::syncMemberRange( + $poolId, + $this->adminId, + $this->adminInfo + ))); + } + public function syncRemoteLinks() { if (!$this->hasBasePagePermission()) { diff --git a/server/app/adminapi/controller/tcm/PrescriptionOrderController.php b/server/app/adminapi/controller/tcm/PrescriptionOrderController.php index 2a6c0bf67..c1f01af6c 100755 --- a/server/app/adminapi/controller/tcm/PrescriptionOrderController.php +++ b/server/app/adminapi/controller/tcm/PrescriptionOrderController.php @@ -131,6 +131,18 @@ class PrescriptionOrderController extends BaseAdminController return $this->success('保存成功', $result); } + /** 独立授权的创建时间修正,逻辑层再次校验权限。 */ + public function editTime() + { + $params = (new PrescriptionOrderValidate())->post()->goCheck('editTime'); + $result = PrescriptionOrderLogic::editTime($params, $this->adminId, $this->adminInfo); + if ($result === false) { + return $this->fail(PrescriptionOrderLogic::getError()); + } + + return $this->success('创建时间已修改', $result); + } + /** * 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。 */ diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index d5ad5ea92..2b72acd8c 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -234,6 +234,7 @@ class AuthMiddleware 'firstvisit.wecompromotion/savelink', 'firstvisit.wecompromotion/savemember', 'firstvisit.wecompromotion/togglemember', + 'firstvisit.wecompromotion/syncmemberrange', 'firstvisit.wecompromotion/checkapipermission', 'firstvisit.wecompromotion/syncremotelinks', 'firstvisit.wecompromotion/remotelinkdetail', diff --git a/server/app/adminapi/lists/doctor/AppointmentLists.php b/server/app/adminapi/lists/doctor/AppointmentLists.php index 788e3f02a..12459de39 100755 --- a/server/app/adminapi/lists/doctor/AppointmentLists.php +++ b/server/app/adminapi/lists/doctor/AppointmentLists.php @@ -2,6 +2,8 @@ namespace app\adminapi\lists\doctor; +use app\common\enum\AppointmentTypeEnum; + use app\adminapi\lists\BaseAdminDataLists; use app\adminapi\logic\dept\DeptLogic; use app\common\model\auth\AdminDept; @@ -335,12 +337,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac ]; $item['status_desc'] = $statusMap[$item['status']] ?? '未知'; - $typeMap = [ - 'video' => '视频问诊', - 'text' => '图文问诊', - 'phone' => '电话问诊', - ]; - $item['appointment_type_desc'] = $typeMap[$item['appointment_type']] ?? '未知'; + $item['appointment_type'] = AppointmentTypeEnum::normalizeStored($item['appointment_type'] ?? null); + $item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']); $periodRaw = (string) ($item['period'] ?? ($item['type'] ?? '')); $periodMap = [ diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php index 008001218..6d167f18c 100755 --- a/server/app/adminapi/lists/tcm/DiagnosisLists.php +++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php @@ -12,7 +12,9 @@ // | author: likeadminTeam // +---------------------------------------------------------------------- -namespace app\adminapi\lists\tcm; +namespace app\adminapi\lists\tcm; + +use app\common\enum\AppointmentTypeEnum; use app\adminapi\lists\BaseAdminDataLists; use app\adminapi\logic\dept\DeptLogic; @@ -191,7 +193,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface } $subQuery - ->field('id, patient_id, doctor_id, appointment_date, appointment_time, status, create_time') + ->field('id, patient_id, doctor_id, appointment_date, appointment_time, appointment_type, status, create_time') ->order('appointment_date', 'asc') ->order('appointment_time', 'asc') ->order('id', 'asc'); @@ -234,7 +236,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface $apt = $aptList[0]; $item['has_appointment'] = 1; $item['appointment_id'] = $apt['id']; - $item['appointment_status'] = (int) ($apt['status'] ?? 0); + $item['appointment_status'] = (int) ($apt['status'] ?? 0); + $item['appointment_type'] = AppointmentTypeEnum::normalizeStored($apt['appointment_type'] ?? null); + $item['appointment_type_desc'] = AppointmentTypeEnum::description($item['appointment_type']); $item['appointment_doctor_id'] = $apt['doctor_id']; $item['appointment_doctor_name'] = $doctorNames[$apt['doctor_id']] ?? '-'; $timePart = $apt['appointment_time'] ?? ''; @@ -253,14 +257,18 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface 'id' => (int) ($a['id'] ?? 0), 'status' => (int) ($a['status'] ?? 0), 'doctor_id' => $doctorId, - 'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'), + 'doctor_name' => (string) ($doctorNames[$doctorId] ?? '-'), + 'appointment_type' => AppointmentTypeEnum::normalizeStored($a['appointment_type'] ?? null), + 'appointment_type_desc' => AppointmentTypeEnum::description($a['appointment_type'] ?? null), 'time_text' => trim((string) ($a['appointment_date'] ?? '') . ' ' . (string) $timePart), ]; }, $aptList); } else { $item['has_appointment'] = 0; $item['appointment_id'] = null; - $item['appointment_status'] = 0; + $item['appointment_status'] = 0; + $item['appointment_type'] = AppointmentTypeEnum::VIDEO; + $item['appointment_type_desc'] = AppointmentTypeEnum::description(null); $item['appointment_doctor_id'] = null; $item['appointment_doctor_name'] = ''; $item['appointment_time_text'] = ''; @@ -272,7 +280,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface $this->appendLatestAppointmentSummary($item, null); $item['has_appointment'] = 0; $item['appointment_id'] = null; - $item['appointment_status'] = 0; + $item['appointment_status'] = 0; + $item['appointment_type'] = AppointmentTypeEnum::VIDEO; + $item['appointment_type_desc'] = AppointmentTypeEnum::description(null); $item['appointment_doctor_id'] = null; $item['appointment_doctor_name'] = ''; $item['appointment_time_text'] = ''; @@ -806,7 +816,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface } $cols = $this->appointmentTableFields(); - $fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'status']; + $fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'appointment_type', 'status']; foreach (['channel_source', 'channel_source_detail', 'channels'] as $col) { if (in_array($col, $cols, true)) { $fields[] = $col; @@ -843,7 +853,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface */ private function appendLatestAppointmentSummary(array &$item, ?array $appointment): void { - $item['latest_appointment_id'] = null; + $item['latest_appointment_id'] = null; + $item['latest_appointment_type'] = AppointmentTypeEnum::VIDEO; + $item['latest_appointment_type_desc'] = AppointmentTypeEnum::description(null); $item['latest_appointment_time_text'] = ''; $item['latest_appointment_channel_source'] = ''; $item['latest_appointment_channel_source_desc'] = ''; @@ -862,7 +874,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface $rawChannel = trim((string) $appointment['channels']); } - $item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0); + $item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0); + $item['latest_appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null); + $item['latest_appointment_type_desc'] = AppointmentTypeEnum::description($item['latest_appointment_type']); $item['latest_appointment_time_text'] = trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $timePart); $item['latest_appointment_channel_source'] = $rawChannel; $item['latest_appointment_channel_source_desc'] = (string) ($appointment['channel_source_desc'] ?? ''); diff --git a/server/app/adminapi/lists/tcm/PrescriptionLists.php b/server/app/adminapi/lists/tcm/PrescriptionLists.php index 16fc13073..0297ca0bd 100755 --- a/server/app/adminapi/lists/tcm/PrescriptionLists.php +++ b/server/app/adminapi/lists/tcm/PrescriptionLists.php @@ -36,7 +36,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa // 仅在「存在有效业务订单」的处方里做风险判定 $orderRxIds = PrescriptionOrder::whereIn('prescription_id', $candidateIds) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->column('prescription_id'); $orderRxIds = array_values(array_unique(array_filter(array_map('intval', $orderRxIds), static function (int $id): bool { return $id > 0; @@ -260,7 +260,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa $bizRejectRows = PrescriptionOrder::whereIn('prescription_id', $rxIds) ->where('prescription_audit_status', 2) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->field(['prescription_id', 'prescription_audit_remark', 'id']) ->order('id', 'desc') ->select() @@ -280,7 +280,7 @@ class PrescriptionLists extends BaseAdminDataLists implements ListsSearchInterfa 'intval', PrescriptionOrder::whereIn('prescription_id', $rxIds) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->column('prescription_id') )); $hasBizOrderRx = array_fill_keys($orderRxIds, true); diff --git a/server/app/adminapi/logic/doctor/AppointmentLogic.php b/server/app/adminapi/logic/doctor/AppointmentLogic.php index 57f29c611..27cfc9268 100755 --- a/server/app/adminapi/logic/doctor/AppointmentLogic.php +++ b/server/app/adminapi/logic/doctor/AppointmentLogic.php @@ -2,7 +2,8 @@ namespace app\adminapi\logic\doctor; -use app\common\logic\BaseLogic; +use app\common\logic\BaseLogic; +use app\common\enum\AppointmentTypeEnum; use app\common\model\doctor\Appointment; use app\common\model\doctor\Roster; use app\common\service\doctor\RosterSegmentService; @@ -46,7 +47,7 @@ class AppointmentLogic extends BaseLogic /** * @param array $cols Db::name('doctor_appointment')->getTableFields() */ - private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string + private static function assertAppointmentChannelWritable(array $cols, string $chSrc): ?string { $hasChannelSource = in_array('channel_source', $cols, true); $hasChannels = in_array('channels', $cols, true); @@ -86,9 +87,12 @@ class AppointmentLogic extends BaseLogic * * @return array */ - private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array - { - $out = []; + private static function filterAppointmentRowByExistingColumns(array $row, array $cols): array + { + if (array_key_exists('appointment_type', $row) && !in_array('appointment_type', $cols, true)) { + throw new \RuntimeException('挂号表缺少 appointment_type 字段,无法保存问诊类型'); + } + $out = []; foreach ($row as $k => $v) { if (in_array((string) $k, $cols, true)) { $out[$k] = $v; @@ -273,9 +277,15 @@ class AppointmentLogic extends BaseLogic * @param array $params * @return array|bool */ - public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = []) - { - try { + public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = []) + { + $params = AppointmentTypeEnum::withDefault($params); + if (!AppointmentTypeEnum::isWritable($params['appointment_type'])) { + self::setError('问诊类型仅支持图文问诊或视频问诊'); + + return false; + } + try { Db::startTrans(); // 同一诊单患者在「所选预约日」仅允许一条「已预约」或「已过号」记录(与 appointment_date 一致,不能误用服务器当天拦其它日期) @@ -345,7 +355,7 @@ class AppointmentLogic extends BaseLogic 'doctor_id' => (int) $params['doctor_id'], 'appointment_date' => $params['appointment_date'], 'appointment_time' => $appointmentTime, - 'appointment_type' => $params['appointment_type'] ?? 'video', + 'appointment_type' => $params['appointment_type'], 'remark' => $params['remark'] ?? '', 'status' => 1, 'create_time' => time(), @@ -491,12 +501,8 @@ class AppointmentLogic extends BaseLogic ]; $appointment['status_desc'] = $statusMap[$appointment['status']] ?? '未知'; - $typeMap = [ - 'video' => '视频问诊', - 'text' => '图文问诊', - 'phone' => '电话问诊', - ]; - $appointment['appointment_type_desc'] = $typeMap[$appointment['appointment_type']] ?? '未知'; + $appointment['appointment_type'] = AppointmentTypeEnum::normalizeStored($appointment['appointment_type'] ?? null); + $appointment['appointment_type_desc'] = AppointmentTypeEnum::description($appointment['appointment_type']); // 格式化时间戳为日期时间 if (isset($appointment['create_time']) && is_numeric($appointment['create_time'])) { @@ -969,9 +975,14 @@ class AppointmentLogic extends BaseLogic * * @param array $params */ - public static function adminEdit(array $params, int $adminId, array $adminInfo): bool - { - try { + public static function adminEdit(array $params, int $adminId, array $adminInfo): bool + { + if (!AppointmentTypeEnum::isWritable($params['appointment_type'] ?? null)) { + self::setError('问诊类型仅支持图文问诊或视频问诊'); + + return false; + } + try { $id = (int) ($params['id'] ?? 0); if ($id <= 0) { self::setError('参数错误'); @@ -1023,12 +1034,7 @@ class AppointmentLogic extends BaseLogic return false; } - $appointmentType = trim((string) ($params['appointment_type'] ?? '')); - if (!in_array($appointmentType, ['video', 'text', 'phone'], true)) { - self::setError('预约类型无效'); - - return false; - } + $appointmentType = $params['appointment_type']; $remark = isset($params['remark']) ? trim((string) $params['remark']) : ''; if (mb_strlen($remark) > 500) { diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php index 64047d5c2..174aa58fb 100644 --- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php +++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php @@ -330,7 +330,8 @@ class WecomPromotionLogic $createdRemote = true; try { $remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId); - if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds)) { + if (!QywxPromotionMemberRange::same($remote['range_userids'], $eligibleUserIds) + || $remote['range_department_ids'] !== []) { throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致'); } } catch (\Throwable $e) { @@ -488,9 +489,7 @@ class WecomPromotionLogic 'range_userids' => $eligibleUserIds, // 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。 'automation_saved' => $automation !== null, - 'sync_error' => $syncError, - 'sync_queued' => $needsQueuedSync && !$syncImmediately, - ]; + ] + self::memberSyncResult($id, $syncError); } /** @@ -679,13 +678,13 @@ class WecomPromotionLogic $memberMatched += $poolMemberResult['matched']; $memberUpdated += $poolMemberResult['updated']; } - $syncError = trim((string) ($saved['sync_error'] ?? '')); + $syncResult = self::memberSyncResult($poolId, (string) ($saved['sync_error'] ?? '')); + $syncError = $syncResult['sync_error']; $updated++; if ($syncError !== '') { $syncErrorCount++; } - $syncQueued = !empty($saved['sync_queued']) - || !empty($poolMemberResult['dispatch']['queued']); + $syncQueued = $syncResult['sync_queued']; if ($syncQueued) { $syncQueuedCount++; } @@ -693,6 +692,7 @@ class WecomPromotionLogic 'id' => $poolId, 'name' => (string) ($pool['name'] ?? ''), 'success' => true, + 'sync_status' => $syncResult['sync_status'], 'sync_error' => $syncError, 'sync_queued' => $syncQueued, 'member_matched' => $poolMemberResult['matched'], @@ -754,7 +754,8 @@ class WecomPromotionLogic ]); } - $dispatch = $updateIds !== [] + // 状态相同也需要重算:上次保存可能只入队,远端仍保留已下线成员。 + $dispatch = $matchedIds !== [] ? QywxPromotionMemberSchedulerService::reconcilePool($poolId) : null; // 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。 @@ -1235,7 +1236,94 @@ class WecomPromotionLogic } } - return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError]; + return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned] + + self::memberSyncResult($poolId, $syncError); + } + + /** 显式重新推送当前范围;一次请求仅处理一个方案,供批量前端逐方案调用。 */ + public static function syncMemberRange( + int $poolId, + int $adminId, + array $adminInfo, + ?QywxPromotionRangeSyncService $syncService = null + ): array { + self::assertMemberDispatchSchema(); + if (!QywxPromotionOperatorAccess::hasPagePermission($adminId, $adminInfo)) { + throw new RuntimeException('权限不足'); + } + self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo); + $ready = Db::transaction(static function () use ($poolId): bool { + // 与成员保存、方案删除使用相同锁顺序,不重启删除中的同步任务。 + $pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find(); + if (!$pool) { + throw new RuntimeException('分流方案不存在或已删除'); + } + $sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); + if ((int) ($sync['status'] ?? 0) === 5 + || ((int) ($sync['status'] ?? 0) === 4 + && str_starts_with((string) ($sync['last_error'] ?? ''), '企业微信官方获客链接删除失败'))) { + return false; + } + $plan = QywxPromotionMemberSchedulerService::reconcilePool($poolId); + if ($plan['blocked']) { + return false; + } + $linkId = (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('promotion_link_id') ?? 0); + // 即便快照看起来一致也重新 update/get,修复未被本地快照发现的企微端变化。 + QywxPromotionMemberSchedulerService::requestPoolSync($poolId, $linkId); + + return true; + }); + $error = ''; + if ($ready) { + try { + ($syncService ?? new QywxPromotionRangeSyncService())->syncPool($poolId); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + return self::memberSyncResult($poolId, $error); + } + + /** 返回已确认的远端范围及任务状态,不能把无异常的 noop 当成同步成功。 */ + private static function memberSyncResult(int $poolId, string $error = ''): array + { + $sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find() ?: []; + $link = Db::name('qywx_promotion_link')->where('id', (int) ($sync['promotion_link_id'] ?? 0)) + ->whereNull('delete_time')->find() ?: []; + $members = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId) + ->whereNull('delete_time')->select()->toArray(); + $range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId)); + $remoteUsers = self::decodeStringList($link['range_user_json'] ?? null); + $remoteDepartments = self::decodeStringList($link['range_department_json'] ?? null); + $status = (int) ($sync['status'] ?? -1); + $syncError = trim($error ?: (string) ($sync['last_error'] ?? $link['sync_error'] ?? '')); + if ($link === [] || trim((string) ($link['remote_link_id'] ?? '')) === '' + || (int) ($link['remote_status'] ?? 0) === 2 || $range['userids'] === [] || in_array($status, [4, 5], true)) { + $resultStatus = 'blocked'; + $syncError = $syncError ?: '当前没有可同步的官方链接或可用成员,请检查方案和成员规则'; + } elseif ($error !== '' || $status === 3) { + $resultStatus = 'failed'; + $syncError = $syncError ?: '企业微信成员范围同步失败,请重试'; + } elseif ($status === 0 && (int) ($sync['desired_version'] ?? 0) > 0 + && (int) ($sync['desired_version'] ?? 0) === (int) ($sync['applied_version'] ?? -1) + && (int) ($link['last_sync_time'] ?? 0) > 0 && $remoteDepartments === [] + && QywxPromotionMemberRange::same($range['userids'], $remoteUsers)) { + $resultStatus = 'synced'; + $syncError = ''; + } else { + $resultStatus = 'pending'; + } + + return [ + 'pool_id' => $poolId, + 'sync_status' => $resultStatus, + 'sync_error' => $syncError, + 'sync_queued' => in_array($resultStatus, ['pending', 'failed'], true), + 'range_userids' => $remoteUsers, + 'range_department_ids' => $remoteDepartments, + ]; } public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array diff --git a/server/app/adminapi/logic/tcm/PrescriptionLogic.php b/server/app/adminapi/logic/tcm/PrescriptionLogic.php index 50e6d59dd..9163598b7 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionLogic.php @@ -748,16 +748,16 @@ class PrescriptionLogic $bizPo = PrescriptionOrder::where('prescription_id', $id) ->where('prescription_audit_status', 2) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->order('id', 'desc') ->find(); $arr['business_prescription_audit_rejected'] = $bizPo ? 1 : 0; $arr['business_prescription_audit_remark'] = $bizPo ? (string) ($bizPo->prescription_audit_remark ?? '') : ''; - // 与 PrescriptionLists「业务订单」角标一致:未删除且非已取消(4) 即视为存在有效业务订单 + // 与列表和创建校验一致:已取消 / 已退款的订单保留历史关联,但不再占用处方。 $hasBizOrder = PrescriptionOrder::where('prescription_id', $id) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->count() > 0; $arr['has_prescription_order'] = $hasBizOrder ? 1 : 0; @@ -1216,7 +1216,7 @@ class PrescriptionLogic $rxId = (int) ($row->id ?? 0); $bizCount = (int) PrescriptionOrder::where('prescription_id', $rxId) ->whereNull('delete_time') - ->where('fulfillment_status', '<>', 4) + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES) ->count(); if ($bizCount > 0) { self::setError('该处方已存在业务订单,无法作废'); diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index 14fc982fb..08f16f0eb 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -963,6 +963,31 @@ class PrescriptionOrderLogic * @return array|false */ public static function create(array $params, int $adminId, array $adminInfo) + { + self::$error = ''; + try { + return Db::transaction(static function () use ($params, $adminId, $adminInfo) { + // 先锁始终存在的处方行;空订单集合也能串行化两次创建。 + // 首个一致性读发生在获得该锁后,等待者可见前一次创建提交的订单。 + $rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0)) + ->whereNull('delete_time')->lock(true)->find(); + if (!$rx) { + throw new \DomainException('处方不存在'); + } + $result = self::createLocked($params, $adminId, $adminInfo); + if ($result === false) { + throw new \DomainException(self::$error ?: '创建业务订单失败'); + } + + return $result; + }); + } catch (\Throwable $e) { + self::$error = $e->getMessage(); + return false; + } + } + + private static function createLocked(array $params, int $adminId, array $adminInfo) { self::$error = ''; $rxId = (int) $params['prescription_id']; @@ -989,8 +1014,9 @@ class PrescriptionOrderLogic return false; } - if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time')->where('fulfillment_status', '<>', 4)->count() > 0) { - self::$error = '该处方已存在有效业务订单(未撤回前不可重复创建)'; + if (PrescriptionOrder::where('prescription_id', $rxId)->whereNull('delete_time') + ->whereNotIn('fulfillment_status', PrescriptionOrder::RELEASED_PRESCRIPTION_STATUSES)->count() > 0) { + self::$error = '该处方已存在有效业务订单(撤回或全额退款后可重新创建)'; return false; } @@ -1087,7 +1113,7 @@ class PrescriptionOrderLogic return false; } - self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单'); + self::writeLog((int) $order->id, $adminId, $adminInfo, 'create', '创建业务订单', true); if ($payOrderIds !== []) { self::replacePayOrderLinks((int) $order->id, $payOrderIds); @@ -1103,6 +1129,52 @@ class PrescriptionOrderLogic return $out; } + /** 只修正业务订单创建时间,不改变关联支付、处方、药房快照或履约状态。 */ + public static function editTime(array $params, int $adminId, array $adminInfo) + { + self::$error = ''; + // 菜单尚未部署时,中间件可能放行未知权限,因此必须显式校验。 + if ((int) ($adminInfo['root'] ?? 0) !== 1 + && !in_array('tcm.prescriptionOrder/editTime', AuthLogic::getAuthByAdminId($adminId), true)) { + self::$error = '无权限修改业务订单创建时间'; + return false; + } + $validator = new \app\adminapi\validate\tcm\PrescriptionOrderValidate(); + if (!$validator->scene('editTime')->check($params)) { + self::$error = (string) $validator->getError(); + return false; + } + + try { + return Db::transaction(static function () use ($params, $adminId, $adminInfo) { + $order = PrescriptionOrder::where('id', (int) $params['id']) + ->whereNull('delete_time')->lock(true)->find(); + if (!$order) { + throw new \DomainException('订单不存在'); + } + if (!self::canAccessOrder($order, $adminId, $adminInfo)) { + throw new \DomainException('无权限操作此订单'); + } + $oldValue = $order->getData('create_time'); + $dateTime = (string) $params['create_time']; + $isTimestamp = is_int($oldValue) || (is_string($oldValue) && ctype_digit($oldValue)); + $newValue = $isTimestamp ? (int) strtotime($dateTime) : $dateTime; + if ((string) $oldValue !== (string) $newValue) { + $order->create_time = $newValue; + $order->save(); + $oldLabel = $isTimestamp ? date('Y-m-d H:i:s', (int) $oldValue) : (string) $oldValue; + self::writeLog((int) $order->id, $adminId, $adminInfo, 'edit_time', + '修改业务订单创建时间:' . $oldLabel . ' → ' . $dateTime, true); + } + + return ['id' => (int) $order->id, 'create_time' => $newValue]; + }); + } catch (\Throwable $e) { + self::$error = $e->getMessage(); + return false; + } + } + public static function detail(int $id, int $adminId, array $adminInfo): ?array { self::$error = ''; diff --git a/server/app/adminapi/validate/doctor/AppointmentValidate.php b/server/app/adminapi/validate/doctor/AppointmentValidate.php index b02b5453f..e04bc4303 100755 --- a/server/app/adminapi/validate/doctor/AppointmentValidate.php +++ b/server/app/adminapi/validate/doctor/AppointmentValidate.php @@ -2,7 +2,8 @@ namespace app\adminapi\validate\doctor; -use app\common\validate\BaseValidate; +use app\common\validate\BaseValidate; +use app\common\enum\AppointmentTypeEnum; /** * 医生预约验证器 @@ -23,7 +24,7 @@ class AppointmentValidate extends BaseValidate 'appointment_date' => 'require|date', 'appointment_time' => 'require', 'period' => 'in:morning,afternoon,all', - 'appointment_type' => 'require|in:video,text,phone', + 'appointment_type' => 'require|checkAppointmentType', 'status' => 'require|integer|between:1,4', 'remark' => 'max:500', 'channel_source' => 'require', @@ -38,7 +39,7 @@ class AppointmentValidate extends BaseValidate * 参数描述 * @var string[] */ - protected $field = [ + protected $field = [ 'id' => '预约ID', 'patient_id' => '患者ID', 'doctor_id' => '医生ID', @@ -53,7 +54,21 @@ class AppointmentValidate extends BaseValidate 'channel_source' => '渠道来源', 'channel_source_detail' => '渠道补充说明', 'ids' => '预约ID列表', - ]; + ]; + + protected function checkAppointmentType($value) + { + return AppointmentTypeEnum::isWritable($value) ? true : '问诊类型仅支持图文问诊或视频问诊'; + } + + public function check(array $data, array $rules = []): bool + { + if ($this->currentScene === 'create') { + $data = AppointmentTypeEnum::withDefault($data); + } + + return parent::check($data, $rules); + } /** * @notes 创建预约场景 diff --git a/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php b/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php index 4d112d3e4..af015f467 100755 --- a/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php +++ b/server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php @@ -10,6 +10,7 @@ class PrescriptionOrderValidate extends BaseValidate { protected $rule = [ 'id' => 'require|integer', + 'create_time' => 'require|string|dateFormat:Y-m-d H:i:s', 'diagnosis_id' => 'require|integer|gt:0', 'prescription_id' => 'require|integer', 'pay_order_ids' => 'array', @@ -62,6 +63,8 @@ class PrescriptionOrderValidate extends BaseValidate 'tracking_number.require' => '请输入快递单号', 'phone_tail.regex' => '手机后四位仅支持数字', 'reason.require' => '请填写退款原因', + 'create_time.require' => '创建时间必填', + 'create_time.dateFormat' => '创建时间格式不正确', ]; protected $scene = [ @@ -71,6 +74,7 @@ class PrescriptionOrderValidate extends BaseValidate 'tracking_number', 'express_company', 'ship_mode', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids', ], 'detail' => ['id'], + 'editTime' => ['id', 'create_time'], 'edit' => [ 'id', 'recipient_name', 'recipient_phone', 'shipping_address', 'is_follow_up', 'prev_staff', 'service_channel', 'service_package', @@ -109,6 +113,11 @@ class PrescriptionOrderValidate extends BaseValidate ->append('pay_order_id', 'require|integer|gt:0'); } + public function sceneEditTime(): PrescriptionOrderValidate + { + return $this->only(['id', 'create_time'])->append('id', 'require|integer|gt:0'); + } + public function updateAmount(): PrescriptionOrderValidate { return $this->only(['id', 'amount']) diff --git a/server/app/common/enum/AppointmentTypeEnum.php b/server/app/common/enum/AppointmentTypeEnum.php new file mode 100644 index 000000000..302dfbd2b --- /dev/null +++ b/server/app/common/enum/AppointmentTypeEnum.php @@ -0,0 +1,44 @@ + '视频问诊', + self::TEXT => '图文问诊', + 'phone' => '电话问诊', // 仅兼容已存在的历史记录,不允许新写入。 + ][self::normalizeStored($value)] ?? '未知'; + } +} diff --git a/server/app/common/model/doctor/Appointment.php b/server/app/common/model/doctor/Appointment.php index 77aa85257..69e97dda3 100755 --- a/server/app/common/model/doctor/Appointment.php +++ b/server/app/common/model/doctor/Appointment.php @@ -3,6 +3,7 @@ namespace app\common\model\doctor; use app\common\model\BaseModel; +use app\common\enum\AppointmentTypeEnum; /** * 医生预约模型 @@ -58,12 +59,12 @@ class Appointment extends BaseModel */ public function getAppointmentTypeDescAttr($value, $data) { - $typeMap = [ - 'video' => '视频问诊', - 'text' => '图文问诊', - 'phone' => '电话问诊', - ]; - return $typeMap[$data['appointment_type']] ?? '未知'; + return AppointmentTypeEnum::description($data['appointment_type'] ?? null); + } + + public function getAppointmentTypeAttr($value) + { + return AppointmentTypeEnum::normalizeStored($value); } /** diff --git a/server/app/common/model/tcm/PrescriptionOrder.php b/server/app/common/model/tcm/PrescriptionOrder.php index 4fb5593ef..1c1675545 100755 --- a/server/app/common/model/tcm/PrescriptionOrder.php +++ b/server/app/common/model/tcm/PrescriptionOrder.php @@ -14,6 +14,9 @@ class PrescriptionOrder extends BaseModel { use SoftDelete; + /** 已取消 / 已退款的历史订单不再占用处方;部分退款仍沿用原履约状态。 */ + public const RELEASED_PRESCRIPTION_STATUSES = [4, 10]; + protected $name = 'tcm_prescription_order'; protected $deleteTime = 'delete_time'; diff --git a/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php b/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php index 908943f72..606245319 100644 --- a/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php +++ b/server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php @@ -261,8 +261,13 @@ class QywxPromotionMemberSchedulerService return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } $appliedUserIds = self::linkRangeUserIds($linkId); - $changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds); - $alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 3], true); + $departmentJson = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_department_json') ?? '[]'); + $departmentIds = json_decode($departmentJson, true); + $changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds) + || !empty($departmentIds); + // 活跃或超时的租约、尚未应用的版本都不能仅凭旧快照被重算成“已同步”。 + $alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 2, 3], true) + || (int) ($sync['desired_version'] ?? 0) > (int) ($sync['applied_version'] ?? 0); $needsSync = $changed || $alreadyPending; self::upsertSync($poolId, $linkId, $needsSync, $sync, $now); diff --git a/server/app/common/service/qywx/QywxPromotionRangeSyncService.php b/server/app/common/service/qywx/QywxPromotionRangeSyncService.php index e5611cbf5..75776347a 100644 --- a/server/app/common/service/qywx/QywxPromotionRangeSyncService.php +++ b/server/app/common/service/qywx/QywxPromotionRangeSyncService.php @@ -99,33 +99,42 @@ class QywxPromotionRangeSyncService $response = $this->api->getLink($remoteLinkId); $remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId); $actualUserIds = $remote['range_userids']; - if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds)) { + if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds) + || $remote['range_department_ids'] !== []) { throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致'); } $url = $remote['url']; $snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([ - 'wecom_url' => $url, - 'remote_status' => 1, - 'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'remote_snapshot' => $snapshot === false ? null : $snapshot, - 'last_sync_time' => time(), - 'sync_error' => '', - 'update_time' => time(), - ]); - - $freshVersion = (int) (Db::name('qywx_promotion_range_sync') - ->where('pool_id', $poolId)->value('desired_version') ?? 0); - Db::name('qywx_promotion_range_sync') - ->where('pool_id', $poolId) - ->where('lock_token', $token) - ->update([ - 'status' => $freshVersion === $desiredVersion ? 0 : 1, + $confirmed = Db::transaction(function () use ( + $poolId, $token, $desiredVersion, $link, $url, $actualUserIds, $remote, $snapshot + ): bool { + $fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); + if (!$fresh || (int) ($fresh['status'] ?? 0) === 5) { + return false; + } + if ((string) ($fresh['lock_token'] ?? '') !== $token + || (int) ($fresh['lock_until'] ?? 0) <= time()) { + // 过期工作不能覆盖新工作的确认结果。它可能较晚触达企微,需再推一次最新范围。 + QywxPromotionMemberSchedulerService::requestPoolSync($poolId, (int) $fresh['promotion_link_id']); + return false; + } + Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([ + 'wecom_url' => $url, + 'remote_status' => 1, + 'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'remote_snapshot' => $snapshot === false ? null : $snapshot, + 'last_sync_time' => time(), + 'sync_error' => '', + 'update_time' => time(), + ]); + $isLatest = (int) ($fresh['desired_version'] ?? 0) === $desiredVersion; + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ + 'status' => $isLatest ? 0 : 1, 'desired_member_id' => 0, 'applied_member_id' => 0, 'applied_version' => $desiredVersion, - 'next_retry' => $freshVersion === $desiredVersion ? 0 : time(), + 'next_retry' => $isLatest ? 0 : time(), 'attempts' => 0, 'lock_token' => '', 'lock_until' => 0, @@ -133,13 +142,19 @@ class QywxPromotionRangeSyncService 'update_time' => time(), ]); - return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => 0]; + return $isLatest; + }); + + return ['status' => $confirmed ? 'synced' : 'pending', 'pool_id' => $poolId, 'member_id' => 0]; } catch (\Throwable $e) { $attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1); - Db::name('qywx_promotion_range_sync') - ->where('pool_id', $poolId) - ->where('lock_token', $token) - ->update([ + Db::transaction(function () use ($poolId, $token, $attempts, $claim, $e): void { + $fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); + if (!$fresh || (string) ($fresh['lock_token'] ?? '') !== $token + || (int) ($fresh['status'] ?? 0) === 5) { + return; + } + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ 'status' => 3, 'next_retry' => time() + min(300, 15 * $attempts), 'lock_token' => '', @@ -147,9 +162,10 @@ class QywxPromotionRangeSyncService 'last_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time(), ]); - Db::name('qywx_promotion_link') - ->where('id', (int) ($claim['promotion_link_id'] ?? 0)) - ->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]); + Db::name('qywx_promotion_link') + ->where('id', (int) ($claim['promotion_link_id'] ?? 0)) + ->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]); + }); throw $e; } diff --git a/server/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql b/server/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql new file mode 100644 index 000000000..1db6a4efe --- /dev/null +++ b/server/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql @@ -0,0 +1,22 @@ +-- 处方业务订单:单独授权修改创建时间。默认不授予任何角色。 +-- 请在角色管理中按需勾选“修改业务订单创建时间”。表前缀如非 zyt_ 请调整。 +START TRANSACTION; + +SET @po_menu_id = ( + SELECT id FROM zyt_system_menu + WHERE perms = 'tcm.prescriptionOrder/lists' ORDER BY id ASC 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 + @po_menu_id, 'A', '修改业务订单创建时间', '', 89, + 'tcm.prescriptionOrder/editTime', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @po_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.prescriptionOrder/editTime'); + +COMMIT; diff --git a/server/sql/1.9.20260909/appointment_type_video_default.sql b/server/sql/1.9.20260909/appointment_type_video_default.sql new file mode 100644 index 000000000..405c62adb --- /dev/null +++ b/server/sql/1.9.20260909/appointment_type_video_default.sql @@ -0,0 +1,9 @@ +-- 问诊类型复用 doctor_appointment.appointment_type;请先备份,在部署窗口执行。 +-- 只回填未记录方式的历史挂号,不改变已有 text、video 或历史 phone 记录。 +UPDATE `zyt_doctor_appointment` +SET `appointment_type` = 'video' +WHERE `appointment_type` IS NULL OR TRIM(`appointment_type`) = ''; + +ALTER TABLE `zyt_doctor_appointment` +MODIFY COLUMN `appointment_type` varchar(20) NOT NULL DEFAULT 'video' +COMMENT '问诊类型:video=视频问诊,text=图文问诊;phone仅保留历史记录'; diff --git a/server/sql/doctor_appointment.sql b/server/sql/doctor_appointment.sql index 937aac0f6..5892af811 100755 --- a/server/sql/doctor_appointment.sql +++ b/server/sql/doctor_appointment.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `zyt_doctor_appointment` ( `appointment_date` date NOT NULL COMMENT '预约日期', `period` enum('morning','afternoon') NOT NULL COMMENT '时段:morning=上午,afternoon=下午', `appointment_time` time NOT NULL COMMENT '预约时间', - `appointment_type` varchar(20) DEFAULT 'video' COMMENT '预约类型:video=视频问诊,text=图文问诊,phone=电话问诊', + `appointment_type` varchar(20) NOT NULL DEFAULT 'video' COMMENT '问诊类型:video=视频问诊,text=图文问诊;phone仅保留历史记录', `status` tinyint(1) DEFAULT '1' COMMENT '状态:1=已预约,2=已取消,3=已完成', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `channel_source` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源(字典channels)', diff --git a/server/tests/AppointmentTypeTest.php b/server/tests/AppointmentTypeTest.php new file mode 100644 index 000000000..fd699ce15 --- /dev/null +++ b/server/tests/AppointmentTypeTest.php @@ -0,0 +1,83 @@ + $validator->setLang($testLang)); + +function appointmentTypeExpect(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +$payload = [ + 'id' => 1, + 'patient_id' => 101, + 'doctor_id' => 202, + 'appointment_date' => '2026-09-10', + 'appointment_time' => '09:30', + 'period' => 'morning', + 'channel_source' => 'test', + 'status' => 1, +]; + +foreach (['video' => '视频问诊', 'text' => '图文问诊'] as $type => $label) { + foreach (['create', 'adminEdit'] as $scene) { + $validator = (new AppointmentValidate())->scene($scene); + appointmentTypeExpect($validator->check($payload + ['appointment_type' => $type]), "$scene accepts $type"); + } + appointmentTypeExpect(AppointmentTypeEnum::description($type) === $label, "$type label round trip"); + appointmentTypeExpect(AppointmentTypeEnum::withDefault(['appointment_type' => $type])['appointment_type'] === $type, 'default does not override an explicit choice'); +} + +$invalidValues = ['', ' ', 'phone', 'Text', ' video ', 'unknown', 0, 1, true, false, null, [], ['text']]; +foreach ($invalidValues as $value) { + foreach (['create', 'adminEdit'] as $scene) { + appointmentTypeExpect(!(new AppointmentValidate())->scene($scene)->check($payload + ['appointment_type' => $value]), "$scene rejects " . json_encode($value)); + } + // Invalid requests must be rejected before touching a database, even if called outside the controller. + appointmentTypeExpect(AppointmentLogic::create(['appointment_type' => $value]) === false, 'create rejects invalid type before DB'); + appointmentTypeExpect(AppointmentLogic::adminEdit(['appointment_type' => $value], 0, []) === false, 'edit rejects invalid type before DB'); +} + +appointmentTypeExpect((new AppointmentValidate())->scene('create')->check($payload), 'legacy create request may omit type'); +appointmentTypeExpect(AppointmentTypeEnum::withDefault([])['appointment_type'] === 'video', 'omitted create type persists as video'); +appointmentTypeExpect(!(new AppointmentValidate())->scene('adminEdit')->check($payload), 'edit cannot silently reset an existing text selection'); +appointmentTypeExpect(AppointmentLogic::adminEdit([], 0, []) === false, 'internal edit also requires explicit type'); + +$model = (new ReflectionClass(Appointment::class))->newInstanceWithoutConstructor(); +foreach ([null, '', ' '] as $legacyEmpty) { + appointmentTypeExpect($model->getAppointmentTypeAttr($legacyEmpty) === 'video', 'legacy empty model value uses video'); + appointmentTypeExpect($model->getAppointmentTypeDescAttr(null, ['appointment_type' => $legacyEmpty]) === '视频问诊', 'legacy empty model label uses video'); +} +appointmentTypeExpect(AppointmentTypeEnum::description('phone') === '电话问诊', 'historical phone records retain accurate labels'); + +$filter = (new ReflectionClass(AppointmentLogic::class))->getMethod('filterAppointmentRowByExistingColumns'); +appointmentTypeExpect($filter->invoke(null, ['appointment_type' => 'text'], ['appointment_type']) === ['appointment_type' => 'text'], 'text is retained in database write payload'); +try { + $filter->invoke(null, ['appointment_type' => 'text'], ['id']); + throw new RuntimeException('missing appointment_type column must not silently lose the selected type'); +} catch (RuntimeException $exception) { + appointmentTypeExpect(str_contains($exception->getMessage(), '挂号表缺少 appointment_type'), 'missing schema yields an actionable error'); +} + +$summary = (new ReflectionClass(DiagnosisLists::class))->getMethod('appendLatestAppointmentSummary'); +$lists = (new ReflectionClass(DiagnosisLists::class))->newInstanceWithoutConstructor(); +$row = []; +$summary->invokeArgs($lists, [&$row, ['id' => 8, 'appointment_type' => 'text']]); +appointmentTypeExpect($row['latest_appointment_id'] === 8 && $row['latest_appointment_type'] === 'text' && $row['latest_appointment_type_desc'] === '图文问诊', 'latest appointment summary keeps its own type'); +$summary->invokeArgs($lists, [&$row, ['id' => 9, 'appointment_type' => null]]); +appointmentTypeExpect($row['latest_appointment_type'] === 'video', 'next legacy appointment does not inherit previous text type'); + +echo "Appointment type validation, defaults, legacy labels and summary: OK\n"; diff --git a/server/tests/PrescriptionOrderReleaseAndTimeTest.php b/server/tests/PrescriptionOrderReleaseAndTimeTest.php new file mode 100644 index 000000000..d8180759a --- /dev/null +++ b/server/tests/PrescriptionOrderReleaseAndTimeTest.php @@ -0,0 +1,268 @@ + PDO::ERRMODE_EXCEPTION]); +if (!$isWorker) $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); +$pdo->exec("USE `{$database}`"); +$testApp = new think\App(); +$manager = new DbManager(); +$manager->setConfig([ + 'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false, + 'connections' => ['mysql' => [ + 'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, + 'database' => $database, 'username' => 'root', 'password' => '', + 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, + ]], +]); +Container::getInstance()->instance('think\DbManager', $manager); +Container::getInstance()->instance('config', new think\Config()); +$testLang = new think\Lang($testApp); +think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang)); +$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员']; +$checks = 0; +$expect = static function (bool $ok, string $message) use (&$checks): void { + if (!$ok) throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError()); + $checks++; +}; +$createParams = static fn (int $rxId): array => [ + 'prescription_id' => $rxId, 'diagnosis_id' => 1, 'recipient_name' => '测试患者', + 'recipient_phone' => '13000000000', 'shipping_address' => '测试地址', 'fee_type' => 3, 'amount' => 100, +]; +if ($isWorker) { + echo "ready\n"; + flush(); + $out = PrescriptionOrderLogic::create($createParams((int) $argv[2]), 1, $admin); + echo json_encode(['success' => is_array($out), 'error' => PrescriptionOrderLogic::getError()]) . "\n"; + exit(0); +} + +try { + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_07_create_tcm_prescription_order.sql')); + $pdo->exec('ALTER TABLE zyt_tcm_prescription_order + ADD agency_collect_amount DECIMAL(10,2) NULL, ADD paid DECIMAL(10,2) DEFAULT 0, + ADD refund_amount DECIMAL(10,2) DEFAULT 0, ADD express_company VARCHAR(20) DEFAULT "auto", + ADD ship_mode VARCHAR(20) DEFAULT "gancao", ADD remark_assistant VARCHAR(500) DEFAULT "", + ADD gancao_reciperl_order_no VARCHAR(100) DEFAULT ""'); + $pdo->exec('CREATE TABLE zyt_tcm_prescription ( + id INT PRIMARY KEY AUTO_INCREMENT, diagnosis_id INT DEFAULT 1, gender INT DEFAULT 0, + creator_id INT DEFAULT 1, assistant_id INT DEFAULT 0, is_shared INT DEFAULT 0, + herbs TEXT, audit_status INT DEFAULT 1, void_status INT DEFAULT 0, visible_role_ids VARCHAR(100) DEFAULT "", + audit_time INT NULL, audit_by INT NULL, audit_by_name VARCHAR(100) DEFAULT "", audit_remark VARCHAR(500) DEFAULT "", + create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL + ) ENGINE=InnoDB'); + $pdo->exec('CREATE TABLE zyt_order ( + id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), patient_id INT DEFAULT 1, + creator_id INT DEFAULT 1, order_type INT DEFAULT 3, amount DECIMAL(10,2), status INT, + remark VARCHAR(200) DEFAULT "", is_exempt INT DEFAULT 0, payment_method VARCHAR(50) DEFAULT "", + create_type VARCHAR(50) DEFAULT "", create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL + ) ENGINE=InnoDB'); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_04_09_prescription_order_pay_links.sql')); + $pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log ( + id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT, + admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT + ) ENGINE=InnoDB'); + $pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT DEFAULT 0, delete_time INT NULL)'); + $pdo->exec('INSERT INTO zyt_tcm_diagnosis (id) VALUES (1)'); + $pdo->exec('CREATE TABLE zyt_pharmacy_submission_claim ( + id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, source_revision INT, status VARCHAR(50) + ) ENGINE=InnoDB'); + $pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(100), delete_time INT NULL)'); + $pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)'); + $pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)'); + $pdo->exec('CREATE TABLE zyt_system_menu ( + id INT PRIMARY KEY AUTO_INCREMENT, pid INT, type VARCHAR(5), name VARCHAR(100), icon VARCHAR(50), + sort INT, perms VARCHAR(100), paths VARCHAR(100), component VARCHAR(100), selected VARCHAR(100), + params VARCHAR(100), is_cache INT, is_show INT, is_disable INT DEFAULT 0, create_time INT, update_time INT + )'); + + $newRx = static fn (array $fields = []): int => (int) Db::name('tcm_prescription')->insertGetId(array_merge(['herbs' => '[]'], $fields)); + $fixture = static fn (int $rx, array $fields = []): int => (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([ + 'prescription_id' => $rx, 'order_no' => 'OLD-' . bin2hex(random_bytes(4)), 'diagnosis_id' => 1, + 'creator_id' => 1, 'amount' => 100, 'paid' => 100, 'payment_slip_audit_status' => 1, + 'create_time' => 1724300000, 'fulfillment_status' => 5, + ], $fields)); + $row = static fn (int $id): array => Db::name('tcm_prescription_order')->where('id', $id)->find(); + $logRows = static fn (int $id): array => Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray(); + $detail = static fn (int $rx): array => PrescriptionLogic::detail($rx, 1, $admin); + $listsClass = new ReflectionClass(PrescriptionLists::class); + $lists = $listsClass->newInstanceWithoutConstructor(); + foreach (['adminInfo' => $admin, 'adminId' => 1, 'params' => [], 'searchWhere' => [], 'limitOffset' => 0, 'limitLength' => 1000] as $name => $value) { + $listsClass->getProperty($name)->setValue($lists, $value); + } + $listRow = static function (int $rx) use ($lists): array { + return array_values(array_filter($lists->lists(), static fn (array $r): bool => (int) $r['id'] === $rx))[0]; + }; + + // Existing refunded/cancelled/deleted history never blocks a new order or contaminates active audit flags. + foreach ([['fulfillment_status' => 10], ['fulfillment_status' => 4], ['delete_time' => time()]] as $released) { + $rx = $newRx(); + $oldId = $fixture($rx, array_merge($released, ['prescription_audit_status' => 2, 'prescription_audit_remark' => '旧驳回'])); + $before = $row($oldId); + $expect($detail($rx)['has_prescription_order'] === 0 && $listRow($rx)['has_prescription_order'] === 0, 'Released history must be available in list and detail'); + $expect($detail($rx)['business_prescription_audit_rejected'] === 0 && $listRow($rx)['business_prescription_audit_rejected'] === 0, 'Released history must not carry rejection badges'); + $expect($listsClass->getMethod('collectRiskPrescriptionIds')->invoke($lists, [$rx]) === [], 'Released orders must not pin herb-risk rows'); + $out = PrescriptionOrderLogic::create($createParams($rx), 1, $admin); + $expect(is_array($out), 'Approved prescription must support creating after released history'); + $expect($row($oldId) === $before, 'Creation must preserve all historical order fields'); + $expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'New order must occupy the prescription in list and detail'); + $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'A second live order must be rejected'); + } + foreach ([1, 2, 3, 5, 6, 7, 8, 9, 11, 12] as $status) { + $rx = $newRx(); + $fixture($rx, ['fulfillment_status' => 10]); + $fixture($rx, ['fulfillment_status' => $status]); + $expect($detail($rx)['has_prescription_order'] === 1 && $listRow($rx)['has_prescription_order'] === 1, 'Every nonreleased live status must occupy prescription, even with refunded history'); + $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Live status must prevent duplicate creation'); + } + + // Exercise the actual refund endpoint logic and preserve payment/remote history and approved prescription. + $rx = $newRx(); + $oldId = $fixture($rx, ['gancao_reciperl_order_no' => 'REMOTE-HISTORY']); + $payId = Db::name('order')->insertGetId(['order_no' => 'PAID-HISTORY', 'status' => 2, 'amount' => 100]); + Db::name('tcm_prescription_order_pay_order')->insert(['prescription_order_id' => $oldId, 'pay_order_id' => $payId, 'create_time' => time()]); + Db::name('tcm_prescription_order')->where('id', $oldId)->update(['linked_pay_order_id' => $payId]); + $out = PrescriptionOrderLogic::refund($oldId, '测试全退', 1, $admin); + $expect(is_array($out) && (int) $out['fulfillment_status'] === 10, 'Full refund must transition to released status'); + $expect((int) Db::name('order')->where('id', $payId)->value('status') === 4, 'Original payment must stay linked and be marked refunded'); + $expect(Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $oldId)->count() === 1, 'Refund must preserve payment association history'); + $expect($detail($rx)['has_prescription_order'] === 0 && (int) $detail($rx)['audit_status'] === 1, 'Refund must release an already approved prescription without resetting approval'); + $expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Actual refund must permit a new business order'); + $expect($row($oldId)['gancao_reciperl_order_no'] === 'REMOTE-HISTORY' && (int) $row($oldId)['prescription_id'] === $rx, 'New order must preserve old remote and prescription associations'); + $rx = $newRx(); + $oldId = $fixture($rx); + $out = PrescriptionOrderLogic::refund($oldId, '测试部分退款', 1, $admin, 20); + $expect(is_array($out) && (int) $out['fulfillment_status'] === 5 && (float) $out['paid'] === 80.0, 'Partial refund must remain active while there is a balance'); + $expect($detail($rx)['has_prescription_order'] === 1 && PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Partial refund must not release prescription'); + $out = PrescriptionOrderLogic::refund($oldId, '剩余全退', 1, $admin); + $expect(is_array($out) && $detail($rx)['has_prescription_order'] === 0, 'Refunding remaining balance must release prescription'); + $rx = $newRx(); + $oldId = $fixture($rx, ['fulfillment_status' => 1]); + $expect(is_array(PrescriptionOrderLogic::withdraw($oldId, 1, $admin)), 'Existing cancellation must still work'); + $expect(is_array(PrescriptionOrderLogic::create($createParams($rx), 1, $admin)), 'Withdrawn order must still release prescription'); + $rx = $newRx(['void_status' => 1]); + $fixture($rx, ['fulfillment_status' => 10]); + $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Refund must never revive a voided prescription'); + $rx = $newRx(['delete_time' => time()]); + $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Deleted prescription must not be orderable'); + + // Audit-log failures roll back order creation and creation-time edits. + $rx = $newRx(); + $oldId = $fixture($rx, ['fulfillment_status' => 10]); + $before = $row($oldId); + $pdo->exec("CREATE TRIGGER reject_order_log BEFORE INSERT ON zyt_tcm_prescription_order_log + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit failure'"); + $expect(PrescriptionOrderLogic::create($createParams($rx), 1, $admin) === false, 'Create log failure must reject creation'); + $expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->count() === 1, 'Create log failure must not leave a partial active order'); + $timeParams = ['id' => $oldId, 'create_time' => '2026-08-22 11:40:03']; + $expect(PrescriptionOrderLogic::editTime($timeParams, 1, $admin) === false && $row($oldId) === $before, 'Time edit must roll back if audit logging fails'); + $pdo->exec('DROP TRIGGER reject_order_log'); + + // Dedicated permission is enforced before menu deployment, including owners and manager roles. + foreach ([[], [3]] as $roles) { + $out = PrescriptionOrderLogic::editTime($timeParams, 1, ['root' => 0, 'admin_id' => 1, 'role_id' => $roles]); + $expect($out === false && $row($oldId) === $before, 'Ownership/general manager role must not imply time-edit permission'); + } + foreach ([[], ['id' => 0, 'create_time' => '2026-08-22 11:40:03'], ['id' => 1, 'create_time' => ''], + ['id' => 1, 'create_time' => '2026-02-30 11:40:03'], ['id' => 1, 'create_time' => '2026-08-22'], + ['id' => 1, 'create_time' => ['2026-08-22 11:40:03']], ['id' => [1], 'create_time' => '2026-08-22 11:40:03']] as $invalid) { + $expect(!(new PrescriptionOrderValidate())->scene('editTime')->check($invalid), 'Invalid time requests must be rejected'); + } + $expect((new PrescriptionOrderValidate())->scene('editTime')->check($timeParams), 'Canonical creation datetime must validate'); + $out = PrescriptionOrderLogic::editTime($timeParams + ['paid' => 999, 'fulfillment_status' => 1], 1, $admin); + $after = $row($oldId); + $expect(is_array($out) && (int) $after['create_time'] === strtotime($timeParams['create_time']), 'Timestamp schema must retain Unix creation time'); + foreach ($before as $key => $value) { + if (!in_array($key, ['create_time', 'update_time'], true)) $expect($after[$key] === $value, 'Time edit must preserve ' . $key); + } + $log = $logRows($oldId)[0]; + $expect($log['action'] === 'edit_time' && (int) $log['admin_id'] === 1 + && str_contains($log['summary'], date('Y-m-d H:i:s', (int) $before['create_time'])) + && str_contains($log['summary'], $timeParams['create_time']), 'Audit log must record operator, previous and new time'); + $expect(is_array(PrescriptionOrderLogic::editTime($timeParams, 1, $admin)) && count($logRows($oldId)) === 1, 'Repeated identical edit must not duplicate audit log'); + $deletedId = $fixture($newRx(), ['delete_time' => time()]); + $expect(PrescriptionOrderLogic::editTime(['id' => $deletedId, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Deleted order time must not be editable'); + $expect(PrescriptionOrderLogic::editTime(['id' => 999999, 'create_time' => $timeParams['create_time']], 1, $admin) === false, 'Missing order time must not be editable'); + + $pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)"); + $sql = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260909/add_prescription_order_edit_time_menu.sql'); + $pdo->exec($sql); + $pdo->exec($sql); + $expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->count() === 1, 'Time permission migration must be idempotent'); + $expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant privileges automatically'); + $menuId = Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/editTime')->value('id'); + Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]); + Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]); + $out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-23 11:40:03'], 2, + ['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']); + $expect(is_array($out) && array_keys($out) === ['id', 'create_time'], 'Explicit time permission must work and return only safe fields'); + + // Two independent PHP connections race behind the prescription lock, including already-refunded history. + foreach ([false, true] as $withHistory) { + $rx = $newRx(); + if ($withHistory) $fixture($rx, ['fulfillment_status' => 10]); + putenv('ZYT_RX_ORDER_TEST_DATABASE=' . $database); + Db::startTrans(); + Db::name('tcm_prescription')->where('id', $rx)->lock(true)->find(); + $workers = []; + try { + foreach ([1, 2] as $_) { + $process = proc_open([PHP_BINARY, __FILE__, '--worker', (string) $rx], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) throw new RuntimeException('Cannot start concurrency worker'); + fclose($pipes[0]); + $workers[] = [$process, $pipes]; + if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Worker failed initialization'); + } + } finally { + Db::commit(); + } + $results = []; + foreach ($workers as [$process, $pipes]) { + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) throw new RuntimeException('Worker failed: ' . $errors . $output); + $results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR); + } + $expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent create must succeed exactly once'); + $expect(Db::name('tcm_prescription_order')->where('prescription_id', $rx)->whereNotIn('fulfillment_status', [4,10])->count() === 1, 'Concurrency must persist only one live order'); + } + + // Compatibility with installations that store create_time as DATETIME. + $pdo->exec('ALTER TABLE zyt_tcm_prescription_order ADD legacy_datetime DATETIME NULL'); + $pdo->exec('UPDATE zyt_tcm_prescription_order SET legacy_datetime=FROM_UNIXTIME(create_time)'); + $pdo->exec('ALTER TABLE zyt_tcm_prescription_order DROP create_time, CHANGE legacy_datetime create_time DATETIME NULL'); + $out = PrescriptionOrderLogic::editTime(['id' => $oldId, 'create_time' => '2026-08-24 11:40:03'], 1, $admin); + $expect(is_array($out) && $row($oldId)['create_time'] === '2026-08-24 11:40:03', 'Datetime schema must preserve canonical datetime strings'); + echo "PrescriptionOrderReleaseAndTimeTest: {$checks} assertions passed\n"; +} finally { + $manager->connect()->close(); + $pdo->exec("DROP DATABASE `{$database}`"); +} diff --git a/server/tests/WecomPromotionMemberSyncTest.php b/server/tests/WecomPromotionMemberSyncTest.php new file mode 100644 index 000000000..eb51f485d --- /dev/null +++ b/server/tests/WecomPromotionMemberSyncTest.php @@ -0,0 +1,385 @@ +updates[] = $payload; + if ($this->failure !== '') { + throw new RuntimeException($this->failure); + } + return ['errcode' => 0, 'errmsg' => 'ok']; + } + + public function getLink(string $linkId): array + { + $this->gets[] = $linkId; + $payload = $this->updates[count($this->updates) - 1] ?? []; + $response = [ + 'errcode' => 0, + 'link' => ['link_id' => $linkId, 'url' => 'https://work.weixin.qq.com/ca/isolated-test'], + // Official GET shape: range is at the root, not under link. + 'range' => [ + 'user_list' => $this->remoteUsers ?? ($payload['range']['user_list'] ?? []), + 'department_list' => $this->remoteDepartments, + ], + ]; + if ($this->onGet !== null) { + ($this->onGet)(); + } + return $response; + } +} + +$port = (int) getenv('ZYT_WECOM_MEMBER_TEST_MYSQL_PORT'); +if ($port < 1024 || $port === 3306 || $port > 65535) { + fwrite(STDERR, "Set ZYT_WECOM_MEMBER_TEST_MYSQL_PORT to an isolated local MySQL port (not 3306).\n"); + exit(1); +} +$database = 'wecom_member_test_' . bin2hex(random_bytes(6)); +$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); +$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); +$pdo->exec("USE `{$database}`"); +$testApp = new think\App(); +$manager = new DbManager(); +$manager->setConfig([ + 'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false, + 'connections' => ['mysql' => [ + 'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, + 'database' => $database, 'username' => 'root', 'password' => '', + 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, + ]], +]); +Container::getInstance()->instance('think\DbManager', $manager); +Container::getInstance()->instance('config', new think\Config()); +$checks = 0; +$passed = []; +$failed = []; +$expect = static function (bool $ok, string $message) use (&$checks): void { + if (!$ok) { + throw new RuntimeException($message); + } + $checks++; +}; +$run = static function (string $name, Closure $test) use (&$passed, &$failed): void { + try { + $test(); + $passed[] = $name; + echo "PASS {$name}\n"; + } catch (Throwable $error) { + $failed[$name] = $error->getMessage(); + echo "FAIL {$name}: {$error->getMessage()}\n"; + } +}; +$admin = ['root' => 1, 'admin_id' => 1, 'name' => 'Isolated test administrator']; + +try { + // Use the deployed table definitions, without executing unrelated menu/cron mutations. + $schemas = [ + '1.9.20260805/add_first_visit_wecom_promotion.sql' => ['qywx_promotion_pool', 'qywx_promotion_link'], + '1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql' => [ + 'qywx_promotion_pool_member', 'qywx_promotion_dispatch_event', 'qywx_promotion_range_sync', + ], + '1.9.20260828/add_wecom_promotion_pool_operators.sql' => ['qywx_promotion_pool_operator'], + ]; + foreach ($schemas as $file => $tables) { + $sql = file_get_contents(dirname(__DIR__) . '/sql/' . $file); + foreach ($tables as $table) { + if (!preg_match('/CREATE TABLE IF NOT EXISTS `zyt_' . preg_quote($table, '/') . '` \([\s\S]*?;/', $sql, $match)) { + throw new RuntimeException('Missing fixture schema: ' . $table); + } + $pdo->exec($match[0]); + } + } + $pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)'); + $pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)'); + $pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(100), is_disable INT DEFAULT 0)'); + + $fixture = static function (array $syncFields = [], array $cachedUsers = ['XuKe', 'OldAssistant'], array $cachedDepartments = []): array { + $poolId = (int) Db::name('qywx_promotion_pool')->insertGetId([ + 'name' => '隔离范围同步测试', 'public_key' => bin2hex(random_bytes(16)), 'owner_admin_id' => 1, + ]); + $linkId = (int) Db::name('qywx_promotion_link')->insertGetId([ + 'pool_id' => $poolId, 'remote_link_id' => 'test-remote-' . $poolId, + 'wecom_url' => 'https://work.weixin.qq.com/ca/isolated-test', 'remote_status' => 1, + 'range_user_json' => json_encode($cachedUsers), 'range_department_json' => json_encode($cachedDepartments), + ]); + foreach ([['XuKe', 1], ['OldAssistant', 0], ['AnotherOldAssistant', 0]] as $index => [$userId, $enabled]) { + Db::name('qywx_promotion_pool_member')->insert([ + 'pool_id' => $poolId, 'admin_id' => $index + 1, 'userid' => $userId, + 'enabled' => $enabled, 'today_date' => date('Y-m-d'), + ]); + } + Db::name('qywx_promotion_range_sync')->insert(array_replace([ + 'pool_id' => $poolId, 'promotion_link_id' => $linkId, 'status' => 1, + 'desired_version' => 2, 'applied_version' => 1, + ], $syncFields)); + return [$poolId, $linkId, new MemberSyncFakeApi()]; + }; + $syncRow = static fn (int $poolId): array => Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find(); + $linkRow = static fn (int $linkId): array => Db::name('qywx_promotion_link')->where('id', $linkId)->find(); + $retry = static fn (int $poolId, MemberSyncFakeApi $api): array => WecomPromotionLogic::syncMemberRange( + $poolId, 1, $admin, new QywxPromotionRangeSyncService($api) + ); + + $run('only XuKe is sent and both remote range dimensions are verified', static function () use ($fixture, $expect, $syncRow, $linkRow): void { + [$poolId, $linkId, $api] = $fixture([], ['XuKe', 'OldAssistant'], ['42']); + $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + $expect($result['status'] === 'synced', 'Exact confirmed range should sync'); + $expect(count($api->updates) === 1 && $api->gets === ['test-remote-' . $poolId], 'Must update then GET the same official link'); + $expect($api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Disabled members and departments must be removed from update'); + $link = $linkRow($linkId); + $expect(json_decode($link['range_user_json'], true) === ['XuKe'] && json_decode($link['range_department_json'], true) === [], 'Save the GET-verified users and empty departments'); + $row = $syncRow($poolId); + $expect((int) $row['status'] === 0 && (int) $row['applied_version'] === (int) $row['desired_version'] && $row['lock_token'] === '', 'Confirmed sync must finish its version and release the lease'); + }); + + foreach (['old user' => [['XuKe', 'OldAssistant'], []], 'department expansion' => [['XuKe'], ['42']]] as $name => [$users, $departments]) { + $run('GET mismatch rejects ' . $name . ' and remains retryable', static function () use ($fixture, $expect, $syncRow, $linkRow, $users, $departments): void { + [$poolId, $linkId, $api] = $fixture(); + $api->remoteUsers = $users; + $api->remoteDepartments = $departments; + $error = null; + try { + (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + } catch (Throwable $caught) { + $error = $caught; + } + $expect($error !== null, 'Mismatched confirmed range must reject sync'); + $row = $syncRow($poolId); + $expect((int) $row['status'] === 3 && (int) $row['next_retry'] > time(), 'Mismatch must leave a scheduled retry'); + $expect((int) $row['applied_version'] === 1 && $row['last_error'] !== '' && $linkRow($linkId)['sync_error'] !== '', 'Failure must not advance confirmed version and must remain visible'); + $expect((int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('enabled') === 0, 'Failure must preserve the saved offline switch'); + }); + } + + $run('transport failure preserves last confirmed snapshot and retry', static function () use ($fixture, $expect, $syncRow, $linkRow): void { + [$poolId, $linkId, $api] = $fixture(); + $before = $linkRow($linkId)['range_user_json']; + $api->failure = 'fake upstream timeout'; + try { + (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + throw new LogicException('Expected fake transport error'); + } catch (RuntimeException $error) { + $expect($error->getMessage() === $api->failure, 'Surface the transport error'); + } + $row = $syncRow($poolId); + $expect((int) $row['status'] === 3 && (int) $row['attempts'] === 1 && (int) $row['next_retry'] > time(), 'Transport failure must retain retry backoff'); + $expect($linkRow($linkId)['range_user_json'] === $before && $api->gets === [], 'Failed update cannot replace confirmed remote snapshot'); + }); + + $run('active sync lease cannot be reported as synced or stolen', static function () use ($fixture, $expect, $syncRow): void { + $token = str_repeat('a', 32); + [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); + $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + $expect($result['status'] !== 'synced' && $api->updates === [] && $syncRow($poolId)['lock_token'] === $token, 'Existing worker keeps its active lease and caller stays pending'); + }); + + $run('version change during GET reports pending and resyncs', static function () use ($fixture, $expect, $syncRow): void { + [$poolId, , $api] = $fixture(); + $api->onGet = static function () use ($poolId): void { + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->inc('desired_version')->update(); + }; + $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + $row = $syncRow($poolId); + $expect($result['status'] === 'pending' && (int) $row['status'] === 1, 'Stale confirmed version must report pending, never synced'); + $expect((int) $row['applied_version'] < (int) $row['desired_version'], 'Concurrent version must remain unconfirmed'); + $api->onGet = null; + $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced', 'Next attempt should confirm the newer version'); + }); + + $run('lease expiry during GET cannot commit success', static function () use ($fixture, $expect, $syncRow): void { + [$poolId, , $api] = $fixture(); + $api->onGet = static function () use ($poolId): void { + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]); + }; + $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + $expect($result['status'] === 'pending' && (int) $syncRow($poolId)['status'] !== 0, 'Expired worker cannot acknowledge a completed sync'); + }); + + $run('superseded lease cannot overwrite newer worker snapshot', static function () use ($fixture, $expect, $syncRow, $linkRow): void { + [$poolId, $linkId, $api] = $fixture(); + $newToken = str_repeat('b', 32); + $api->onGet = static function () use ($poolId, $linkId, $newToken): void { + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ + 'lock_token' => $newToken, 'lock_until' => time() + 90, 'desired_version' => 3, + ]); + Db::name('qywx_promotion_link')->where('id', $linkId)->update(['range_user_json' => '["NewWorkerSnapshot"]']); + }; + $result = (new QywxPromotionRangeSyncService($api))->syncPool($poolId); + $expect($result['status'] === 'pending' && $syncRow($poolId)['lock_token'] === $newToken, 'Superseded worker must not release or acknowledge the new lease'); + $expect($linkRow($linkId)['range_user_json'] === '["NewWorkerSnapshot"]', 'Superseded worker must not overwrite a newer remote snapshot'); + }); + + foreach ([ + 'expired running lease' => ['status' => 2, 'lock_token' => str_repeat('c', 32), 'lock_until' => time() - 1], + 'unconfirmed version' => ['status' => 0, 'desired_version' => 7, 'applied_version' => 6], + ] as $name => $fields) { + $run('reconcile keeps ' . $name . ' pending despite matching cache', static function () use ($fixture, $expect, $syncRow, $fields): void { + [$poolId] = $fixture($fields, ['XuKe']); + $result = QywxPromotionMemberSchedulerService::reconcilePool($poolId); + $expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'An unverified version or expired worker must be retried even when user cache matches'); + }); + } + + $run('reconcile removes cached departments even when users match', static function () use ($fixture, $expect, $syncRow): void { + [$poolId] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2], ['XuKe'], ['42']); + $result = QywxPromotionMemberSchedulerService::reconcilePool($poolId); + $expect($result['queued'] === true && (int) $syncRow($poolId)['status'] === 1, 'Residual department routes require a fresh update'); + }); + + foreach (['pending' => ['status' => 1], 'failed backoff' => ['status' => 3, 'next_retry' => time() + 300, 'last_error' => 'previous failure']] as $name => $fields) { + $run('explicit retry repairs old ' . $name . ' without changing member switches', static function () use ($fixture, $expect, $syncRow, $retry, $fields): void { + [$poolId, , $api] = $fixture($fields); + $before = Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid'); + $result = $retry($poolId, $api); + $expect($result['sync_status'] === 'synced' && $result['sync_error'] === '' && !$result['sync_queued'], 'Explicit retry must complete and return confirmed structured state'); + $expect($result['range_userids'] === ['XuKe'] && $result['range_department_ids'] === [] && count($api->updates) === 1, 'Retry must return the GET-confirmed member range'); + $expect($before === Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->column('enabled', 'userid') && (int) $syncRow($poolId)['status'] === 0, 'Retry must leave all switches unchanged'); + }); + } + + $run('explicit retry active lease returns pending without false success', static function () use ($fixture, $expect, $retry, $syncRow): void { + $token = str_repeat('d', 32); + [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); + $result = $retry($poolId, $api); + $expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $api->updates === [], 'In-flight retry must say pending'); + $expect($syncRow($poolId)['lock_token'] === $token && (int) $syncRow($poolId)['status'] === 2, 'Retry must preserve active worker ownership'); + }); + + $run('explicit retry API failure remains failed with saved local state', static function () use ($fixture, $expect, $retry, $syncRow): void { + [$poolId, , $api] = $fixture(); + $api->failure = 'fake permission denied'; + $result = $retry($poolId, $api); + $expect($result['sync_status'] === 'failed' && $result['sync_error'] !== '' && $result['sync_queued'], 'Failure should return a visible error and scheduled retry'); + $expect((int) $syncRow($poolId)['status'] === 3 && $result['range_userids'] === ['XuKe', 'OldAssistant'], 'Failure must expose the last confirmed range, including pending removal'); + }); + + $run('no eligible member is blocked and never sends empty official range', static function () use ($fixture, $expect, $retry, $syncRow): void { + [$poolId, , $api] = $fixture(); + Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 0]); + $result = $retry($poolId, $api); + $expect($result['sync_status'] === 'blocked' && $result['sync_error'] !== '' && !$result['sync_queued'], 'Empty eligible range must clearly report blocked'); + $expect($api->updates === [] && (int) $syncRow($poolId)['status'] === 4, 'Blocked pool must not send an empty enterprise WeChat range'); + }); + + $run('explicit retry rejects pools outside operator scope', static function () use ($fixture, $expect): void { + [$poolId, , $api] = $fixture(); + $error = null; + try { + WecomPromotionLogic::syncMemberRange($poolId, 99, ['root' => 0, 'admin_id' => 99], new QywxPromotionRangeSyncService($api)); + } catch (RuntimeException $caught) { + $error = $caught; + } + $expect($error !== null && $api->updates === [], 'Unrelated account must not mutate remote member ranges'); + }); + + $run('batch repeated offline selection requeues the unsynced remote range', static function () use ($fixture, $expect, $syncRow, $admin): void { + [$poolId, , $api] = $fixture(['status' => 0, 'desired_version' => 2, 'applied_version' => 2]); + $result = WecomPromotionLogic::batchUpdatePools([ + 'pool_ids' => [$poolId], + 'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]], + ], 1, $admin); + $expect($result['failed'] === 0 && $result['member_matched'] === 2 && $result['member_updated'] === 0, 'An already-offline selection remains a valid repeat action'); + $expect($result['sync_queued_count'] === 1 && $result['results'][0]['sync_queued'] && (int) $syncRow($poolId)['status'] === 1, 'No-op local switches must still queue the stale official range'); + $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'The queued batch repair must leave only XuKe in the official link'); + }); + + $run('batch disables both old assistants and queue confirms XuKe only', static function () use ($fixture, $expect, $admin): void { + [$poolId, , $api] = $fixture([], ['XuKe', 'OldAssistant', 'AnotherOldAssistant']); + Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->update(['enabled' => 1]); + $result = WecomPromotionLogic::batchUpdatePools([ + 'pool_ids' => [$poolId], + 'changes' => ['member_status' => ['member_admin_ids' => [2, 3], 'status' => 0]], + ], 1, $admin); + $expect($result['member_updated'] === 2 && $result['failed'] === 0 && $result['sync_queued_count'] === 1, 'Batch offline must save both switches and report queued remote work'); + $expect((new QywxPromotionRangeSyncService($api))->syncPool($poolId)['status'] === 'synced' && $api->updates[0]['range'] === ['user_list' => ['XuKe'], 'department_list' => []], 'Actual batch queue must update/get the final exact range'); + }); + + $run('single offline with active worker reports pending then retries successfully', static function () use ($fixture, $expect, $admin, $retry, $syncRow): void { + $token = str_repeat('e', 32); + [$poolId, , $api] = $fixture(['status' => 2, 'lock_token' => $token, 'lock_until' => time() + 90]); + Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->update(['enabled' => 1]); + $memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'OldAssistant')->value('id'); + // Active lease prevents any real API request from the endpoint's default service. + $result = WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin); + $expect($result['sync_status'] === 'pending' && $result['sync_queued'] && $syncRow($poolId)['lock_token'] === $token, 'Single toggle must not turn a service noop into success: ' . json_encode($result, JSON_UNESCAPED_UNICODE)); + $expect((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, 'Single toggle must persist offline locally while waiting for its worker'); + Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update(['lock_until' => time() - 1]); + $expect($retry($poolId, $api)['sync_status'] === 'synced' && $api->updates[0]['range']['user_list'] === ['XuKe'], 'Explicit retry must clear the single-toggle pending removal'); + }); + + $run('single and batch preserve at least one eligible assistant', static function () use ($fixture, $expect, $admin): void { + [$poolId] = $fixture(); + $memberId = (int) Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->where('userid', 'XuKe')->value('id'); + foreach (['single', 'batch'] as $method) { + $error = null; + try { + if ($method === 'single') { + WecomPromotionLogic::toggleMember($memberId, 0, 1, $admin); + } else { + WecomPromotionLogic::batchUpdatePools([ + 'pool_ids' => [$poolId], 'changes' => ['member_status' => ['member_admin_ids' => [1], 'status' => 0]], + ], 1, $admin); + } + } catch (RuntimeException $caught) { + $error = $caught; + } + $expect($error !== null && (int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 1, 'The final available member must remain online after rejected ' . $method . ' action'); + } + }); + + $run('shared operator can retry its own assigned pool', static function () use ($fixture, $expect): void { + [$poolId, , $api] = $fixture(); + Db::name('system_menu')->insert(['id' => 1, 'perms' => 'firstvisit.wecomPromotion/overview']); + Db::name('qywx_promotion_pool_operator')->insert(['pool_id' => $poolId, 'admin_id' => 88]); + $result = WecomPromotionLogic::syncMemberRange($poolId, 88, ['root' => 0, 'admin_id' => 88], new QywxPromotionRangeSyncService($api)); + $expect($result['sync_status'] === 'synced' && count($api->updates) === 1, 'Assigned operator should be allowed the targeted range retry'); + }); + + foreach ([ + 'deleting' => ['status' => 5, 'lock_token' => str_repeat('f', 32), 'lock_until' => time() + 90], + 'delete failed' => ['status' => 4, 'last_error' => '企业微信官方获客链接删除失败: fake timeout'], + ] as $name => $fields) { + $run('retry never revives ' . $name . ' pool', static function () use ($fixture, $expect, $retry, $syncRow, $fields): void { + [$poolId, , $api] = $fixture($fields); + $before = $syncRow($poolId); + $result = $retry($poolId, $api); + $expect($result['sync_status'] === 'blocked' && $api->updates === [] && $syncRow($poolId) === $before, 'Retry must preserve deletion ownership and leave remote API untouched'); + }); + } + + echo json_encode(['passed' => count($passed), 'failed' => count($failed), 'checks' => $checks, 'failures' => $failed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; +} finally { + $pdo->exec("DROP DATABASE IF EXISTS `{$database}`"); + echo "Disposable test database dropped.\n"; +} +exit($failed === [] ? 0 : 1);