diff --git a/apps/web-antd/src/util/address-recognizer.js b/apps/web-antd/src/util/address-recognizer.js index 73b793c1..f2c1fd78 100644 --- a/apps/web-antd/src/util/address-recognizer.js +++ b/apps/web-antd/src/util/address-recognizer.js @@ -387,7 +387,8 @@ export class AddressRecognizer { const m = after.match(/^\s*[,,、]?\s*([\u4E00-\u9FA5]{2,6})/); if (!m) return null; const chunk = m[1]; - for (let len = 2; len <= chunk.length; len++) { + // 长到短:与手机号前取名同口径,避免短姓误截 + for (let len = chunk.length; len >= 2; len--) { const cand = chunk.slice(0, len); if (this.isValidName(cand)) { return { value: cand, confidence: len <= 3 ? 0.8 : 0.7 }; @@ -397,7 +398,8 @@ export class AddressRecognizer { } /** - * 从手机号前取名:短到长尝试,避免「张三杭州」整段误判;支持复姓最长 6 字 + * 从手机号前取名:长到短优先,避免姓氏表含生僻单姓(如「朝」)时把「罗朝亮」截成「朝亮」 + * 支持复姓最长 6 字 */ _nameBeforePhone(text, phoneInfo) { if (!phoneInfo || !phoneInfo.value) return null; @@ -407,7 +409,7 @@ export class AddressRecognizer { const m = before.match(/([\u4E00-\u9FA5]{2,6})\s*$/); if (!m) return null; const chunk = m[1]; - for (let len = 2; len <= chunk.length; len++) { + for (let len = chunk.length; len >= 2; len--) { const cand = chunk.slice(chunk.length - len); if (this.isValidName(cand)) { return { value: cand, confidence: len <= 3 ? 0.85 : 0.75 }; @@ -770,8 +772,9 @@ export class AddressRecognizer { normalizeEcommerceText(text) { const src = String(text || ''); if (src.indexOf('\n') < 0 && src.indexOf('\r') < 0) return src; + // 保留标签名:展平后仍能走「收货人:xxx」关键词取名,避免只剩裸姓名被短姓截断 const labelRe = - /^(?:收货人|收件人|联系人|姓名|手机号码|手机号|手机|联系电话|联系方式|电话|所在地区|详细地址|地址)\s*[::]\s*(.+)$/; + /^(收货人|收件人|联系人|姓名|手机号码|手机号|手机|联系电话|联系方式|电话|所在地区|详细地址|地址)\s*[::]\s*(.+)$/; const lines = src.split(/\r?\n/); const parts = []; let hitLabel = false; @@ -779,9 +782,9 @@ export class AddressRecognizer { const line = String(lines[i] || '').trim(); if (!line) continue; const m = line.match(labelRe); - if (m && m[1]) { + if (m && m[2]) { hitLabel = true; - parts.push(m[1].trim()); + parts.push(`${m[1]}:${String(m[2]).trim()}`); } else { parts.push(line); } diff --git a/apps/web-antd/src/views/business/order/product-order/api/index.ts b/apps/web-antd/src/views/business/order/product-order/api/index.ts index 64ee33c8..06049dbb 100644 --- a/apps/web-antd/src/views/business/order/product-order/api/index.ts +++ b/apps/web-antd/src/views/business/order/product-order/api/index.ts @@ -120,6 +120,30 @@ export async function getStoreShipAddress(orderId: number) { }); } +/** + * 单独修改收货信息(不发货;后端校验未同步 ERP) + */ +export async function updateShipAddressApi(data: { + order_id: number; + express_name?: string; + express_mobile?: string; + express_region?: string; + express_address?: string; + province?: string; + city?: string; + area?: string; +}) { + return requestClient.post<{ + order_id: number; + order_no: string; + delivery_method: number; + express_name: string; + express_mobile: string; + express_region: string; + express_address: string; + }>(`${prefix}update-ship-address`, data); +} + /** * 订单物流追踪 * @param data diff --git a/apps/web-antd/src/views/business/order/product-order/components/EditShipAddressModal.vue b/apps/web-antd/src/views/business/order/product-order/components/EditShipAddressModal.vue new file mode 100644 index 00000000..abf69f8e --- /dev/null +++ b/apps/web-antd/src/views/business/order/product-order/components/EditShipAddressModal.vue @@ -0,0 +1,295 @@ + + + + + + + + 用诊所地址 + + 加载中… + + + + 收件人 + + + + 手机号 + + + + 所在地区 + + + + 详细地址 + + + + 仅未同步 ERP 的订单可改;保存后写回订单收件信息,不触发发货 + + + + + + diff --git a/apps/web-antd/src/views/business/order/product-order/components/detail.vue b/apps/web-antd/src/views/business/order/product-order/components/detail.vue index 2e698c22..afbdcd50 100644 --- a/apps/web-antd/src/views/business/order/product-order/components/detail.vue +++ b/apps/web-antd/src/views/business/order/product-order/components/detail.vue @@ -296,7 +296,12 @@ const [Modal, modalApi] = useVbenModal({ gridApi.value = isOpen ? modalApi.getData()?.gridApi : null; if (isOpen) { const modalData = modalApi.getData>() || {}; - const { values, id, onShipPackage: shipFn } = modalData; + const { + values, + id, + onShipPackage: shipFn, + onEditShipAddress: editShipFn, + } = modalData; fromTrace.value = !!modalData.fromTrace; onOpenTraceFn.value = typeof modalData.onOpenTrace === 'function' @@ -308,6 +313,8 @@ const [Modal, modalApi] = useVbenModal({ : null; // 列表传入:按 warehouse_id 打开发货弹窗(平台包=0) onShipPackage.value = typeof shipFn === 'function' ? shipFn : null; + onEditShipAddress.value = + typeof editShipFn === 'function' ? editShipFn : null; if (id) { getOrderInfo(id).then((res) => { data.value = res; @@ -322,6 +329,7 @@ const [Modal, modalApi] = useVbenModal({ } } else { onShipPackage.value = null; + onEditShipAddress.value = null; fromTrace.value = false; onOpenTraceFn.value = null; onOpenPrescriptionFn.value = null; @@ -352,6 +360,18 @@ const packageTabs = computed(() => { const activePkg = ref('0'); /** 详情内「发本包」回调(由列表传入时仅平台包裹可发) */ const onShipPackage = ref<((warehouseId: number) => void) | null>(null); +/** 详情内打开「修改收货信息」(未同步 ERP) */ +const onEditShipAddress = ref<((row: Record) => void) | null>( + null, +); +/** 快递且未同步 ERP 时可改收货 */ +const canEditShipAddress = computed( + () => + deliveryMethod.value === 0 && + data.value && + Number(data.value.is_sync_erp) !== 1 && + typeof onEditShipAddress.value === 'function', +); /** * Tab 文案:有仓名才拼括号;空串不兜底「平台/仓」 @@ -614,15 +634,25 @@ function prescriptionStatusColor() { {{ deliveryMethod === 0 ? '收货人' : '就诊人' }}信息 - - 一键复制 - + + + 修改收货 + + + 一键复制 + + { const [FormModal, formModalApi] = useVbenModal({ connectedComponent: FormModalDemo, }); +/** 发货前单独改收货信息(未同步 ERP) */ +const [EditShipAddressModalComp, editShipAddressModalApi] = useVbenModal({ + connectedComponent: EditShipAddressModal, +}); const [Modal, modalApi] = useVbenModal({ connectedComponent: DetailModal, }); @@ -400,6 +405,11 @@ const infoModal = (data: Record = {}) => { modalApi.close(); wareSend(data, warehouseId); }, + // 详情内改收货:关详情后打开改址弹窗(避免叠层) + onEditShipAddress: (row: Record) => { + modalApi.close(); + openEditShipAddress(row || data); + }, // 二级弹窗必须页面级挂载,详情里再嵌溯源会被盖住 onOpenTrace: (payload: Record) => { openOrderPopup(traceDrawerApi, payload, ORDER_POPUP_Z_INDEX.NESTED); @@ -421,6 +431,27 @@ const openLogisticsModal = (data: Record = {}) => { logisticsModalApi.open(); }; +/** + * 打开「修改收货信息」弹窗(不发货;仅未同步 ERP) + */ +const openEditShipAddress = (data: Record = {}) => { + const addr = + data?.address && typeof data.address === 'object' ? data.address : {}; + editShipAddressModalApi.setData({ + values: { + order_id: data?.id, + order_no: data?.order_no, + express_name: data?.express_name || addr.name || '', + express_mobile: data?.express_mobile || addr.mobile || '', + express_region: data?.express_region || addr.region || '', + express_address: data?.express_address || addr.detail_address || '', + address: addr, + }, + gridApi: gridApiProxy, + }); + editShipAddressModalApi.open(); +}; + /** * 打开发货弹窗。 * 列表不传 warehouse_id(后端平台默认 0);详情按包发时仅传合法非负整数。 @@ -1328,6 +1359,15 @@ function buildRowActions(row: Record): { (Number(row.status) === 2 || Number(row.status) === 7), onClick: handleRetryConfirmReceive.bind(null, row), }, + { + // 发货前独立改址通道;已同步 ERP 后端也会拒绝 + label: '改收货', + type: 'link', + icon: 'mdi:map-marker-path', + auth: ['Super Admin', 'Admin'], + ifShow: row.is_sync_erp !== 1, + onClick: () => openEditShipAddress(row), + }, { label: '发货', type: 'link', @@ -1362,6 +1402,7 @@ function buildRowActions(row: Record): { + diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/CreateUserPatientModal.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/CreateUserPatientModal.vue index 4bff6495..4958084a 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/components/CreateUserPatientModal.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/CreateUserPatientModal.vue @@ -39,11 +39,14 @@ import { formatAddressDisplay, resolveAddressPairFromNames, } from '#/util/address-index'; +import { formatShipAddressCopyText } from '#/util/ship-address-copy'; import { checkCreatedUserPatientDuplicateApi, createUserPatientApi, + getCreatedUserPatientDetailApi, getCurrentStoreTypeApi, startOfflineReceptionByPatientApi, + updateCreatedUserPatientApi, } from '#/views/doctor/doctor-reception/api'; import { ALLERGIC_HISTORY_OPTIONS, @@ -96,6 +99,11 @@ const submitting = ref(false); /** 部分重复时用户确认仍新建 */ const forceCreate = ref(0); const form = ref(buildEmptyForm()); +/** + * 中文省市区兜底:粘贴识别/诊所地址写入;ID 解析失败时仍能提交 ship_* + * 为什么:ensureResolvedAddress 失败只回 ID、无 label,旧逻辑会静默丢掉整段地址 + */ +const shipLabels = ref({ province: '', city: '', area: '' }); const onSuccessCb = ref<((res: CreateUserPatientResult) => void) | null>(null); const onSelectExistingCb = ref< ((res: DoctorCreatedPatientStartResult) => Promise | void) | null @@ -117,6 +125,15 @@ const dupItems = ref< }> >([]); const dupStartingId = ref(0); +/** 选择并接诊:本次填了地址时,预览确认是否导入到已有就诊人 */ +const importConfirmVisible = ref(false); +const importConfirmLoading = ref(false); +const pendingDupItem = ref<{ id: number; mobile?: string; name?: string } | null>( + null, +); +const importHasOld = ref(false); +const importOldText = ref(''); +const importNewText = ref(''); /** 识别回填字段高亮 */ const flashMap = ref({ name: false, @@ -332,6 +349,52 @@ function setHistoryTags( (form.value[historyField] as string[]) = tags || []; } +/** + * 组装本次填写的收货 ship_*(有详细地址 + 省市区才返回;解析失败抛错) + * 供创建提交、以及「选择并接诊」导入已有就诊人地址复用 + */ +async function buildShipPayloadFromForm(): Promise> { + const f = form.value; + const detail = String(f.ship_detail_address || '').trim(); + const hasRegion = !!(f.ship_region && f.ship_region.length >= 2); + if (!detail || !hasRegion) return {}; + const resolved = await ensureResolvedAddress(f.ship_region); + const province = resolved.province?.label || shipLabels.value.province || ''; + const city = resolved.city?.label || shipLabels.value.city || ''; + const area = resolved.district?.label || shipLabels.value.area || ''; + if (!province && !city && !area) { + throw new Error('省市区解析失败,请重新选择后再保存'); + } + return { + ship_province: province, + ship_city: city, + ship_area: area, + ship_region: + resolved.pathLabel || + formatAddressDisplay(f.ship_region) || + [province, city, area].filter(Boolean).join(' '), + ship_detail_address: detail, + ship_name: String(f.ship_name || f.name || '').trim(), + ship_mobile: String(f.ship_mobile || f.mobile || '').trim(), + }; +} + +/** 本次收货预览文案(选择并接诊确认框用) */ +function formatDraftShipPreviewText() { + const f = form.value; + const region = + formatAddressDisplay(f.ship_region) || + [shipLabels.value.province, shipLabels.value.city, shipLabels.value.area] + .filter(Boolean) + .join(''); + return formatShipAddressCopyText({ + name: f.ship_name || f.name, + mobile: f.ship_mobile || f.mobile, + region, + detail_address: f.ship_detail_address, + }); +} + /** * 组装提交体:不展示的月经/婚育强制清零;history 仅在 status=有 时带出 * 收货地址:有详细地址才带省市区字段,后端据此建 yii_address @@ -340,22 +403,7 @@ async function buildSubmitPayload() { const f = form.value; const menstrualOk = showMenstrualHistory.value; const maritalOk = showMaritalHistory.value; - // 提交前按 ID 解析名称(含软删回显),避免本地树删除后取不到省市区文案 - const resolved = await ensureResolvedAddress(f.ship_region); - const detail = String(f.ship_detail_address || '').trim(); - const shipPayload = - detail && (resolved.province || resolved.city || resolved.district) - ? { - ship_province: resolved.province?.label || '', - ship_city: resolved.city?.label || '', - ship_area: resolved.district?.label || '', - ship_region: - resolved.pathLabel || formatAddressDisplay(f.ship_region), - ship_detail_address: detail, - ship_name: String(f.ship_name || f.name || '').trim(), - ship_mobile: String(f.ship_mobile || f.mobile || '').trim(), - } - : {}; + const shipPayload = await buildShipPayloadFromForm(); const { ship_region: _shipRegion, ship_detail_address: _shipDetail, @@ -405,26 +453,97 @@ async function checkDuplicateBeforeCreate(): Promise { } } -/** 选择已有就诊人并接诊(exact / 部分重复「使用已有」) */ -async function selectExistingPatient(item: { id: number }) { - if (!item?.id) return; +/** + * 真正开始接诊(可选:先把本次收货地址导入到已有就诊人) + */ +async function doStartWithExisting( + item: { id: number; mobile?: string }, + importShip: boolean, +) { dupStartingId.value = item.id; try { + if (importShip) { + const shipPayload = await buildShipPayloadFromForm(); + if (Object.keys(shipPayload).length > 0) { + await updateCreatedUserPatientApi({ + user_patient_id: item.id, + mobile: String(form.value.mobile || item.mobile || '').trim(), + ...shipPayload, + }); + } + } const res = await startOfflineReceptionByPatientApi({ user_patient_id: item.id, waive_register_fee: form.value.waive_register_fee ? 1 : 0, }); - message.success('已选择已有就诊人'); + message.success(importShip ? '已导入地址并接诊' : '已选择已有就诊人'); + importConfirmVisible.value = false; dupVisible.value = false; + pendingDupItem.value = null; modalApi.close(); await onSelectExistingCb.value?.(res as DoctorCreatedPatientStartResult); } catch (error: any) { message.error(error?.message || '接诊失败'); } finally { dupStartingId.value = 0; + importConfirmLoading.value = false; } } +/** + * 选择已有就诊人并接诊;本次填了收货地址时先预览确认是否导入 + */ +async function selectExistingPatient(item: { + id: number; + mobile?: string; + name?: string; +}) { + if (!item?.id) return; + // 未填本次地址:直接接诊 + if (!hasFilledShipAddress()) { + await doStartWithExisting(item, false); + return; + } + dupStartingId.value = item.id; + try { + const detail = await getCreatedUserPatientDetailApi(item.id); + const oldShip = (detail?.ship_address || {}) as Record; + const oldText = formatShipAddressCopyText(oldShip); + importHasOld.value = !!oldText; + importOldText.value = oldText; + importNewText.value = formatDraftShipPreviewText(); + pendingDupItem.value = item; + importConfirmVisible.value = true; + } catch (error: any) { + message.error(error?.message || '加载就诊人地址失败'); + } finally { + dupStartingId.value = 0; + } +} + +/** 确认框:导入地址并接诊 */ +async function confirmImportAndStart() { + const item = pendingDupItem.value; + if (!item?.id) return; + importConfirmLoading.value = true; + await doStartWithExisting(item, true); +} + +/** 确认框:不导入,直接接诊 */ +async function skipImportAndStart() { + const item = pendingDupItem.value; + if (!item?.id) return; + importConfirmLoading.value = true; + await doStartWithExisting(item, false); +} + +/** 关闭导入确认,回到去重列表(不接诊) */ +function closeImportConfirm() { + if (importConfirmLoading.value) return; + importConfirmVisible.value = false; + pendingDupItem.value = null; +} + /** 部分重复时仍创建新的 */ async function forceCreateNew() { dupVisible.value = false; @@ -507,6 +626,11 @@ async function applyClinicAddress() { } form.value.ship_region = [a.province_id, a.city_id]; form.value.ship_detail_address = a.position; + shipLabels.value = { + province: a.province || '', + city: a.city || '', + area: '', + }; await ensureResolvedAddress(form.value.ship_region); if (a.contact) { form.value.ship_name = a.contact.slice(0, 10); @@ -589,6 +713,7 @@ const [Modal, modalApi] = useVbenModal({ onOpenChange(isOpen: boolean) { if (isOpen) { form.value = buildEmptyForm(); + shipLabels.value = { province: '', city: '', area: '' }; clinicAddress.value = null; forceCreate.value = 0; dupVisible.value = false; @@ -637,6 +762,12 @@ async function onAddressRecognized(result: AddressRecognizeResult) { flash.detail = true; } if (result.province || result.city || result.area) { + // 先缓存中文省市区,即使后续 ID 解析失败也能提交 + shipLabels.value = { + province: String(result.province || '').trim(), + city: String(result.city || '').trim(), + area: String(result.area || '').trim(), + }; flash.region = true; await resolveShipRegionIds(result.province, result.city, result.area); } @@ -965,6 +1096,58 @@ function triggerFieldFlash(flash: { + + + + 本次填写了收货地址,确认后写入所选就诊人;也可跳过仅接诊。 + + + + 原地址 + + + {{ importOldText }} + + + + + {{ importHasOld ? '将覆盖为' : '将写入' }} + + + {{ importNewText || '—' }} + + + + + 返回 + + + 不导入,直接接诊 + + + 导入并接诊 + + + diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/EditCreatedUserPatientModal.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/EditCreatedUserPatientModal.vue index 04a2e81e..7206e930 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/components/EditCreatedUserPatientModal.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/EditCreatedUserPatientModal.vue @@ -57,6 +57,8 @@ const saving = ref(false); const editMode = ref<'full' | 'limited'>('full'); const userPatientId = ref(0); const clinicAddress = ref(null); +/** 中文省市区兜底:详情回填/粘贴写入,ID 解析失败时仍能提交 */ +const shipLabels = ref({ province: '', city: '', area: '' }); const form = ref({ name: '', mobile: '', @@ -115,6 +117,11 @@ async function applyClinicAddress() { } form.value.ship_region = [a.province_id, a.city_id]; form.value.ship_detail_address = a.position; + shipLabels.value = { + province: a.province || '', + city: a.city || '', + area: '', + }; await ensureResolvedAddress(form.value.ship_region); if (a.contact) { form.value.ship_name = a.contact.slice(0, 10); @@ -152,6 +159,11 @@ async function loadDetail(id: number) { form.value.ship_name = ship.name || ''; form.value.ship_mobile = ship.mobile || ''; form.value.ship_detail_address = ship.detail_address || ''; + shipLabels.value = { + province: String(ship.province || '').trim(), + city: String(ship.city || '').trim(), + area: String(ship.area || '').trim(), + }; if (ship.province || ship.city || ship.area) { const pair = await resolveAddressPairFromNames( ship.province, @@ -173,6 +185,11 @@ async function onAddressRecognized(result: AddressRecognizeResult) { if (result.phone) form.value.ship_mobile = result.phone; if (result.detail) form.value.ship_detail_address = result.detail; if (result.province || result.city || result.area) { + shipLabels.value = { + province: String(result.province || '').trim(), + city: String(result.city || '').trim(), + area: String(result.area || '').trim(), + }; const pair = await resolveAddressPairFromNames( result.province, result.city, @@ -185,21 +202,50 @@ async function onAddressRecognized(result: AddressRecognizeResult) { } } +/** + * 组装更新体:省市区优先 ID 解析,失败回落详情/粘贴缓存的中文名 + * 有详细地址却无任何省市区文案时阻断,避免只写 detail、后端报错或半截更新 + */ async function buildPayload() { + const detail = String(form.value.ship_detail_address || '').trim(); const payload: Record = { user_patient_id: userPatientId.value, mobile: form.value.mobile, ship_name: form.value.ship_name, ship_mobile: form.value.ship_mobile, - ship_detail_address: form.value.ship_detail_address, + ship_detail_address: detail, }; if (form.value.ship_region?.length) { const resolved = await ensureResolvedAddress(form.value.ship_region); - payload.ship_province = resolved.province?.label || ''; - payload.ship_city = resolved.city?.label || ''; - payload.ship_area = resolved.district?.label || ''; + const province = + resolved.province?.label || shipLabels.value.province || ''; + const city = resolved.city?.label || shipLabels.value.city || ''; + const area = resolved.district?.label || shipLabels.value.area || ''; + if (detail && !province && !city && !area) { + throw new Error('省市区解析失败,请重新选择后再保存'); + } + payload.ship_province = province; + payload.ship_city = city; + payload.ship_area = area; payload.ship_region = - resolved.pathLabel || formatAddressDisplay(form.value.ship_region); + resolved.pathLabel || + formatAddressDisplay(form.value.ship_region) || + [province, city, area].filter(Boolean).join(' '); + } else if ( + detail && + (shipLabels.value.province || shipLabels.value.city || shipLabels.value.area) + ) { + // 有详情中文省市区但 ID 未解析出:仍提交中文,避免地址丢 + payload.ship_province = shipLabels.value.province; + payload.ship_city = shipLabels.value.city; + payload.ship_area = shipLabels.value.area; + payload.ship_region = [ + shipLabels.value.province, + shipLabels.value.city, + shipLabels.value.area, + ] + .filter(Boolean) + .join(' '); } if (editMode.value === 'full') { payload.name = form.value.name;
+ 仅未同步 ERP 的订单可改;保存后写回订单收件信息,不触发发货 +
+ 本次填写了收货地址,确认后写入所选就诊人;也可跳过仅接诊。 +