diff --git a/sql/sample_type.sql b/sql/sample_type.sql new file mode 100644 index 0000000..5820f19 --- /dev/null +++ b/sql/sample_type.sql @@ -0,0 +1,37 @@ +-- 患者表新增字段 +ALTER TABLE `patient` + ADD COLUMN `hospital` varchar(100) DEFAULT '' COMMENT '医院名称' AFTER `address`, + ADD COLUMN `sample_types` varchar(255) DEFAULT '' COMMENT '送检样本类型(JSON数组)', + ADD COLUMN `wax_return` tinyint(1) DEFAULT 0 COMMENT '是否需寄回: 0否 1是', + ADD COLUMN `return_name` varchar(50) DEFAULT '' COMMENT '寄回收件人姓名', + ADD COLUMN `return_phone` varchar(20) DEFAULT '' COMMENT '寄回收件人电话', + ADD COLUMN `return_province_code` varchar(10) DEFAULT '' COMMENT '寄回省份编码', + ADD COLUMN `return_city_code` varchar(10) DEFAULT '' COMMENT '寄回城市编码', + ADD COLUMN `return_district_code` varchar(10) DEFAULT '' COMMENT '寄回区县编码', + ADD COLUMN `return_address` varchar(255) DEFAULT '' COMMENT '寄回详细地址', + ADD COLUMN `report_email` varchar(100) DEFAULT '' COMMENT '报告接收邮箱', + ADD COLUMN `sample_tracking_no` varchar(100) DEFAULT '' COMMENT '送检样本物流单号', + ADD COLUMN `sample_photos` json DEFAULT NULL COMMENT '送检单照片(JSON数组)'; + +-- 送检样本类型配置表 +CREATE TABLE `sample_type` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL COMMENT '样本类型名称', + `need_return` tinyint(1) DEFAULT 0 COMMENT '是否可选需寄回: 0否 1是', + `sort` int(11) DEFAULT 0 COMMENT '排序(越大越前)', + `status` tinyint(1) DEFAULT 1 COMMENT '状态: 1启用 0禁用', + `is_deleted` tinyint(1) DEFAULT 0 COMMENT '软删除', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) COMMENT='送检样本类型配置'; + +INSERT INTO `sample_type` (`name`, `need_return`, `sort`, `status`) VALUES +('蜡块', 1, 4, 1), +('白片', 0, 3, 1), +('血液', 0, 2, 1), +('新鲜组织', 0, 1, 1); + +-- sys_config 新增送检样本是否必选配置 +INSERT INTO `sys_config` (`config_key`, `config_value`, `remark`) VALUES +('sample_required', '0', '送检样本是否必选: 0否 1是'); diff --git a/src/config/router.js b/src/config/router.js index 631d33b..f9f79d8 100644 --- a/src/config/router.js +++ b/src/config/router.js @@ -55,6 +55,7 @@ module.exports = [ ['/common/regions', 'common/regions'], ['/common/ocr/idcard', 'common/ocrIdcard', 'post'], ['/common/ocr/hmt', 'common/ocrHmt', 'post'], + ['/common/sampleTypes', 'common/sampleTypes'], ['/api/content', 'common/content'], // 小程序接口 @@ -85,6 +86,15 @@ module.exports = [ ['/admin/tag/delete', 'admin/tag/delete', 'post'], ['/admin/tag/sort', 'admin/tag/sort', 'post'], + // 送检样本管理 + ['/admin/sample_type', 'admin/sample_type/index'], + ['/admin/sample_type/list', 'admin/sample_type/list'], + ['/admin/sample_type/add', 'admin/sample_type/add', 'post'], + ['/admin/sample_type/edit', 'admin/sample_type/edit', 'post'], + ['/admin/sample_type/delete', 'admin/sample_type/delete', 'post'], + ['/admin/sample_type/sort', 'admin/sample_type/sort', 'post'], + ['/admin/sample_type/setRequired', 'admin/sample_type/setRequired', 'post'], + // 内容管理 ['/admin/content', 'admin/content/index'], ['/admin/content/list', 'admin/content/list'], diff --git a/src/controller/admin/patient.js b/src/controller/admin/patient.js index e404b44..7f5e844 100644 --- a/src/controller/admin/patient.js +++ b/src/controller/admin/patient.js @@ -91,25 +91,27 @@ module.exports = class extends Base { } // 解析 JSON 字段 - try { - patient.documents = JSON.parse(patient.documents || '[]'); - } catch (e) { - patient.documents = []; - } - - // 查询省市区名称 - if (patient.province_code || patient.city_code || patient.district_code) { - const codes = [patient.province_code, patient.city_code, patient.district_code].filter(Boolean); - if (codes.length) { - const regions = await this.model('sys_region') - .where({ code: ['in', codes] }) - .select(); - const regionMap = {}; - regions.forEach(r => { regionMap[r.code] = r.name; }); - patient.province_name = regionMap[patient.province_code] || ''; - patient.city_name = regionMap[patient.city_code] || ''; - patient.district_name = regionMap[patient.district_code] || ''; - } + try { patient.documents = JSON.parse(patient.documents || '[]'); } catch (e) { patient.documents = []; } + try { patient.sample_types = JSON.parse(patient.sample_types || '[]'); } catch (e) { patient.sample_types = []; } + try { patient.sample_photos = JSON.parse(patient.sample_photos || '[]'); } catch (e) { patient.sample_photos = []; } + + // 查询省市区名称(包含寄回地址) + const allCodes = [ + patient.province_code, patient.city_code, patient.district_code, + patient.return_province_code, patient.return_city_code, patient.return_district_code + ].filter(Boolean); + if (allCodes.length) { + const regions = await this.model('sys_region') + .where({ code: ['in', allCodes] }) + .select(); + const regionMap = {}; + regions.forEach(r => { regionMap[r.code] = r.name; }); + patient.province_name = regionMap[patient.province_code] || ''; + patient.city_name = regionMap[patient.city_code] || ''; + patient.district_name = regionMap[patient.district_code] || ''; + patient.return_province_name = regionMap[patient.return_province_code] || ''; + patient.return_city_name = regionMap[patient.return_city_code] || ''; + patient.return_district_name = regionMap[patient.return_district_code] || ''; } // 获取审核记录 @@ -124,7 +126,7 @@ module.exports = class extends Base { // 新增患者 async addAction() { const data = this.post(); - const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; + const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; if (!name || !phone || !id_card || !gender || !birth_date) { return this.fail('请填写完整信息'); @@ -175,10 +177,22 @@ module.exports = class extends Base { city_code: city_code || '', district_code: district_code || '', address: address || '', + hospital: hospital || '', emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', tag: tag || '', documents: JSON.stringify(documents || []), + sample_types: JSON.stringify(sample_types || []), + wax_return: wax_return ? 1 : 0, + return_name: return_name || '', + return_phone: return_phone || '', + return_province_code: return_province_code || '', + return_city_code: return_city_code || '', + return_district_code: return_district_code || '', + return_address: return_address || '', + report_email: report_email || '', + sample_tracking_no: sample_tracking_no || '', + sample_photos: JSON.stringify(sample_photos || []), sign_income: sign_income || '', sign_privacy: sign_privacy || '', sign_promise: sign_promise || '', @@ -318,7 +332,7 @@ module.exports = class extends Base { // 编辑患者 async editAction() { const data = this.post(); - const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, emergency_contact, emergency_phone, tag, documents, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; + const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, tag, documents, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_promise, sign_privacy_jhr, income_amount, guardian_name, guardian_id_card, guardian_relation } = data; if (!id) return this.fail('参数错误'); if (!name || !phone || !id_card || !gender || !birth_date) return this.fail('请填写完整信息'); @@ -340,10 +354,22 @@ module.exports = class extends Base { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address: address || '', + hospital: hospital || '', emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', tag: tag || '', documents: JSON.stringify(documents || []), + sample_types: JSON.stringify(sample_types || []), + wax_return: wax_return ? 1 : 0, + return_name: return_name || '', + return_phone: return_phone || '', + return_province_code: return_province_code || '', + return_city_code: return_city_code || '', + return_district_code: return_district_code || '', + return_address: return_address || '', + report_email: report_email || '', + sample_tracking_no: sample_tracking_no || '', + sample_photos: JSON.stringify(sample_photos || []), sign_income: sign_income || '', sign_privacy: sign_privacy || '', sign_promise: sign_promise || '', @@ -365,12 +391,15 @@ module.exports = class extends Base { const model = this.model('patient'); const list = await model.getAll({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code }); - // 批量查省市区名称 + // 批量查省市区名称(包含寄回地址) const allCodes = new Set(); list.forEach(item => { if (item.province_code) allCodes.add(item.province_code); if (item.city_code) allCodes.add(item.city_code); if (item.district_code) allCodes.add(item.district_code); + if (item.return_province_code) allCodes.add(item.return_province_code); + if (item.return_city_code) allCodes.add(item.return_city_code); + if (item.return_district_code) allCodes.add(item.return_district_code); }); const regionMap = {}; if (allCodes.size) { @@ -394,7 +423,13 @@ module.exports = class extends Base { } const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' }; - const header = ['ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '提交时间', '审核状态', '审核日期', '审核驳回原因', '瘤种']; + const header = [ + 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址', + '医院名称', '紧急联系人', '紧急联系电话', '瘤种', + '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址', + '报告接收邮箱', '送检物流单号', + '提交时间', '审核状态', '审核日期', '审核驳回原因' + ]; const ExcelJS = require('exceljs'); const workbook = new ExcelJS.Workbook(); @@ -402,9 +437,9 @@ module.exports = class extends Base { // 表头 sheet.addRow(header); - // 表头样式:绿色背景、白色加粗字体(仅到瘤种列,即K列=11列)、冻结首行 + // 表头样式:绿色背景、白色加粗字体、冻结首行 const headerRow = sheet.getRow(1); - const colCount = header.length; // 11列 + const colCount = header.length; for (let i = 1; i <= colCount; i++) { const cell = headerRow.getCell(i); cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; @@ -428,6 +463,12 @@ module.exports = class extends Base { // 数据行 list.forEach(item => { const audit = auditMap[item.id]; + let sampleTypes = ''; + try { sampleTypes = JSON.parse(item.sample_types || '[]').join('、'); } catch (e) { sampleTypes = ''; } + const returnAddr = item.return_address + ? [regionMap[item.return_province_code] || '', regionMap[item.return_city_code] || '', regionMap[item.return_district_code] || '', item.return_address].filter(Boolean).join('') + : ''; + const row = sheet.addRow([ item.patient_no, item.name, @@ -436,11 +477,22 @@ module.exports = class extends Base { item.phone, regionMap[item.province_code] || '', regionMap[item.city_code] || '', + item.address || '', + item.hospital || '', + item.emergency_contact || '', + item.emergency_phone || '', + item.tag || '', + sampleTypes, + item.wax_return ? '是' : '否', + item.return_name || '', + item.return_phone || '', + returnAddr, + item.report_email || '', + item.sample_tracking_no || '', item.create_time || '', statusMap[item.status] || '', audit ? (audit.create_time || '') : '', - (audit && audit.action === 'reject') ? (audit.reason || '') : '', - item.tag || '' + (audit && audit.action === 'reject') ? (audit.reason || '') : '' ]); // 数据行加边框 for (let i = 1; i <= colCount; i++) { diff --git a/src/controller/admin/sample_type.js b/src/controller/admin/sample_type.js new file mode 100644 index 0000000..ac56bbf --- /dev/null +++ b/src/controller/admin/sample_type.js @@ -0,0 +1,75 @@ +const Base = require('../base'); + +module.exports = class extends Base { + async indexAction() { + this.assign('currentPage', 'sample-type'); + this.assign('pageTitle', '送检样本管理'); + this.assign('breadcrumb', [ + { name: '系统管理', url: '/admin/system/user.html' }, + { name: '送检样本管理' } + ]); + // 获取当前必选配置 + const required = await this.model('sys_config').getByKey('sample_required'); + this.assign('sampleRequired', required === '1' || required === 1 ? '1' : '0'); + return this.display(); + } + + async listAction() { + const list = await this.model('sample_type') + .where({ is_deleted: 0 }) + .order('sort DESC, id ASC') + .select(); + return this.json({ code: 0, data: list }); + } + + async addAction() { + const { name, need_return, sort } = this.post(); + if (!name) return this.fail('请输入样本类型名称'); + await this.model('sample_type').add({ + name, + need_return: need_return ? 1 : 0, + sort: sort || 0, + status: 1 + }); + await this.log('add', '送检样本管理', `新增样本类型: ${name}`); + return this.success(); + } + + async editAction() { + const { id, name, need_return, sort, status } = this.post(); + if (!id) return this.fail('参数错误'); + if (!name) return this.fail('请输入样本类型名称'); + await this.model('sample_type').where({ id }).update({ + name, + need_return: need_return ? 1 : 0, + sort: sort || 0, + status: status !== undefined ? parseInt(status) : 1 + }); + await this.log('edit', '送检样本管理', `编辑样本类型(ID:${id}): ${name}`); + return this.success(); + } + + async deleteAction() { + const { id } = this.post(); + if (!id) return this.fail('参数错误'); + await this.model('sample_type').where({ id }).update({ is_deleted: 1 }); + await this.log('edit', '送检样本管理', `删除样本类型(ID:${id})`); + return this.success(); + } + + async sortAction() { + const { ids } = this.post(); + if (!ids || !ids.length) return this.fail('参数错误'); + for (let i = 0; i < ids.length; i++) { + await this.model('sample_type').where({ id: ids[i] }).update({ sort: ids.length - i }); + } + return this.success(); + } + + async setRequiredAction() { + const { value } = this.post(); + await this.model('sys_config').setByKey('sample_required', value ? '1' : '0', '送检样本是否必选'); + await this.log('edit', '送检样本管理', `设置送检样本必选: ${value ? '是' : '否'}`); + return this.success(); + } +}; diff --git a/src/controller/common.js b/src/controller/common.js index 123ad1c..8fc3d9c 100644 --- a/src/controller/common.js +++ b/src/controller/common.js @@ -149,6 +149,21 @@ module.exports = class extends think.Controller { } } + /** + * 获取送检样本类型列表及配置 + * GET /common/sampleTypes + */ + async sampleTypesAction() { + try { + const list = await this.model('sample_type').getEnabledList(); + const required = await this.model('sys_config').getByKey('sample_required'); + return this.json({ code: 0, data: { list, required: required === '1' || required === 1 } }); + } catch (error) { + think.logger.error('获取送检样本类型失败:', error); + return this.json({ code: 0, data: { list: [], required: false } }); + } + } + /** * 获取省市区树形数据(一次返回) * GET /common/regions diff --git a/src/controller/mp.js b/src/controller/mp.js index 4957777..6c0268c 100644 --- a/src/controller/mp.js +++ b/src/controller/mp.js @@ -355,6 +355,10 @@ module.exports = class extends Base { } let documents = []; try { documents = JSON.parse(patient.documents || '[]'); } catch (e) { documents = []; } + let samplePhotos = []; + try { samplePhotos = JSON.parse(patient.sample_photos || '[]'); } catch (e) { samplePhotos = []; } + let sampleTypes = []; + try { sampleTypes = JSON.parse(patient.sample_types || '[]'); } catch (e) { sampleTypes = []; } // 如果是驳回状态,查最近一条驳回原因 let rejectReason = ''; @@ -375,9 +379,21 @@ module.exports = class extends Base { city_name: regionMap[patient.city_code] || '', district_name: regionMap[patient.district_code] || '', address: patient.address || '', + hospital: patient.hospital || '', emergency_contact: patient.emergency_contact || '', emergency_phone: patient.emergency_phone || '', tag: patient.tag || '', documents, + sample_types: sampleTypes, + wax_return: patient.wax_return || 0, + return_name: patient.return_name || '', + return_phone: patient.return_phone || '', + return_province_code: patient.return_province_code || '', + return_city_code: patient.return_city_code || '', + return_district_code: patient.return_district_code || '', + return_address: patient.return_address || '', + report_email: patient.report_email || '', + sample_tracking_no: patient.sample_tracking_no || '', + sample_photos: samplePhotos, sign_income: patient.sign_income || '', sign_privacy: patient.sign_privacy || '', sign_privacy_jhr: patient.sign_privacy_jhr || '', @@ -399,11 +415,12 @@ module.exports = class extends Base { async saveMyInfoAction() { const mpUser = this.mpUser; if (!mpUser) return this.json({ code: 1009, msg: '请先登录' }); - const { gender, province_code, city_code, district_code, address, emergency_contact, emergency_phone, documents, sign_income, sign_privacy, sign_privacy_jhr, sign_promise, income_amount, guardian_name, guardian_id_card, guardian_relation, mp_env_version } = this.post(); + const { gender, province_code, city_code, district_code, address, hospital, emergency_contact, emergency_phone, documents, tag, sample_types, wax_return, return_name, return_phone, return_province_code, return_city_code, return_district_code, return_address, report_email, sample_tracking_no, sample_photos, sign_income, sign_privacy, sign_privacy_jhr, sign_promise, income_amount, guardian_name, guardian_id_card, guardian_relation, mp_env_version } = this.post(); const user = await this.model('wechat_user').where({ id: mpUser.id, status: 1 }).find(); if (think.isEmpty(user) || !user.patient_id) return this.json({ code: 1, msg: '请先完成实名认证' }); if (!province_code || !city_code || !district_code) return this.json({ code: 1, msg: '请选择省市区' }); if (!address) return this.json({ code: 1, msg: '请填写详细地址' }); + if (!emergency_contact || !emergency_phone) return this.json({ code: 1, msg: '请填写紧急联系人信息' }); const now = think.datetime(new Date()); try { @@ -414,8 +431,21 @@ module.exports = class extends Base { await this.model('patient').where({ id: user.patient_id }).update({ gender: gender || '', province_code: province_code || '', city_code: city_code || '', district_code: district_code || '', address: address || '', + hospital: hospital || '', emergency_contact: emergency_contact || '', emergency_phone: emergency_phone || '', + tag: tag || '', documents: JSON.stringify(documents || []), + sample_types: JSON.stringify(sample_types || []), + wax_return: (sample_types && sample_types.length && wax_return) ? 1 : 0, + return_name: (sample_types && sample_types.length && wax_return) ? (return_name || '') : '', + return_phone: (sample_types && sample_types.length && wax_return) ? (return_phone || '') : '', + return_province_code: (sample_types && sample_types.length && wax_return) ? (return_province_code || '') : '', + return_city_code: (sample_types && sample_types.length && wax_return) ? (return_city_code || '') : '', + return_district_code: (sample_types && sample_types.length && wax_return) ? (return_district_code || '') : '', + return_address: (sample_types && sample_types.length && wax_return) ? (return_address || '') : '', + report_email: (sample_types && sample_types.length) ? (report_email || '') : '', + sample_tracking_no: (sample_types && sample_types.length) ? (sample_tracking_no || '') : '', + sample_photos: (sample_types && sample_types.length) ? JSON.stringify(sample_photos || []) : '[]', sign_income: sign_income || '', sign_privacy: sign_privacy || '', sign_privacy_jhr: sign_privacy_jhr || '', @@ -435,27 +465,6 @@ module.exports = class extends Base { operator_name: '用户自助提交' }); - // 异步 OCR 识别瘤种(不阻塞用户提交) - const patientId = user.patient_id; - const docUrls = documents || []; - if (docUrls.length) { - setImmediate(async () => { - try { - const tagOptions = await think.model('sys_config').getByKey('tag_options'); - if (tagOptions && tagOptions.length) { - const ocrService = think.service('ocr'); - const matchedTag = await ocrService.matchTag(docUrls, tagOptions); - if (matchedTag) { - await think.model('patient').where({ id: patientId }).update({ tag: matchedTag }); - think.logger.info(`[OCR] 患者(ID:${patientId}) 识别瘤种: ${matchedTag}`); - } - } - } catch (err) { - think.logger.error(`[OCR] 患者(ID:${patientId}) 识别失败:`, err.message); - } - }); - } - return this.json({ code: 0, data: {}, msg: '提交成功' }); } catch (error) { think.logger.error('saveMyInfo error:', error); diff --git a/src/model/sample_type.js b/src/model/sample_type.js new file mode 100644 index 0000000..29fc336 --- /dev/null +++ b/src/model/sample_type.js @@ -0,0 +1,14 @@ +module.exports = class extends think.Model { + get tableName() { + return 'sample_type'; + } + + /** + * 获取启用的样本类型列表 + */ + async getEnabledList() { + return this.where({ status: 1, is_deleted: 0 }) + .order('sort DESC, id ASC') + .select(); + } +}; diff --git a/view/admin/common/_sidebar.html b/view/admin/common/_sidebar.html index 1dbf996..36b98d9 100644 --- a/view/admin/common/_sidebar.html +++ b/view/admin/common/_sidebar.html @@ -32,6 +32,16 @@ {% endif %} + {# 送检样本管理 #} + {% if isSuperAdmin or (userPermissions and 'tag' in userPermissions) %} +
+ {% endif %} + {# 内容管理 #} {% if isSuperAdmin or (userPermissions and 'content' in userPermissions) %} + +患者签署的三份授权材料
+患者签署的授权材料
请选择或填写驳回原因(必填):