Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 

678 строки
27 KiB

  1. const Base = require('../base.js');
  2. const dayjs = require('dayjs');
  3. const AUDIT_APPROVED_CONTENT = '您提交的肠愈同行患者关爱项目资料已审核通过,请于7天内在小程序-个人中心-送检信息中提交相关信息。';
  4. module.exports = class extends Base {
  5. _canEditPatient() {
  6. return this.isSuperAdmin || (this.userPermissions || []).includes('patient:edit');
  7. }
  8. // 患者列表页面
  9. async indexAction() {
  10. this.assign('currentPage', 'patient');
  11. this.assign('pageTitle', '患者管理');
  12. this.assign('breadcrumb', [{ name: '患者管理' }]);
  13. this.assign('adminUser', this.adminUser || {});
  14. this.assign('canAdd', this.isSuperAdmin || (this.userPermissions || []).includes('patient:add'));
  15. this.assign('canEdit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:edit'));
  16. this.assign('canExport', this.isSuperAdmin || (this.userPermissions || []).includes('patient:export'));
  17. this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit'));
  18. this.assign('canView', this.isSuperAdmin || (this.userPermissions || []).includes('patient:view'));
  19. this.assign('canDelete', this.isSuperAdmin || (this.userPermissions || []).includes('patient:delete'));
  20. return this.display();
  21. }
  22. // 获取患者列表接口
  23. async listAction() {
  24. const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page = 1, pageSize = 10 } = this.get();
  25. const model = this.model('patient');
  26. const list = await model.getList({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code, page, pageSize });
  27. // 收集所有省市区 code 批量查询名称
  28. const allCodes = new Set();
  29. list.data.forEach(item => {
  30. if (item.province_code) allCodes.add(item.province_code);
  31. if (item.city_code) allCodes.add(item.city_code);
  32. if (item.district_code) allCodes.add(item.district_code);
  33. });
  34. const regionMap = {};
  35. if (allCodes.size) {
  36. const regions = await this.model('sys_region')
  37. .where({ code: ['in', [...allCodes]] })
  38. .select();
  39. regions.forEach(r => { regionMap[r.code] = r.name; });
  40. }
  41. // 脱敏 + 拼接地区
  42. list.data.forEach(item => {
  43. if (item.id_card && item.id_card.length === 18) {
  44. item.id_card_mask = item.id_card.slice(0, 3) + '***********' + item.id_card.slice(-4);
  45. } else {
  46. item.id_card_mask = item.id_card || '';
  47. }
  48. if (item.phone && item.phone.length === 11) {
  49. item.phone_mask = item.phone.slice(0, 3) + '****' + item.phone.slice(-4);
  50. } else {
  51. item.phone_mask = item.phone || '';
  52. }
  53. const pName = regionMap[item.province_code] || '';
  54. const cName = regionMap[item.city_code] || '';
  55. const dName = regionMap[item.district_code] || '';
  56. item.region_name = [pName, cName, dName].filter(Boolean).join(' ');
  57. });
  58. const counts = await model.getStatusCounts({ keyword, tag, startDate, endDate, province_code, city_code, district_code });
  59. return this.success({ ...list, counts });
  60. }
  61. // 患者详情页面
  62. async detailAction() {
  63. const { id } = this.get();
  64. if (!id) return this.redirect('/admin/patient.html');
  65. this.assign('currentPage', 'patient');
  66. this.assign('pageTitle', '患者详情');
  67. this.assign('breadcrumb', [
  68. { name: '患者管理', url: '/admin/patient.html' },
  69. { name: '患者详情' }
  70. ]);
  71. this.assign('patientId', id);
  72. this.assign('adminUser', this.adminUser || {});
  73. this.assign('canAudit', this.isSuperAdmin || (this.userPermissions || []).includes('patient:audit'));
  74. this.assign('canEdit', this._canEditPatient());
  75. return this.display();
  76. }
  77. // 获取患者详情接口(不脱敏)
  78. async infoAction() {
  79. const { id } = this.get();
  80. if (!id) return this.fail('参数错误');
  81. const patient = await this.model('patient')
  82. .where({ id, is_deleted: 0 })
  83. .find();
  84. if (think.isEmpty(patient)) {
  85. return this.fail('患者不存在');
  86. }
  87. // 解析 JSON 字段
  88. try { patient.documents = JSON.parse(patient.documents || '[]'); } catch (e) { patient.documents = []; }
  89. try { patient.sample_types = JSON.parse(patient.sample_types || '[]'); } catch (e) { patient.sample_types = []; }
  90. try { patient.sample_photos = JSON.parse(patient.sample_photos || '[]'); } catch (e) { patient.sample_photos = []; }
  91. // 查询省市区名称(包含寄回地址)
  92. const allCodes = [
  93. patient.province_code, patient.city_code, patient.district_code,
  94. patient.return_province_code, patient.return_city_code, patient.return_district_code,
  95. patient.hospital_province_code, patient.hospital_city_code, patient.hospital_district_code
  96. ].filter(Boolean);
  97. if (allCodes.length) {
  98. const regions = await this.model('sys_region')
  99. .where({ code: ['in', allCodes] })
  100. .select();
  101. const regionMap = {};
  102. regions.forEach(r => { regionMap[r.code] = r.name; });
  103. patient.province_name = regionMap[patient.province_code] || '';
  104. patient.city_name = regionMap[patient.city_code] || '';
  105. patient.district_name = regionMap[patient.district_code] || '';
  106. patient.return_province_name = regionMap[patient.return_province_code] || '';
  107. patient.return_city_name = regionMap[patient.return_city_code] || '';
  108. patient.return_district_name = regionMap[patient.return_district_code] || '';
  109. patient.hospital_province_name = regionMap[patient.hospital_province_code] || '';
  110. patient.hospital_city_name = regionMap[patient.hospital_city_code] || '';
  111. patient.hospital_district_name = regionMap[patient.hospital_district_code] || '';
  112. }
  113. // 获取审核记录
  114. const audits = await this.model('patient_audit')
  115. .where({ patient_id: id })
  116. .order('id DESC')
  117. .select();
  118. return this.success({ patient, audits });
  119. }
  120. async sampleReceiverConfigAction() {
  121. const config = await this.model('sys_config').getSampleReceiverInfo();
  122. return this.success(config);
  123. }
  124. async saveSampleReceiverConfigAction() {
  125. if (!this._canEditPatient()) return this.fail('暂无权限');
  126. const { address, receiver, phone, contact_phone } = this.post();
  127. if (!address || !address.trim()) return this.fail('请填写收件地址');
  128. if (!receiver || !receiver.trim()) return this.fail('请填写收件人');
  129. if (!phone || !phone.trim()) return this.fail('请填写电话');
  130. await this.model('sys_config').setSampleReceiverInfo({ address, receiver, phone, contact_phone });
  131. await this.log('edit', '患者管理', '编辑送检信息配置');
  132. const config = await this.model('sys_config').getSampleReceiverInfo();
  133. return this.success(config);
  134. }
  135. // 新增患者
  136. async addAction() {
  137. const data = this.post();
  138. const { name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, hospital_province_code, hospital_city_code, hospital_district_code, 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;
  139. if (!name || !phone || !id_card || !gender || !birth_date) {
  140. return this.fail('请填写完整信息');
  141. }
  142. if (!province_code || !city_code || !district_code) {
  143. return this.fail('请选择省市区');
  144. }
  145. if (!address) {
  146. return this.fail('请填写详细地址');
  147. }
  148. if (!/^1\d{10}$/.test(phone)) {
  149. return this.fail('手机号格式不正确');
  150. }
  151. if (!/^\d{17}[\dXx]$/.test(id_card)) {
  152. return this.fail('身份证号格式不正确');
  153. }
  154. const model = this.model('patient');
  155. // 唯一性校验
  156. const existByIdCardAndPhone = await model.where({ id_card, phone, is_deleted: 0 }).find();
  157. if (!think.isEmpty(existByIdCardAndPhone)) {
  158. return this.fail('该患者已存在(身份证+手机号匹配)');
  159. }
  160. const existByPhone = await model.where({ phone, is_deleted: 0 }).find();
  161. if (!think.isEmpty(existByPhone)) {
  162. return this.fail('该手机号已被其他患者使用');
  163. }
  164. const existByIdCard = await model.where({ id_card, is_deleted: 0 }).find();
  165. if (!think.isEmpty(existByIdCard)) {
  166. return this.fail('该身份证号已被其他患者使用');
  167. }
  168. const patientNo = model.generatePatientNo();
  169. const id = await model.add({
  170. patient_no: patientNo,
  171. name,
  172. phone,
  173. id_card,
  174. gender,
  175. birth_date,
  176. province_code: province_code || '',
  177. city_code: city_code || '',
  178. district_code: district_code || '',
  179. address: address || '',
  180. hospital: hospital || '',
  181. hospital_province_code: hospital_province_code || '',
  182. hospital_city_code: hospital_city_code || '',
  183. hospital_district_code: hospital_district_code || '',
  184. emergency_contact: emergency_contact || '',
  185. emergency_phone: emergency_phone || '',
  186. tag: tag || '',
  187. documents: JSON.stringify(documents || []),
  188. sample_types: JSON.stringify(sample_types || []),
  189. wax_return: wax_return ? 1 : 0,
  190. return_name: return_name || '',
  191. return_phone: return_phone || '',
  192. return_province_code: return_province_code || '',
  193. return_city_code: return_city_code || '',
  194. return_district_code: return_district_code || '',
  195. return_address: return_address || '',
  196. report_email: report_email || '',
  197. sample_tracking_no: sample_tracking_no || '',
  198. sample_photos: JSON.stringify(sample_photos || []),
  199. sign_income: sign_income || '',
  200. sign_privacy: sign_privacy || '',
  201. sign_promise: sign_promise || '',
  202. sign_privacy_jhr: sign_privacy_jhr || '',
  203. income_amount: income_amount || '',
  204. guardian_name: guardian_name || '',
  205. guardian_id_card: guardian_id_card || '',
  206. guardian_relation: guardian_relation || '',
  207. status: 1,
  208. create_by: this.adminUser?.id || 0
  209. });
  210. // 记录审核日志(后台新增直接审核通过)
  211. await this.model('patient_audit').add({
  212. patient_id: id,
  213. action: 'approve',
  214. operator_id: this.adminUser?.id || 0,
  215. operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
  216. });
  217. await this.log('add', '患者管理', `新增患者「${name}」编号:${patientNo}`);
  218. return this.success({ id, patient_no: patientNo });
  219. }
  220. // 审核通过
  221. async approveAction() {
  222. const { id } = this.post();
  223. if (!id) return this.fail('参数错误');
  224. const patient = await this.model('patient')
  225. .where({ id, is_deleted: 0 })
  226. .find();
  227. if (think.isEmpty(patient)) return this.fail('患者不存在');
  228. if (patient.status === 1) return this.fail('该患者已审核通过');
  229. await this.model('patient').where({ id }).update({
  230. status: 1,
  231. update_by: this.adminUser?.id || 0
  232. });
  233. await this.model('patient_audit').add({
  234. patient_id: id,
  235. action: 'approve',
  236. operator_id: this.adminUser?.id || 0,
  237. operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
  238. });
  239. // 发送消息通知
  240. await this.model('message').add({
  241. patient_id: id,
  242. type: 1,
  243. title: '审核通过',
  244. content: AUDIT_APPROVED_CONTENT,
  245. reason: ''
  246. });
  247. // 发送订阅消息
  248. await this._sendAuditSubscribeMessage(id, patient.name, '审核通过');
  249. // 发送短信通知
  250. await this._sendAuditSms(patient.phone, 'approved');
  251. await this.log('edit', '患者管理', `审核通过患者(ID:${id})`);
  252. return this.success();
  253. }
  254. // 驳回
  255. async rejectAction() {
  256. const { id, reason } = this.post();
  257. if (!id) return this.fail('参数错误');
  258. if (!reason) return this.fail('请填写驳回原因');
  259. const patient = await this.model('patient')
  260. .where({ id, is_deleted: 0 })
  261. .find();
  262. if (think.isEmpty(patient)) return this.fail('患者不存在');
  263. if (patient.status === 2) return this.fail('该患者已被驳回');
  264. await this.model('patient').where({ id }).update({
  265. status: 2,
  266. update_by: this.adminUser?.id || 0
  267. });
  268. await this.model('patient_audit').add({
  269. patient_id: id,
  270. action: 'reject',
  271. reason,
  272. operator_id: this.adminUser?.id || 0,
  273. operator_name: this.adminUser?.nickname || this.adminUser?.username || ''
  274. });
  275. // 发送消息通知
  276. await this.model('message').add({
  277. patient_id: id,
  278. type: 2,
  279. title: '审核未通过',
  280. content: '您提交的个人资料未通过审核,请根据以下原因修改后重新提交。',
  281. reason: reason
  282. });
  283. // 发送订阅消息
  284. await this._sendAuditSubscribeMessage(id, patient.name, '审核驳回');
  285. // 发送短信通知
  286. await this._sendAuditSms(patient.phone, 'rejected', reason);
  287. await this.log('edit', '患者管理', `驳回患者(ID:${id}),原因:${reason}`);
  288. return this.success();
  289. }
  290. // 删除患者(软删除,支持批量)
  291. async deleteAction() {
  292. const { ids } = this.post();
  293. if (!ids || !ids.length) return this.fail('参数错误');
  294. const model = this.model('patient');
  295. const patients = await model.where({ id: ['in', ids], is_deleted: 0 }).select();
  296. if (!patients.length) return this.fail('患者不存在');
  297. await model.where({ id: ['in', ids] }).update({
  298. is_deleted: 1,
  299. update_by: this.adminUser?.id || 0
  300. });
  301. // 清除 wechat_user 表中的患者关联,避免用户重新认证时走 update 逻辑
  302. await this.model('wechat_user')
  303. .where({ patient_id: ['in', ids] })
  304. .update({ patient_id: 0 });
  305. const names = patients.map(p => p.name).join('、');
  306. await this.log('delete', '患者管理', `删除患者「${names}」共${patients.length}条`);
  307. return this.success();
  308. }
  309. async resetSampleInfoAction() {
  310. if (!this._canEditPatient()) return this.fail('暂无权限');
  311. const { id } = this.post();
  312. if (!id) return this.fail('参数错误');
  313. const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
  314. if (think.isEmpty(patient)) return this.fail('患者不存在');
  315. await this.model('patient').where({ id }).update({
  316. sample_info_status: 0,
  317. update_by: this.adminUser?.id || 0
  318. });
  319. await this.log('edit', '患者管理', `重置送检信息编辑权限(ID:${id})`);
  320. return this.success();
  321. }
  322. async saveReturnTrackingNoAction() {
  323. if (!this._canEditPatient()) return this.fail('暂无权限');
  324. const { id, return_tracking_no } = this.post();
  325. if (!id) return this.fail('参数错误');
  326. if (!return_tracking_no || !return_tracking_no.trim()) return this.fail('请填写回寄物流单号');
  327. const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
  328. if (think.isEmpty(patient)) return this.fail('患者不存在');
  329. if (!patient.wax_return) return this.fail('该患者未选择样本寄回');
  330. if (![1, 2].includes(Number(patient.sample_info_status))) return this.fail('送检信息未生效,暂不能填写回寄物流单号');
  331. const trackingNo = return_tracking_no.trim();
  332. const now = think.datetime(new Date());
  333. await this.model('patient').where({ id }).update({
  334. sample_info_status: 2,
  335. return_tracking_no: trackingNo,
  336. return_time: now,
  337. update_by: this.adminUser?.id || 0
  338. });
  339. await this.model('message').where({ patient_id: id, type: 3 }).delete();
  340. await this.model('message').add({
  341. patient_id: id,
  342. type: 3,
  343. title: '回寄信息',
  344. content: '样品已经寄回,请注意查收。',
  345. reason: trackingNo,
  346. is_read: 0
  347. });
  348. await this.log('edit', '患者管理', `填写回寄物流单号(ID:${id}):${trackingNo}`);
  349. return this.success();
  350. }
  351. // 编辑患者
  352. async editAction() {
  353. const data = this.post();
  354. const { id, name, phone, id_card, gender, birth_date, province_code, city_code, district_code, address, hospital, hospital_province_code, hospital_city_code, hospital_district_code, 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;
  355. if (!id) return this.fail('参数错误');
  356. if (!name || !phone || !id_card || !gender || !birth_date) return this.fail('请填写完整信息');
  357. if (!province_code || !city_code || !district_code) return this.fail('请选择省市区');
  358. if (!address) return this.fail('请填写详细地址');
  359. if (!/^1\d{10}$/.test(phone)) return this.fail('手机号格式不正确');
  360. if (!/^\d{17}[\dXx]$/.test(id_card)) return this.fail('身份证号格式不正确');
  361. const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
  362. if (think.isEmpty(patient)) return this.fail('患者不存在');
  363. // 唯一性校验(排除自身)
  364. const existByPhone = await this.model('patient').where({ phone, id: ['!=', id], is_deleted: 0 }).find();
  365. if (!think.isEmpty(existByPhone)) return this.fail('该手机号已被其他患者使用');
  366. const existByIdCard = await this.model('patient').where({ id_card, id: ['!=', id], is_deleted: 0 }).find();
  367. if (!think.isEmpty(existByIdCard)) return this.fail('该身份证号已被其他患者使用');
  368. await this.model('patient').where({ id }).update({
  369. name, phone, id_card, gender, birth_date,
  370. province_code, city_code, district_code,
  371. address: address || '',
  372. hospital: hospital || '',
  373. hospital_province_code: hospital_province_code || '',
  374. hospital_city_code: hospital_city_code || '',
  375. hospital_district_code: hospital_district_code || '',
  376. emergency_contact: emergency_contact || '',
  377. emergency_phone: emergency_phone || '',
  378. tag: tag || '',
  379. documents: JSON.stringify(documents || []),
  380. sample_types: JSON.stringify(sample_types || []),
  381. wax_return: wax_return ? 1 : 0,
  382. return_name: return_name || '',
  383. return_phone: return_phone || '',
  384. return_province_code: return_province_code || '',
  385. return_city_code: return_city_code || '',
  386. return_district_code: return_district_code || '',
  387. return_address: return_address || '',
  388. report_email: report_email || '',
  389. sample_tracking_no: sample_tracking_no || '',
  390. sample_photos: JSON.stringify(sample_photos || []),
  391. sign_income: sign_income || '',
  392. sign_privacy: sign_privacy || '',
  393. sign_promise: sign_promise || '',
  394. sign_privacy_jhr: sign_privacy_jhr || '',
  395. income_amount: income_amount || '',
  396. guardian_name: guardian_name || '',
  397. guardian_id_card: guardian_id_card || '',
  398. guardian_relation: guardian_relation || '',
  399. update_by: this.adminUser?.id || 0
  400. });
  401. await this.log('edit', '患者管理', `编辑患者「${name}」(ID:${id})`);
  402. return this.success();
  403. }
  404. // 导出患者数据(CSV,不脱敏)
  405. async exportAction() {
  406. const { keyword, tag, status, startDate, endDate, province_code, city_code, district_code } = this.get();
  407. const model = this.model('patient');
  408. const list = await model.getAll({ keyword, tag, status, startDate, endDate, province_code, city_code, district_code });
  409. // 批量查省市区名称(包含寄回地址)
  410. const allCodes = new Set();
  411. list.forEach(item => {
  412. if (item.province_code) allCodes.add(item.province_code);
  413. if (item.city_code) allCodes.add(item.city_code);
  414. if (item.district_code) allCodes.add(item.district_code);
  415. if (item.return_province_code) allCodes.add(item.return_province_code);
  416. if (item.return_city_code) allCodes.add(item.return_city_code);
  417. if (item.return_district_code) allCodes.add(item.return_district_code);
  418. if (item.hospital_province_code) allCodes.add(item.hospital_province_code);
  419. if (item.hospital_city_code) allCodes.add(item.hospital_city_code);
  420. if (item.hospital_district_code) allCodes.add(item.hospital_district_code);
  421. });
  422. const regionMap = {};
  423. if (allCodes.size) {
  424. const regions = await this.model('sys_region')
  425. .where({ code: ['in', [...allCodes]] })
  426. .select();
  427. regions.forEach(r => { regionMap[r.code] = r.name; });
  428. }
  429. // 批量查最近一条审核记录(审核日期、驳回原因)
  430. const patientIds = list.map(item => item.id);
  431. const auditMap = {};
  432. if (patientIds.length) {
  433. const audits = await this.model('patient_audit')
  434. .where({ patient_id: ['in', patientIds], action: ['in', ['approve', 'reject']] })
  435. .order('id DESC')
  436. .select();
  437. audits.forEach(a => {
  438. if (!auditMap[a.patient_id]) auditMap[a.patient_id] = a;
  439. });
  440. }
  441. const statusMap = { '-1': '待提交', 0: '待审核', 1: '审核通过', 2: '已驳回' };
  442. const header = [
  443. 'ID', '姓名', '性别', '身份证', '手机号', '省份', '城市', '详细地址',
  444. '医院名称', '医院省份', '医院城市', '医院区县', '紧急联系人', '紧急联系电话', '瘤种',
  445. '送检样本类型', '是否需寄回', '收件人', '收件电话', '收件地址',
  446. '报告接收邮箱', '送检物流单号',
  447. '提交时间', '审核状态', '审核日期', '审核驳回原因'
  448. ];
  449. const ExcelJS = require('exceljs');
  450. const workbook = new ExcelJS.Workbook();
  451. const sheet = workbook.addWorksheet('患者信息');
  452. // 表头
  453. sheet.addRow(header);
  454. // 表头样式:绿色背景、白色加粗字体、冻结首行
  455. const headerRow = sheet.getRow(1);
  456. const colCount = header.length;
  457. for (let i = 1; i <= colCount; i++) {
  458. const cell = headerRow.getCell(i);
  459. cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
  460. cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4CAF50' } };
  461. cell.alignment = { vertical: 'middle', horizontal: 'center' };
  462. }
  463. sheet.views = [{ state: 'frozen', ySplit: 1 }];
  464. // 边框样式
  465. const thinBorder = {
  466. top: { style: 'thin' },
  467. left: { style: 'thin' },
  468. bottom: { style: 'thin' },
  469. right: { style: 'thin' }
  470. };
  471. // 表头加边框
  472. for (let i = 1; i <= colCount; i++) {
  473. headerRow.getCell(i).border = thinBorder;
  474. }
  475. // 数据行
  476. list.forEach(item => {
  477. const audit = auditMap[item.id];
  478. let sampleTypes = '';
  479. try { sampleTypes = JSON.parse(item.sample_types || '[]').join('、'); } catch (e) { sampleTypes = ''; }
  480. const returnAddr = item.return_address
  481. ? [regionMap[item.return_province_code] || '', regionMap[item.return_city_code] || '', regionMap[item.return_district_code] || '', item.return_address].filter(Boolean).join('')
  482. : '';
  483. const row = sheet.addRow([
  484. item.patient_no,
  485. item.name,
  486. item.gender,
  487. item.id_card,
  488. item.phone,
  489. regionMap[item.province_code] || '',
  490. regionMap[item.city_code] || '',
  491. item.address || '',
  492. item.hospital || '',
  493. regionMap[item.hospital_province_code] || '',
  494. regionMap[item.hospital_city_code] || '',
  495. regionMap[item.hospital_district_code] || '',
  496. item.emergency_contact || '',
  497. item.emergency_phone || '',
  498. item.tag || '',
  499. sampleTypes,
  500. item.wax_return ? '是' : '否',
  501. item.return_name || '',
  502. item.return_phone || '',
  503. returnAddr,
  504. item.report_email || '',
  505. item.sample_tracking_no || '',
  506. item.create_time || '',
  507. statusMap[item.status] || '',
  508. audit ? (audit.create_time || '') : '',
  509. (audit && audit.action === 'reject') ? (audit.reason || '') : ''
  510. ]);
  511. // 数据行加边框
  512. for (let i = 1; i <= colCount; i++) {
  513. row.getCell(i).border = thinBorder;
  514. }
  515. });
  516. // 自动列宽
  517. sheet.columns.forEach(col => {
  518. let maxLen = 10;
  519. col.eachCell({ includeEmpty: true }, cell => {
  520. const len = String(cell.value || '').length;
  521. if (len > maxLen) maxLen = len;
  522. });
  523. col.width = Math.min(maxLen + 4, 40);
  524. });
  525. const buffer = await workbook.xlsx.writeBuffer();
  526. const ts = dayjs().format('YYYYMMDDHHmmss');
  527. const fileName = encodeURIComponent(`患者信息_${ts}.xlsx`);
  528. this.ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
  529. this.ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
  530. this.ctx.body = Buffer.from(buffer);
  531. await this.log('export', '患者管理', `导出患者数据 ${list.length} 条`);
  532. }
  533. // @private 发送审核结果订阅消息
  534. async _sendAuditSubscribeMessage(patientId, patientName, result) {
  535. try {
  536. const APP_REMARK = 'pap_mini_cytx';
  537. // 查找该患者关联的微信用户
  538. const wechatUser = await this.model('wechat_user')
  539. .where({ patient_id: patientId, app_remark: APP_REMARK, status: 1 })
  540. .find();
  541. if (think.isEmpty(wechatUser) || !wechatUser.open_id) return;
  542. const wechatService = this.service('wechat');
  543. const templates = await wechatService.getSubscribeTemplates(APP_REMARK);
  544. const templateId = templates.audit_result;
  545. if (!templateId) return;
  546. // 映射小程序版本: __wxConfig.envVersion -> miniprogram_state
  547. // develop -> developer, trial -> trial, release -> formal
  548. const envMap = { develop: 'developer', trial: 'trial', release: 'formal' };
  549. const miniprogramState = envMap[wechatUser.mp_env_version] || 'formal';
  550. const dayjs = require('dayjs');
  551. await wechatService.sendSubscribeMessage({
  552. remark: APP_REMARK,
  553. openid: wechatUser.open_id,
  554. templateId,
  555. page: 'pages/profile/profile',
  556. miniprogramState,
  557. data: {
  558. thing2: { value: '肠愈同行患者关爱' },
  559. thing14: { value: patientName || '用户' },
  560. phrase1: { value: result },
  561. time13: { value: dayjs().format('YYYY-MM-DD HH:mm:ss') }
  562. }
  563. });
  564. } catch (error) {
  565. // 订阅消息发送失败不影响审核流程
  566. think.logger.error('[Subscribe] 审核消息发送失败:', error);
  567. }
  568. }
  569. // @private 发送审核结果短信通知
  570. async _sendAuditSms(phone, type, reason) {
  571. if (!phone) return;
  572. try {
  573. const smsConfig = require('../../config/sms.js');
  574. const templates = smsConfig.templates;
  575. think.logger.info(`[SMS] _sendAuditSms 开始 - phone: ${phone}, type: ${type}, reason: ${reason || '(无)'}`);
  576. think.logger.info(`[SMS] 模板配置 - auditApproved: ${templates.auditApproved || '(空)'}, auditRejected: ${templates.auditRejected || '(空)'}`);
  577. if (type === 'approved' && templates.auditApproved) {
  578. await this.sendNotifySms(phone, templates.auditApproved, [], 'audit_approved');
  579. } else if (type === 'rejected' && templates.auditRejected) {
  580. // 驳回原因截取前15字符(短信模板变量有长度限制)
  581. const shortReason = (reason || '资料不符合要求').slice(0, 15);
  582. await this.sendNotifySms(phone, templates.auditRejected, [shortReason], 'audit_rejected');
  583. }
  584. } catch (error) {
  585. think.logger.error('[SMS] 审核短信发送失败:', error);
  586. }
  587. }
  588. };