docs: 完善项目说明和注释
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok } from "../../shared/response";
|
||||
import { catalogService } from "./catalog.service";
|
||||
import {
|
||||
createRoleBodySchema,
|
||||
createStoreBodySchema,
|
||||
idParamSchema,
|
||||
listStoresQuerySchema,
|
||||
updateRoleBodySchema,
|
||||
updateStoreBodySchema,
|
||||
} from "./catalog.schema";
|
||||
|
||||
// catalogRoutes 管理“字典/基础资料”接口:门店和角色。
|
||||
// controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。
|
||||
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/stores", async (request) => {
|
||||
const query = listStoresQuerySchema.parse(request.query);
|
||||
// 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。
|
||||
const stores = query.includeInactive
|
||||
? await catalogService.listStores(query)
|
||||
: await catalogService.listActiveStoreOptions();
|
||||
|
||||
return ok(stores);
|
||||
});
|
||||
|
||||
app.get("/stores/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const store = await catalogService.getStoreById(params.id);
|
||||
|
||||
return ok(store);
|
||||
});
|
||||
|
||||
app.post("/stores", async (request, reply) => {
|
||||
const body = createStoreBodySchema.parse(request.body);
|
||||
const store = await catalogService.createStore(body);
|
||||
|
||||
return reply.code(201).send(created(store));
|
||||
});
|
||||
|
||||
app.patch("/stores/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const body = updateStoreBodySchema.parse(request.body);
|
||||
const store = await catalogService.updateStore(params.id, body);
|
||||
|
||||
return ok(store);
|
||||
});
|
||||
|
||||
app.delete("/stores/:id", async (request, reply) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await catalogService.deleteStore(params.id);
|
||||
|
||||
// 204 表示删除成功且没有响应体。
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get("/roles", async () => {
|
||||
const roles = await catalogService.listRoles();
|
||||
|
||||
return ok(roles);
|
||||
});
|
||||
|
||||
app.get("/roles/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const role = await catalogService.getRoleById(params.id);
|
||||
|
||||
return ok(role);
|
||||
});
|
||||
|
||||
app.post("/roles", async (request, reply) => {
|
||||
const body = createRoleBodySchema.parse(request.body);
|
||||
const role = await catalogService.createRole(body);
|
||||
|
||||
return reply.code(201).send(created(role));
|
||||
});
|
||||
|
||||
app.patch("/roles/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const body = updateRoleBodySchema.parse(request.body);
|
||||
const role = await catalogService.updateRole(params.id, body);
|
||||
|
||||
return ok(role);
|
||||
});
|
||||
|
||||
app.delete("/roles/:id", async (request, reply) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await catalogService.deleteRole(params.id);
|
||||
|
||||
// 角色删除成功后同样不返回 body。
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type {
|
||||
CreateRoleInput,
|
||||
CreateStoreInput,
|
||||
ListStoresQuery,
|
||||
Role,
|
||||
RoleOption,
|
||||
Store,
|
||||
StoreOption,
|
||||
StoreStatus,
|
||||
UpdateRoleInput,
|
||||
UpdateStoreInput,
|
||||
} from "./catalog.types";
|
||||
|
||||
type SqlParam = string | number | boolean | Date | null;
|
||||
|
||||
interface StoreRow extends RowDataPacket {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
status: StoreStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface RoleRow extends RowDataPacket {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 数据库返回 Date 对象,接口层统一输出 ISO 字符串,方便前端和接口测试处理。
|
||||
function toIso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// repository 层把数据库字段 snake_case 转换成接口使用的 camelCase。
|
||||
function toStore(row: StoreRow): Store {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
status: row.status,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toStoreOption(row: StoreRow): StoreOption {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
};
|
||||
}
|
||||
|
||||
function toRole(row: RoleRow): Role {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toRoleOption(row: RoleRow): RoleOption {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
};
|
||||
}
|
||||
|
||||
export const catalogRepository = {
|
||||
async listStores(query: ListStoresQuery = {}): Promise<Store[]> {
|
||||
// includeInactive=true 用于管理列表;默认只查启用且未软删除的门店。
|
||||
const where = query.includeInactive
|
||||
? "deleted_at IS NULL"
|
||||
: "status = 'ACTIVE' AND deleted_at IS NULL";
|
||||
|
||||
const [rows] = await pool.execute<StoreRow[]>(
|
||||
`
|
||||
SELECT id, name, address, phone, status, created_at, updated_at
|
||||
FROM stores
|
||||
WHERE ${where}
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
|
||||
return rows.map(toStore);
|
||||
},
|
||||
|
||||
async listActiveStoreOptions(): Promise<StoreOption[]> {
|
||||
const [rows] = await pool.execute<StoreRow[]>(
|
||||
`
|
||||
SELECT id, name, address, phone, status, created_at, updated_at
|
||||
FROM stores
|
||||
WHERE status = 'ACTIVE' AND deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
|
||||
return rows.map(toStoreOption);
|
||||
},
|
||||
|
||||
async findStoreById(id: number): Promise<Store | null> {
|
||||
const [rows] = await pool.execute<StoreRow[]>(
|
||||
`
|
||||
SELECT id, name, address, phone, status, created_at, updated_at
|
||||
FROM stores
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
return rows[0] ? toStore(rows[0]) : null;
|
||||
},
|
||||
|
||||
async findActiveStoreByName(
|
||||
name: string,
|
||||
excludeStoreId?: number,
|
||||
): Promise<Store | null> {
|
||||
const params: SqlParam[] = [name];
|
||||
let excludeSql = "";
|
||||
|
||||
if (excludeStoreId !== undefined) {
|
||||
// 修改门店名称时需要排除自己,否则会误判为名称重复。
|
||||
excludeSql = " AND id <> ?";
|
||||
params.push(excludeStoreId);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute<StoreRow[]>(
|
||||
`
|
||||
SELECT id, name, address, phone, status, created_at, updated_at
|
||||
FROM stores
|
||||
WHERE name = ?
|
||||
AND deleted_at IS NULL
|
||||
${excludeSql}
|
||||
LIMIT 1
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return rows[0] ? toStore(rows[0]) : null;
|
||||
},
|
||||
|
||||
async createStore(input: CreateStoreInput): Promise<number> {
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO stores (name, address, phone, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`,
|
||||
[input.name, input.address ?? null, input.phone ?? null, input.status],
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async updateStore(id: number, input: UpdateStoreInput): Promise<void> {
|
||||
// PATCH 只更新调用方提交的字段;没有提交的字段保持数据库原值。
|
||||
const fieldMap: Array<[keyof UpdateStoreInput, string]> = [
|
||||
["name", "name"],
|
||||
["address", "address"],
|
||||
["phone", "phone"],
|
||||
["status", "status"],
|
||||
];
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
for (const [inputKey, columnName] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||
sets.push(`${columnName} = ?`);
|
||||
params.push(input[inputKey] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE stores
|
||||
SET ${sets.join(", ")}
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
|
||||
async softDeleteStore(id: number): Promise<void> {
|
||||
// 门店使用软删除,保留历史数据;同时置为 INACTIVE,避免继续被业务使用。
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE stores
|
||||
SET status = 'INACTIVE', deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async countEmployeesByStore(storeId: number): Promise<number> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM employees
|
||||
WHERE store_id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[storeId],
|
||||
);
|
||||
|
||||
return rows[0]?.total ?? 0;
|
||||
},
|
||||
|
||||
async listRoles(): Promise<Role[]> {
|
||||
const [rows] = await pool.execute<RoleRow[]>(
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
|
||||
return rows.map(toRole);
|
||||
},
|
||||
|
||||
async listRoleOptions(): Promise<RoleOption[]> {
|
||||
const [rows] = await pool.execute<RoleRow[]>(
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
|
||||
return rows.map(toRoleOption);
|
||||
},
|
||||
|
||||
async findRoleById(id: number): Promise<Role | null> {
|
||||
const [rows] = await pool.execute<RoleRow[]>(
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
return rows[0] ? toRole(rows[0]) : null;
|
||||
},
|
||||
|
||||
async findRoleByCode(
|
||||
code: string,
|
||||
excludeRoleId?: number,
|
||||
): Promise<Role | null> {
|
||||
const params: SqlParam[] = [code];
|
||||
let excludeSql = "";
|
||||
|
||||
if (excludeRoleId !== undefined) {
|
||||
// 修改角色编码时排除当前角色,避免自己和自己冲突。
|
||||
excludeSql = " AND id <> ?";
|
||||
params.push(excludeRoleId);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute<RoleRow[]>(
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE code = ?
|
||||
${excludeSql}
|
||||
LIMIT 1
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return rows[0] ? toRole(rows[0]) : null;
|
||||
},
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<number> {
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO roles (code, name, description)
|
||||
VALUES (?, ?, ?)
|
||||
`,
|
||||
[input.code, input.name, input.description ?? null],
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async updateRole(id: number, input: UpdateRoleInput): Promise<void> {
|
||||
// 和门店更新一样,角色 PATCH 也只更新请求里明确出现的字段。
|
||||
const fieldMap: Array<[keyof UpdateRoleInput, string]> = [
|
||||
["code", "code"],
|
||||
["name", "name"],
|
||||
["description", "description"],
|
||||
];
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
for (const [inputKey, columnName] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||
sets.push(`${columnName} = ?`);
|
||||
params.push(input[inputKey] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE roles
|
||||
SET ${sets.join(", ")}
|
||||
WHERE id = ?
|
||||
`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteRole(id: number): Promise<void> {
|
||||
await pool.execute("DELETE FROM roles WHERE id = ?", [id]);
|
||||
},
|
||||
|
||||
async countEmployeesByRole(roleId: number): Promise<number> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM employee_roles
|
||||
WHERE role_id = ?
|
||||
`,
|
||||
[roleId],
|
||||
);
|
||||
|
||||
return rows[0]?.total ?? 0;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import { STORE_STATUS } from "./catalog.types";
|
||||
|
||||
// URL 查询参数里空字符串通常表示“没有筛选条件”,这里统一转成 undefined。
|
||||
const emptyStringToUndefined = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// Fastify 接收到的 query 参数都是字符串,这里把 "true"/"false" 转成布尔值。
|
||||
const stringToBoolean = (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 可选文本字段允许不传,也允许传空字符串;空字符串会被保存成 NULL。
|
||||
const nullableText = (max: number) =>
|
||||
z.preprocess((value) => {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}, z.string().trim().max(max).nullable().optional());
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const listStoresQuerySchema = z.object({
|
||||
includeInactive: z.preprocess(
|
||||
(value) => stringToBoolean(emptyStringToUndefined(value)),
|
||||
z.boolean().optional(),
|
||||
),
|
||||
});
|
||||
|
||||
export const createStoreBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
address: nullableText(255),
|
||||
phone: nullableText(30),
|
||||
status: z.enum(STORE_STATUS).default("ACTIVE"),
|
||||
});
|
||||
|
||||
export const updateStoreBodySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
address: nullableText(255),
|
||||
phone: nullableText(30),
|
||||
status: z.enum(STORE_STATUS).optional(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段",
|
||||
});
|
||||
|
||||
export const createRoleBodySchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(50)
|
||||
// code 作为程序里的稳定标识,限制成简单格式可以减少大小写和特殊字符带来的混乱。
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]*$/,
|
||||
"角色编码只能使用小写字母、数字和下划线,并以字母开头",
|
||||
),
|
||||
name: z.string().trim().min(1).max(50),
|
||||
description: nullableText(255),
|
||||
});
|
||||
|
||||
export const updateRoleBodySchema = createRoleBodySchema
|
||||
.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段",
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { conflict, notFound } from "../../shared/http-error";
|
||||
import { catalogRepository } from "./catalog.repository";
|
||||
import type {
|
||||
CreateRoleInput,
|
||||
CreateStoreInput,
|
||||
ListStoresQuery,
|
||||
Role,
|
||||
RoleOption,
|
||||
Store,
|
||||
StoreOption,
|
||||
UpdateRoleInput,
|
||||
UpdateStoreInput,
|
||||
} from "./catalog.types";
|
||||
|
||||
// service 层承载业务规则。
|
||||
// repository 只管 SQL,controller 只管 HTTP,业务判断集中在这里更容易维护。
|
||||
export const catalogService = {
|
||||
async listStores(query: ListStoresQuery): Promise<Store[]> {
|
||||
return catalogRepository.listStores(query);
|
||||
},
|
||||
|
||||
async listActiveStoreOptions(): Promise<StoreOption[]> {
|
||||
return catalogRepository.listActiveStoreOptions();
|
||||
},
|
||||
|
||||
async getStoreById(id: number): Promise<Store> {
|
||||
const store = await catalogRepository.findStoreById(id);
|
||||
|
||||
if (!store) {
|
||||
throw notFound("门店不存在");
|
||||
}
|
||||
|
||||
return store;
|
||||
},
|
||||
|
||||
async createStore(input: CreateStoreInput): Promise<Store> {
|
||||
// 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。
|
||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||
input.name,
|
||||
);
|
||||
|
||||
if (duplicatedStore) {
|
||||
throw conflict("门店名称已存在");
|
||||
}
|
||||
|
||||
// 创建类 SQL 只返回 insertId,再查询一次完整记录,保证接口返回格式和详情接口一致。
|
||||
const storeId = await catalogRepository.createStore(input);
|
||||
return this.getStoreById(storeId);
|
||||
},
|
||||
|
||||
async updateStore(id: number, input: UpdateStoreInput): Promise<Store> {
|
||||
const currentStore = await this.getStoreById(id);
|
||||
|
||||
if (input.name !== undefined) {
|
||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||
input.name,
|
||||
id,
|
||||
);
|
||||
|
||||
if (duplicatedStore) {
|
||||
throw conflict("门店名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果门店下还有员工,停用门店会让员工数据失去可用归属,所以这里先阻止。
|
||||
if (currentStore.status === "ACTIVE" && input.status === "INACTIVE") {
|
||||
const employeeCount = await catalogRepository.countEmployeesByStore(id);
|
||||
|
||||
if (employeeCount > 0) {
|
||||
throw conflict("门店下还有员工,不能停用");
|
||||
}
|
||||
}
|
||||
|
||||
await catalogRepository.updateStore(id, input);
|
||||
return this.getStoreById(id);
|
||||
},
|
||||
|
||||
async deleteStore(id: number): Promise<void> {
|
||||
await this.getStoreById(id);
|
||||
|
||||
// 有员工绑定时不允许删除门店,避免产生孤立员工。
|
||||
const employeeCount = await catalogRepository.countEmployeesByStore(id);
|
||||
|
||||
if (employeeCount > 0) {
|
||||
throw conflict("门店下还有员工,不能删除");
|
||||
}
|
||||
|
||||
await catalogRepository.softDeleteStore(id);
|
||||
},
|
||||
|
||||
async listRoles(): Promise<Role[]> {
|
||||
return catalogRepository.listRoles();
|
||||
},
|
||||
|
||||
async listRoleOptions(): Promise<RoleOption[]> {
|
||||
return catalogRepository.listRoleOptions();
|
||||
},
|
||||
|
||||
async getRoleById(id: number): Promise<Role> {
|
||||
const role = await catalogRepository.findRoleById(id);
|
||||
|
||||
if (!role) {
|
||||
throw notFound("角色不存在");
|
||||
}
|
||||
|
||||
return role;
|
||||
},
|
||||
|
||||
async createRole(input: CreateRoleInput): Promise<Role> {
|
||||
// code 是权限判断用的稳定编码,必须唯一。
|
||||
const duplicatedRole = await catalogRepository.findRoleByCode(input.code);
|
||||
|
||||
if (duplicatedRole) {
|
||||
throw conflict("角色编码已存在");
|
||||
}
|
||||
|
||||
const roleId = await catalogRepository.createRole(input);
|
||||
return this.getRoleById(roleId);
|
||||
},
|
||||
|
||||
async updateRole(id: number, input: UpdateRoleInput): Promise<Role> {
|
||||
await this.getRoleById(id);
|
||||
|
||||
if (input.code !== undefined) {
|
||||
const duplicatedRole = await catalogRepository.findRoleByCode(
|
||||
input.code,
|
||||
id,
|
||||
);
|
||||
|
||||
if (duplicatedRole) {
|
||||
throw conflict("角色编码已存在");
|
||||
}
|
||||
}
|
||||
|
||||
await catalogRepository.updateRole(id, input);
|
||||
return this.getRoleById(id);
|
||||
},
|
||||
|
||||
async deleteRole(id: number): Promise<void> {
|
||||
await this.getRoleById(id);
|
||||
|
||||
// 角色被员工使用时不允许删除,避免员工详情里出现失效角色。
|
||||
const employeeCount = await catalogRepository.countEmployeesByRole(id);
|
||||
|
||||
if (employeeCount > 0) {
|
||||
throw conflict("角色已绑定员工,不能删除");
|
||||
}
|
||||
|
||||
await catalogRepository.deleteRole(id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
// 从常量数组推导联合类型,避免状态枚举在 schema 和类型定义里写两遍。
|
||||
export type StoreStatus = (typeof STORE_STATUS)[number];
|
||||
|
||||
// Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。
|
||||
export interface StoreOption {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface RoleOption {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// Store/Role 是详情或管理列表使用的完整返回结构。
|
||||
export interface Store extends StoreOption {
|
||||
status: StoreStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Role extends RoleOption {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListStoresQuery {
|
||||
includeInactive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateStoreInput {
|
||||
name: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
status: StoreStatus;
|
||||
}
|
||||
|
||||
export interface UpdateStoreInput {
|
||||
name?: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
status?: StoreStatus;
|
||||
}
|
||||
|
||||
export interface CreateRoleInput {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateRoleInput {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { employeeService } from "./employee.service";
|
||||
import {
|
||||
createEmployeeBodySchema,
|
||||
idParamSchema,
|
||||
listEmployeesQuerySchema,
|
||||
updateEmployeeBodySchema,
|
||||
updateEmployeeStatusBodySchema
|
||||
} from "./employee.schema";
|
||||
|
||||
// 员工接口是本项目的核心 CRUD。
|
||||
// controller 层保持轻量:解析请求参数,调用 service,返回统一响应。
|
||||
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/employees", async (request) => {
|
||||
const query = listEmployeesQuerySchema.parse(request.query);
|
||||
const result = await employeeService.list(query);
|
||||
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.get("/employees/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const employee = await employeeService.getById(params.id);
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.post("/employees", async (request, reply) => {
|
||||
const body = createEmployeeBodySchema.parse(request.body);
|
||||
const employee = await employeeService.create(body);
|
||||
|
||||
return reply.code(201).send(created(employee));
|
||||
});
|
||||
|
||||
app.patch("/employees/:id", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const body = updateEmployeeBodySchema.parse(request.body);
|
||||
const employee = await employeeService.update(params.id, body);
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.patch("/employees/:id/status", async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const body = updateEmployeeStatusBodySchema.parse(request.body);
|
||||
// 单独提供状态接口,方便前端做“启用/停用”开关,而不必提交完整员工表单。
|
||||
const employee = await employeeService.updateStatus(params.id, body.status);
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.delete("/employees/:id", async (request, reply) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await employeeService.delete(params.id);
|
||||
|
||||
// 删除使用软删除实现;接口层仍返回标准的 204 No Content。
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
EmployeeStatus,
|
||||
ListEmployeesQuery,
|
||||
RoleSummary,
|
||||
UpdateEmployeeInput
|
||||
} from "./employee.types";
|
||||
|
||||
type DbExecutor = typeof pool | PoolConnection;
|
||||
type SqlParam = string | number | boolean | Date | null;
|
||||
|
||||
interface EmployeeRow extends RowDataPacket {
|
||||
id: number;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: EmployeeStatus;
|
||||
remark: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface RoleRow extends RowDataPacket {
|
||||
employee_id: number;
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。
|
||||
function toIso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// 把 JOIN 查询出来的数据库行转换成接口返回结构。
|
||||
function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee {
|
||||
return {
|
||||
id: row.id,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
roles,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
// 员工列表支持多个筛选条件,where 和 params 必须一起构造,保持参数化查询。
|
||||
function buildListWhere(query: ListEmployeesQuery): { whereSql: string; params: SqlParam[] } {
|
||||
const where: string[] = ["e.deleted_at IS NULL"];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.storeId !== undefined) {
|
||||
where.push("e.store_id = ?");
|
||||
params.push(query.storeId);
|
||||
}
|
||||
|
||||
if (query.status !== undefined) {
|
||||
where.push("e.status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
if (query.keyword !== undefined) {
|
||||
where.push("(e.name LIKE ? OR e.phone LIKE ?)");
|
||||
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||
}
|
||||
|
||||
return {
|
||||
whereSql: where.join(" AND "),
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
export const employeeRepository = {
|
||||
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// 所有传入 handler 的 SQL 都使用同一个连接,才能处在同一个事务里。
|
||||
await connection.beginTransaction();
|
||||
const result = await handler(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
},
|
||||
|
||||
async storeExists(storeId: number, db: DbExecutor = pool): Promise<boolean> {
|
||||
// 员工只能绑定到启用且未删除的门店。
|
||||
const [rows] = await db.execute<CountRow[]>(
|
||||
"SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL",
|
||||
[storeId]
|
||||
);
|
||||
|
||||
return rows[0]?.total > 0;
|
||||
},
|
||||
|
||||
async existingRoleIds(roleIds: number[], db: DbExecutor = pool): Promise<number[]> {
|
||||
if (roleIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// IN 条件的占位符数量必须和 roleIds 长度一致,仍然使用参数化查询避免 SQL 注入。
|
||||
const placeholders = roleIds.map(() => "?").join(", ");
|
||||
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
||||
`SELECT id FROM roles WHERE id IN (${placeholders})`,
|
||||
roleIds
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
},
|
||||
|
||||
async findActiveByStoreAndPhone(
|
||||
storeId: number,
|
||||
phone: string,
|
||||
excludeEmployeeId?: number,
|
||||
db: DbExecutor = pool
|
||||
): Promise<Employee | null> {
|
||||
const params: SqlParam[] = [storeId, phone];
|
||||
let excludeSql = "";
|
||||
|
||||
if (excludeEmployeeId !== undefined) {
|
||||
// 更新员工时排除当前员工,否则手机号不变也会被误判重复。
|
||||
excludeSql = " AND e.id <> ?";
|
||||
params.push(excludeEmployeeId);
|
||||
}
|
||||
|
||||
const [rows] = await db.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.store_id = ?
|
||||
AND e.phone = ?
|
||||
AND e.deleted_at IS NULL
|
||||
${excludeSql}
|
||||
LIMIT 1
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows[0] ? toEmployee(rows[0]) : null;
|
||||
},
|
||||
|
||||
async list(query: ListEmployeesQuery): Promise<{ items: Employee[]; total: number }> {
|
||||
const { whereSql, params } = buildListWhere(query);
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
|
||||
// 列表总数和分页数据分开查,前端才能正确渲染分页器。
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE ${whereSql}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
// LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。
|
||||
const [rows] = await pool.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY e.id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params
|
||||
);
|
||||
|
||||
const rolesByEmployeeId = await this.findRolesByEmployeeIds(rows.map((row) => row.id));
|
||||
const items = rows.map((row) => toEmployee(row, rolesByEmployeeId.get(row.id) ?? []));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: countRows[0]?.total ?? 0
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: number, db: DbExecutor = pool): Promise<Employee | null> {
|
||||
const [rows] = await db.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.id = ? AND e.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolesByEmployeeId = await this.findRolesByEmployeeIds([id], db);
|
||||
return toEmployee(rows[0], rolesByEmployeeId.get(id) ?? []);
|
||||
},
|
||||
|
||||
async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise<number> {
|
||||
const [result] = await db.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO employees (store_id, name, phone, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
[input.storeId, input.name, input.phone, input.status, input.remark ?? null]
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async update(id: number, input: Omit<UpdateEmployeeInput, "roleIds">, db: DbExecutor = pool): Promise<void> {
|
||||
// PATCH 只更新请求中出现的字段,未出现字段保持原值。
|
||||
const fieldMap: Array<[keyof typeof input, string]> = [
|
||||
["storeId", "store_id"],
|
||||
["name", "name"],
|
||||
["phone", "phone"],
|
||||
["status", "status"],
|
||||
["remark", "remark"]
|
||||
];
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
for (const [inputKey, columnName] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||
sets.push(`${columnName} = ?`);
|
||||
params.push(input[inputKey] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE employees
|
||||
SET ${sets.join(", ")}
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
params
|
||||
);
|
||||
},
|
||||
|
||||
async softDelete(id: number): Promise<void> {
|
||||
// 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE employees
|
||||
SET status = 'INACTIVE', deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
},
|
||||
|
||||
async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise<void> {
|
||||
// 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。
|
||||
await db.execute("DELETE FROM employee_roles WHERE employee_id = ?", [employeeId]);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
INSERT INTO employee_roles (employee_id, role_id)
|
||||
VALUES ${roleIds.map(() => "(?, ?)").join(", ")}
|
||||
`,
|
||||
roleIds.flatMap((roleId) => [employeeId, roleId])
|
||||
);
|
||||
},
|
||||
|
||||
async findRolesByEmployeeIds(
|
||||
employeeIds: number[],
|
||||
db: DbExecutor = pool
|
||||
): Promise<Map<number, RoleSummary[]>> {
|
||||
const result = new Map<number, RoleSummary[]>();
|
||||
|
||||
if (employeeIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const placeholders = employeeIds.map(() => "?").join(", ");
|
||||
// 一次性查出多个员工的角色,避免列表接口为每个员工单独查询产生 N+1 问题。
|
||||
const [rows] = await db.execute<RoleRow[]>(
|
||||
`
|
||||
SELECT er.employee_id, r.id, r.code, r.name
|
||||
FROM employee_roles er
|
||||
INNER JOIN roles r ON r.id = er.role_id
|
||||
WHERE er.employee_id IN (${placeholders})
|
||||
ORDER BY r.id ASC
|
||||
`,
|
||||
employeeIds
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const roles = result.get(row.employee_id) ?? [];
|
||||
roles.push({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name
|
||||
});
|
||||
result.set(row.employee_id, roles);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import { EMPLOYEE_STATUS } from "./employee.types";
|
||||
|
||||
// 查询参数里的空字符串统一当作“未传”,方便前端表单清空筛选条件。
|
||||
const emptyStringToUndefined = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const phoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
// 这里先按中国大陆手机号做校验;如果你的店里有港澳台或海外员工,可以改成更宽松的规则。
|
||||
.regex(/^1[3-9]\d{9}$/, "手机号格式不正确");
|
||||
|
||||
const roleIdsSchema = z
|
||||
.array(z.coerce.number().int().positive())
|
||||
// 角色过多通常说明权限模型需要重新设计,这里先给出合理上限。
|
||||
.max(20, "一个员工不建议绑定过多角色")
|
||||
.default([]);
|
||||
|
||||
export const idParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
export const listEmployeesQuerySchema = z.object({
|
||||
// z.coerce 用于把 query string 转成数字,例如 ?page=1。
|
||||
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
status: z.preprocess(emptyStringToUndefined, z.enum(EMPLOYEE_STATUS).optional()),
|
||||
keyword: z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(100).optional()),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20)
|
||||
});
|
||||
|
||||
export const createEmployeeBodySchema = z.object({
|
||||
storeId: z.coerce.number().int().positive(),
|
||||
name: z.string().trim().min(1).max(50),
|
||||
phone: phoneSchema,
|
||||
status: z.enum(EMPLOYEE_STATUS).default("ACTIVE"),
|
||||
remark: z.string().trim().max(500).nullable().optional(),
|
||||
roleIds: roleIdsSchema
|
||||
});
|
||||
|
||||
export const updateEmployeeBodySchema = z
|
||||
.object({
|
||||
storeId: z.coerce.number().int().positive().optional(),
|
||||
name: z.string().trim().min(1).max(50).optional(),
|
||||
phone: phoneSchema.optional(),
|
||||
status: z.enum(EMPLOYEE_STATUS).optional(),
|
||||
remark: z.string().trim().max(500).nullable().optional(),
|
||||
roleIds: roleIdsSchema.optional()
|
||||
})
|
||||
// PATCH 不允许提交空对象,否则调用方无法判断到底修改了什么。
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段"
|
||||
});
|
||||
|
||||
export const updateEmployeeStatusBodySchema = z.object({
|
||||
status: z.enum(EMPLOYEE_STATUS)
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
||||
import { employeeRepository } from "./employee.repository";
|
||||
import type { CreateEmployeeInput, Employee, ListEmployeesQuery, UpdateEmployeeInput } from "./employee.types";
|
||||
|
||||
// 角色 id 可能从前端重复提交;进入数据库前先去重,减少无意义 SQL 和主键冲突。
|
||||
function uniqueIds(ids: number[]): number[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
// 创建或修改员工时,必须保证门店存在且处于启用状态。
|
||||
async function assertStoreExists(storeId: number): Promise<void> {
|
||||
const exists = await employeeRepository.storeExists(storeId);
|
||||
|
||||
if (!exists) {
|
||||
throw badRequest("门店不存在或已停用");
|
||||
}
|
||||
}
|
||||
|
||||
// 提交的角色必须全部存在;只要有一个不存在,就拒绝整个请求。
|
||||
async function assertRolesExist(roleIds: number[]): Promise<number[]> {
|
||||
const dedupedRoleIds = uniqueIds(roleIds);
|
||||
const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds);
|
||||
|
||||
if (existingRoleIds.length !== dedupedRoleIds.length) {
|
||||
throw badRequest("提交的角色包含不存在的角色");
|
||||
}
|
||||
|
||||
return dedupedRoleIds;
|
||||
}
|
||||
|
||||
// 员工业务规则集中在 service:门店有效性、角色有效性、手机号唯一性和事务边界。
|
||||
export const employeeService = {
|
||||
async list(query: ListEmployeesQuery): Promise<{ items: Employee[]; total: number }> {
|
||||
return employeeRepository.list(query);
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<Employee> {
|
||||
const employee = await employeeRepository.findById(id);
|
||||
|
||||
if (!employee) {
|
||||
throw notFound("员工不存在");
|
||||
}
|
||||
|
||||
return employee;
|
||||
},
|
||||
|
||||
async create(input: CreateEmployeeInput): Promise<Employee> {
|
||||
await assertStoreExists(input.storeId);
|
||||
const roleIds = await assertRolesExist(input.roleIds);
|
||||
|
||||
// 手机号只要求在同一个未删除门店内唯一;不同门店可以存在同一手机号。
|
||||
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(input.storeId, input.phone);
|
||||
|
||||
if (duplicatedEmployee) {
|
||||
throw conflict("同一门店下手机号已存在");
|
||||
}
|
||||
|
||||
// 创建员工和绑定角色必须放在一个事务里,避免员工创建成功但角色绑定失败。
|
||||
const employeeId = await employeeRepository.withTransaction(async (connection) => {
|
||||
const createdEmployeeId = await employeeRepository.create({ ...input, roleIds }, connection);
|
||||
await employeeRepository.replaceRoles(createdEmployeeId, roleIds, connection);
|
||||
return createdEmployeeId;
|
||||
});
|
||||
|
||||
return this.getById(employeeId);
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateEmployeeInput): Promise<Employee> {
|
||||
const currentEmployee = await this.getById(id);
|
||||
|
||||
if (input.storeId !== undefined) {
|
||||
await assertStoreExists(input.storeId);
|
||||
}
|
||||
|
||||
const nextStoreId = input.storeId ?? currentEmployee.storeId;
|
||||
const nextPhone = input.phone ?? currentEmployee.phone;
|
||||
|
||||
// 只有门店或手机号发生变化时才需要重新检查唯一性。
|
||||
if (input.storeId !== undefined || input.phone !== undefined) {
|
||||
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(nextStoreId, nextPhone, id);
|
||||
|
||||
if (duplicatedEmployee) {
|
||||
throw conflict("同一门店下手机号已存在");
|
||||
}
|
||||
}
|
||||
|
||||
// roleIds 没传表示不修改角色;传空数组表示清空角色。
|
||||
const roleIds = input.roleIds === undefined ? undefined : await assertRolesExist(input.roleIds);
|
||||
const { roleIds: _roleIds, ...employeeFields } = input;
|
||||
|
||||
// 员工基础信息和角色关系要么一起成功,要么一起回滚。
|
||||
await employeeRepository.withTransaction(async (connection) => {
|
||||
await employeeRepository.update(id, employeeFields, connection);
|
||||
|
||||
if (roleIds !== undefined) {
|
||||
await employeeRepository.replaceRoles(id, roleIds, connection);
|
||||
}
|
||||
});
|
||||
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async updateStatus(id: number, status: Employee["status"]): Promise<Employee> {
|
||||
await this.getById(id);
|
||||
// 这里只改变状态,不影响角色、手机号和门店关系。
|
||||
await employeeRepository.update(id, { status });
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.getById(id);
|
||||
// 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。
|
||||
await employeeRepository.softDelete(id);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
export const EMPLOYEE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
// 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。
|
||||
export type EmployeeStatus = (typeof EMPLOYEE_STATUS)[number];
|
||||
|
||||
// 员工详情里只需要角色摘要,完整角色管理由 catalog 模块负责。
|
||||
export interface RoleSummary {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Employee 是接口返回给前端的员工结构,字段名使用 camelCase。
|
||||
export interface Employee {
|
||||
id: number;
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: EmployeeStatus;
|
||||
remark: string | null;
|
||||
roles: RoleSummary[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListEmployeesQuery {
|
||||
storeId?: number;
|
||||
status?: EmployeeStatus;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateEmployeeInput {
|
||||
storeId: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: EmployeeStatus;
|
||||
remark?: string | null;
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
export interface UpdateEmployeeInput {
|
||||
storeId?: number;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
status?: EmployeeStatus;
|
||||
remark?: string | null;
|
||||
roleIds?: number[];
|
||||
}
|
||||
Reference in New Issue
Block a user