feat: 完成客户拜访模块全功能开发

本次提交实现了完整的客户拜访业务系统:
1.  新增对象存储上传服务,支持S3兼容协议的RustFS存储
2.  完善客户拜访实体与数据库表结构,补充全业务字段
3.  开发拜访台账与记录的前后端接口,包含增删改查、批量创建、状态流转等能力
4.  新增拜访表单页面与路由,支持父级菜单补全面包屑
5.  新增勘察列表页预填客户ID跳转逻辑
6.  补充UI设计文档与页面截图
7.  调整日志中间件跳过上传路由,优化RBAC权限配置
This commit is contained in:
andyesfly committed 2026-09-14 11:42:10 +08:00
1 parent 68af363ca9
commit 1c83e09fa1
33 files changed
+6920 -587

No files matched your search

+288 -32
View File
@@ -1,12 +1,19 @@
import { Provide } from "@midwayjs/core";
import { Provide, Inject } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Op, UniqueConstraintError, literal } from "sequelize";
import { Context } from "@midwayjs/koa";
import CustomerVisit from "../entity/customer_visit.entity";
import Customer from "../entity/customer.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
import { UploadService } from "./upload.service";
// 客户拜访状态:0待执行 1已完成待复核 2取证待技术复核 3已立项转勘察 4已归档 5已取消
const STATUS = { PENDING: 0, DONE: 1, EVIDENCE: 2, SURVEY: 3, ARCHIVED: 4, CANCELED: 5 };
// 有转化价值的意向评级:1=A高意向 2=B中意向
const INTENT_LEVELS = [1, 2];
@Provide()
export class CustomerVisitService {
@@ -16,12 +23,18 @@ export class CustomerVisitService {
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
@Inject()
uploadService!: UploadService;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
@@ -33,6 +46,13 @@ export class CustomerVisitService {
return !!customer && customer.salesmanId === userId;
}
// 业务负责人姓名(用于填充必填的 visit_person 字段)
private async getUserRealName(userId: number): Promise<string> {
if (!userId) return "未知";
const user = await this.userModel.findByPk(userId, { attributes: ["id", "realName", "username"] });
return user?.realName || (user as any)?.username || "未知";
}
private async generateVisitNo(retry: number = 0): Promise<string> {
const now = new Date();
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
@@ -51,61 +71,239 @@ export class CustomerVisitService {
return result;
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
// 把 query 参数转换为 Sequelize where 条件
private buildWhere(query: any): any {
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ visitNo: { [Op.like]: `%${query.keyword}%` } },
{ subject: { [Op.like]: `%${query.keyword}%` } },
{ visitPerson: { [Op.like]: `%${query.keyword}%` } },
{ visitPhone: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.salesmanId) where.salesmanId = query.salesmanId;
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.salesmanId) where.salesmanId = Number(query.salesmanId);
if (query.visitType) where.visitType = Number(query.visitType);
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
if (query.priority) where.priority = Number(query.priority);
if (query.planStartDate || query.planEndDate) {
const range: any = {};
if (query.planStartDate) range[Op.gte] = new Date(`${query.planStartDate}T00:00:00+08:00`);
if (query.planEndDate) range[Op.lte] = new Date(`${query.planEndDate}T23:59:59+08:00`);
where.planTime = range;
}
return where;
}
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
} else {
where.customerId = { [Op.in]: ids };
}
// 行级隔离:非管理员仅能查看自己名下客户的拜访记录;返回 null 表示无可见数据
private async scopeWhere(baseWhere: any, ctx: Context): Promise<any | null> {
const userId = ctx.state.user.userId;
const where: any = { ...baseWhere };
if (await this.isAdmin(userId)) return where;
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) return null;
if (where.customerId !== undefined) {
if (!ids.includes(Number(where.customerId))) return null;
} else {
where.customerId = { [Op.in]: ids };
}
return where;
}
private parsePhotos(raw: any): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw;
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
// 输出前把 photos 从 JSON 字符串转为 key 数组,并附带预签名访问 URL
private async toVO(record: CustomerVisit, urlMap?: Record<string, string>): Promise<any> {
const json: any = record.toJSON();
const keys = this.parsePhotos(json.photos);
json.photos = keys;
if (keys.length === 0) {
json.photoUrls = [];
return json;
}
const map = urlMap || (await this.uploadService.getPresignedUrlMap(keys));
json.photoUrls = keys.map((k) => map[k] || "");
return json;
}
// 补充客户与业务员信息,便于台账列表直接渲染
private async decorate(rows: any[]): Promise<any[]> {
if (rows.length === 0) return rows;
const allKeys = rows.flatMap((r) => this.parsePhotos(r.rawPhotos));
const urlMap = allKeys.length ? await this.uploadService.getPresignedUrlMap(allKeys) : {};
const salesmanIds = [...new Set(rows.map((r) => r.salesmanId).filter(Boolean))];
const users = salesmanIds.length
? await this.userModel.findAll({ where: { id: { [Op.in]: salesmanIds } }, attributes: ["id", "realName", "phone"] })
: [];
const userMap = new Map(users.map((u: any) => [u.id, u]));
for (const row of rows) {
const keys = this.parsePhotos(row.rawPhotos);
row.photos = keys;
row.photoUrls = keys.map((k) => urlMap[k] || "");
delete row.rawPhotos;
const user: any = userMap.get(row.salesmanId);
row.salesmanName = user?.realName || "";
row.salesmanPhone = user?.phone || "";
}
return rows;
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const where = await this.scopeWhere(this.buildWhere(query), ctx);
if (!where) {
return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page, pageSize } };
}
const { count, rows } = await this.visitModel.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, order: [["created_at", "DESC"]] });
return { code: 200, message: "获取客户拜访列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
const { count, rows } = await this.visitModel.findAndCountAll({
where,
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address"] }],
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["plan_time", "DESC"], ["id", "DESC"]],
distinct: true
});
const list = rows.map((r: any) => {
const json = r.toJSON();
json.rawPhotos = json.photos;
json.photos = [];
return json;
});
return { code: 200, message: "获取客户拜访列表成功", data: { list: await this.decorate(list), total: count, page, pageSize } };
}
// KPI 统计卡(仅按当前用户可见范围统计,不受列表筛选条件影响)
async stats(ctx: Context): Promise<any> {
const where = await this.scopeWhere({}, ctx);
const empty = {
total: 0,
pending: 0,
todayPlan: 0,
urgentPending: 0,
conversionRate: 0,
pendingReview: 0,
completed: 0,
converted: 0,
intentTotal: 0
};
if (!where) return { code: 200, message: "获取拜访统计成功", data: empty };
const countBy = (extra: any) => this.visitModel.count({ where: { ...where, ...extra } });
const [total, pending, todayPlan, urgentPending, pendingReview, completed, converted, intentTotal] = await Promise.all([
countBy({}),
countBy({ status: STATUS.PENDING }),
countBy({
status: STATUS.PENDING,
[Op.and]: [literal("(plan_time AT TIME ZONE 'Asia/Shanghai')::date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date")]
}),
countBy({ status: STATUS.PENDING, priority: 1 }),
countBy({ status: STATUS.EVIDENCE }),
countBy({ status: { [Op.in]: [STATUS.DONE, STATUS.ARCHIVED] } }),
countBy({ status: STATUS.SURVEY }),
countBy({ intentLevel: { [Op.in]: INTENT_LEVELS } })
]);
const conversionRate = intentTotal > 0 ? Number(((converted / intentTotal) * 100).toFixed(1)) : 0;
return {
code: 200,
message: "获取拜访统计成功",
data: { total, pending, todayPlan, urgentPending, conversionRate, pendingReview, completed, converted, intentTotal }
};
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.visitModel.findByPk(id);
const record: any = await this.visitModel.findByPk(id, {
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address", "salesmanId"] }]
});
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该拜访记录", data: null };
return { code: 200, message: "获取客户拜访详情成功", data: record.toJSON() };
const vo = await this.toVO(record);
vo.salesmanName = await this.getUserRealName(record.salesmanId);
return { code: 200, message: "获取客户拜访详情成功", data: vo };
}
async create(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建拜访记录", data: null };
if (!(await this.isAdmin(userId))) data.salesmanId = userId;
if (!isAdmin) data.salesmanId = userId;
const photos = this.parsePhotos(data.photos);
if (photos.length > 2) return { code: 400, message: "现场照片最多上传 2 张", data: null };
const payload = this.normalizePayload(data, photos);
if (!payload.visitPerson) payload.visitPerson = await this.getUserRealName(payload.salesmanId || userId);
try {
data.visitNo = await this.generateVisitNo();
const created = await this.visitModel.create(data);
return { code: 200, message: "创建客户拜访成功", data: created.toJSON() };
payload.visitNo = await this.generateVisitNo();
const created = await this.visitModel.create(payload);
return { code: 200, message: "创建客户拜访成功", data: await this.toVO(created) };
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
data.visitNo = await this.generateVisitNo();
const created = await this.visitModel.create(data);
return { code: 200, message: "创建客户拜访成功", data: created.toJSON() };
payload.visitNo = await this.generateVisitNo();
const created = await this.visitModel.create(payload);
return { code: 200, message: "创建客户拜访成功", data: await this.toVO(created) };
}
throw err;
}
}
// 批量安排拜访:多选客户生成统一的待执行计划
async batchCreate(data: any, ctx: Context): Promise<any> {
const userId = ctx.state.user.userId;
const isAdmin = await this.isAdmin(userId);
const customerIds = [...new Set((data.customerIds || []).map(Number).filter(Boolean))];
if (customerIds.length === 0) return { code: 400, message: "请至少选择一个客户", data: null };
if (!data.planTime) return { code: 400, message: "请选择计划拜访时间", data: null };
const salesmanId = isAdmin && data.salesmanId ? Number(data.salesmanId) : userId;
if (!isAdmin) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId, id: { [Op.in]: customerIds } }, attributes: ["id"] });
if (myCustomers.length !== customerIds.length) return { code: 403, message: "存在无权限安排的客户", data: null };
} else {
const exists = await this.customerModel.count({ where: { id: { [Op.in]: customerIds } } });
if (exists !== customerIds.length) return { code: 400, message: "存在无效的客户", data: null };
}
const visitPerson = await this.getUserRealName(salesmanId);
const ids: number[] = [];
for (const customerId of customerIds) {
const payload: any = {
visitNo: await this.generateVisitNo(),
customerId,
visitPerson,
salesmanId,
status: STATUS.PENDING,
visitType: data.visitType || 1,
visitMode: data.visitMode || 1,
priority: data.priority || 3,
subject: data.subject || "批量安排的拜访计划",
planTime: data.planTime,
planEndTime: data.planEndTime || null
};
const created = await this.visitModel.create(payload);
ids.push(created.id);
}
return { code: 200, message: `成功创建 ${ids.length} 条拜访计划`, data: { total: ids.length, ids } };
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.visitModel.findByPk(id);
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
@@ -116,8 +314,56 @@ export class CustomerVisitService {
delete data.customerId;
}
delete data.visitNo;
await record.update(data);
return { code: 200, message: "更新客户拜访成功", data: record.toJSON() };
const photos = data.photos === undefined ? undefined : this.parsePhotos(data.photos);
if (photos && photos.length > 2) return { code: 400, message: "现场照片最多上传 2 张", data: null };
const payload = this.normalizePayload(data, photos);
await record.update(payload);
const fresh = await this.visitModel.findByPk(id);
return { code: 200, message: "更新客户拜访成功", data: await this.toVO(fresh!) };
}
// 状态流转:执行跟进(1) / 审核现场取证(2、3) / 归档(4) / 取消(5)
async changeStatus(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.visitModel.findByPk(id);
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
const isAdmin = await this.isAdmin(ctx.state.user.userId);
if (!isAdmin && !(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) {
return { code: 403, message: "无权限变更该拜访记录状态", data: null };
}
const target = Number(data.status);
if (!Object.values(STATUS).includes(target)) return { code: 400, message: "状态值不合法", data: null };
const patch: any = { status: target };
// 标记完成/取证时同步到场签到信息
if (target === STATUS.DONE || target === STATUS.EVIDENCE) {
patch.checkinStatus = 1;
patch.checkinTime = data.checkinTime || record.checkinTime || new Date();
}
if (data.resultRating) patch.resultRating = data.resultRating;
if (data.surveyOrderId) patch.surveyOrderId = Number(data.surveyOrderId);
if (data.projectNo) patch.projectNo = data.projectNo;
await record.update(patch);
return { code: 200, message: "更新拜访状态成功", data: await this.toVO(record) };
}
// 改派:更换跟进业务员
async reassign(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.visitModel.findByPk(id);
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) {
return { code: 403, message: "无权限改派该拜访记录", data: null };
}
const salesmanId = Number(data.salesmanId);
if (!salesmanId) return { code: 400, message: "请选择改派业务员", data: null };
const user = await this.userModel.findByPk(salesmanId, { attributes: ["id"] });
if (!user) return { code: 400, message: "改派业务员不存在", data: null };
await record.update({ salesmanId, visitPerson: await this.getUserRealName(salesmanId) });
return { code: 200, message: "改派成功", data: await this.toVO(record) };
}
async delete(id: number, ctx: Context): Promise<any> {
@@ -127,4 +373,14 @@ export class CustomerVisitService {
await record.destroy();
return { code: 200, message: "删除客户拜访成功", data: null };
}
}
// 统一清洗入参:空串转 null、photos 数组转 JSON 字符串
private normalizePayload(data: any, photos?: string[]): any {
const payload: any = { ...data };
if (photos !== undefined) payload.photos = photos.length ? JSON.stringify(photos) : null;
for (const key of Object.keys(payload)) {
if (payload[key] === "") payload[key] = null;
}
return payload;
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Provide, Config } from "@midwayjs/core";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createReadStream, statSync } from "fs";
import { randomUUID } from "crypto";
import { extname } from "path";
import { UploadFileInfo } from "@midwayjs/upload";
// RustFS 配置结构(见 config.default.ts 的 rustfs 段)
interface RustFsConfig {
endpoint: string;
port: number;
useSSL: boolean;
accessKey: string;
secretKey: string;
region: string;
bucket: string;
bucketProd: string;
bucketTest: string;
forcePathStyle: boolean;
presignExpires: number;
keyPrefix: string;
}
export interface UploadResult {
key: string;
url: string;
name: string;
size: number;
}
@Provide()
export class UploadService {
@Config("rustfs")
rustfsConfig!: RustFsConfig;
private client: S3Client | null = null;
private getClient(): S3Client {
if (!this.client) {
const protocol = this.rustfsConfig.useSSL ? "https" : "http";
this.client = new S3Client({
endpoint: `${protocol}://${this.rustfsConfig.endpoint}:${this.rustfsConfig.port}`,
region: this.rustfsConfig.region,
credentials: {
accessKeyId: this.rustfsConfig.accessKey,
secretAccessKey: this.rustfsConfig.secretKey
},
// RustFS 仅支持 path-style 访问
forcePathStyle: this.rustfsConfig.forcePathStyle
});
}
return this.client;
}
private get bucket(): string {
return this.rustfsConfig.bucket;
}
/**
* 上传单个文件到 RustFS,返回对象 key 与预签名访问 URL
*/
async uploadFile(file: UploadFileInfo<string>): Promise<UploadResult> {
if (!file || !file.data) {
throw new Error("上传文件为空");
}
const ext = extname(file.filename || "").toLowerCase();
const now = new Date();
const yyyymm = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
const key = `${this.rustfsConfig.keyPrefix}/${yyyymm}/${randomUUID()}${ext}`;
await this.getClient().send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: createReadStream(file.data),
ContentType: file.mimeType || "application/octet-stream"
})
);
let size = 0;
try {
size = statSync(file.data).size;
} catch {
size = 0;
}
return {
key,
url: await this.getPresignedUrl(key),
name: file.filename,
size
};
}
/**
* 按对象 key 生成预签名访问 URL(默认 1 小时有效)
*/
async getPresignedUrl(key: string): Promise<string> {
if (!key) return "";
return getSignedUrl(
this.getClient(),
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
{ expiresIn: this.rustfsConfig.presignExpires }
);
}
/**
* 批量把对象 key 转成预签名 URL,返回 key → url 映射(key 非法时返回空串)
*/
async getPresignedUrlMap(keys: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {};
for (const key of keys) {
try {
map[key] = await this.getPresignedUrl(key);
} catch {
map[key] = "";
}
}
return map;
}
}