Przeglądaj źródła

修改流程

master
leiyun 2 tygodni temu
rodzic
commit
6cb49bed38
9 zmienionych plików z 734 dodań i 23 usunięć
  1. +1
    -1
      sql/message.sql
  2. +24
    -0
      sql/sample_edit_request.sql
  3. +3
    -1
      sql/subscribe.sql
  4. +4
    -0
      src/config/router.js
  5. +279
    -3
      src/controller/admin/patient.js
  6. +114
    -5
      src/controller/mp.js
  7. +19
    -0
      src/model/patient_sample_log.js
  8. +283
    -11
      view/admin/patient_detail.html
  9. +7
    -2
      view/admin/patient_index.html

+ 1
- 1
sql/message.sql Wyświetl plik

@@ -2,7 +2,7 @@
CREATE TABLE `message` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`patient_id` INT UNSIGNED NOT NULL COMMENT '患者ID',
`type` TINYINT NOT NULL DEFAULT 0 COMMENT '类型:1=审核通过 2=审核拒绝 3=回寄信息',
`type` TINYINT NOT NULL DEFAULT 0 COMMENT '类型:1=审核通过 2=审核拒绝 3=回寄信息 4=送检修改申请审核结果',
`title` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '标题',
`content` TEXT COMMENT '详细内容',
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '驳回原因',


+ 24
- 0
sql/sample_edit_request.sql Wyświetl plik

@@ -0,0 +1,24 @@
-- 送检信息修改申请与送检流转日志
ALTER TABLE `patient`
MODIFY COLUMN `sample_info_status` tinyint(1) DEFAULT 0 COMMENT '送检信息状态: 0可修改 1已生效 2已寄回 3修改申请待审核',
ADD COLUMN `sample_edit_reason` varchar(500) DEFAULT '' COMMENT '送检信息修改申请原因' AFTER `return_time`,
ADD COLUMN `sample_edit_reject_reason` varchar(500) DEFAULT '' COMMENT '送检信息修改申请驳回原因' AFTER `sample_edit_reason`,
ADD COLUMN `sample_edit_apply_time` datetime DEFAULT NULL COMMENT '送检信息修改申请时间' AFTER `sample_edit_reject_reason`,
ADD COLUMN `sample_edit_audit_time` datetime DEFAULT NULL COMMENT '送检信息修改申请审核时间' AFTER `sample_edit_apply_time`;

