修改了employee相关
This commit is contained in:
+98
-4
@@ -1,3 +1,4 @@
|
||||
import mongoose from "mongoose";
|
||||
import { DBModel } from "../models/index.js";
|
||||
import ResponseUtil from "../utils/responseUtil.js";
|
||||
|
||||
@@ -5,15 +6,25 @@ import ResponseUtil from "../utils/responseUtil.js";
|
||||
* 陪诊师公示:面向 C 端患者的公开信息展示
|
||||
*
|
||||
* 依据 GB/T 47801—2026 4.2.1.5「陪同人员信息公示机制」组织字段:
|
||||
* 基本信息 —— 培训记录、服务年限、累计服务次数
|
||||
* 基本信息 —— 健康证明有效期、培训记录、服务年限、累计服务次数
|
||||
* 专业技能 —— 擅长服务场景、特殊技能(语言)
|
||||
*
|
||||
* 两条红线:
|
||||
* 1. 白名单式输出,绝不返回手机号 / 身份证号 / 证书编号原文;
|
||||
* 「资质已核验」只对外给布尔值,不给编号
|
||||
* 2. 不输出评分。attendantInfo.rating 是默认 5.0 的裸值,
|
||||
* 后端尚无评价数据来源,公示它等于无依据背书
|
||||
* 「资质已核验」与健康证明都只对外给布尔值 / 有效期,不给编号
|
||||
* 2. 评分只来自 employee_review 的真实评价聚合,且条数达
|
||||
* MIN_PUBLIC_REVIEW_COUNT 才输出。attendantInfo.rating 恒为默认 5.0,
|
||||
* 任何分支都不得读它——那是无依据背书
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评价对外公示的最低条数
|
||||
*
|
||||
* 少于这个条数时平均分不具备参考性:一条 5 分就等于满分,展示反而误导用户。
|
||||
* 故未达阈值时只回条数、分数给 null,由前端显示"暂不显示评分"
|
||||
*/
|
||||
const MIN_PUBLIC_REVIEW_COUNT = 5;
|
||||
|
||||
class HandlerEmployee {
|
||||
// 姓名脱敏:只留姓 + 「陪诊师」,不给全名,降低私下联系(跳单)的可能
|
||||
static maskName(name) {
|
||||
@@ -22,6 +33,40 @@ class HandlerEmployee {
|
||||
return trimmed.slice(0, 1) + "陪诊师";
|
||||
}
|
||||
|
||||
// 日期 → YYYY-MM-DD;无值或非法值返回空串,前端按空串隐藏该行
|
||||
static formatDate(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 健康证明是否在有效期内。
|
||||
// 没登到期日一律判为"不有效"——不能默认有效,那等于无依据背书(同 rating 的处理)。
|
||||
// 到期当天仍算有效,故与当天 24:00 比较
|
||||
static isHealthCertificateValid(expireDate) {
|
||||
if (!expireDate) return false;
|
||||
const endOfDay = new Date(expireDate);
|
||||
if (Number.isNaN(endOfDay.getTime())) return false;
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
return endOfDay.getTime() >= Date.now();
|
||||
}
|
||||
|
||||
// 培训记录:只公示培训名称 / 机构 / 结业日期,内部备注一类不外泄
|
||||
static toPublicTrainings(work) {
|
||||
const list = Array.isArray(work.trainings) ? work.trainings : [];
|
||||
return list
|
||||
.map((item) => ({
|
||||
title: item.title || "",
|
||||
org: item.org || "",
|
||||
date: HandlerEmployee.formatDate(item.date),
|
||||
}))
|
||||
// 没有名称的条目是脏数据,公示出去只是一行空白
|
||||
.filter((item) => item.title);
|
||||
}
|
||||
|
||||
// 白名单式脱敏:逐字段挑选,避免 employee 文档整体外泄
|
||||
static toPublicAttendant(employee) {
|
||||
const profile = employee.profile || {};
|
||||
@@ -35,6 +80,12 @@ class HandlerEmployee {
|
||||
sex: profile.sex || "",
|
||||
workYears: work.workYears || 0,
|
||||
qualificationVerified: !!work.qualification,
|
||||
// 健康证明:4.2.1.5 要求公示的是「有效期」,
|
||||
// 只给是否在有效期内 + 到期日,编号与发证机构不外泄(同 qualification)
|
||||
healthCertificate: {
|
||||
valid: HandlerEmployee.isHealthCertificateValid(work.healthCertificate?.expireDate),
|
||||
expireDate: HandlerEmployee.formatDate(work.healthCertificate?.expireDate),
|
||||
},
|
||||
specialties: info.specialties || [],
|
||||
serviceAreas: info.serviceAreas || [],
|
||||
languages: info.languages || [],
|
||||
@@ -71,6 +122,49 @@ class HandlerEmployee {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 单个陪诊师的公开详情。
|
||||
// 与列表共用同一套白名单——详情页不因为"多进一层"就多给字段,
|
||||
// 手机号 / 身份证号 / 证书编号在这个分支里同样不出现。
|
||||
// 筛选条件也照抄列表:只认在职陪诊员,否则列表里看不到的人却能按 ID 查到
|
||||
async getAttendantById(ctx) {
|
||||
try {
|
||||
const id = ctx.params.id;
|
||||
|
||||
// 非法 ObjectId 会让 mongoose 抛 CastError,先挡掉,也避免把驱动栈信息带出去
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return ResponseUtil.badRequest(ctx, "陪诊师ID不合法");
|
||||
}
|
||||
|
||||
const employee = await DBModel.Employee.findOne({
|
||||
_id: id,
|
||||
role: "attendant",
|
||||
status: "active",
|
||||
}).exec();
|
||||
|
||||
if (!employee) {
|
||||
return ResponseUtil.notFound(ctx, "陪诊师不存在或已下架");
|
||||
}
|
||||
|
||||
const attendant = HandlerEmployee.toPublicAttendant(employee);
|
||||
|
||||
// 详情比列表多两块:
|
||||
// 1) 培训记录——它是数组、体积不确定,列表接口不带
|
||||
attendant.trainings = HandlerEmployee.toPublicTrainings(employee.work || {});
|
||||
|
||||
// 2) 评价聚合——多一次查询,只有详情这一个入口需要
|
||||
const review = await DBModel.EmployeeReview.statsByEmployee(id);
|
||||
attendant.review = {
|
||||
count: review.count,
|
||||
// 未达阈值不给分数:条数太少时平均分不具备参考性
|
||||
score: review.count >= MIN_PUBLIC_REVIEW_COUNT ? review.score : null,
|
||||
};
|
||||
|
||||
return ResponseUtil.success(ctx, { attendant }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { HandlerEmployee };
|
||||
+3
-1
@@ -5,6 +5,7 @@ import { EscortRecordSchema } from "./schema/escort_record.js"
|
||||
import { HealthProfileSchema } from "./schema/health_profile.js"
|
||||
import { OrganizationSchema } from "./schema/organization.js"
|
||||
import { EmployeeSchema } from "./schema/employee.js"
|
||||
import { EmployeeReviewSchema } from "./schema/employee_review.js"
|
||||
import config from '../config.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
@@ -16,6 +17,7 @@ class MongoDBSchema {
|
||||
this.Organization = null;
|
||||
this.Employee = null;
|
||||
this.HealthProfile = null;
|
||||
this.EmployeeReview = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -45,9 +47,9 @@ class MongoDBSchema {
|
||||
this.HealthProfile = this.dbConnection.model('health_profile', HealthProfileSchema)
|
||||
this.Organization = this.dbConnection.model('organization', OrganizationSchema)
|
||||
this.Employee = this.dbConnection.model('employee', EmployeeSchema)
|
||||
this.EmployeeReview = this.dbConnection.model('employee_review', EmployeeReviewSchema)
|
||||
}
|
||||
}
|
||||
|
||||
const DBModel = new MongoDBSchema()
|
||||
export { DBModel };
|
||||
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
import mongoose from "mongoose";
|
||||
import { pinyin } from "pinyin-pro";
|
||||
|
||||
/**
|
||||
* 培训记录子文档
|
||||
*
|
||||
* 国标 GB/T 47801—2026 4.2.1.5 把「培训记录」列为陪同人员信息公示项,
|
||||
* 故按一条一次培训存成数组,便于逐条核对;不用 _id,这些条目只跟着员工走
|
||||
*/
|
||||
const TrainingSchema = mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "", comment: "培训名称/课程" },
|
||||
org: { type: String, default: "", comment: "培训机构" },
|
||||
date: { type: Date, comment: "结业日期" },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
/**
|
||||
* Employee Schema
|
||||
* 员工/人员信息,支持陪诊员、护士、医生、管理员等角色
|
||||
@@ -56,6 +71,19 @@ const EmployeeSchema = mongoose.Schema(
|
||||
entryDate: { type: Date, comment: "入职日期" },
|
||||
workYears: { type: Number, default: 0, comment: "工作年限" },
|
||||
qualification: { type: String, default: "", comment: "资质证书编号" },
|
||||
|
||||
// 健康证明:国标 4.2.1.5 公示的是「健康证明有效期」。
|
||||
// 公示接口只输出"是否在有效期内"+ 到期日;number 属资质原文,
|
||||
// 与 qualification 一样只作内部留存,绝不随公示接口下发
|
||||
healthCertificate: {
|
||||
number: { type: String, default: "", comment: "健康证明编号(不对外公示)" },
|
||||
org: { type: String, default: "", comment: "发证机构" },
|
||||
issueDate: { type: Date, comment: "签发日期" },
|
||||
expireDate: { type: Date, comment: "有效期至" },
|
||||
},
|
||||
|
||||
// 培训记录:同为 4.2.1.5 公示项
|
||||
trainings: { type: [TrainingSchema], default: [], comment: "培训记录列表" },
|
||||
},
|
||||
|
||||
// 陪诊员特有信息
|
||||
@@ -63,7 +91,10 @@ const EmployeeSchema = mongoose.Schema(
|
||||
serviceAreas: { type: [String], default: [], comment: "服务区域/城市列表" },
|
||||
specialties: { type: [String], default: [], comment: "擅长科室/领域" },
|
||||
languages: { type: [String], default: ["普通话"], comment: "掌握语言" },
|
||||
rating: { type: Number, default: 5.0, min: 1, max: 5, comment: "评分(1-5)" },
|
||||
// 注意:本字段全项目没有任何写入点(恒为默认 5.0),不可作为评级来源。
|
||||
// 对外评级一律由 employee_review 真实评价聚合得出(employee_review.js),
|
||||
// 达阈值才展示,详见 .trae/documents/陪诊师公示与指定陪诊师.md
|
||||
rating: { type: Number, default: 5.0, min: 1, max: 5, comment: "评分(无写入点,勿用于公示)" },
|
||||
orderCount: { type: Number, default: 0, comment: "接单数量" },
|
||||
isOnline: { type: Boolean, default: false, comment: "是否在线" },
|
||||
},
|
||||
@@ -197,6 +228,9 @@ EmployeeSchema.statics.updateEmployee = async function (id, update) {
|
||||
|
||||
/**
|
||||
* 查询在线陪诊员列表
|
||||
*
|
||||
* 排序口径与 handler/employee.js 的公示列表保持一致:服务年限优先、累计服务次数次之。
|
||||
* 曾按 attendantInfo.rating 排,但该字段没有任何写入点(恒为 5.0),等于没排序
|
||||
*/
|
||||
EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
@@ -206,7 +240,7 @@ EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) {
|
||||
status: "active",
|
||||
"attendantInfo.isOnline": true,
|
||||
})
|
||||
.sort({ "attendantInfo.rating": -1 })
|
||||
.sort({ "work.workYears": -1, "attendantInfo.orderCount": -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec();
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* 员工评价 Schema
|
||||
*
|
||||
* 用途:为「员工信息公示」提供唯一可信的评级来源。当前只有陪诊师(role: attendant)
|
||||
* 有对外的评价公示场景,故本表目前只被 handler/employee.js 的陪诊师详情页消费;
|
||||
* 但表本身按「员工」建,不绑死陪诊师——后期护士 / 医生等角色接评价时直接复用,
|
||||
* 不必再建一张平行的表(这也是它叫 employee_review 而不是 attendant_review 的原因)。
|
||||
*
|
||||
* 设计前提(见 .trae/documents/陪诊师公示与指定陪诊师.md):
|
||||
* - 一次服务最多产生一条评价,故 recordId 建唯一索引,从数据层断掉刷分
|
||||
* - 评级不由 employee.attendantInfo.rating 提供——那个字段没有任何写入点
|
||||
* - 只有 status 为 published 的评价计入评分,运营隐藏掉的不参与
|
||||
* - 是否对外展示分数由阈值决定(阈值在 handler/employee.js),本表只负责算平均分
|
||||
*/
|
||||
const EmployeeReviewSchema = mongoose.Schema(
|
||||
{
|
||||
/**
|
||||
* 关联的服务记录ID(一次服务一条评价)
|
||||
*
|
||||
* 当前评价只发生在陪诊服务上,故指向 escort_record。
|
||||
* 若未来非陪诊类评价(如问诊)接入,此处需补一个来源标识字段来区分记录所属集合——
|
||||
* ObjectId 全局唯一,唯一索引可以照旧沿用,但读取时要先知道去哪个集合取原文。
|
||||
* 该字段现在不加:不想再造一个没有写入点、等不到用处的空字段。
|
||||
*
|
||||
* @type {ObjectId}
|
||||
* @ref escort_record
|
||||
*/
|
||||
recordId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "escort_record",
|
||||
required: true,
|
||||
comment: "关联服务记录ID",
|
||||
},
|
||||
|
||||
/**
|
||||
* 被评价的员工ID
|
||||
*
|
||||
* 名字用 employeeId 而非 attendantId:面向 employee 集合,不预设角色。
|
||||
* 角色本身不冗余在这里,需要时由 employee 文档的 role 提供,避免两处口径打架。
|
||||
*
|
||||
* @type {ObjectId}
|
||||
* @ref employee
|
||||
*/
|
||||
employeeId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "employee",
|
||||
index: true,
|
||||
required: true,
|
||||
comment: "被评价员工ID",
|
||||
},
|
||||
|
||||
/**
|
||||
* 评价人用户ID
|
||||
* @type {ObjectId}
|
||||
* @ref user
|
||||
*/
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "user",
|
||||
index: true,
|
||||
comment: "评价人用户ID",
|
||||
},
|
||||
|
||||
/**
|
||||
* 评分(1-5 星)
|
||||
*/
|
||||
score: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5,
|
||||
required: true,
|
||||
comment: "评分(1-5)",
|
||||
},
|
||||
|
||||
/**
|
||||
* 评价正文
|
||||
*/
|
||||
content: { type: String, default: "", comment: "评价正文" },
|
||||
|
||||
/**
|
||||
* 状态
|
||||
* - published: 已发布 - 计入评分,可对外展示
|
||||
* - hidden: 已隐藏 - 运营判定为不当内容,不计入评分
|
||||
*/
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["published", "hidden"],
|
||||
default: "published",
|
||||
comment: "状态:已发布、已隐藏",
|
||||
},
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
meta: {
|
||||
createtime: { type: Date, default: Date.now, comment: "创建时间" },
|
||||
updatetime: { type: Date, default: Date.now, comment: "更新时间" },
|
||||
},
|
||||
},
|
||||
{
|
||||
minimize: false,
|
||||
strict: false,
|
||||
collection: "employee_review",
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 统计某位员工的公开评价数与平均分
|
||||
*
|
||||
* 只聚合 published,隐藏评价不参与;平均分保留一位小数,
|
||||
* 公示页只展示一位,多出来的小数位没有意义。
|
||||
* 是否对外展示由调用方按阈值决定,本方法不做门槛判断
|
||||
*
|
||||
* @param {ObjectId|string} employeeId - 员工ID
|
||||
* @returns {Promise<{count: number, score: number|null}>} 评价条数与平均分(无评价时 score 为 null)
|
||||
*/
|
||||
EmployeeReviewSchema.statics.statsByEmployee = async function (employeeId) {
|
||||
// 非法 ID 直接返回空统计,不抛 CastError
|
||||
if (!employeeId || !mongoose.Types.ObjectId.isValid(employeeId)) {
|
||||
return { count: 0, score: null };
|
||||
}
|
||||
|
||||
const rows = await this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
employeeId: new mongoose.Types.ObjectId(employeeId),
|
||||
status: "published",
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
count: { $sum: 1 },
|
||||
score: { $avg: "$score" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (!rows.length) {
|
||||
return { count: 0, score: null };
|
||||
}
|
||||
|
||||
return {
|
||||
count: rows[0].count,
|
||||
score: Math.round(rows[0].score * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 索引定义 ====================
|
||||
|
||||
/**
|
||||
* 服务记录唯一索引 - 一次服务只允许一条评价,从数据层堵住刷分
|
||||
*/
|
||||
EmployeeReviewSchema.index({ recordId: 1 }, { unique: true });
|
||||
|
||||
/**
|
||||
* 员工 + 状态索引 - 用于按员工聚合公开评价
|
||||
*/
|
||||
EmployeeReviewSchema.index({ employeeId: 1, status: 1 });
|
||||
|
||||
export { EmployeeReviewSchema };
|
||||
@@ -13,7 +13,9 @@ const agreement = {
|
||||
"全程陪诊半天不超过4小时,全天不超过8小时。",
|
||||
"预约成功后,平台将提前电话联系确认就诊资料及时间,并以短信或微信通知预约人。",
|
||||
"陪诊师只代您向医生陈述病情并转达医嘱,不负责诊断和治疗。需要时陪诊师会安排医生与您直接通话,最终以医嘱为准。",
|
||||
"服务流程:预约、沟通信息、选择陪诊师、支付费用、全程陪诊、服务完成。可在「陪诊师团队」页面选择陪诊师,未指定时由平台按医院与时间匹配。",
|
||||
// 流程与 services.js 各服务的 flow 一致(同 guide.js)。
|
||||
// 原列的「选择陪诊师」不在任何服务的 flow 里,且该选择功能尚未上线,故一并去掉
|
||||
"服务流程:预约、沟通信息、支付费用、准备资料、全程陪诊、服务完成。",
|
||||
"医生停诊等特殊情况,请及时联系客服处理。"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -395,7 +395,7 @@ const services = [
|
||||
description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。夜间就诊、不便独自前往或家属不便陪同时,建议选择本服务。',
|
||||
// 步骤名与 id 1-5 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」;
|
||||
// 执行人由「客服人员」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉。
|
||||
// 原「分配陪诊师」一步已删:陪诊师可在「陪诊师团队」页面自选,匹配不是用户可感知的必要环节,
|
||||
// 原「分配陪诊师」一步已删:陪诊师由平台按医院与时间匹配,不是用户可感知的必要环节,
|
||||
// 删后步骤数与 id 1/2 一致
|
||||
flow: [{
|
||||
title: '预约',
|
||||
|
||||
@@ -76,6 +76,8 @@ function registerRoutes(app) {
|
||||
// 陪诊师公示(公开):面向 C 端患者展示陪同人员信息。
|
||||
// 与上面的 /service、/agreement 一样属公示类资源,不加 requireAuth
|
||||
router.get("/attendant", handlerEmployee.getAttendants.bind(handlerEmployee));
|
||||
// 单个陪诊师详情。必须注册在 /attendant 之后,避免被它抢匹配
|
||||
router.get("/attendant/:id", handlerEmployee.getAttendantById.bind(handlerEmployee));
|
||||
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
Reference in New Issue
Block a user