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

+30
View File
@@ -1,6 +1,7 @@
import { MidwayConfig } from "@midwayjs/core";
import { existsSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { config as loadEnv } from "dotenv";
// 加载项目根目录的环境变量文件:
@@ -65,6 +66,35 @@ export default {
refreshExpiresIn: "7d"
},
// multipart/form-data 解析配置(现场照片上传)
upload: {
mode: "file",
fileSize: "10mb",
whitelist: [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
tmpdir: join(tmpdir(), "smart-grid-upload"),
cleanTimeout: 5 * 60 * 1000
},
// RustFS 对象存储(S3 兼容协议、path-style 访问)
rustfs: {
endpoint: process.env.RUSTFS_ENDPOINT || "127.0.0.1",
port: Number(process.env.RUSTFS_PORT) || 9000,
useSSL: process.env.RUSTFS_USE_SSL === "true",
accessKey: process.env.RUSTFS_ACCESS_KEY || "",
secretKey: process.env.RUSTFS_SECRET_KEY || "",
region: process.env.RUSTFS_REGION || "us-east-1",
// 当前生效的桶(生产/测试通过 .env 与 .env.local 切换)
bucket: process.env.RUSTFS_BUCKET || process.env.RUSTFS_BUCKET_PROD || "",
bucketProd: process.env.RUSTFS_BUCKET_PROD || "",
bucketTest: process.env.RUSTFS_BUCKET_TEST || "",
// RustFS 仅支持 path-style 访问
forcePathStyle: true,
// 预签名 URL 有效期(秒)
presignExpires: 3600,
// 上传对象 key 前缀
keyPrefix: "customer-visit"
},
cors: {
origin: "*",
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
+2 -1
View File
@@ -2,13 +2,14 @@ import { Configuration, App, ILifeCycle } from "@midwayjs/core";
import * as Koa from "@midwayjs/koa";
import * as Sequelize from "@midwayjs/sequelize";
import * as CacheManager from "@midwayjs/cache-manager";
import * as Upload from "@midwayjs/upload";
import { join } from "path";
import { AuthMiddleware } from "./middleware/auth.middleware";
import { RbacMiddleware } from "./middleware/rbac.middleware";
import { LogMiddleware } from "./middleware/log.middleware";
@Configuration({
imports: [Koa, Sequelize, CacheManager],
imports: [Koa, Sequelize, CacheManager, Upload],
importConfigs: [join(__dirname, "./config")]
})
export class ContainerConfigLifeCycle implements ILifeCycle {
@@ -8,8 +8,26 @@ export class CustomerVisitController {
@Inject() customerVisitService!: CustomerVisitService;
@Get("/") async list(@Query() query: any) { return this.customerVisitService.list(query, this.ctx); }
// KPI 统计卡;必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/stats") async stats() { return this.customerVisitService.stats(this.ctx); }
// 批量安排拜访(多选客户生成待执行计划)
@Post("/batch") async batch(@Body() body: any) { return this.customerVisitService.batchCreate(body, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.customerVisitService.detail(Number(id), this.ctx); }
@Post("/") async create(@Body() body: any) { return this.customerVisitService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerVisitService.update(Number(id), body, this.ctx); }
// 状态流转:执行跟进 / 审核现场取证 / 归档 / 取消
@Put("/:id/status") async changeStatus(@Param("id") id: string, @Body() body: any) {
return this.customerVisitService.changeStatus(Number(id), body, this.ctx);
}
// 改派:更换跟进业务员
@Put("/:id/reassign") async reassign(@Param("id") id: string, @Body() body: any) {
return this.customerVisitService.reassign(Number(id), body, this.ctx);
}
@Del("/:id") async delete(@Param("id") id: string) { return this.customerVisitService.delete(Number(id), this.ctx); }
}
}
@@ -0,0 +1,28 @@
import { Controller, Post, Inject, Files } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { UploadFileInfo } from "@midwayjs/upload";
import { UploadService } from "../service/upload.service";
@Controller("/api/upload")
export class UploadController {
@Inject()
ctx!: Context;
@Inject()
uploadService!: UploadService;
// 上传现场照片,返回对象 key(用于持久化)与预签名 URL(用于即时预览)
@Post("/image")
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
const file = files && files[0];
if (!file) {
return { code: 400, message: "请选择要上传的图片", data: null };
}
try {
const data = await this.uploadService.uploadFile(file);
return { code: 200, message: "上传成功", data };
} catch (err: any) {
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
}
}
}
+68 -10
View File
@@ -1,4 +1,16 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
import {
IsNotEmpty,
IsOptional,
IsString,
IsInt,
IsArray,
IsDateString,
ArrayNotEmpty,
Matches,
Min,
Max,
MaxLength
} from "class-validator";
export class CustomerVisitQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@@ -6,24 +18,70 @@ export class CustomerVisitQueryDTO {
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() salesmanId?: number;
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
@IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
// 计划拜访日期窗口(YYYY-MM-DD)
@IsOptional() @IsString() @MaxLength(10) planStartDate?: string;
@IsOptional() @IsString() @MaxLength(10) planEndDate?: string;
}
export class CustomerVisitCreateDTO {
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
@IsNotEmpty({ message: '拜访人不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson!: string;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson?: string;
@IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' }) visitPhone?: string;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string;
@IsOptional() @IsString() @MaxLength(10) visitTime?: string;
@IsOptional() @IsInt() salesmanId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number;
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
@IsOptional() @IsInt() @Min(1) @Max(4) visitMode?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) subject?: string;
@IsOptional() @IsDateString() planTime?: string;
@IsOptional() @IsDateString() planEndTime?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) spaceDesc?: string;
@IsOptional() @IsInt() @Min(1) @Max(3) intentLevel?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) intentDesc?: string;
@IsOptional() @IsString() @MaxLength(512) @Matches(/^[^<>]*$/) demands?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) companion?: string;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) receptionPerson?: string;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) receptionTitle?: string;
@IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '接待人联系方式格式不正确' }) receptionPhone?: string;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) resultRating?: string;
@IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
@IsOptional() @IsString() @MaxLength(10) nextVisitTime?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) nextAction?: string;
// 现场照片对象 key 列表(对象存储),最多 2 张
@IsOptional() @IsArray() @MaxLength(2) photos?: string[];
}
export class CustomerVisitUpdateDTO {
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson?: string;
@IsOptional() @Matches(/^1[3-9]\d{9}$/) visitPhone?: string;
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string;
@IsOptional() @IsString() @MaxLength(10) visitTime?: string;
export class CustomerVisitUpdateDTO extends CustomerVisitCreateDTO {
@IsOptional() @IsInt() declare customerId: number;
@IsOptional() @IsInt() @Min(0) @Max(5) declare status?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) declare checkinStatus?: number;
@IsOptional() @IsDateString() declare checkinTime?: string;
@IsOptional() declare checkinLng?: number;
@IsOptional() declare checkinLat?: number;
}
// 批量安排拜访:多选客户 + 统一计划信息,批量生成待执行(status=0)记录
export class CustomerVisitBatchCreateDTO {
@IsArray() @ArrayNotEmpty({ message: '请至少选择一个客户' }) @IsInt({ each: true }) customerIds!: number[];
@IsNotEmpty({ message: '计划拜访时间不能为空' }) @IsDateString() planTime!: string;
@IsOptional() @IsDateString() planEndTime?: string;
@IsOptional() @IsInt() salesmanId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number;
}
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
@IsOptional() @IsInt() @Min(1) @Max(4) visitMode?: number;
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) subject?: string;
}
// 状态流转(执行跟进 / 审核现场取证 / 取消 / 归档)
export class CustomerVisitStatusDTO {
@IsNotEmpty({ message: '目标状态不能为空' }) @IsInt() @Min(0) @Max(5) status!: number;
@IsOptional() @IsDateString() checkinTime?: string;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) resultRating?: string;
@IsOptional() @IsInt() surveyOrderId?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) projectNo?: string;
}
+83 -5
View File
@@ -1,4 +1,4 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
@Table({
@@ -6,7 +6,7 @@ import Customer from "./customer.entity";
comment: "客户拜访信息表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
updatedAt: "updated_at",
indexes: [
{ name: "idx_visit_customer", fields: ["customer_id"] },
{ name: "idx_visit_salesman", fields: ["salesman_id"] }
@@ -29,10 +29,10 @@ export default class CustomerVisit extends Model {
@Column({ type: DataType.STRING(20), field: "visit_phone", comment: "拜访人电话" })
visitPhone!: string;
@Column({ type: DataType.TEXT, field: "visit_content", comment: "拜访内容" })
@Column({ type: DataType.TEXT, field: "visit_content", comment: "拜访内容/沟通纪要" })
visitContent!: string;
@Column({ type: DataType.DATEONLY, field: "visit_time", comment: "拜访时间" })
@Column({ type: DataType.DATEONLY, field: "visit_time", comment: "拜访时间(日期)" })
visitTime!: string;
@Column({ type: DataType.BIGINT, field: "salesman_id", comment: "业务员(关联sys_user.id)" })
@@ -41,9 +41,87 @@ export default class CustomerVisit extends Model {
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "will_plan", comment: "是否做方案:0不同意 1同意" })
willPlan!: number;
@Column({ type: DataType.SMALLINT, defaultValue: 1, field: "visit_type", comment: "拜访类型:1首访摸排 2方案沟通 3商务二访 4维保回访 5应急巡检" })
visitType!: number;
@Column({ type: DataType.SMALLINT, defaultValue: 1, field: "visit_mode", comment: "拜访方式:1现场拜访 2商务拜访 3电话沟通 4线上会议" })
visitMode!: number;
@Column({ type: DataType.SMALLINT, defaultValue: 3, field: "priority", comment: "优先级:1特急P1 2加急P2 3常规P3" })
priority!: number;
@Column({ type: DataType.STRING(128), field: "subject", comment: "拜访主题/目的" })
subject!: string;
@Column({ type: DataType.DATE, field: "plan_time", comment: "计划拜访开始时间" })
planTime!: Date;
@Column({ type: DataType.DATE, field: "plan_end_time", comment: "计划拜访结束时间" })
planEndTime!: Date;
@Column({ type: DataType.DATE, field: "checkin_time", comment: "到场签到时间" })
checkinTime!: Date;
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "checkin_status", comment: "签到状态:0未签到 1已签到" })
checkinStatus!: number;
@Column({ type: DataType.DECIMAL(10, 6), field: "checkin_lng", comment: "签到经度" })
checkinLng!: number;
@Column({ type: DataType.DECIMAL(10, 6), field: "checkin_lat", comment: "签到纬度" })
checkinLat!: number;
@Column({ type: DataType.STRING(128), field: "space_desc", comment: "客户空间性质/场所描述" })
spaceDesc!: string;
@Column({ type: DataType.SMALLINT, field: "intent_level", comment: "意向评级:1=A高意向 2=B中意向 3=C低意向" })
intentLevel!: number;
@Column({ type: DataType.STRING(64), field: "intent_desc", comment: "现场意向说明" })
intentDesc!: string;
@Column({ type: DataType.STRING(512), field: "demands", comment: "诉求痛点(逗号分隔)" })
demands!: string;
@Column({ type: DataType.STRING(128), field: "companion", comment: "陪同人员" })
companion!: string;
@Column({ type: DataType.STRING(64), field: "reception_person", comment: "客户接待人" })
receptionPerson!: string;
@Column({ type: DataType.STRING(64), field: "reception_title", comment: "接待人职务" })
receptionTitle!: string;
@Column({ type: DataType.STRING(20), field: "reception_phone", comment: "接待人联系方式" })
receptionPhone!: string;
@Column({ type: DataType.STRING(64), field: "result_rating", comment: "现场成果评级/结论" })
resultRating!: string;
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "status", comment: "状态:0待执行 1已完成待复核 2取证待技术复核 3已立项转勘察 4已归档 5已取消" })
status!: number;
@Column({ type: DataType.DATEONLY, field: "next_visit_time", comment: "预计下次回访日期" })
nextVisitTime!: string;
@Column({ type: DataType.STRING(128), field: "next_action", comment: "下一步行动目标" })
nextAction!: string;
@Column({ type: DataType.STRING(1024), field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
photos!: string;
@Column({ type: DataType.BIGINT, field: "survey_order_id", comment: "关联勘察单ID" })
surveyOrderId!: number;
@Column({ type: DataType.STRING(64), field: "project_no", comment: "关联工程编号" })
projectNo!: string;
@BelongsTo(() => Customer)
customer!: Customer;
@CreatedAt
created_at!: Date;
}
@UpdatedAt
updated_at!: Date;
}
+2 -2
View File
@@ -6,8 +6,8 @@ import SysOperationLog from '../entity/sysOperationLog.entity';
@Provide()
export class LogMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过日志查询、健康检查与登录(登录由内部记录)
ignore = ['/api/logs/*', '/api/health', '/api/auth/login'];
// 中间件匹配范围:全局生效,跳过日志查询、健康检查、登录(登录由内部记录)与文件上传(multipart 无记录价值)
ignore = ['/api/logs/*', '/api/health', '/api/auth/login', '/api/upload/*'];
@InjectRepository(SysOperationLog)
logModel!: typeof SysOperationLog;
+1
View File
@@ -17,6 +17,7 @@ const PERMISSION_MAP: Record<string, string> = {
'/api/customer-departments': 'customer:department',
'/api/customer-accounts': 'customer:account',
'/api/customer-visits': 'customer:visit',
'/api/upload': 'customer:visit',
'/api/customer-buildings': 'customer:building',
'/api/customer-rooms': 'customer:room',
'/api/survey-orders': 'project:survey',
+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;
}
}