CREATE TABLE IF NOT EXISTS `patient_sample_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`patient_id` int unsigned NOT NULL COMMENT '患者ID',
`action` varchar(50) NOT NULL DEFAULT '' COMMENT '操作类型',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '日志标题',
`content` text COMMENT '日志内容',
`reason` varchar(500) NOT NULL DEFAULT '' COMMENT '原因',
`operator_type` varchar(20) NOT NULL DEFAULT '' COMMENT '操作人类型: patient/admin/system',
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作人ID',
`operator_name` varchar(100) NOT NULL DEFAULT '' COMMENT '操作人名称',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_patient_id` (`patient_id`),
KEY `idx_action` (`action`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者送检流转日志';

+ 3
- 1
sql/subscribe.sql Wyświetl plik

@@ -12,4 +12,6 @@ ALTER TABLE `wechat_user`
ADD COLUMN `mp_env_version` varchar(20) DEFAULT 'release' COMMENT '小程序版本: develop/trial/release' AFTER `patient_id`;

-- 更新 pap_mini_cytx 应用的模板配置
UPDATE `sys_wechat_app` SET `subscribe_templates` = '{"audit_result":"OcMLIGS8pMnpGzZmPkdMEOmmWmvvvU_nN-kmiK5sUGE"}' WHERE `remark` = 'pap_mini_cytx';
UPDATE `sys_wechat_app`
SET `subscribe_templates` = '{"audit_result":"OcMLIGS8pMnpGzZmPkdMEOmmWmvvvU_nN-kmiK5sUGE","sample_edit_audit":"OcMLIGS8pMnpGzZmPkdMELDgFefqOeQ-1jsit8Ldw2w"}'
WHERE `remark` = 'pap_mini_cytx';

+ 4
- 0
src/config/router.js Wyświetl plik

@@ -54,6 +54,9 @@ module.exports = [
['/admin/patient/saveSampleReceiverConfig', 'admin/patient/saveSampleReceiverConfig', 'post'],
['/admin/patient/resetSampleInfo', 'admin/patient/resetSampleInfo', 'post'],
['/admin/patient/saveReturnTrackingNo', 'admin/patient/saveReturnTrackingNo', 'post'],
['/admin/patient/approveSampleEdit', 'admin/patient/approveSampleEdit', 'post'],
['/admin/patient/rejectSampleEdit', 'admin/patient/rejectSampleEdit', 'post'],
['/admin/patient/editSampleInfo', 'admin/patient/editSampleInfo', 'post'],
['/admin/patient/export', 'admin/patient/export'],
// 公共接口
['/common/regions', 'common/regions'],
@@ -83,6 +86,7 @@ module.exports = [
['/api/mp/subscribeConfig', 'mp/subscribeConfig'],
['/api/mp/sampleInfo', 'mp/sampleInfo'],
['/api/mp/saveSampleInfo', 'mp/saveSampleInfo', 'post'],
['/api/mp/applySampleInfoEdit', 'mp/applySampleInfoEdit', 'post'],
['/api/mp/regenerateSign', 'mp/regenerateSign'],
['/api/mp/regenerateSignByUrl', 'mp/regenerateSignByUrl', 'post'],



+ 279
- 3
src/controller/admin/patient.js Wyświetl plik

@@ -1,13 +1,119 @@
const Base = require('../base.js');
const dayjs = require('dayjs');

const APP_REMARK = 'pap_mini_cytx';
const AUDIT_APPROVED_CONTENT = '您提交的肠愈同行患者关爱项目资料已审核通过,请于7天内在小程序-个人中心-送检信息中提交相关信息。';
const SAMPLE_EDIT_AUDIT_TEMPLATE_ID = 'OcMLIGS8pMnpGzZmPkdMELDgFefqOeQ-1jsit8Ldw2w';

module.exports = class extends Base {
_canEditPatient() {
return this.isSuperAdmin || (this.userPermissions || []).includes('patient:edit');
}

_adminOperator() {
const user = this.adminUser || {};
return {
operator_id: user.id || 0,
operator_name: user.nickname || user.username || ''
};
}

_parseArrayValue(value) {
if (Array.isArray(value)) return value;
if (!value) return [];
try { return JSON.parse(value || '[]') || []; } catch (e) { return []; }
}

_hasSampleInfo(patient = {}) {
const sampleTypes = this._parseArrayValue(patient.sample_types);
const samplePhotos = this._parseArrayValue(patient.sample_photos);
return !!(
sampleTypes.length ||
samplePhotos.length ||
patient.report_email ||
patient.sample_tracking_no ||
patient.return_name ||
patient.return_phone ||
patient.return_address
);
}

_sampleInfoStatusText(patient = {}) {
const status = Number(patient.sample_info_status) || 0;
if (status === 3) return '修改申请待审核';
if (status === 2) return '已寄回';
if (status === 1) return '已生效';
return this._hasSampleInfo(patient) ? '可修改' : '未提交';
}

_sampleInfoStatusType(patient = {}) {
const status = Number(patient.sample_info_status) || 0;
if (status === 3) return 'warning';
if (status === 2) return 'primary';
if (status === 1) return 'success';
return this._hasSampleInfo(patient) ? 'warning' : 'info';
}

_buildFlowLogs(audits = [], sampleLogs = []) {
const auditLogs = audits.map(item => {
const action = item.action || '';
let title = '资料流转';
let content = '';
if (action === 'submit') {
title = '提交患者资料';
content = `${item.operator_name || '系统'} 提交了患者资料,等待审核`;
} else if (action === 'approve') {
title = '患者资料审核通过';
content = `${item.operator_name || '系统'} 审核通过`;
} else if (action === 'reject') {
title = '患者资料审核驳回';
content = `${item.operator_name || '系统'} 驳回了资料`;
}
return {
id: item.id,
source: 'audit',
action,
title,
content,
reason: item.reason || '',
operator_name: item.operator_name || '',
operator_type: 'admin',
create_time: item.create_time
};
});
const logs = sampleLogs.map(item => ({
id: item.id,
source: 'sample',
action: item.action || '',
title: item.title || '送检信息',
content: item.content || '',
reason: item.reason || '',
operator_name: item.operator_name || '',
operator_type: item.operator_type || '',
create_time: item.create_time
}));
return auditLogs.concat(logs).sort((a, b) => {
const ta = new Date(a.create_time || 0).getTime();
const tb = new Date(b.create_time || 0).getTime();
if (tb !== ta) return tb - ta;
return (b.id || 0) - (a.id || 0);
});
}

async _addSampleLog(patientId, action, title, content, reason = '') {
const operator = this._adminOperator();
return this.model('patient_sample_log').addLog({
patient_id: patientId,
action,
title,
content,
reason,
operator_type: 'admin',
operator_id: operator.operator_id,
operator_name: operator.operator_name
});
}

// 患者列表页面
async indexAction() {
this.assign('currentPage', 'patient');
@@ -60,6 +166,8 @@ module.exports = class extends Base {
const cName = regionMap[item.city_code] || '';
const dName = regionMap[item.district_code] || '';
item.region_name = [pName, cName, dName].filter(Boolean).join(' ');
item.sample_info_status_text = this._sampleInfoStatusText(item);
item.sample_info_status_type = this._sampleInfoStatusType(item);
});

const counts = await model.getStatusCounts({ keyword, tag, startDate, endDate, province_code, city_code, district_code });
@@ -125,13 +233,21 @@ module.exports = class extends Base {
patient.hospital_district_name = regionMap[patient.hospital_district_code] || '';
}

// 获取审核记录
patient.sample_info_status_text = this._sampleInfoStatusText(patient);
patient.sample_info_status_type = this._sampleInfoStatusType(patient);

// 获取审核记录和送检流转日志
const audits = await this.model('patient_audit')
.where({ patient_id: id })
.order('id DESC')
.select();
const sampleLogs = await this.model('patient_sample_log')
.where({ patient_id: id })
.order('id DESC')
.select();
const flowLogs = this._buildFlowLogs(audits, sampleLogs);

return this.success({ patient, audits });
return this.success({ patient, audits: flowLogs, flowLogs });
}

async sampleReceiverConfigAction() {
@@ -372,9 +488,14 @@ module.exports = class extends Base {

await this.model('patient').where({ id }).update({
sample_info_status: 0,
sample_edit_reason: '',
sample_edit_reject_reason: '',
sample_edit_apply_time: null,
sample_edit_audit_time: null,
update_by: this.adminUser?.id || 0
});

await this._addSampleLog(id, 'sample_reset_edit', '重置送检信息编辑', '后台重置送检信息为可修改');
await this.log('edit', '患者管理', `重置送检信息编辑权限(ID:${id})`);
return this.success();
}
@@ -409,10 +530,119 @@ module.exports = class extends Base {
is_read: 0
});

await this._addSampleLog(id, 'sample_return', Number(patient.sample_info_status) === 2 ? '修改回寄物流单号' : '填写回寄物流单号', `回寄物流单号:${trackingNo}`);
await this.log('edit', '患者管理', `填写回寄物流单号(ID:${id}):${trackingNo}`);
return this.success();
}

async approveSampleEditAction() {
if (!this._canEditPatient()) return this.fail('暂无权限');
const { id } = this.post();
if (!id) return this.fail('参数错误');

const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.fail('患者不存在');
if (Number(patient.sample_info_status) !== 3) return this.fail('当前没有待审核的送检修改申请');

const reason = patient.sample_edit_reason || '';
await this.model('patient').where({ id }).update({
sample_info_status: 0,
sample_edit_reason: '',
sample_edit_reject_reason: '',
sample_edit_apply_time: null,
sample_edit_audit_time: null,
update_by: this.adminUser?.id || 0
});

await this._addSampleLog(id, 'sample_approve_edit', '送检修改申请通过', '后台通过送检信息修改申请,患者可重新编辑送检信息', reason);
await this._addSampleEditMessage(id, true);
await this._sendSampleEditSubscribeMessage(id, patient.name, '审核通过', '请重新提交送检信息');
await this.log('edit', '患者管理', `通过送检信息修改申请(ID:${id})`);
return this.success();
}

async rejectSampleEditAction() {
if (!this._canEditPatient()) return this.fail('暂无权限');
const { id, reason } = this.post();
if (!id) return this.fail('参数错误');
if (!reason || !reason.trim()) return this.fail('请填写驳回原因');

const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.fail('患者不存在');
if (Number(patient.sample_info_status) !== 3) return this.fail('当前没有待审核的送检修改申请');

const rejectReason = reason.trim();
const now = think.datetime(new Date());
await this.model('patient').where({ id }).update({
sample_info_status: 1,
sample_edit_reject_reason: rejectReason,
sample_edit_audit_time: now,
update_by: this.adminUser?.id || 0
});

await this._addSampleLog(id, 'sample_reject_edit', '送检修改申请驳回', '后台驳回送检信息修改申请', rejectReason);
await this._addSampleEditMessage(id, false, rejectReason);
await this._sendSampleEditSubscribeMessage(id, patient.name, '审核拒绝', '修改申请未通过,请查看详情');
await this.log('edit', '患者管理', `驳回送检信息修改申请(ID:${id}),原因:${rejectReason}`);
return this.success();
}

async editSampleInfoAction() {
if (!this._canEditPatient()) return this.fail('暂无权限');
const data = this.post();
const { id, 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 } = data;
if (!id) return this.fail('参数错误');

const patient = await this.model('patient').where({ id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.fail('患者不存在');

const finalSampleTypes = this._parseArrayValue(sample_types);
const finalSamplePhotos = this._parseArrayValue(sample_photos);
const hasSample = finalSampleTypes.length > 0;
const needReturn = hasSample && Number(wax_return) === 1;
const finalReportEmail = hasSample ? (report_email || '').trim() : '';

if (needReturn) {
if (!return_name || !return_name.trim()) return this.fail('请填写回寄收件人');
if (!return_phone || !return_phone.trim()) return this.fail('请填写回寄收件电话');
if (!return_province_code || !return_city_code || !return_district_code) return this.fail('请选择回寄收件地址');
if (!return_address || !return_address.trim()) return this.fail('请填写回寄详细地址');
}
if (finalReportEmail) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(finalReportEmail)) return this.fail('邮箱格式不正确');
const dup = await this.model('patient')
.where({ report_email: finalReportEmail, is_deleted: 0, id: ['!=', id] })
.find();
if (!think.isEmpty(dup)) return this.fail('该邮箱已被其他患者使用');
}

await this.model('patient').where({ id }).update({
sample_types: JSON.stringify(finalSampleTypes),
wax_return: needReturn ? 1 : 0,
return_name: needReturn ? (return_name || '').trim() : '',
return_phone: needReturn ? (return_phone || '').trim() : '',
return_province_code: needReturn ? (return_province_code || '') : '',
return_city_code: needReturn ? (return_city_code || '') : '',
return_district_code: needReturn ? (return_district_code || '') : '',
return_address: needReturn ? (return_address || '').trim() : '',
report_email: hasSample ? finalReportEmail : '',
sample_tracking_no: hasSample ? (sample_tracking_no || '').trim() : '',
sample_photos: hasSample ? JSON.stringify(finalSamplePhotos) : '[]',
sample_info_status: 1,
return_tracking_no: '',
return_time: null,
sample_edit_reason: '',
sample_edit_reject_reason: '',
sample_edit_apply_time: null,
sample_edit_audit_time: null,
update_by: this.adminUser?.id || 0
});

await this._addSampleLog(id, 'sample_admin_edit', '后台编辑送检信息', '后台直接编辑送检信息,状态已生效');
await this.log('edit', '患者管理', `后台编辑送检信息(ID:${id})`);
return this.success();
}

// 编辑患者
async editAction() {
const data = this.post();
@@ -617,7 +847,6 @@ module.exports = class extends Base {
// @private 发送审核结果订阅消息
async _sendAuditSubscribeMessage(patientId, patientName, result) {
try {
const APP_REMARK = 'pap_mini_cytx';
// 查找该患者关联的微信用户
const wechatUser = await this.model('wechat_user')
.where({ patient_id: patientId, app_remark: APP_REMARK, status: 1 })
@@ -654,6 +883,53 @@ module.exports = class extends Base {
}
}

async _addSampleEditMessage(patientId, approved, reason = '') {
await this.model('message').add({
patient_id: patientId,
type: 4,
title: approved ? '送检信息修改申请已通过' : '送检信息修改申请未通过',
content: approved
? '您的送检信息修改申请已通过,请前往送检信息页面重新提交。'
: '您的送检信息修改申请未通过,请查看原因。',
reason: approved ? '' : reason,
is_read: 0
});
}

// @private 发送送检修改申请审核结果订阅消息
async _sendSampleEditSubscribeMessage(patientId, patientName, result, remark) {
try {
const wechatUser = await this.model('wechat_user')
.where({ patient_id: patientId, app_remark: APP_REMARK, status: 1 })
.find();
if (think.isEmpty(wechatUser) || !wechatUser.open_id) return;

const wechatService = this.service('wechat');
const templates = await wechatService.getSubscribeTemplates(APP_REMARK);
const templateId = templates.sample_edit_audit || SAMPLE_EDIT_AUDIT_TEMPLATE_ID;
if (!templateId) return;

const envMap = { develop: 'developer', trial: 'trial', release: 'formal' };
const miniprogramState = envMap[wechatUser.mp_env_version] || 'formal';
const limitThing = value => String(value || '').slice(0, 20);
await wechatService.sendSubscribeMessage({
remark: APP_REMARK,
openid: wechatUser.open_id,
templateId,
page: 'pages/profile/profile',
miniprogramState,
data: {
thing4: { value: limitThing(remark || '送检信息修改申请已处理') },
phrase1: { value: result },
thing16: { value: limitThing(patientName || '患者') },
time13: { value: dayjs().format('YYYY-MM-DD HH:mm:ss') }
}
});
} catch (error) {
think.logger.error('[Subscribe] 送检修改申请审核消息发送失败:', error);
}
}

// @private 发送审核结果短信通知
async _sendAuditSms(phone, type, reason) {
if (!phone) return;


+ 114
- 5
src/controller/mp.js Wyświetl plik

@@ -7,8 +7,29 @@ const cosConfig = require('../config/cos.js');

const APP_REMARK = 'pap_mini_cytx';
const AUDIT_APPROVED_CONTENT = '您提交的肠愈同行患者关爱项目资料已审核通过,请于7天内在小程序-个人中心-送检信息中提交相关信息。';
const SAMPLE_EDIT_AUDIT_TEMPLATE_ID = 'OcMLIGS8pMnpGzZmPkdMELDgFefqOeQ-1jsit8Ldw2w';

module.exports = class extends Base {
_parseArrayValue(value) {
if (Array.isArray(value)) return value;
if (!value) return [];
try { return JSON.parse(value || '[]') || []; } catch (e) { return []; }
}

_hasSampleInfo(patient = {}) {
const sampleTypes = this._parseArrayValue(patient.sample_types);
const samplePhotos = this._parseArrayValue(patient.sample_photos);
return !!(
sampleTypes.length ||
samplePhotos.length ||
patient.report_email ||
patient.sample_tracking_no ||
patient.return_name ||
patient.return_phone ||
patient.return_address
);
}

// POST /api/mp/login
async loginAction() {
const code = this.post('code');
@@ -672,10 +693,11 @@ module.exports = class extends Base {
try {
const wechatService = this.service('wechat');
const templates = await wechatService.getSubscribeTemplates(APP_REMARK);
if (!templates.sample_edit_audit) templates.sample_edit_audit = SAMPLE_EDIT_AUDIT_TEMPLATE_ID;
return this.json({ code: 0, data: templates });
} catch (error) {
think.logger.error('subscribeConfig error:', error);
return this.json({ code: 0, data: {} });
return this.json({ code: 0, data: { sample_edit_audit: SAMPLE_EDIT_AUDIT_TEMPLATE_ID } });
}
}

@@ -729,6 +751,10 @@ module.exports = class extends Base {
sample_info_status: Number(patient.sample_info_status) || 0,
return_tracking_no: patient.return_tracking_no || '',
return_time: patient.return_time || '',
sample_edit_reason: patient.sample_edit_reason || '',
sample_edit_reject_reason: patient.sample_edit_reject_reason || '',
sample_edit_apply_time: patient.sample_edit_apply_time || '',
sample_edit_audit_time: patient.sample_edit_audit_time || '',
sample_receiver_info: sampleReceiverInfo
}});
} catch (error) {
@@ -768,16 +794,30 @@ module.exports = class extends Base {
}
if (sampleInfoStatus === 1) {
const contactPhone = sampleReceiverInfo.contact_phone || '';
const msg = '送检信息已生效,如需修改请点击页面的【申请修改送检信息】按钮申请,通过后可重新提交送检信息。' +
(contactPhone ? `详情咨询${contactPhone}。` : '');
return this.json({
code: 1010,
data: { sample_info_status: 1, sample_receiver_info: sampleReceiverInfo },
msg: contactPhone
? `送检信息已生效,如需修改请联系平台【${contactPhone}】重置修改权限。`
: '送检信息已生效,如需修改请联系平台重置修改权限。'
msg
});
}
if (sampleInfoStatus === 3) {
return this.json({
code: 1010,
data: {
sample_info_status: 3,
sample_edit_reason: patient.sample_edit_reason || '',
sample_edit_apply_time: patient.sample_edit_apply_time || '',
sample_receiver_info: sampleReceiverInfo
},
msg: '送检信息修改申请正在审核中,请等待平台处理。'
});
}

const hasSample = sample_types && sample_types.length > 0;
const hadSampleInfo = this._hasSampleInfo(patient);
const now = think.datetime(new Date());

// 邮箱格式与重复校验
if (hasSample && report_email) {
@@ -802,7 +842,22 @@ module.exports = class extends Base {
report_email: hasSample ? (report_email || '') : '',
sample_tracking_no: hasSample ? (sample_tracking_no || '') : '',
sample_photos: hasSample ? JSON.stringify(sample_photos || []) : '[]',
sample_info_status: 1
sample_info_status: 1,
sample_edit_reason: '',
sample_edit_reject_reason: '',
sample_edit_apply_time: null,
sample_edit_audit_time: null,
update_time: now
});

await this.model('patient_sample_log').addLog({
patient_id: user.patient_id,
action: hadSampleInfo ? 'sample_update' : 'sample_submit',
title: hadSampleInfo ? '修改送检信息' : '提交送检信息',
content: hadSampleInfo ? '患者修改并提交送检信息' : '患者提交送检信息',
operator_type: 'patient',
operator_id: user.id || 0,
operator_name: patient.name || '患者'
});

return this.json({ code: 0, data: {}, msg: '提交成功' });
@@ -812,6 +867,60 @@ module.exports = class extends Base {
}
}

/**
* 申请修改送检信息
* POST /api/mp/applySampleInfoEdit
*/
async applySampleInfoEditAction() {
const mpUser = this.mpUser;
if (!mpUser) return this.json({ code: 1009, msg: '请先登录' });
const { reason, mp_env_version } = this.post();
if (!reason || !reason.trim()) return this.json({ code: 1, msg: '请填写申请原因' });

try {
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: '请先完成实名认证' });
const patient = await this.model('patient').where({ id: user.patient_id, is_deleted: 0 }).find();
if (think.isEmpty(patient)) return this.json({ code: 1, msg: '患者信息不存在' });
if (patient.status !== 1) return this.json({ code: 1, msg: '请先通过审核' });

const sampleInfoStatus = Number(patient.sample_info_status) || 0;
if (sampleInfoStatus === 3) return this.json({ code: 1, msg: '修改申请正在审核中,请勿重复提交' });
if (sampleInfoStatus === 2) return this.json({ code: 1, msg: '样品已经寄回,暂不支持申请修改送检信息' });
if (sampleInfoStatus !== 1) return this.json({ code: 1, msg: '当前送检信息无需申请修改' });

const now = think.datetime(new Date());
const applyReason = reason.trim();
if (mp_env_version) {
await this.model('wechat_user').where({ id: user.id }).update({ mp_env_version });
}
await this.model('patient').where({ id: user.patient_id }).update({
sample_info_status: 3,
sample_edit_reason: applyReason,
sample_edit_reject_reason: '',
sample_edit_apply_time: now,
sample_edit_audit_time: null,
update_time: now
});

await this.model('patient_sample_log').addLog({
patient_id: user.patient_id,
action: 'sample_apply_edit',
title: '申请修改送检信息',
content: '患者申请修改送检信息',
reason: applyReason,
operator_type: 'patient',
operator_id: user.id || 0,
operator_name: patient.name || '患者'
});

return this.json({ code: 0, data: { sample_info_status: 3 }, msg: '申请已提交,请等待平台审核' });
} catch (error) {
think.logger.error('applySampleInfoEdit error:', error);
return this.json({ code: 1, msg: '提交失败: ' + error.message });
}
}

/**
* 批量重新生成声明与承诺签署图并更新数据库
* GET /api/mp/regenerateSign


+ 19
- 0
src/model/patient_sample_log.js Wyświetl plik

@@ -0,0 +1,19 @@
module.exports = class extends think.Model {
get tableName() {
return 'patient_sample_log';
}

async addLog(data = {}) {
return this.add({
patient_id: data.patient_id || 0,
action: data.action || '',
title: data.title || '',
content: data.content || '',
reason: data.reason || '',
operator_type: data.operator_type || '',
operator_id: data.operator_id || 0,
operator_name: data.operator_name || '',
create_time: data.create_time || think.datetime(new Date())
});
}
};

+ 283
- 11
view/admin/patient_detail.html Wyświetl plik

@@ -20,8 +20,12 @@
.timeline-list li::before { content: ''; position: absolute; left: -6px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--el-color-primary); }
.timeline-list li .time { font-size: 12px; color: #909399; margin-bottom: 4px; }
.timeline-list li .desc { font-size: 14px; color: #303133; }
.timeline-list li .subdesc { font-size: 13px; color: #606266; margin-top: 4px; }
.timeline-list li .reason { font-size: 13px; color: #F56C6C; margin-top: 4px; }
.fixed-bottom-bar { position: fixed; bottom: 0; left: 220px; right: 0; height: 64px; background: #fff; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: center; gap: 16px; z-index: 10; padding: 0 24px; }
.sample-photo-edit { position: relative; width: 80px; height: 80px; }
.sample-photo-edit .el-image { width: 80px; height: 80px; border-radius: 6px; border: 1px solid #eee; }
.sample-photo-edit .del { position: absolute; top: -6px; right: -6px; cursor: pointer; background: rgba(0,0,0,0.6); color: #fff; border-radius: 50%; width: 20px; height: 20px; display:flex; align-items:center; justify-content:center; font-size:12px; z-index:10; line-height:1; }
</style>
{% endblock %}

@@ -172,6 +176,10 @@
<div class="info-item" v-if="patient.sample_tracking_no"><span class="label">物流单号:</span><span class="value">${ patient.sample_tracking_no }</span></div>
<div class="info-item" v-if="patient.sample_info_status == 2 && patient.return_tracking_no"><span class="label">回寄单号:</span><span class="value">${ patient.return_tracking_no }</span></div>
<div class="info-item" v-if="patient.sample_info_status == 2 && patient.return_time"><span class="label">回寄时间:</span><span class="value">${ patient.return_time }</span></div>
<div class="info-item" v-if="patient.sample_edit_reason"><span class="label">申请原因:</span><span class="value">${ patient.sample_edit_reason }</span></div>
<div class="info-item" v-if="patient.sample_edit_apply_time"><span class="label">申请时间:</span><span class="value">${ patient.sample_edit_apply_time }</span></div>
<div class="info-item" v-if="patient.sample_edit_reject_reason"><span class="label">驳回原因:</span><span class="value">${ patient.sample_edit_reject_reason }</span></div>
<div class="info-item" v-if="patient.sample_edit_audit_time"><span class="label">处理时间:</span><span class="value">${ patient.sample_edit_audit_time }</span></div>
</div>
<div v-if="patient.sample_photos && patient.sample_photos.length" style="margin-top:16px;">
<div style="font-size:13px;color:#909399;margin-bottom:8px;">送检单照片</div>
@@ -184,19 +192,18 @@
</div>
</div>

<!-- 审核记录 -->
<!-- 流转日志 -->
<div class="detail-panel">
<h3>审核记录</h3>
<h3>流转日志</h3>
<ul class="timeline-list" v-if="audits.length">
<li v-for="(item, idx) in audits" :key="idx">
<div class="time">${ item.create_time }</div>
<div class="desc" v-if="item.action === 'submit'">${ item.operator_name || '系统' } 提交了患者资料,等待审核</div>
<div class="desc" v-else-if="item.action === 'approve'">${ item.operator_name } 审核通过</div>
<div class="desc" v-else-if="item.action === 'reject'">${ item.operator_name } 驳回了资料</div>
<div class="reason" v-if="item.reason">驳回原因:${ item.reason }</div>
<div class="desc">${ item.title || item.content || '流转记录' }</div>
<div class="subdesc" v-if="item.content && item.title">${ item.content }</div>
<div class="reason" v-if="item.reason">${ item.action === 'reject' || item.action === 'sample_reject_edit' ? '驳回原因' : '原因' }:${ item.reason }</div>
</li>
</ul>
<el-empty v-else description="暂无审核记录" :image-size="60" />
<el-empty v-else description="暂无流转日志" :image-size="60" />
</div>
</div>

@@ -205,6 +212,9 @@
<el-button @click="goBack">返 回</el-button>
<el-button v-if="(patient.status === 0 || patient.status === 2) && canAudit" type="success" @click="handleApprove">审核通过</el-button>
<el-button v-if="(patient.status === 0 || patient.status === 1) && canAudit" type="danger" @click="showRejectDialog">驳 回</el-button>
<el-button v-if="canEdit" type="primary" @click="showSampleEditDialog">编辑送检信息</el-button>
<el-button v-if="patient.sample_info_status == 3 && canEdit" type="success" @click="approveSampleEdit">通过送检修改申请</el-button>
<el-button v-if="patient.sample_info_status == 3 && canEdit" type="danger" @click="showRejectSampleEditDialog">驳回送检修改申请</el-button>
<el-button v-if="patient.sample_info_status == 1 && canEdit" type="warning" @click="resetSampleInfo">重置送检信息编辑</el-button>
<el-button v-if="patient.wax_return && (patient.sample_info_status == 1 || patient.sample_info_status == 2) && canEdit" type="primary" @click="showReturnTrackingDialog">
${ patient.sample_info_status == 2 ? '修改回寄物流单号' : '填写回寄物流单号' }
@@ -239,6 +249,75 @@
</div>
</el-dialog>

<!-- 驳回送检修改申请弹窗 -->
<el-dialog v-model="sampleRejectVisible" title="驳回送检修改申请" width="520px" destroy-on-close :close-on-click-modal="false" :z-index="2000">
<el-form label-width="90px">
<el-form-item label="驳回原因" required>
<el-input v-model="sampleRejectReason" type="textarea" :rows="4" placeholder="请输入驳回原因" maxlength="500" show-word-limit></el-input>
</el-form-item>
</el-form>
<div style="text-align:right;margin-top:20px;">
<el-button @click="sampleRejectVisible = false">取消</el-button>
<el-button type="danger" @click="rejectSampleEdit" :loading="sampleRejectSaving">确认驳回</el-button>
</div>
</el-dialog>

<!-- 编辑送检信息弹窗 -->
<el-dialog v-model="sampleEditVisible" title="编辑送检信息" width="760px" destroy-on-close draggable :close-on-click-modal="false" :z-index="2000">
<el-form :model="sampleEditForm" label-width="120px">
<el-form-item label="送检样本类型">
<el-checkbox-group v-model="sampleEditForm.sample_types" @change="onSampleEditTypesChange">
<el-checkbox v-for="st in sampleTypeList" :key="st.id" :label="st.name">${ st.name }</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="sampleEditShowWaxReturn" :label="sampleEditNeedReturnNames + '是否需寄回'">
<el-radio-group v-model="sampleEditForm.wax_return">
<el-radio :label="1">是</el-radio>
<el-radio :label="0">否</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="sampleEditShowWaxReturn && sampleEditForm.wax_return === 1">
<el-form-item label="回寄收件人" required>
<el-input v-model="sampleEditForm.return_name" placeholder="请输入回寄收件人"></el-input>
</el-form-item>
<el-form-item label="回寄电话" required>
<el-input v-model="sampleEditForm.return_phone" placeholder="请输入回寄电话" maxlength="20"></el-input>
</el-form-item>
<el-form-item label="回寄地区" required>
<el-cascader v-model="sampleEditForm.returnRegionCodes" :options="regionTree"
:props="{ value: 'code', label: 'name', children: 'children' }"
placeholder="请选择省/市/区" clearable style="width:100%;" />
</el-form-item>
<el-form-item label="详细地址" required>
<el-input v-model="sampleEditForm.return_address" placeholder="请输入详细地址"></el-input>
</el-form-item>
</template>
<template v-if="sampleEditForm.sample_types && sampleEditForm.sample_types.length">
<el-form-item label="报告接收邮箱">
<el-input v-model="sampleEditForm.report_email" placeholder="请输入邮箱地址"></el-input>
</el-form-item>
<el-form-item label="送检物流单号">
<el-input v-model="sampleEditForm.sample_tracking_no" placeholder="请输入送检物流单号"></el-input>
</el-form-item>
<el-form-item label="送检单照片">
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<div class="sample-photo-edit" v-for="(photo, idx) in sampleEditForm.sample_photos" :key="idx">
<el-image :src="photo" fit="cover" :preview-src-list="sampleEditForm.sample_photos" :initial-index="idx"></el-image>
<div class="del" @click="sampleEditForm.sample_photos.splice(idx, 1)">×</div>
</div>
<el-upload action="/admin/upload" :show-file-list="false" accept="image/*" :headers="uploadHeaders" :on-success="onSampleEditPhotoUpload">
<div style="width:80px;height:80px;border:1px dashed #DCDFE6;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#909399;font-size:24px;cursor:pointer;">+</div>
</el-upload>
</div>
</el-form-item>
</template>
</el-form>
<div style="text-align:right;margin-top:20px;">
<el-button @click="sampleEditVisible = false">取消</el-button>
<el-button type="primary" @click="saveSampleEdit" :loading="sampleEditSaving">保存并生效</el-button>
</div>
</el-dialog>

</div>{% endblock %}

{% block js %}
@@ -269,6 +348,8 @@ var app = createApp({
return_address: '',
report_email: '', sample_tracking_no: '', sample_photos: [],
sample_info_status: 0, return_tracking_no: '', return_time: '',
sample_edit_reason: '', sample_edit_reject_reason: '',
sample_edit_apply_time: '', sample_edit_audit_time: '',
sign_income: '', sign_privacy: '', sign_promise: '',
emergency_contact: '', emergency_phone: '',
status: -1, create_time: ''
@@ -280,7 +361,27 @@ var app = createApp({
var returnTrackingVisible = ref(false);
var returnTrackingSaving = ref(false);
var returnTrackingNo = ref('');
var selectedReasons = ref([]); var commonReasons = [
var uploadHeaders = {};
var sampleRejectVisible = ref(false);
var sampleRejectSaving = ref(false);
var sampleRejectReason = ref('');
var sampleEditVisible = ref(false);
var sampleEditSaving = ref(false);
var sampleTypeList = ref([]);
var regionTree = ref([]);
var sampleEditForm = reactive({
sample_types: [],
wax_return: 0,
return_name: '',
return_phone: '',
returnRegionCodes: [],
return_address: '',
report_email: '',
sample_tracking_no: '',
sample_photos: []
});
var selectedReasons = ref([]);
var commonReasons = [
'身份证照片模糊,请重新上传',
'病历资料不完整,请补充',
'检查报告缺失,请上传',
@@ -305,6 +406,20 @@ var app = createApp({
}
}

async function loadSampleTypes() {
try {
var res = await fetch('/common/sampleTypes').then(function(r) { return r.json(); });
if (res.code === 0) sampleTypeList.value = (res.data && res.data.list) || [];
} catch(e) {}
}

async function loadRegions() {
try {
var res = await fetch('/common/regions').then(function(r) { return r.json(); });
if (res.code === 0) regionTree.value = res.data || [];
} catch(e) {}
}

function goBack() {
window.location.href = '/admin/patient.html';
}
@@ -349,13 +464,31 @@ var app = createApp({
return [patient.id_card_front, patient.id_card_back, patient.photo].filter(Boolean);
});

var sampleEditShowWaxReturn = Vue.computed(function() {
if (!sampleEditForm.sample_types || !sampleEditForm.sample_types.length) return false;
return sampleEditForm.sample_types.some(function(name) {
var st = sampleTypeList.value.find(function(item) { return item.name === name; });
return st && st.need_return;
});
});

var sampleEditNeedReturnNames = Vue.computed(function() {
if (!sampleEditForm.sample_types || !sampleEditForm.sample_types.length) return '';
return sampleEditForm.sample_types.filter(function(name) {
var st = sampleTypeList.value.find(function(item) { return item.name === name; });
return st && st.need_return;
}).join('、');
});

function sampleInfoStatusText(status) {
if (Number(status) === 3) return '修改申请待审核';
if (Number(status) === 2) return '已寄回';
if (Number(status) === 1) return '已生效';
return '可修改';
}

function sampleInfoStatusType(status) {
if (Number(status) === 3) return 'warning';
if (Number(status) === 2) return 'primary';
if (Number(status) === 1) return 'success';
return 'warning';
@@ -466,16 +599,155 @@ var app = createApp({
}
}

onMounted(function() { loadDetail(); });
async function approveSampleEdit() {
try {
await ElementPlus.ElMessageBox.confirm(
'确认通过该患者的送检信息修改申请吗?通过后患者可在小程序重新编辑并提交送检信息。',
'通过送检修改申请',
{ confirmButtonText: '确认通过', cancelButtonText: '取消', type: 'success' }
);
var res = await fetch('/admin/patient/approveSampleEdit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: patient.id })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('已通过申请');
loadDetail();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} catch(e) {}
}

function showRejectSampleEditDialog() {
sampleRejectReason.value = '';
sampleRejectVisible.value = true;
}

async function rejectSampleEdit() {
if (!sampleRejectReason.value.trim()) {
ElementPlus.ElMessage.warning('请填写驳回原因');
return;
}
sampleRejectSaving.value = true;
try {
var res = await fetch('/admin/patient/rejectSampleEdit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: patient.id, reason: sampleRejectReason.value.trim() })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('已驳回申请');
sampleRejectVisible.value = false;
loadDetail();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} finally {
sampleRejectSaving.value = false;
}
}

function onSampleEditTypesChange() {
if (!sampleEditShowWaxReturn.value) {
sampleEditForm.wax_return = 0;
sampleEditForm.return_name = '';
sampleEditForm.return_phone = '';
sampleEditForm.returnRegionCodes = [];
sampleEditForm.return_address = '';
}
if (!sampleEditForm.sample_types || !sampleEditForm.sample_types.length) {
sampleEditForm.report_email = '';
sampleEditForm.sample_tracking_no = '';
sampleEditForm.sample_photos = [];
}
}

function showSampleEditDialog() {
Object.assign(sampleEditForm, {
sample_types: (patient.sample_types || []).slice(),
wax_return: patient.wax_return ? 1 : 0,
return_name: patient.return_name || '',
return_phone: patient.return_phone || '',
returnRegionCodes: [patient.return_province_code, patient.return_city_code, patient.return_district_code].filter(Boolean),
return_address: patient.return_address || '',
report_email: patient.report_email || '',
sample_tracking_no: patient.sample_tracking_no || '',
sample_photos: (patient.sample_photos || []).slice()
});
sampleEditVisible.value = true;
}

function onSampleEditPhotoUpload(res) {
if (res.code === 0 && res.data && res.data.url) {
sampleEditForm.sample_photos.push(res.data.url);
} else {
ElementPlus.ElMessage.error(res.msg || '上传失败');
}
}

async function saveSampleEdit() {
var regionCodes = sampleEditForm.returnRegionCodes || [];
if (sampleEditShowWaxReturn.value && sampleEditForm.wax_return === 1) {
if (!sampleEditForm.return_name.trim()) return ElementPlus.ElMessage.warning('请填写回寄收件人');
if (!sampleEditForm.return_phone.trim()) return ElementPlus.ElMessage.warning('请填写回寄电话');
if (regionCodes.length !== 3) return ElementPlus.ElMessage.warning('请选择回寄地区');
if (!sampleEditForm.return_address.trim()) return ElementPlus.ElMessage.warning('请填写回寄详细地址');
}
if (sampleEditForm.report_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(sampleEditForm.report_email)) {
return ElementPlus.ElMessage.warning('邮箱格式不正确');
}
sampleEditSaving.value = true;
try {
var res = await fetch('/admin/patient/editSampleInfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: patient.id,
sample_types: sampleEditForm.sample_types || [],
wax_return: sampleEditShowWaxReturn.value ? sampleEditForm.wax_return : 0,
return_name: sampleEditForm.return_name.trim(),
return_phone: sampleEditForm.return_phone.trim(),
return_province_code: regionCodes[0] || '',
return_city_code: regionCodes[1] || '',
return_district_code: regionCodes[2] || '',
return_address: sampleEditForm.return_address.trim(),
report_email: sampleEditForm.report_email.trim(),
sample_tracking_no: sampleEditForm.sample_tracking_no.trim(),
sample_photos: sampleEditForm.sample_photos || []
})
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('送检信息已保存并生效');
sampleEditVisible.value = false;
loadDetail();
} else {
ElementPlus.ElMessage.error(res.msg || '保存失败');
}
} finally {
sampleEditSaving.value = false;
}
}

onMounted(function() {
loadDetail();
loadSampleTypes();
loadRegions();
});

return {
loading, patient, audits, canAudit, canEdit,
returnTrackingVisible, returnTrackingSaving, returnTrackingNo,
sampleRejectVisible, sampleRejectSaving, sampleRejectReason,
sampleEditVisible, sampleEditSaving, sampleEditForm, sampleTypeList, regionTree, uploadHeaders,
rejectVisible, rejectSaving, rejectReason, selectedReasons, commonReasons,
hospitalRegionText, goBack, downloadSign, isImageUrl, signDocs, signImageList,
authImageList, sampleInfoStatusText, sampleInfoStatusType,
authImageList, sampleInfoStatusText, sampleInfoStatusType, sampleEditShowWaxReturn, sampleEditNeedReturnNames,
handleApprove, showRejectDialog, toggleReason, doReject, resetSampleInfo,
showReturnTrackingDialog, saveReturnTrackingNo
showReturnTrackingDialog, saveReturnTrackingNo,
approveSampleEdit, showRejectSampleEditDialog, rejectSampleEdit,
showSampleEditDialog, onSampleEditTypesChange, onSampleEditPhotoUpload, saveSampleEdit
};
}
});


+ 7
- 2
view/admin/patient_index.html Wyświetl plik

@@ -90,11 +90,16 @@
<el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
</template>
</el-table-column>
<el-table-column label="送检状态" min-width="130" align="center">
<template #default="{ row }">
<el-tag :type="row.sample_info_status_type || 'info'" size="small">${ row.sample_info_status_text || '未提交' }</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
<el-button v-if="perms.canEdit" type="info" link @click="showEditDialog(row)">编辑</el-button>
<el-button v-if="row.status === 0 && perms.canAudit" type="warning" link @click="viewDetail(row)">去审核</el-button>
<el-button v-if="(row.status === 0 || row.sample_info_status == 3) && perms.canAudit" type="warning" link @click="viewDetail(row)">去审核</el-button>
<el-button v-if="perms.canDelete" type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -348,7 +353,7 @@ const app = createApp({
const loading = ref(false);
const tableData = ref([]);
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const counts = reactive({ all: 0, pending: 0, approved: 0, rejected: 0 });
const counts = reactive({ all: 0, draft: 0, pending: 0, approved: 0, rejected: 0 });
const uploadHeaders = {};

// 新增弹窗


Ładowanie…
Anuluj
Zapisz