From 928f72ec3ddcde5c486cc9456546616dc695d6b3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 3 Sep 2026 16:03:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/api/first_visit.ts | 36 ++ admin/src/api/qywx.ts | 5 + admin/src/views/fans/qywx.vue | 320 ++++++++++++++++-- .../components/PromotionAutomationForm.vue | 84 +++-- .../first_visit/wecom_promotion/index.vue | 270 ++++++++++++++- app/src/doctor_workstation/__init__.py | 4 +- .../ui/dialogs/diagnosis.py | 102 ++++-- .../ui/dialogs/prescription.py | 276 ++++++--------- app/tests/test_diagnosis_drawer_visual.py | 17 + app/tests/test_prescription_security_ui.py | 187 +++++----- .../firstvisit/WecomPromotionController.php | 13 + .../controller/qywx/CustomerController.php | 58 +++- .../http/middleware/AuthMiddleware.php | 1 + .../app/adminapi/lists/qywx/CustomerLists.php | 228 ++++++++++++- .../logic/firstvisit/WecomPromotionLogic.php | 165 ++++++++- .../app/adminapi/logic/qywx/CustomerLogic.php | 67 ++++ .../validate/qywx/CustomerValidate.php | 12 + .../create_qywx_external_contact_event.sql | 1 + .../add_qywx_customer_channel_index.sql | 19 ++ .../add_qywx_customer_delete_menu.sql | 36 ++ .../QywxCustomerChannelProjectionTest.php | 185 ++++++++++ .../QywxCustomerChannelUiContractTest.mjs | 60 ++++ ...wxCustomerDeletePermissionContractTest.php | 80 +++++ .../tests/WecomPromotionAutomationUiTest.mjs | 4 + .../WecomPromotionBatchUpdateContractTest.php | 90 +++++ 25 files changed, 1926 insertions(+), 394 deletions(-) create mode 100644 server/sql/1.9.20260902/add_qywx_customer_channel_index.sql create mode 100644 server/sql/1.9.20260902/add_qywx_customer_delete_menu.sql create mode 100644 server/tests/QywxCustomerChannelProjectionTest.php create mode 100644 server/tests/QywxCustomerChannelUiContractTest.mjs create mode 100644 server/tests/QywxCustomerDeletePermissionContractTest.php create mode 100644 server/tests/WecomPromotionBatchUpdateContractTest.php diff --git a/admin/src/api/first_visit.ts b/admin/src/api/first_visit.ts index 68452db28..737f427cd 100644 --- a/admin/src/api/first_visit.ts +++ b/admin/src/api/first_visit.ts @@ -293,6 +293,42 @@ export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOp }) } +export interface WecomPromotionBatchUpdatePoolsParams { + pool_ids: number[] + changes: { + skip_verify?: 0 | 1 + fallback_url?: string + status?: 0 | 1 + automation_config?: Record + } +} + +export interface WecomPromotionBatchUpdatePoolResult { + id: number + name: string + success: boolean + sync_error?: string + sync_queued?: boolean + error?: string +} + +export interface WecomPromotionBatchUpdatePoolsResult { + pool_ids: number[] + updated: number + failed: number + sync_error_count: number + sync_queued_count: number + results: WecomPromotionBatchUpdatePoolResult[] +} + +export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) { + return request.post({ + url: '/firstvisit.wecomPromotion/batchUpdatePools', + params, + timeout: 120000 + }, { ignoreCancelToken: true }) +} + export function wecomPromotionDeletePool(params: { id: number }) { return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 }) } diff --git a/admin/src/api/qywx.ts b/admin/src/api/qywx.ts index a07040532..4b93db64b 100644 --- a/admin/src/api/qywx.ts +++ b/admin/src/api/qywx.ts @@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) { return request.get({ url: '/qywx.customer/lists', params }) } +// 删除一条本地企业微信客户同步记录 +export function qywxCustomerDelete(params: { id: number }) { + return request.post({ url: '/qywx.customer/delete', params }) +} + // 同步企业微信客户 export function qywxCustomerSync() { return request.post({ url: '/qywx.customer/sync' }) diff --git a/admin/src/views/fans/qywx.vue b/admin/src/views/fans/qywx.vue index 657520ec8..f2b39c128 100644 --- a/admin/src/views/fans/qywx.vue +++ b/admin/src/views/fans/qywx.vue @@ -94,15 +94,32 @@ @keyup.enter="resetPage" /> - - + - - + @keyup.enter="resetPage" + /> + + + + + + + — + + + - + @@ -500,6 +553,21 @@ {{ formatTime(firstExternalAddTime(currentCustomer)) }} + + +
+ + + {{ source.label }} + + +
+ 未记录
{{ formatTime(currentCustomer.update_time) }} @@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v import { usePaging } from '@/hooks/usePaging' import feedback from '@/utils/feedback' import { - qywxCustomerLists, - qywxCustomerSync, + qywxCustomerLists, + qywxCustomerDelete, + qywxCustomerSync, qywxCustomerStats, qywxSyncSettingsGet, qywxSyncSettingsSave, @@ -563,6 +632,7 @@ const syncing = ref(false) const showSyncSettings = ref(false) const showDetail = ref(false) const currentCustomer = ref(null) +const deletingCustomerId = ref(null) const stats = reactive({ total: 0, @@ -591,17 +661,19 @@ const syncSettings = reactive({ interval: 3600 }) -const queryParams = reactive<{ - name: string - follow_user: string - tag_ids: string[] +const queryParams = reactive<{ + name: string + follow_user: string + add_way: number | '' + tag_ids: string[] add_time_start: string add_time_end: string dedupe_mode: 'first' | 'any' }>({ - name: '', - follow_user: '', - tag_ids: [], + name: '', + follow_user: '', + add_way: '', + tag_ids: [], add_time_start: '', add_time_end: '', dedupe_mode: 'first' @@ -639,6 +711,22 @@ interface TagStatsPayload { groups: TagGroup[] } +interface AddChannel { + state: string + label: string + source_type: 'promotion_pool' | 'state' + pool_id: number + user_id: string + event_time: number +} + +interface AddSource extends AddChannel { + key: string + add_way: number | null + channel_label: string + staff_name: string +} + const tagStats = reactive({ total_tags: 0, total_relations: 0, @@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({ params: queryParams }) -function handleReset() { - queryParams.name = '' - queryParams.follow_user = '' - queryParams.tag_ids = [] +function handleReset() { + queryParams.name = '' + queryParams.follow_user = '' + queryParams.add_way = '' + queryParams.tag_ids = [] queryParams.add_time_start = '' queryParams.add_time_end = '' queryParams.dedupe_mode = 'first' @@ -964,6 +1053,34 @@ function viewDetail(row: any) { showDetail.value = true } +async function handleDelete(row: Record) { + const id = Number(row.id) + if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return + + const customerName = String(row.name || row.external_userid || '该客户') + try { + await feedback.confirm( + `确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。` + ) + } catch { + return + } + + deletingCustomerId.value = id + try { + await qywxCustomerDelete({ id }) + if (pager.page > 1 && pager.lists.length === 1) { + pager.page -= 1 + } + await Promise.all([getLists(), loadStats(), loadTagStats()]) + feedback.msgSuccess('删除成功') + } catch (e: any) { + feedback.msgError(e?.message || e?.msg || '删除失败') + } finally { + deletingCustomerId.value = null + } +} + /** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */ function formatFollowUser(user: Record) { const adminName = String(user?.admin_name ?? '').trim() @@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record) { return parts.join('|') } +function customerAddChannels(row: Record | null | undefined): AddChannel[] { + if (!row) return [] + if (Array.isArray(row.add_channels)) { + return row.add_channels + .map((channel: Record): AddChannel => ({ + state: String(channel?.state ?? '').trim(), + label: String(channel?.label ?? channel?.state ?? '').trim(), + source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state', + pool_id: Number(channel?.pool_id ?? 0), + user_id: String(channel?.user_id ?? '').trim(), + event_time: Number(channel?.event_time ?? 0) + })) + .filter((channel: AddChannel) => channel.state !== '') + } + + // 兼容仅返回原始渠道数组的旧接口/灰度节点。 + if (!Array.isArray(row.add_channel_states)) return [] + return row.add_channel_states + .map((state: unknown) => String(state ?? '').trim()) + .filter((state: string) => state !== '') + .map((state: string) => ({ + state, + label: state, + source_type: 'state' as const, + pool_id: 0, + user_id: '', + event_time: 0 + })) +} + +const ADD_WAY_LABELS: Record = { + 0: '未知添加方式', + 1: '通过扫描二维码添加', + 2: '通过搜索手机号添加', + 3: '通过名片分享添加', + 4: '通过群聊添加', + 5: '通过手机通讯录添加', + 6: '通过微信联系人添加', + 8: '安装第三方应用时自动添加', + 9: '通过搜索邮箱添加', + 10: '通过视频号添加', + 11: '通过日程参与人添加', + 12: '通过会议参与人添加', + 13: '通过微信好友添加', + 14: '通过智慧硬件专属客服添加', + 15: '通过上门服务客服添加', + 16: '通过获客链接添加', + 17: '通过定制开发添加', + 18: '通过需求回复添加', + 21: '通过第三方售前客服添加', + 22: '通过可能的商务伙伴添加', + 24: '通过接受微信好友申请添加', + 201: '通过内部成员共享添加', + 202: '通过管理员或负责人分配添加' +} + +const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({ + value: Number(value), + label +})) + +function normalizeAddWay(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value + if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null + return Number(value.trim()) +} + +function addWayLabel(addWay: number) { + return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})` +} + +function customerAddSources(row: Record | null | undefined): AddSource[] { + if (!row) return [] + + const channels = customerAddChannels(row) + const usedChannelIndexes = new Set() + const sources: AddSource[] = [] + const followUsers = Array.isArray(row.follow_users) ? row.follow_users : [] + + followUsers.forEach((user: Record, index: number) => { + const userId = String(user?.userid ?? user?.UserId ?? '').trim() + const state = String(user?.state ?? user?.State ?? '').trim() + const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay) + + let channelIndex = channels.findIndex( + (channel, i) => + !usedChannelIndexes.has(i) && + userId !== '' && + state !== '' && + channel.user_id === userId && + channel.state === state + ) + if (channelIndex < 0 && state !== '') { + channelIndex = channels.findIndex( + (channel, i) => !usedChannelIndexes.has(i) && channel.state === state + ) + } + if (channelIndex < 0 && userId !== '') { + channelIndex = channels.findIndex( + (channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId + ) + } + + const channel = channelIndex >= 0 ? channels[channelIndex] : undefined + if (channelIndex >= 0) usedChannelIndexes.add(channelIndex) + if (addWay === null && state === '' && !channel) return + + const labelFromApi = String(user?.add_way_label ?? '').trim() + const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state') + const label = labelFromApi || (addWay !== null + ? addWayLabel(addWay) + : sourceType === 'promotion_pool' + ? '通过获客链接添加' + : '通过其他渠道添加') + + sources.push({ + key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`, + add_way: addWay, + label, + state: state || channel?.state || '', + channel_label: channel?.label || '', + source_type: sourceType, + pool_id: channel?.pool_id || 0, + user_id: userId || channel?.user_id || '', + staff_name: formatFollowUser(user), + event_time: channel?.event_time || Number(user?.createtime ?? 0) + }) + }) + + // 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。 + channels.forEach((channel, index) => { + if (usedChannelIndexes.has(index)) return + sources.push({ + ...channel, + key: `channel:${index}:${channel.user_id}:${channel.state}`, + add_way: channel.source_type === 'promotion_pool' ? 16 : null, + label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加', + channel_label: channel.label, + staff_name: channel.user_id || '—' + }) + }) + + return sources.sort((a, b) => b.event_time - a.event_time) +} + +function addSourceTooltip(source: AddSource) { + const parts: string[] = [] + parts.push(`添加方式:${source.label}`) + if (source.source_type === 'promotion_pool' && source.channel_label) { + parts.push(`获客助手方案:${source.channel_label}`) + } + if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`) + if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`) + if (source.state) parts.push(`渠道参数:${source.state}`) + return parts.join('|') +} + +function remainingAddSourcesTooltip(row: Record) { + return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n') +} + /** * 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底); * 再解析 follow_users;最后退回 create_time diff --git a/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue b/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue index 737fd0876..af05c1f9b 100644 --- a/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue +++ b/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue @@ -2,9 +2,10 @@
+

接待设置

- + 全天接待 按星期时段自动上下线 @@ -12,50 +13,52 @@
-
接待时段 {{ index + 1 }}删除时段
- {{ day }} -
次日结束
- +
接待时段 {{ index + 1 }}删除时段
+ {{ day }} +
次日结束
+

该时段含已从主接待移除的成员,请重新选择。

- 添加接待时段 + 添加接待时段

最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。

- - + +

备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。

+
+

客户设置

- +
- + - 自定义标签 - {{ tagsError ? '重试' : '刷新标签' }} + 自定义标签 + {{ tagsError ? '重试' : '刷新标签' }}

每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。客户添加成功后由系统调用企微接口打标,不会显示在企微获客链接详情的“客户标签”配置中。

- - 创建并选用 + + 创建并选用

创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。

@@ -64,22 +67,24 @@
- +
-
插入{{ token.label }}
- +
插入{{ token.label }}
+
备注预览{{ remarkPreview || '—' }}{{ Array.from(remarkPreview).length }}/20 字

示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。

- - + + +
+

欢迎语设置

- + 渠道欢迎语 默认欢迎语 不发送欢迎语 @@ -90,20 +95,21 @@ +
@@ -116,9 +122,22 @@ import WelcomeMessageEditor from './WelcomeMessageEditor.vue' import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation' import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation' -const props = defineProps<{ modelValue: PromotionAutomationConfig; mainMemberIds: number[]; members: PromotionMemberChoice[]; disabled?: boolean }>() +type AutomationSection = 'reception' | 'customer' | 'welcome' +const props = defineProps<{ + modelValue: PromotionAutomationConfig + mainMemberIds: number[] + members: PromotionMemberChoice[] + disabled?: boolean + disabledSections?: AutomationSection[] + backupExcludedMemberIds?: number[] +}>() const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>() const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) }) +const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section)) +const receptionDisabled = computed(() => sectionDisabled('reception')) +const customerDisabled = computed(() => sectionDisabled('customer')) +const welcomeDisabled = computed(() => sectionDisabled('welcome')) +const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds) const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id)))) const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id))) const employeeName = computed(() => mainMembers.value[0]?.name || '小陈') @@ -153,7 +172,9 @@ const unknownTagIds = computed(() => { const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id))) return config.value.tag_ids.filter((id) => !ids.has(id)) }) -watch(() => config.value.tags_enabled, (enabled) => { if (enabled && !tagsLoaded.value && !tagsLoading.value) void loadTags() }, { immediate: true }) +watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => { + if (enabled && !sectionIsDisabled && !tagsLoaded.value && !tagsLoading.value) void loadTags() +}, { immediate: true }) function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` } function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) } function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) } @@ -173,7 +194,7 @@ async function loadTags() { } finally { tagsLoading.value = false } } async function createCustomTag() { - if (props.disabled || tagsCreating.value || tagsLoading.value) return + if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return customTagError.value = validateCustomTagName(customTagName.value) customTagSuccess.value = '' if (customTagError.value) return @@ -215,6 +236,7 @@ onBeforeUnmount(() => emit('busy', false)) diff --git a/app/src/doctor_workstation/__init__.py b/app/src/doctor_workstation/__init__.py index a33caea94..ab7e72592 100644 --- a/app/src/doctor_workstation/__init__.py +++ b/app/src/doctor_workstation/__init__.py @@ -3,9 +3,9 @@ __all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"] # Single source of truth for runtime, package, installer, and executable versions. -__version__ = "1.4.0" +__version__ = "1.4.1" # 调试模式开启时,登录页显示“演示模式”和“服务器设置”。 # 正式发布请保持 False;此时程序只使用下面配置的线上域名。 -DEBUG_MODE = True +DEBUG_MODE = False ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn" diff --git a/app/src/doctor_workstation/ui/dialogs/diagnosis.py b/app/src/doctor_workstation/ui/dialogs/diagnosis.py index fef532601..2a3b1b7c1 100644 --- a/app/src/doctor_workstation/ui/dialogs/diagnosis.py +++ b/app/src/doctor_workstation/ui/dialogs/diagnosis.py @@ -758,9 +758,11 @@ class DiagnosisDialog(QDialog): parent: QWidget | None = None, *, permissions: Any = None, + embedded: bool = False, ) -> None: super().__init__(parent) self.repository = repository + self._embedded = bool(embedded) self.permissions = ( permissions if permissions is not None @@ -882,11 +884,17 @@ class DiagnosisDialog(QDialog): ) self.setObjectName("DiagnosisDialogRoot") - self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + if self._embedded: + self.setWindowFlags(Qt.WindowType.Widget) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) + self.setMinimumSize(0, 0) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) + else: + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setMinimumSize(760, 520) + self.resize(1024, 640) self.setWindowTitle("患者信息详情") - self.setMinimumSize(760, 520) - self.resize(1024, 640) self.setStyleSheet(DIAGNOSIS_QSS) self.view_stack = QStackedLayout(self) @@ -911,16 +919,25 @@ class DiagnosisDialog(QDialog): root = QVBoxLayout(page) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) + self.readonly_header = QWidget(page) + self.readonly_header.setObjectName("DiagnosisReadonlyHeader") + header_layout = QVBoxLayout(self.readonly_header) + header_layout.setContentsMargins(16, 16, 16, 0) + header_layout.setSpacing(0) + self.readonly_hero = self._build_readonly_hero() + header_layout.addWidget(self.readonly_hero) + root.addWidget(self.readonly_header) self.readonly_scroll = QScrollArea() self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll") self.readonly_scroll.setWidgetResizable(True) self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.readonly_scroll.verticalScrollBar().setSingleStep(28) content = QWidget() self.readonly_content_layout = QVBoxLayout(content) self.readonly_content_layout.setContentsMargins(16, 16, 16, 16) self.readonly_content_layout.setSpacing(16) - self.readonly_hero = self._build_readonly_hero() - self.readonly_content_layout.addWidget(self.readonly_hero) self.readonly_error = QFrame() self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard") self.readonly_error.setProperty("diagnosisReadonlyCard", True) @@ -981,16 +998,16 @@ class DiagnosisDialog(QDialog): left_layout = QHBoxLayout(self.readonly_hero_left) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(12) - back = QPushButton("← 返回") - back.setObjectName("DiagnosisReadonlyBack") - back.setCursor(Qt.CursorShape.PointingHandCursor) - back.setStyleSheet( + self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回") + self.readonly_back_button.setObjectName("DiagnosisReadonlyBack") + self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.readonly_back_button.setStyleSheet( "QPushButton{height:32px;padding:0 8px;border:0;background:transparent;" "color:#5265F6;font-size:13px;font-weight:500;}" "QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}" ) - back.clicked.connect(self.reject) - left_layout.addWidget(back) + self.readonly_back_button.clicked.connect(self.reject) + left_layout.addWidget(self.readonly_back_button) title = QLabel("患者信息详情") title.setObjectName("DiagnosisReadonlyTitle") left_layout.addWidget(title) @@ -1009,6 +1026,13 @@ class DiagnosisDialog(QDialog): self.readonly_status.setObjectName("DiagnosisReadonlyStatus") self.readonly_status.setProperty("severity", "neutral") right_layout.addWidget(self.readonly_status) + self.readonly_close_button = QPushButton("×") + self.readonly_close_button.setObjectName("DiagnosisCloseButton") + self.readonly_close_button.setToolTip("关闭诊单详情") + self.readonly_close_button.setAccessibleName("关闭诊单详情") + self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.readonly_close_button.clicked.connect(self.reject) + right_layout.addWidget(self.readonly_close_button) layout.addWidget(self.readonly_hero_left, 0, 0) layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) layout.setColumnStretch(0, 1) @@ -1790,6 +1814,8 @@ class DiagnosisDialog(QDialog): label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) def _sync_host_geometry(self) -> None: + if self._embedded: + return owner = self._owner if owner is None: if self.width() < 760 or self.height() < 520: @@ -1823,6 +1849,8 @@ class DiagnosisDialog(QDialog): layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight) def _install_owner_filter(self) -> None: + if self._embedded: + return if self._owner is not None and not self._owner_filter_installed: self._owner.installEventFilter(self) self._owner_filter_installed = True @@ -1830,6 +1858,10 @@ class DiagnosisDialog(QDialog): def _rebind_owner(self) -> None: """Resolve the live Shell window for every open/show cycle.""" + if self._embedded: + self._owner = None + self._owner_filter_installed = False + return parent = self.parentWidget() candidate = parent.window() if parent is not None else None if candidate is self: @@ -1852,8 +1884,9 @@ class DiagnosisDialog(QDialog): super().resizeEvent(event) def showEvent(self, event: Any) -> None: - self._rebind_owner() - self._sync_host_geometry() + if not self._embedded: + self._rebind_owner() + self._sync_host_geometry() self._update_drawer_geometry() self._reflow_readonly_hero() super().showEvent(event) @@ -1938,10 +1971,12 @@ class DiagnosisDialog(QDialog): *, editable: bool = False, seed: Any = None, + authoritative_detail: Any = None, view_only: bool = False, modeless: bool = False, + auto_show: bool = True, ) -> None: - """Open immediately, then replace the seed with authoritative server data.""" + """Prepare a diagnosis view, optionally showing it immediately.""" self._rebind_owner() for player in list(self._recording_players): @@ -1986,13 +2021,13 @@ class DiagnosisDialog(QDialog): self._daily_todo_status = None self._orders_page = 1 self._orders_total = 0 - self._detail = seed + self._detail = authoritative_detail if authoritative_detail is not None else seed self.save_button.set_state("idle") self.refresh_permissions() self.view_stack.setCurrentWidget( self.readonly_page if self._standalone_readonly else self.drawer_overlay ) - self.setModal(not modeless and not self._standalone_readonly) + self.setModal(False if self._embedded else not modeless and not self._standalone_readonly) self.setWindowTitle( "患者信息详情" if self._standalone_readonly @@ -2018,15 +2053,38 @@ class DiagnosisDialog(QDialog): ) self._clear_tables() self._clear_message() - if seed is not None: - self._render(seed, [], []) + if self._detail is not None: + self._render(self._detail, [], []) self._sync_form_interactivity() self._sync_save_button() + self._sync_host_geometry() + if auto_show: + self.show() + if not self._embedded: + self.raise_() + if authoritative_detail is not None: + diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail + patient = get_value(authoritative_detail, "patient", None) or {} + self._patient_id = _int( + first_value( + diagnosis, + "patient_id", + "source_patient_id", + default=first_value(patient, "patient_id", "id", default=0), + ), + 0, + ) + self._authoritative_detail_loaded = True + self._show_authoritative_content(True) + self._clear_message() + self._set_loading(False) + if self._standalone_readonly: + self._load_visible_readonly_sections() + else: + self._ensure_tab_loaded(self._current_tab_key()) + return self._show_message("正在加载权威诊单详情…", "info") self._set_loading(True) - self._sync_host_geometry() - self.show() - self.raise_() self._start_detail_load() def _start_detail_load(self) -> None: diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index a5de25a30..8f40e5ac4 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -16,7 +16,7 @@ import re import sys import tempfile from collections.abc import Iterable, Mapping, Sequence -from datetime import date, datetime +from datetime import date from pathlib import Path from time import monotonic from typing import Any @@ -80,6 +80,7 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QSpinBox, + QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, @@ -104,6 +105,7 @@ from ..widgets import ( run_async, show_toast, ) +from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog PRESCRIPTION_DRAWER_QSS = r""" QFrame#PrescriptionDrawerSurface { @@ -2697,6 +2699,7 @@ class PrescriptionEditorDialog(QDialog): self._source = _mapping(prescription) self._loading_data = False self._linked_order_generation = 0 + self._diagnosis_view: _StructuredDiagnosisDialog | None = None self._prescribing_creator_id = _int( first_value( prescription, @@ -2709,17 +2712,37 @@ class PrescriptionEditorDialog(QDialog): self.setModal(True) self.setMinimumSize(420, 600) self.resize(self.DRAWER_WIDTH, 900) - root = QVBoxLayout(self) + root = QHBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) + self.workspace_splitter = QSplitter(Qt.Orientation.Horizontal, self) + self.workspace_splitter.setObjectName("PrescriptionWorkspaceSplitter") + self.workspace_splitter.setChildrenCollapsible(False) + self.workspace_splitter.setHandleWidth(1) + root.addWidget(self.workspace_splitter) + + self.diagnosis_host = QFrame(self.workspace_splitter) + self.diagnosis_host.setObjectName("PrescriptionDiagnosisPane") + self.diagnosis_host.setMinimumWidth(300) + diagnosis_layout = QVBoxLayout(self.diagnosis_host) + diagnosis_layout.setContentsMargins(0, 0, 0, 0) + diagnosis_layout.setSpacing(0) + self.diagnosis_layout = diagnosis_layout + self.workspace_splitter.addWidget(self.diagnosis_host) + self.diagnosis_host.hide() + self.drawer_surface = QFrame() self.drawer_surface.setObjectName("PrescriptionDrawerSurface") self.drawer_surface.setStyleSheet(_prescription_drawer_qss()) + self.drawer_surface.setMinimumWidth(self.minimumWidth()) + self.drawer_surface.setMaximumWidth(self.DRAWER_WIDTH) surface_layout = QVBoxLayout(self.drawer_surface) surface_layout.setContentsMargins(0, 0, 0, 0) surface_layout.setSpacing(0) - root.addWidget(self.drawer_surface) + self.workspace_splitter.addWidget(self.drawer_surface) + self.workspace_splitter.setStretchFactor(0, 1) + self.workspace_splitter.setStretchFactor(1, 0) self.header = self._build_header() surface_layout.addWidget(self.header) @@ -2920,7 +2943,7 @@ class PrescriptionEditorDialog(QDialog): self.diagnosis_button.setProperty("size", "small") self.diagnosis_button.setVisible(diagnosis_id > 0) self.diagnosis_button.clicked.connect( - lambda _checked=False, value=diagnosis_id: self.diagnosis_requested.emit(value) + lambda _checked=False, value=diagnosis_id: self._toggle_diagnosis_view(value) ) diagnosis_layout.addWidget(self.diagnosis_button) self.diagnosis_id_hint = QLabel(f"关联诊单 #{diagnosis_id}" if diagnosis_id > 0 else "") @@ -3237,15 +3260,58 @@ class PrescriptionEditorDialog(QDialog): target = targets[max(0, min(len(targets) - 1, index))] self.body_scroll.ensureWidgetVisible(target, 0, 24) + def _toggle_diagnosis_view(self, diagnosis_id: int) -> None: + if self.diagnosis_host.isVisible(): + if self._diagnosis_view is not None: + self._diagnosis_view.reject() + return + if diagnosis_id <= 0: + return + if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail", default=True): + self.context_banner.show_message("无权查看诊单详情。", "danger") + return + if self._diagnosis_view is None: + self._diagnosis_view = _StructuredDiagnosisDialog( + self.repository, + self.diagnosis_host, + permissions=self.permissions, + embedded=True, + ) + self._diagnosis_view.finished.connect( + lambda _result, view=self._diagnosis_view: self._diagnosis_view_finished(view) + ) + self.diagnosis_layout.addWidget(self._diagnosis_view) + self.diagnosis_host.show() + self.diagnosis_button.setText("收起患者诊单") + self._fit_drawer_geometry() + self._diagnosis_view.open_for(diagnosis_id, editable=False) + + def _diagnosis_view_finished(self, view: _StructuredDiagnosisDialog) -> None: + if view is not self._diagnosis_view: + return + self.diagnosis_host.hide() + self.diagnosis_button.setText("查看患者诊单详情") + self._fit_drawer_geometry() + def _fit_drawer_geometry(self) -> None: parent = self.parentWidget() if parent is None: return anchor = parent.window() origin = anchor.mapToGlobal(QPoint(0, 0)) - width = min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width())) + expanded = self.diagnosis_host.isVisible() + width = ( + anchor.width() + if expanded + else min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width())) + ) height = max(self.minimumHeight(), anchor.height()) self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height) + if expanded: + editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55))) + self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width]) + else: + self.workspace_splitter.setSizes([0, width]) def showEvent(self, event: Any) -> None: super().showEvent(event) @@ -3836,6 +3902,11 @@ class PrescriptionEditorDialog(QDialog): self.validation.clear() super().accept() + def done(self, result: int) -> None: + if self._diagnosis_view is not None and self._diagnosis_view.isVisible(): + self._diagnosis_view.reject() + super().done(result) + class PatchPatientDialog(QDialog): """Narrow patient identity correction that preserves audit state.""" @@ -5102,8 +5173,8 @@ class PrescriptionDetailDialog(QDialog): painter.end() -class DiagnosisDetailDialog(QDialog): - """Read-only diagnosis view preserving the important admin tab boundaries.""" +class DiagnosisDetailDialog(_StructuredDiagnosisDialog): + """Compatibility entry that renders prescription-linked diagnoses with the shared UI.""" def __init__( self, @@ -5113,180 +5184,29 @@ class DiagnosisDetailDialog(QDialog): repository: Any = None, permissions: Any = None, ) -> None: - super().__init__(parent) - source = _mapping(diagnosis) - self.repository = repository - self.permissions = permissions - self._order_detail_generation = 0 - self._order_detail_order_id = 0 - self._order_detail_table: QTableWidget | None = None - self._order_detail_button: QPushButton | None = None + if repository is None: + raise ValueError("repository is required to show diagnosis details") + super().__init__(repository, parent, permissions=permissions) self.setWindowTitle("诊单详情(只读)") - self.resize(880, 700) - root = QVBoxLayout(self) - tabs = QTabWidget() - groups = ( - ( - "病历", - ( - "id", - "patient_id", - "patient_name", - "gender", - "age", - "phone", - "chief_complaint", - "present_illness", - "past_history", - "diagnosis", - "syndrome", - "treatment", - ), + source = _mapping(diagnosis) + nested = _mapping(source.get("diagnosis")) + diagnosis_id = _int( + first_value( + nested, + "id", + "diagnosis_id", + default=first_value(source, "id", "diagnosis_id"), ), - ("医生备注", ("doctor_notes", "doctor_note", "notes")), - ("日常记录", ("daily_records", "blood_records")), - ("处方", ("prescriptions", "case_records")), - ("沟通与指派", ("call_records", "chat_records", "assign_logs", "appointments")), + 0, ) - for title, keys in groups: - browser = QTextBrowser() - rows = [] - for key in keys: - value = source.get(key) - if value in (None, "", [], {}): - continue - rendered = ( - json.dumps(value, ensure_ascii=False, indent=2, default=str) - if isinstance(value, (Mapping, list, tuple)) - else str(value) - ) - rows.append(f"

{html.escape(key)}

{html.escape(rendered)}
") - browser.setHtml("".join(rows) or "

暂无数据

") - tabs.addTab(browser, title) - tabs.addTab(self._build_orders_tab(source), "业务订单") - root.addWidget(tabs, 1) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) - buttons.rejected.connect(self.reject) - root.addWidget(buttons) - - def _build_orders_tab(self, source: Mapping[str, Any]) -> QWidget: - host = QWidget() - layout = QVBoxLayout(host) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(8) - rows: list[Any] = [] - for key in ("prescription_orders", "orders"): - value = source.get(key) - if isinstance(value, list): - rows.extend(value) - latest = source.get("latest_prescription_order") - if isinstance(latest, Mapping) and latest: - latest_id = _int(first_value(latest, "id", "order_id"), 0) - if latest_id and not any( - _int(first_value(row, "id", "order_id"), 0) == latest_id for row in rows - ): - rows.insert(0, latest) - if not rows: - empty = QLabel("暂无关联业务订单") - empty.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(empty, 1) - return host - table = QTableWidget(len(rows), 6) - table.setHorizontalHeaderLabels( - ["订单号", "金额", "履约状态", "收货人", "手机", "创建时间"] - ) - table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - table.verticalHeader().hide() - table.horizontalHeader().setStretchLastSection(True) - self._order_detail_table = table - for row_index, row in enumerate(rows): - values = ( - first_value(row, "order_no", "sn", "id"), - first_value(row, "amount", "effective_amount"), - first_value(row, "fulfillment_status_text", "status_text", "status"), - first_value(row, "recipient_name", "patient_name"), - first_value(row, "recipient_phone", "phone"), - first_value(row, "create_time_text", "create_time"), - ) - for column, value in enumerate(values): - item = QTableWidgetItem(display_text(value)) - item.setData(Qt.ItemDataRole.UserRole, row) - table.setItem(row_index, column, item) - layout.addWidget(table, 1) - actions = QHBoxLayout() - view = QPushButton("查看订单详情") - view.setProperty("variant", "primary") - self._order_detail_button = view - view.clicked.connect(self._open_selected_order) - table.itemDoubleClicked.connect(lambda _item: self._open_selected_order()) - actions.addWidget(view) - actions.addStretch(1) - layout.addLayout(actions) - return host - - def _set_order_detail_loading(self, loading: bool) -> None: - if self._order_detail_table is not None: - self._order_detail_table.setEnabled(not loading) - if self._order_detail_button is not None: - self._order_detail_button.setEnabled(not loading) - - def _open_selected_order(self) -> None: - table = self._order_detail_table - if table is None: - return - row = table.currentRow() - item = table.item(row, 0) if row >= 0 else None - order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None - if order is None: - return - order_id = _int(first_value(order, "id", "order_id"), 0) - self._order_detail_generation += 1 - generation = self._order_detail_generation - self._order_detail_order_id = order_id - getter = getattr(self.repository, "get_prescription_order", None) - if order_id <= 0 or not callable(getter): - self._set_order_detail_loading(False) - self._present_order_detail(order, order_id) - return - - self._set_order_detail_loading(True) - run_async( - lambda: getter(order_id), - on_success=lambda result: self._order_detail_success(result, order_id, generation), - on_error=lambda error: self._order_detail_error(error, order, order_id, generation), - on_finished=lambda: self._order_detail_finished(order_id, generation), - ) - - def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None: - if generation != self._order_detail_generation or order_id != self._order_detail_order_id: - return - self._present_order_detail(order, order_id) - - def _order_detail_error( - self, - _error: Exception, - fallback_order: Any, - order_id: int, - generation: int, - ) -> None: - if generation != self._order_detail_generation or order_id != self._order_detail_order_id: - return - self._present_order_detail(fallback_order, order_id) - - def _order_detail_finished(self, order_id: int, generation: int) -> None: - if generation == self._order_detail_generation and order_id == self._order_detail_order_id: - self._set_order_detail_loading(False) - - def _present_order_detail(self, order: Any, order_id: int) -> None: - from .diagnosis import present_order_detail - - present_order_detail( - self.window() if self.window() is not None else self, - order, - order_id=order_id, - permissions=self.permissions, - exec_=True, + if diagnosis_id <= 0: + raise ValueError("diagnosis detail is missing a valid diagnosis id") + self.open_for( + diagnosis_id, + editable=False, + seed=source, + authoritative_detail=source, + auto_show=False, ) diff --git a/app/tests/test_diagnosis_drawer_visual.py b/app/tests/test_diagnosis_drawer_visual.py index 0d9fa5026..f89f30a64 100644 --- a/app/tests/test_diagnosis_drawer_visual.py +++ b/app/tests/test_diagnosis_drawer_visual.py @@ -639,6 +639,23 @@ def test_readonly_is_an_independent_vertical_page_flow( application.processEvents() +def test_readonly_scroll_keeps_close_controls_reachable(application: QApplication) -> None: + dialog = _open_dialog(application, (760, 520), mode="readonly") + header_before = dialog.readonly_header.geometry() + scroll_bar = dialog.readonly_scroll.verticalScrollBar() + + assert scroll_bar.maximum() > 0 + assert dialog.readonly_close_button.isVisibleTo(dialog) + scroll_bar.setValue(scroll_bar.maximum()) + application.processEvents() + + assert dialog.readonly_header.geometry() == header_before + assert dialog.readonly_close_button.isVisibleTo(dialog) + dialog.readonly_close_button.click() + application.processEvents() + assert not dialog.isVisible() + + @pytest.mark.parametrize("size", [(1024, 640), (1440, 900)]) @pytest.mark.parametrize("mode", ["edit", "viewOnly"]) def test_drawer_is_full_height_rtl_and_sixty_percent_wide( diff --git a/app/tests/test_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py index 86d6ac55f..784b30926 100644 --- a/app/tests/test_prescription_security_ui.py +++ b/app/tests/test_prescription_security_ui.py @@ -288,122 +288,121 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save( application.processEvents() -def test_diagnosis_order_detail_lookup_is_queued_before_repository_call( +def test_prescription_diagnosis_detail_uses_shared_structured_readonly_ui( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: - queued: list[tuple[Any, dict[str, Any]]] = [] - requested: list[int] = [] - shown: list[tuple[int, str]] = [] - class Repository: - def get_prescription_order(self, order_id: int) -> dict[str, Any]: - requested.append(order_id) - return {"id": order_id, "order_no": f"DETAIL-{order_id}"} + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + assert diagnosis_id == 745 + return [] - def queue_async(function: Any, **options: Any) -> object: - queued.append((function, options)) - return object() - - def present_order_detail( - _host: Any, - order: dict[str, Any], - *, - order_id: int, - permissions: Any, - exec_: bool, - ) -> None: - del permissions, exec_ - shown.append((order_id, order["order_no"])) - - monkeypatch.setattr(dialog_module, "run_async", queue_async) - monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail) + monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async) dialog = DiagnosisDetailDialog( - {"orders": [{"id": 17, "order_no": "ROW-17"}]}, + { + "diagnosis": { + "id": 745, + "patient_id": 745, + "patient_name": "庄志芳", + "phone": "13823549442", + "id_card": "440305196701011234", + "gender": 0, + "age": 59, + "chief_complaint": "睡眠不好、出汗多", + "past_history": ["高血压", "高脂血症"], + }, + "patient": {"id": 745}, + }, repository=Repository(), + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), ) - table = dialog._order_detail_table - button = dialog._order_detail_button - assert table is not None - assert button is not None - table.setCurrentCell(0, 0) - button.click() - - assert len(queued) == 1 - assert requested == [] - assert shown == [] - assert not table.isEnabled() - assert not button.isEnabled() - - function, options = queued[0] - options["on_success"](function()) - options["on_finished"]() - assert requested == [17] - assert shown == [(17, "DETAIL-17")] - assert table.isEnabled() - assert button.isEnabled() + assert isinstance(dialog, DiagnosisDialog) + assert dialog.view_stack.currentWidget() is dialog.readonly_page + assert dialog.edit_fields["chief_complaint"].toPlainText() == "睡眠不好、出汗多" + assert dialog.summary_fields["phone"].text() == "138****9442" + assert dialog.case_grid.isVisibleTo(dialog) + assert not dialog.findChildren(dialog_module.QTextBrowser) dialog.close() application.processEvents() -def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback( +@pytest.mark.parametrize("owner_width", [1024, 1440, 1710]) +def test_prescription_editor_embeds_scrollable_diagnosis_beside_editable_form( application: QApplication, monkeypatch: pytest.MonkeyPatch, + owner_width: int, ) -> None: - queued: list[dict[str, Any]] = [] - shown: list[tuple[int, str]] = [] + class Repository: + def get_diagnosis_detail( + self, diagnosis_id: int, *, readonly: bool = False + ) -> dict[str, Any]: + assert readonly + return { + "diagnosis": { + "id": diagnosis_id, + "patient_id": 745, + "patient_name": "庄志芳", + "chief_complaint": "睡眠不好、出汗多", + } + } - def queue_async(_function: Any, **options: Any) -> object: - queued.append(options) - return object() + def get_doctor_notes(self, _diagnosis_id: int) -> list[dict[str, Any]]: + return [] - def present_order_detail( - _host: Any, - order: dict[str, Any], - *, - order_id: int, - permissions: Any, - exec_: bool, - ) -> None: - del permissions, exec_ - shown.append((order_id, order["order_no"])) - - repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id}) - monkeypatch.setattr(dialog_module, "run_async", queue_async) - monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail) - dialog = DiagnosisDetailDialog( - { - "orders": [ - {"id": 21, "order_no": "ROW-21"}, - {"id": 22, "order_no": "ROW-22"}, - ] - }, - repository=repository, + monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async) + owner = QDialog() + owner.resize(owner_width, 720) + owner.show() + repository = Repository() + editor = PrescriptionEditorDialog( + repository, + {"diagnosis_id": 745, "patient_name": "庄志芳"}, + mode="edit", + current_user=SimpleNamespace(id=9, name="周医生"), + permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), + parent=owner, ) - table = dialog._order_detail_table - button = dialog._order_detail_button - assert table is not None - assert button is not None + editor.show() + application.processEvents() + assert QApplication.activeModalWidget() is editor + assert editor.width() == editor.DRAWER_WIDTH - table.setCurrentCell(0, 0) - dialog._open_selected_order() - table.setCurrentCell(1, 0) - dialog._open_selected_order() - assert len(queued) == 2 + editor.diagnosis_button.click() + application.processEvents() - queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"}) - queued[0]["on_finished"]() - assert shown == [] - assert not table.isEnabled() - assert not button.isEnabled() + detail = editor._diagnosis_view + assert detail is not None + assert QApplication.activeModalWidget() is editor + assert not detail.isWindow() + assert detail.parentWidget() is editor.diagnosis_host + assert editor.diagnosis_host.isVisibleTo(editor) + assert editor.drawer_surface.isVisibleTo(editor) + assert editor.width() == owner.width() + assert editor.diagnosis_host.geometry().right() < editor.drawer_surface.geometry().left() + scroll_bar = detail.readonly_scroll.verticalScrollBar() + assert scroll_bar.maximum() > 0 + scroll_bar.setValue(scroll_bar.maximum()) + assert scroll_bar.value() == scroll_bar.maximum() - queued[1]["on_error"](RuntimeError("detail unavailable")) - queued[1]["on_finished"]() - assert shown == [(22, "ROW-22")] - assert table.isEnabled() - assert button.isEnabled() - dialog.close() + editor.patient_name.setText("庄志芳(已核对)") + assert editor.patient_name.isEnabled() + assert editor.payload()["patient_name"] == "庄志芳(已核对)" + + detail.readonly_close_button.click() + application.processEvents() + assert editor.isVisible() + assert not editor.diagnosis_host.isVisible() + assert editor.width() == editor.DRAWER_WIDTH + assert editor.patient_name.text() == "庄志芳(已核对)" + editor.diagnosis_button.click() + application.processEvents() + assert editor.diagnosis_host.isVisibleTo(editor) + editor.reject() + application.processEvents() + assert not editor.isVisible() + assert not detail.isVisible() + owner.close() application.processEvents() diff --git a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php index d0a52f7a8..ab6034180 100644 --- a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php +++ b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php @@ -78,6 +78,19 @@ class WecomPromotionController extends BaseAdminController ))); } + public function batchUpdatePools() + { + if (!$this->hasBasePagePermission()) { + return $this->fail('权限不足'); + } + + return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools( + $this->request->post(), + $this->adminId, + $this->adminInfo + ))); + } + public function saveWidget() { if (!$this->hasPagePermission()) { diff --git a/server/app/adminapi/controller/qywx/CustomerController.php b/server/app/adminapi/controller/qywx/CustomerController.php index 9fdb768e0..4cdcab45c 100755 --- a/server/app/adminapi/controller/qywx/CustomerController.php +++ b/server/app/adminapi/controller/qywx/CustomerController.php @@ -4,16 +4,19 @@ declare(strict_types=1); namespace app\adminapi\controller\qywx; -use app\adminapi\controller\BaseAdminController; -use app\adminapi\lists\qywx\CustomerLists; -use app\adminapi\logic\qywx\CustomerLogic; -use app\adminapi\validate\qywx\CustomerValidate; +use app\adminapi\controller\BaseAdminController; +use app\adminapi\lists\qywx\CustomerLists; +use app\adminapi\logic\auth\AuthLogic; +use app\adminapi\logic\qywx\CustomerLogic; +use app\adminapi\validate\qywx\CustomerValidate; /** * 企业微信客户管理控制器 */ -class CustomerController extends BaseAdminController -{ +class CustomerController extends BaseAdminController +{ + private const DELETE_PERMISSION = 'qywx.customer/delete'; + /** * @notes 客户列表 */ @@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController /** * @notes 同步企业微信客户 */ - public function sync() - { + public function sync() + { $result = CustomerLogic::triggerBackgroundSync(); if ($result === false) { return $this->fail(CustomerLogic::getError()); } $msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步'; - return $this->success($msg, $result); - } + return $this->success($msg, $result); + } + + /** + * @notes 删除一条本地企业微信客户同步记录 + */ + public function delete() + { + // 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。 + if (!$this->canDeleteCustomer()) { + return $this->fail('权限不足,无法删除企业微信客户'); + } + + $params = (new CustomerValidate())->post()->goCheck('delete'); + if (!CustomerLogic::deleteCustomer((int) $params['id'])) { + return $this->fail(CustomerLogic::getError()); + } + + return $this->success('删除成功'); + } /** * @notes 获取统计信息 @@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController /** * @notes 保存同步设置 */ - public function saveSyncSettings() + public function saveSyncSettings() { $params = (new CustomerValidate())->post()->goCheck('syncSettings'); $result = CustomerLogic::saveSyncSettings($params); if ($result === false) { return $this->fail(CustomerLogic::getError()); } - return $this->success('保存成功'); - } -} + return $this->success('保存成功'); + } + + private function canDeleteCustomer(): bool + { + if ((int) ($this->adminInfo['root'] ?? 0) === 1) { + return true; + } + + return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true); + } +} diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index 45dc2d339..d5ad5ea92 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -227,6 +227,7 @@ class AuthMiddleware 'firstvisit.wecompromotion/uploadwelcomemedia', 'firstvisit.wecompromotion/overview', 'firstvisit.wecompromotion/savepool', + 'firstvisit.wecompromotion/batchupdatepools', 'firstvisit.wecompromotion/savewidget', 'firstvisit.wecompromotion/batchsetoperators', 'firstvisit.wecompromotion/deletepool', diff --git a/server/app/adminapi/lists/qywx/CustomerLists.php b/server/app/adminapi/lists/qywx/CustomerLists.php index 166ea4463..c37a7a6b6 100755 --- a/server/app/adminapi/lists/qywx/CustomerLists.php +++ b/server/app/adminapi/lists/qywx/CustomerLists.php @@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface $query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]); } - return false; - } - - private function baseQuery() + return false; + } + + /** + * 按客户当前跟进关系中的添加方式筛选。 + * + * follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值; + * 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。 + */ + private function applyAddWayFilter($query): void + { + if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') { + return; + } + + $addWay = self::normalizeAddWay($this->params['add_way']); + if ($addWay === null) { + $query->whereRaw('1=0'); + return; + } + + $numberPrefix = '%"add_way":' . $addWay; + $stringPrefix = '%"add_way":"' . $addWay; + $query->where(function ($q) use ($numberPrefix, $stringPrefix) { + $q->where('follow_users', 'like', $numberPrefix . ',%') + ->whereOr('follow_users', 'like', $numberPrefix . '}%') + ->whereOr('follow_users', 'like', $stringPrefix . '",%') + ->whereOr('follow_users', 'like', $stringPrefix . '"}%'); + }); + } + + private function baseQuery() { $query = QywxExternalContact::where($this->searchWhere); // 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users $followAlreadyScoped = $this->applyAddTimeFilter($query); - if (!$followAlreadyScoped) { - $this->applyFollowUserFilter($query); - } - - // 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。 + if (!$followAlreadyScoped) { + $this->applyFollowUserFilter($query); + } + $this->applyAddWayFilter($query); + + // 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。 // 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多 $tagIds = $this->normalizeTagIds(); if ($tagIds !== []) { @@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface return $query; } + /** + * 批量读取当前页客户的加客渠道流水。 + * + * 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。 + * 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。 + * + * @param string[] $externalUserids + * @return array>> + */ + private function loadAddChannelsByExternalUserid(array $externalUserids): array + { + $ids = []; + foreach ($externalUserids as $externalUserid) { + $externalUserid = trim((string) $externalUserid); + if ($externalUserid !== '') { + $ids[$externalUserid] = true; + } + } + $ids = array_keys($ids); + if ($ids === []) { + return []; + } + + $events = Db::name('qywx_external_contact_event') + ->where('change_type', 'add_external_contact') + ->where('state', '<>', '') + ->whereIn('external_userid', $ids) + ->field(['id', 'external_userid', 'user_id', 'state', 'event_time']) + ->order('event_time', 'desc') + ->order('id', 'desc') + ->select() + ->toArray(); + + $poolIds = []; + foreach ($events as $event) { + $state = trim((string) ($event['state'] ?? '')); + if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) { + $poolIds[(int) $matches[1]] = true; + } + } + + $poolNamesById = []; + if ($poolIds !== []) { + // 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。 + $poolNamesById = Db::name('qywx_promotion_pool') + ->whereIn('id', array_keys($poolIds)) + ->column('name', 'id'); + } + + return self::projectAddChannelEvents($events, $poolNamesById); + } + + /** + * @param array> $events 已按 event_time DESC, id DESC 排序 + * @param array $poolNamesById + * @return array>> + */ + private static function projectAddChannelEvents(array $events, array $poolNamesById): array + { + $channelsByExternalUserid = []; + $seen = []; + + foreach ($events as $event) { + $externalUserid = trim((string) ($event['external_userid'] ?? '')); + $state = trim((string) ($event['state'] ?? '')); + if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) { + continue; + } + $seen[$externalUserid][$state] = true; + + $poolId = 0; + if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) { + $poolId = (int) $matches[1]; + } + $poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : ''; + + $channelsByExternalUserid[$externalUserid][] = [ + 'state' => $state, + 'label' => $poolName !== '' + ? $poolName + : ($poolId > 0 ? '获客助手方案 #' . $poolId : $state), + 'source_type' => $poolId > 0 ? 'promotion_pool' : 'state', + 'pool_id' => $poolId, + 'user_id' => trim((string) ($event['user_id'] ?? '')), + 'event_time' => (int) ($event['event_time'] ?? 0), + ]; + } + + return $channelsByExternalUserid; + } + + /** + * 企业微信客户详情 follow_user.add_way 的可读文案。 + * + * add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。 + * 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。 + */ + private static function addWayLabel(int $addWay): string + { + $labels = [ + 0 => '未知添加方式', + 1 => '通过扫描二维码添加', + 2 => '通过搜索手机号添加', + 3 => '通过名片分享添加', + 4 => '通过群聊添加', + 5 => '通过手机通讯录添加', + 6 => '通过微信联系人添加', + 8 => '安装第三方应用时自动添加', + 9 => '通过搜索邮箱添加', + 10 => '通过视频号添加', + 11 => '通过日程参与人添加', + 12 => '通过会议参与人添加', + 13 => '通过微信好友添加', + 14 => '通过智慧硬件专属客服添加', + 15 => '通过上门服务客服添加', + 16 => '通过获客链接添加', + 17 => '通过定制开发添加', + 18 => '通过需求回复添加', + 21 => '通过第三方售前客服添加', + 22 => '通过可能的商务伙伴添加', + 24 => '通过接受微信好友申请添加', + 201 => '通过内部成员共享添加', + 202 => '通过管理员或负责人分配添加', + ]; + + return $labels[$addWay] ?? '其他添加方式(' . $addWay . ')'; + } + + /** + * @param mixed $value + */ + private static function normalizeAddWay($value): ?int + { + if (is_int($value)) { + return $value >= 0 ? $value : null; + } + if (!is_string($value)) { + return null; + } + + $value = trim($value); + if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) { + return null; + } + + return (int) $value; + } + /** * @notes 获取列表 */ @@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface ->toArray(); $wxUserids = []; + $externalUserids = []; foreach ($lists as $item) { + $externalUserid = trim((string) ($item['external_userid'] ?? '')); + if ($externalUserid !== '') { + $externalUserids[$externalUserid] = true; + } $raw = json_decode($item['follow_users'] ?? '[]', true); if (!is_array($raw)) { continue; @@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface if ($wxUserids !== []) { $adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid'); } + $addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids)); foreach ($lists as &$item) { $followUsers = json_decode($item['follow_users'] ?? '[]', true); $followUsers = is_array($followUsers) ? $followUsers : []; - foreach ($followUsers as &$fu) { - if (!is_array($fu)) { - continue; - } - $wx = trim((string) ($fu['userid'] ?? '')); - if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') { - $fu['admin_name'] = $adminNameByWx[$wx]; - } - } + foreach ($followUsers as &$fu) { + if (!is_array($fu)) { + continue; + } + $wx = trim((string) ($fu['userid'] ?? '')); + if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') { + $fu['admin_name'] = $adminNameByWx[$wx]; + } + $addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null); + if ($addWay !== null) { + $fu['add_way'] = $addWay; + $fu['add_way_label'] = self::addWayLabel($addWay); + } + } unset($fu); $item['follow_users'] = $followUsers; $followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true); @@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface $tags = json_decode((string) ($item['tags'] ?? '[]'), true); $item['tags'] = is_array($tags) ? $tags : []; + $externalUserid = trim((string) ($item['external_userid'] ?? '')); + $item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? []; + $item['add_channel_states'] = array_column($item['add_channels'], 'state'); + $fromDb = (int) ($item['external_first_add_time'] ?? 0); $fromJson = CustomerLogic::minFollowCreatetime($followUsers); $item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson; diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php index a40d86308..f44cce41c 100644 --- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php +++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php @@ -225,7 +225,12 @@ class WecomPromotionLogic ]; } - public static function savePool(array $params, int $adminId, array $adminInfo): array + public static function savePool( + array $params, + int $adminId, + array $adminInfo, + bool $syncImmediately = true + ): array { self::assertMemberDispatchSchema(); $id = max(0, (int) ($params['id'] ?? 0)); @@ -425,11 +430,13 @@ class WecomPromotionLogic $syncError = ''; if (!$createdRemote) { QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId); - try { - (new QywxPromotionRangeSyncService())->syncPool($id); - } catch (\Throwable $e) { - // 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。 - $syncError = $e->getMessage(); + if ($syncImmediately) { + try { + (new QywxPromotionRangeSyncService())->syncPool($id); + } catch (\Throwable $e) { + // 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。 + $syncError = $e->getMessage(); + } } } $savedLink = Db::name('qywx_promotion_link')->where('id', $linkId)->find() ?: []; @@ -448,6 +455,152 @@ class WecomPromotionLogic // 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。 'automation_saved' => $automation !== null, 'sync_error' => $syncError, + 'sync_queued' => !$createdRemote && !$syncImmediately, + ]; + } + + /** + * 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用 + * savePool 的成员、自动化、素材和企业微信同步校验。 + * + * @return array{pool_ids:list,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,results:list>} + */ + public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array + { + self::assertMemberDispatchSchema(); + self::assertBasePagePermission($adminId, $adminInfo); + $poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? [])); + if ($poolIds === []) { + throw new RuntimeException('请至少选择一个分流方案'); + } + if (count($poolIds) > 100) { + throw new RuntimeException('单次最多设置 100 个分流方案'); + } + + $changes = $params['changes'] ?? null; + if (!is_array($changes)) { + throw new RuntimeException('批量修改内容格式不正确'); + } + $allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config']; + $unknownFields = array_diff(array_keys($changes), $allowedFields); + if ($unknownFields !== []) { + throw new RuntimeException('批量修改包含不支持的字段'); + } + if ($changes === []) { + throw new RuntimeException('请至少选择一项需要批量修改的配置'); + } + if (array_key_exists('fallback_url', $changes) && !is_string($changes['fallback_url'])) { + throw new RuntimeException('兜底获客助手链接格式不正确'); + } + + $automationPatch = null; + if (array_key_exists('automation_config', $changes)) { + if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) { + throw new RuntimeException('自动化配置格式不正确'); + } + $allowedAutomationFields = [ + 'reception_mode', 'reception_schedule', 'backup_member_admin_ids', + 'tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template', + 'description_enabled', 'description', 'welcome_mode', 'welcome', + 'welcome_schedule_enabled', 'welcome_schedule', + ]; + if (array_diff(array_keys($changes['automation_config']), $allowedAutomationFields) !== []) { + throw new RuntimeException('自动化配置包含不支持的字段'); + } + QywxPromotionConfig::assertInstalled(); + $automationPatch = $changes['automation_config']; + } + + // 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。 + $pools = []; + foreach ($poolIds as $poolId) { + $pools[$poolId] = self::assertScopedRow( + 'qywx_promotion_pool', + $poolId, + $adminId, + $adminInfo, + false + ); + } + + $results = []; + $updated = 0; + $failed = 0; + $syncErrorCount = 0; + $syncQueuedCount = 0; + foreach ($poolIds as $poolId) { + $pool = $pools[$poolId]; + $currentAutomation = QywxPromotionConfig::forPool($poolId); + $memberAdminIds = self::normalizePositiveIds(Db::name('qywx_promotion_pool_member') + ->where('pool_id', $poolId) + ->whereNull('delete_time') + ->column('admin_id')); + $primaryMemberAdminIds = array_values(array_diff( + $memberAdminIds, + self::normalizePositiveIds((array) ($currentAutomation['backup_member_admin_ids'] ?? [])) + )); + $officialLink = Db::name('qywx_promotion_link') + ->where('pool_id', $poolId) + ->whereNull('delete_time') + ->where('remote_link_id', '<>', '') + ->where('remote_status', '<>', 2) + ->order('id', 'desc') + ->find() ?: []; + $saveParams = [ + 'id' => $poolId, + 'name' => (string) ($pool['name'] ?? ''), + 'fallback_url' => array_key_exists('fallback_url', $changes) + ? trim($changes['fallback_url']) + : (string) ($pool['fallback_url'] ?? ''), + 'status' => array_key_exists('status', $changes) + ? ((int) $changes['status'] === 1 ? 1 : 0) + : (int) ($pool['status'] ?? 0), + 'member_admin_ids' => $primaryMemberAdminIds, + 'skip_verify' => array_key_exists('skip_verify', $changes) + ? ((int) $changes['skip_verify'] === 1 ? 1 : 0) + : (int) ($officialLink['skip_verify'] ?? 0), + ]; + if ($automationPatch !== null) { + $saveParams['automation_config'] = array_replace($currentAutomation, $automationPatch); + } + + try { + // 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。 + $saved = self::savePool($saveParams, $adminId, $adminInfo, false); + $syncError = trim((string) ($saved['sync_error'] ?? '')); + $updated++; + if ($syncError !== '') { + $syncErrorCount++; + } + $syncQueued = !empty($saved['sync_queued']); + if ($syncQueued) { + $syncQueuedCount++; + } + $results[] = [ + 'id' => $poolId, + 'name' => (string) ($pool['name'] ?? ''), + 'success' => true, + 'sync_error' => $syncError, + 'sync_queued' => $syncQueued, + ]; + } catch (\Throwable $error) { + $failed++; + $results[] = [ + 'id' => $poolId, + 'name' => (string) ($pool['name'] ?? ''), + 'success' => false, + 'error' => $error->getMessage(), + ]; + } + } + + return [ + 'pool_ids' => $poolIds, + 'updated' => $updated, + 'failed' => $failed, + 'sync_error_count' => $syncErrorCount, + 'sync_queued_count' => $syncQueuedCount, + 'results' => $results, ]; } diff --git a/server/app/adminapi/logic/qywx/CustomerLogic.php b/server/app/adminapi/logic/qywx/CustomerLogic.php index 1942361be..fe8d809f4 100755 --- a/server/app/adminapi/logic/qywx/CustomerLogic.php +++ b/server/app/adminapi/logic/qywx/CustomerLogic.php @@ -722,6 +722,73 @@ class CustomerLogic extends BaseLogic } } + /** + * 后台手工删除一条本地同步记录。 + * + * 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复 + * external_userid。只有该客户已无其他有效行时才清理共享的标签关系。 + */ + public static function deleteCustomer(int $id): bool + { + if ($id <= 0) { + self::$error = '客户参数错误'; + + return false; + } + + try { + $externalUserId = Db::transaction(static function () use ($id): string { + $row = Db::name('qywx_external_contact') + ->where('id', $id) + ->whereNull('delete_time') + ->lock(true) + ->find(); + if (!$row) { + throw new \DomainException('客户不存在或已删除'); + } + + $now = time(); + Db::name('qywx_external_contact') + ->where('id', $id) + ->whereNull('delete_time') + ->update([ + 'delete_time' => $now, + 'update_time' => $now, + ]); + + $externalUserId = trim((string) ($row['external_userid'] ?? '')); + if ($externalUserId !== '') { + $activeRows = (int) Db::name('qywx_external_contact') + ->where('external_userid', $externalUserId) + ->whereNull('delete_time') + ->count(); + if ($activeRows === 0) { + Db::name('qywx_external_contact_tag') + ->where('external_userid', $externalUserId) + ->delete(); + } + } + + return $externalUserId; + }); + + if ($externalUserId !== '') { + MediaChannelService::forgetCurrentTagCatalogCache(); + } + + return true; + } catch (\DomainException $e) { + self::$error = $e->getMessage(); + + return false; + } catch (\Throwable $e) { + Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage()); + self::$error = '删除失败,请稍后重试'; + + return false; + } + } + /** * 客户联系「删除企业客户」等事件:本地软删除一行。 */ diff --git a/server/app/adminapi/validate/qywx/CustomerValidate.php b/server/app/adminapi/validate/qywx/CustomerValidate.php index cff98587f..95f19d952 100755 --- a/server/app/adminapi/validate/qywx/CustomerValidate.php +++ b/server/app/adminapi/validate/qywx/CustomerValidate.php @@ -12,11 +12,15 @@ use app\common\validate\BaseValidate; class CustomerValidate extends BaseValidate { protected $rule = [ + 'id' => 'require|integer|gt:0', 'auto_sync' => 'require|boolean', 'interval' => 'require|integer|between:3600,86400', ]; protected $message = [ + 'id.require' => '请选择要删除的客户', + 'id.integer' => '客户参数格式错误', + 'id.gt' => '客户参数格式错误', 'auto_sync.require' => '请选择是否自动同步', 'auto_sync.boolean' => '自动同步参数格式错误', 'interval.require' => '请选择同步间隔', @@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate { return $this->only(['auto_sync', 'interval']); } + + /** + * @notes 删除客户场景 + */ + public function sceneDelete() + { + return $this->only(['id']); + } } diff --git a/server/database/migrations/create_qywx_external_contact_event.sql b/server/database/migrations/create_qywx_external_contact_event.sql index 5a71d4106..79c2204b7 100755 --- a/server/database/migrations/create_qywx_external_contact_event.sql +++ b/server/database/migrations/create_qywx_external_contact_event.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event` ( PRIMARY KEY (`id`), UNIQUE KEY `uk_change_user_ext_time` (`change_type`, `user_id`, `external_userid`, `event_time`), KEY `idx_change_time` (`change_type`, `event_time`), + KEY `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`), KEY `idx_event_time` (`event_time`), KEY `idx_state` (`state`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业微信外部联系人事件流水(用于进入计数)'; diff --git a/server/sql/1.9.20260902/add_qywx_customer_channel_index.sql b/server/sql/1.9.20260902/add_qywx_customer_channel_index.sql new file mode 100644 index 000000000..a01772bef --- /dev/null +++ b/server/sql/1.9.20260902/add_qywx_customer_channel_index.sql @@ -0,0 +1,19 @@ +-- 客户列表按当前页 external_userid 批量读取加客渠道流水。 +-- 覆盖 change_type + external_userid + event_time,避免事件量增长后反复扫描全量 add 事件。 +SET @idx_change_ext_time_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'zyt_qywx_external_contact_event' + AND INDEX_NAME = 'idx_change_ext_time' +); + +SET @add_idx_change_ext_time_sql := IF( + @idx_change_ext_time_exists = 0, + 'ALTER TABLE `zyt_qywx_external_contact_event` ADD INDEX `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`)', + 'SELECT 1' +); + +PREPARE add_idx_change_ext_time_stmt FROM @add_idx_change_ext_time_sql; +EXECUTE add_idx_change_ext_time_stmt; +DEALLOCATE PREPARE add_idx_change_ext_time_stmt; diff --git a/server/sql/1.9.20260902/add_qywx_customer_delete_menu.sql b/server/sql/1.9.20260902/add_qywx_customer_delete_menu.sql new file mode 100644 index 000000000..2e886b2c8 --- /dev/null +++ b/server/sql/1.9.20260902/add_qywx_customer_delete_menu.sql @@ -0,0 +1,36 @@ +-- 企业微信客户管理:新增独立“删除”操作权限。 +-- 仅创建权限节点,不自动授予已有角色;请在角色权限中按需勾选。 + +SET @qywx_customer_menu_id := ( + SELECT `id` + FROM `zyt_system_menu` + WHERE `component` = 'fans/qywx' + OR `perms` = 'qywx.customer/lists' + ORDER BY CASE WHEN `component` = 'fans/qywx' THEN 0 ELSE 1 END, `id` + LIMIT 1 +); + +INSERT INTO `zyt_system_menu` +(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT + @qywx_customer_menu_id, + 'A', + '删除', + '', + 1, + 'qywx.customer/delete', + '', + '', + '', + '', + 1, + 0, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP() +FROM DUAL +WHERE @qywx_customer_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM `zyt_system_menu` + WHERE `perms` = 'qywx.customer/delete' + ); diff --git a/server/tests/QywxCustomerChannelProjectionTest.php b/server/tests/QywxCustomerChannelProjectionTest.php new file mode 100644 index 000000000..958cd51c2 --- /dev/null +++ b/server/tests/QywxCustomerChannelProjectionTest.php @@ -0,0 +1,185 @@ + */ + public array $likes = []; + /** @var string[] */ + public array $raw = []; + + public function where($field, $operator = null, $value = null): self + { + if ($field instanceof Closure) { + $field($this); + return $this; + } + $this->likes[] = [ + 'field' => (string) $field, + 'operator' => (string) $operator, + 'value' => (string) $value, + 'logic' => 'and', + ]; + return $this; + } + + public function whereOr($field, $operator = null, $value = null): self + { + $this->likes[] = [ + 'field' => (string) $field, + 'operator' => (string) $operator, + 'value' => (string) $value, + 'logic' => 'or', + ]; + return $this; + } + + public function whereRaw(string $condition): self + { + $this->raw[] = $condition; + return $this; + } +} + +$method = new ReflectionMethod(CustomerLists::class, 'projectAddChannelEvents'); +$method->setAccessible(true); +$addWayLabelMethod = new ReflectionMethod(CustomerLists::class, 'addWayLabel'); +$addWayLabelMethod->setAccessible(true); +$normalizeAddWayMethod = new ReflectionMethod(CustomerLists::class, 'normalizeAddWay'); +$normalizeAddWayMethod->setAccessible(true); + +$events = [ + ['id' => 12, 'external_userid' => 'ext-a', 'user_id' => 'staff-2', 'state' => 'channel-b', 'event_time' => 300], + ['id' => 11, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => ' ', 'event_time' => 300], + ['id' => 10, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'zyt_pool:2', 'event_time' => 250], + ['id' => 9, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-b', 'event_time' => 200], + ['id' => 8, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-a', 'event_time' => 100], + ['id' => 7, 'external_userid' => 'ext-zero', 'user_id' => 'staff-1', 'state' => '0', 'event_time' => 100], + ['id' => 6, 'external_userid' => '', 'user_id' => 'staff-1', 'state' => 'ignored', 'event_time' => 100], +]; + +/** @var array>> $projected */ +$projected = $method->invoke(null, $events, [2 => '九月投放方案']); + +qywxChannelExpect( + array_column($projected['ext-a'] ?? [], 'state') === ['channel-b', 'zyt_pool:2', 'channel-a'], + '渠道必须按最近事件排序、排除空值并按 state 去重' +); +qywxChannelExpect(($projected['ext-a'][0]['user_id'] ?? '') === 'staff-2', '重复渠道必须保留最近事件的员工'); +qywxChannelExpect(($projected['ext-a'][0]['event_time'] ?? 0) === 300, '重复渠道必须保留最近事件时间'); +qywxChannelExpect(($projected['ext-a'][1]['label'] ?? '') === '九月投放方案', '获客助手 state 必须映射方案名称'); +qywxChannelExpect(($projected['ext-a'][1]['source_type'] ?? '') === 'promotion_pool', '获客助手渠道类型错误'); +qywxChannelExpect(($projected['ext-a'][1]['pool_id'] ?? 0) === 2, '获客助手方案 ID 解析错误'); +qywxChannelExpect(array_column($projected['ext-zero'] ?? [], 'state') === ['0'], '字符串 0 是有效渠道,不能被 empty/filter 丢弃'); +qywxChannelExpect(!isset($projected['']), '空 external_userid 不得生成渠道投影'); + +$fallback = $method->invoke(null, [ + ['id' => 1, 'external_userid' => 'ext-b', 'user_id' => '', 'state' => 'zyt_pool:99', 'event_time' => 1], +], []); +qywxChannelExpect( + ($fallback['ext-b'][0]['label'] ?? '') === '获客助手方案 #99', + '已删除或缺失的获客助手方案应保留可读兜底名称' +); + +$knownAddWays = [ + 0 => '未知添加方式', + 1 => '通过扫描二维码添加', + 2 => '通过搜索手机号添加', + 3 => '通过名片分享添加', + 4 => '通过群聊添加', + 5 => '通过手机通讯录添加', + 6 => '通过微信联系人添加', + 8 => '安装第三方应用时自动添加', + 9 => '通过搜索邮箱添加', + 10 => '通过视频号添加', + 11 => '通过日程参与人添加', + 12 => '通过会议参与人添加', + 13 => '通过微信好友添加', + 14 => '通过智慧硬件专属客服添加', + 15 => '通过上门服务客服添加', + 16 => '通过获客链接添加', + 17 => '通过定制开发添加', + 18 => '通过需求回复添加', + 21 => '通过第三方售前客服添加', + 22 => '通过可能的商务伙伴添加', + 24 => '通过接受微信好友申请添加', + 201 => '通过内部成员共享添加', + 202 => '通过管理员或负责人分配添加', +]; +foreach ($knownAddWays as $addWay => $expectedLabel) { + qywxChannelExpect( + $addWayLabelMethod->invoke(null, $addWay) === $expectedLabel, + "add_way={$addWay} 缺少正确的可读文案" + ); +} +qywxChannelExpect( + $addWayLabelMethod->invoke(null, 999) === '其他添加方式(999)', + '未知的新 add_way 必须保留编号作为可读兜底' +); +qywxChannelExpect($normalizeAddWayMethod->invoke(null, '16') === 16, '数字字符串 add_way 应被规范化'); +qywxChannelExpect($normalizeAddWayMethod->invoke(null, '1future') === null, '异常 add_way 不得被强转成错误来源'); +qywxChannelExpect($normalizeAddWayMethod->invoke(null, null) === null, '缺失 add_way 应保持未记录'); + +$filterMethod = new ReflectionMethod(CustomerLists::class, 'applyAddWayFilter'); +$filterMethod->setAccessible(true); +$list = (new ReflectionClass(CustomerLists::class))->newInstanceWithoutConstructor(); +$paramsProperty = new ReflectionProperty(app\common\lists\BaseDataLists::class, 'params'); +$paramsProperty->setAccessible(true); + +$paramsProperty->setValue($list, ['add_way' => 1]); +$filterQuery = new QywxChannelFilterQueryFake(); +$filterMethod->invoke($list, $filterQuery); +qywxChannelExpect( + array_column($filterQuery->likes, 'value') === [ + '%"add_way":1,%', + '%"add_way":1}%', + '%"add_way":"1",%', + '%"add_way":"1"}%', + ], + '渠道筛选必须精确匹配数字或字符串 add_way,不能让 1 误命中 16' +); + +$paramsProperty->setValue($list, ['add_way' => 0]); +$zeroFilterQuery = new QywxChannelFilterQueryFake(); +$filterMethod->invoke($list, $zeroFilterQuery); +qywxChannelExpect(count($zeroFilterQuery->likes) === 4, 'add_way=0 是有效渠道筛选,不能按空值忽略'); + +$paramsProperty->setValue($list, ['add_way' => 'invalid']); +$invalidFilterQuery = new QywxChannelFilterQueryFake(); +$filterMethod->invoke($list, $invalidFilterQuery); +qywxChannelExpect($invalidFilterQuery->raw === ['1=0'], '非法渠道参数必须返回空结果,不能泄露全量客户'); + +$paramsProperty->setValue($list, ['add_way' => '']); +$emptyFilterQuery = new QywxChannelFilterQueryFake(); +$filterMethod->invoke($list, $emptyFilterQuery); +qywxChannelExpect($emptyFilterQuery->likes === [] && $emptyFilterQuery->raw === [], '空渠道参数应表示不限'); + +$source = file_get_contents(__DIR__ . '/../app/adminapi/lists/qywx/CustomerLists.php'); +qywxChannelExpect(is_string($source), '无法读取 CustomerLists.php'); +foreach ([ + "->where('change_type', 'add_external_contact')", + "->where('state', '<>', '')", + "->whereIn('external_userid', \$ids)", + "->order('event_time', 'desc')", + "->order('id', 'desc')", + "\$item['add_channels']", + "\$item['add_channel_states']", + "\$fu['add_way_label'] = self::addWayLabel(\$addWay)", + '$this->applyAddWayFilter($query)', +] as $needle) { + qywxChannelExpect(str_contains($source, $needle), "客户渠道投影缺少契约:{$needle}"); +} + +echo "QYWX_CUSTOMER_CHANNEL_PROJECTION_OK\n"; diff --git a/server/tests/QywxCustomerChannelUiContractTest.mjs b/server/tests/QywxCustomerChannelUiContractTest.mjs new file mode 100644 index 000000000..0e096d1f2 --- /dev/null +++ b/server/tests/QywxCustomerChannelUiContractTest.mjs @@ -0,0 +1,60 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const pagePath = path.resolve(currentDir, '../../admin/src/views/fans/qywx.vue') +const source = fs.readFileSync(pagePath, 'utf8') + +function expect(condition, message) { + if (!condition) throw new Error(message) +} + +expect(source.includes(' __DIR__ . '/../app/adminapi/controller/qywx/CustomerController.php', + 'logic' => __DIR__ . '/../app/adminapi/logic/qywx/CustomerLogic.php', + 'validate' => __DIR__ . '/../app/adminapi/validate/qywx/CustomerValidate.php', + 'api' => $root . '/admin/src/api/qywx.ts', + 'page' => $root . '/admin/src/views/fans/qywx.vue', + 'migration' => __DIR__ . '/../sql/1.9.20260902/add_qywx_customer_delete_menu.sql', +]; + +$sources = []; +foreach ($paths as $name => $path) { + $source = file_get_contents($path); + if (!is_string($source)) { + throw new RuntimeException("无法读取 {$name}: {$path}"); + } + $sources[$name] = $source; +} + +function qywxDeleteExpect(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +$controller = $sources['controller']; +$permissionCheck = strpos($controller, 'if (!$this->canDeleteCustomer())'); +$deleteCall = strpos($controller, 'CustomerLogic::deleteCustomer('); +qywxDeleteExpect(str_contains($controller, "private const DELETE_PERMISSION = 'qywx.customer/delete';"), '控制器缺少独立删除权限'); +qywxDeleteExpect($permissionCheck !== false && $deleteCall !== false && $permissionCheck < $deleteCall, '控制器必须在删除前显式鉴权'); +qywxDeleteExpect(str_contains($controller, "(int) (\$this->adminInfo['root'] ?? 0) === 1") + && str_contains($controller, 'AuthLogic::getAuthByAdminId($this->adminId)') + && str_contains($controller, 'in_array(self::DELETE_PERMISSION,'), '控制器删除权限必须仅放行 root 或显式授权账号'); + +qywxDeleteExpect(str_contains($sources['validate'], "'id' => 'require|integer|gt:0'") + && str_contains($sources['validate'], 'public function sceneDelete()'), '删除请求缺少正整数 ID 校验'); + +$logic = $sources['logic']; +$logicStart = strpos($logic, 'public static function deleteCustomer(int $id): bool'); +$logicEnd = strpos($logic, 'public static function softDeleteExternalContactRow(', $logicStart === false ? 0 : $logicStart); +qywxDeleteExpect($logicStart !== false && $logicEnd !== false, '无法定位客户删除逻辑'); +$method = substr($logic, $logicStart, $logicEnd - $logicStart); +foreach ([ + "->where('id', \$id)", + "->whereNull('delete_time')", + "'delete_time' => \$now", + "->where('external_userid', \$externalUserId)", + 'if ($activeRows === 0)', + "Db::name('qywx_external_contact_tag')", + 'MediaChannelService::forgetCurrentTagCatalogCache()', +] as $needle) { + qywxDeleteExpect(str_contains($method, $needle), "客户删除逻辑缺少契约:{$needle}"); +} +qywxDeleteExpect(!str_contains($method, 'WechatWorkService'), '后台删除不得调用企微接口删除外部客户关系'); + +qywxDeleteExpect(str_contains($sources['api'], '/qywx.customer/delete') + && str_contains($sources['api'], 'qywxCustomerDelete'), '前端缺少客户删除 API'); +foreach ([ + "v-perms=\"['qywx.customer/delete']\"", + 'handleDelete(row)', + '仅删除系统内的同步记录', + 'qywxCustomerDelete({ id })', + 'Promise.all([getLists(), loadStats(), loadTagStats()])', +] as $needle) { + qywxDeleteExpect(str_contains($sources['page'], $needle), "客户列表删除交互缺少:{$needle}"); +} + +$migration = $sources['migration']; +qywxDeleteExpect(str_contains($migration, "`component` = 'fans/qywx'") + && str_contains($migration, "'qywx.customer/delete'") + && str_contains($migration, "'A'") + && str_contains($migration, 'NOT EXISTS'), '删除权限迁移必须按页面定位并可重复执行'); +qywxDeleteExpect(!str_contains(strtolower($migration), 'system_role_menu'), '删除权限不得自动授予已有角色'); + +echo "QYWX_CUSTOMER_DELETE_PERMISSION_CONTRACT_OK\n"; diff --git a/server/tests/WecomPromotionAutomationUiTest.mjs b/server/tests/WecomPromotionAutomationUiTest.mjs index 9eb2b25a0..d80664489 100644 --- a/server/tests/WecomPromotionAutomationUiTest.mjs +++ b/server/tests/WecomPromotionAutomationUiTest.mjs @@ -62,5 +62,9 @@ assert.match(previewTemplate('{customer_name}-{employee_name}-{add_time}', '小 assert.equal(Array.from(previewTemplate('王'.repeat(30), '小陈', 20)).length, 20) assert.match(formSource, /不会写入企微获客链接详情中的“欢迎语\/客户标签”配置/) assert.match(formSource, /客户添加回调中立即发送渠道欢迎语并添加标签/) +assert.match(formSource, /disabledSections\?: AutomationSection\[\]/) +assert.match(formSource, /backupExcludedMemberIds\?: number\[\]/) assert.match(pageSource, /result\?\.automation_saved !== true/) +assert.match(pageSource, /仅勾选的项目会覆盖到所选方案/) +assert.match(pageSource, /batchSharedPrimaryMemberIds/) console.log('WECOM_PROMOTION_AUTOMATION_UI_OK') diff --git a/server/tests/WecomPromotionBatchUpdateContractTest.php b/server/tests/WecomPromotionBatchUpdateContractTest.php new file mode 100644 index 000000000..7e82390f4 --- /dev/null +++ b/server/tests/WecomPromotionBatchUpdateContractTest.php @@ -0,0 +1,90 @@ + __DIR__ . '/../app/adminapi/logic/firstvisit/WecomPromotionLogic.php', + 'controller' => __DIR__ . '/../app/adminapi/controller/firstvisit/WecomPromotionController.php', + 'middleware' => __DIR__ . '/../app/adminapi/http/middleware/AuthMiddleware.php', + 'api' => $root . '/admin/src/api/first_visit.ts', + 'page' => $root . '/admin/src/views/first_visit/wecom_promotion/index.vue', + 'automationForm' => $root . '/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue', +]; +$sources = []; +foreach ($paths as $name => $path) { + $source = file_get_contents($path); + if (!is_string($source)) { + throw new RuntimeException("无法读取 {$name}: {$path}"); + } + $sources[$name] = $source; +} + +if (!str_contains($sources['controller'], 'public function batchUpdatePools()') + || !str_contains($sources['controller'], 'WecomPromotionLogic::batchUpdatePools(')) { + throw new RuntimeException('控制器缺少批量修改方案配置接口'); +} +if (!str_contains($sources['middleware'], "'firstvisit.wecompromotion/batchupdatepools'")) { + throw new RuntimeException('批量修改方案接口未加入获客助手权限白名单'); +} +if (!str_contains($sources['api'], '/firstvisit.wecomPromotion/batchUpdatePools') + || !str_contains($sources['api'], 'WecomPromotionBatchUpdatePoolsParams')) { + throw new RuntimeException('前端缺少类型化批量修改 API'); +} + +$logic = $sources['logic']; +$start = strpos($logic, 'public static function batchUpdatePools('); +$end = strpos($logic, 'public static function saveWidget(', $start === false ? 0 : $start); +if ($start === false || $end === false) { + throw new RuntimeException('无法定位 batchUpdatePools 方法'); +} +$method = substr($logic, $start, $end - $start); +foreach ([ + 'assertBasePagePermission($adminId, $adminInfo)', + "array_key_exists('fallback_url', \$changes)", + "array_key_exists('status', \$changes)", + "array_key_exists('skip_verify', \$changes)", + "array_key_exists('automation_config', \$changes)", + 'array_replace($currentAutomation, $automationPatch)', + 'self::savePool($saveParams, $adminId, $adminInfo, false)', + "'sync_error_count' => \$syncErrorCount", + "'sync_queued_count' => \$syncQueuedCount", + "'results' => \$results", +] as $needle) { + if (!str_contains($method, $needle)) { + throw new RuntimeException("批量修改方案逻辑缺少契约:{$needle}"); + } +} +$permissionValidation = strpos($method, "self::assertScopedRow("); +$writeLoop = strpos($method, '$results = []'); +if ($permissionValidation === false || $writeLoop === false || $permissionValidation > $writeLoop) { + throw new RuntimeException('批量修改必须在任何方案保存前完成全部方案权限校验'); +} +if (!str_contains($method, "false\n );")) { + throw new RuntimeException('批量修改不得允许共享操作人绕过原管理范围批量编辑'); +} + +$page = $sources['page']; +foreach ([ + '批量修改方案', + '仅勾选的项目会覆盖到所选方案', + 'batchConfigApply.skip_verify', + 'batchConfigApply.fallback_url', + 'batchConfigApply.status', + 'batchConfigApply.reception', + 'batchConfigApply.customer', + 'batchConfigApply.welcome', + 'batchSharedPrimaryMemberIds', + 'batchPrimaryMemberUnion', + 'wecomPromotionBatchUpdatePools', +] as $needle) { + if (!str_contains($page, $needle)) { + throw new RuntimeException("前端批量修改交互缺少 {$needle}"); + } +} +if (!str_contains($sources['automationForm'], 'disabledSections?: AutomationSection[]') + || !str_contains($sources['automationForm'], 'backupExcludedMemberIds?: number[]')) { + throw new RuntimeException('自动化表单未支持批量场景的分组禁用或主成员冲突约束'); +} + +echo "WECOM_PROMOTION_BATCH_UPDATE_CONTRACT_OK\n";