feat: 增加员工端工作台后端能力

This commit is contained in:
湛兮
2026-06-02 12:23:00 +08:00
parent 667dc411fc
commit 98cea63203
33 changed files with 3021 additions and 18 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from "fastify";
import { created, ok, paginated } from "../../shared/response";
import { authGuard, permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { PERMISSIONS } from "../permissions/permission.policy";
import { createShiftBodySchema, listShiftsQuerySchema, shiftIdParamSchema, updateShiftBodySchema } from "./shift.schema";
import { shiftService } from "./shift.service";
export async function shiftAdminRoutes(app: FastifyInstance): Promise<void> {
app.get("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_VIEW) }, async (request) => {
const query = listShiftsQuerySchema.parse(request.query);
const result = await shiftService.list(query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.post("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
const user = await authService.getCurrentUser(request.user);
const body = createShiftBodySchema.parse(request.body);
const shift = await shiftService.create(body, user);
return reply.code(201).send(created(shift));
});
app.patch("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request) => {
const params = shiftIdParamSchema.parse(request.params);
const body = updateShiftBodySchema.parse(request.body);
return ok(await shiftService.update(params.id, body));
});
app.delete("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
const params = shiftIdParamSchema.parse(request.params);
await shiftService.cancel(params.id);
return reply.code(204).send();
});
}
export async function shiftMobileRoutes(app: FastifyInstance): Promise<void> {
app.get("/mobile/shifts/today", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
return ok(await shiftService.todayForEmployee(employee));
});
app.get("/mobile/shifts", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const query = listShiftsQuerySchema.parse(request.query);
const result = await shiftService.listForEmployee(employee, query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
}
+254
View File
@@ -0,0 +1,254 @@
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type { CreateShiftInput, ListShiftsQuery, Shift, UpdateShiftInput } from "./shift.types";
type SqlParam = string | number | Date | null;
interface ShiftRow extends RowDataPacket {
id: number;
store_id: number;
store_name: string;
employee_id: number;
employee_name: string;
role_name: string | null;
start_at: Date;
end_at: Date;
status: Shift["status"];
created_at: Date;
updated_at: Date;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date): string {
return value.toISOString();
}
function toShift(row: ShiftRow): Shift {
return {
id: row.id,
storeId: row.store_id,
storeName: row.store_name,
employeeId: row.employee_id,
employeeName: row.employee_name,
roleName: row.role_name,
startAt: toIso(row.start_at),
endAt: toIso(row.end_at),
status: row.status,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function actorValues(user: AuthUser): [number | null, number | null] {
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
}
function buildWhere(query: ListShiftsQuery): { whereSql: string; params: SqlParam[] } {
const where = ["sh.deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.storeId !== undefined) {
where.push("sh.store_id = ?");
params.push(query.storeId);
}
if (query.employeeId !== undefined) {
where.push("sh.employee_id = ?");
params.push(query.employeeId);
}
if (query.status) {
where.push("sh.status = ?");
params.push(query.status);
}
if (query.startDate) {
where.push("sh.start_at >= ?");
params.push(`${query.startDate} 00:00:00.000`);
}
if (query.endDate) {
where.push("sh.start_at < DATE_ADD(?, INTERVAL 1 DAY)");
params.push(`${query.endDate} 00:00:00.000`);
}
return { whereSql: where.join(" AND "), params };
}
export const shiftRepository = {
async employeeBelongsToStore(employeeId: number, storeId: number): Promise<boolean> {
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM employees
WHERE id = ?
AND store_id = ?
AND status = 'ACTIVE'
AND deleted_at IS NULL
`,
[employeeId, storeId],
);
return (rows[0]?.total ?? 0) > 0;
},
async hasOverlappingShift(
employeeId: number,
startAt: string,
endAt: string,
excludeShiftId?: number,
): Promise<boolean> {
const params: SqlParam[] = [employeeId, new Date(endAt), new Date(startAt)];
const excludeSql = excludeShiftId === undefined ? "" : "AND id <> ?";
if (excludeShiftId !== undefined) {
params.push(excludeShiftId);
}
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts
WHERE employee_id = ?
AND deleted_at IS NULL
AND status <> 'CANCELLED'
AND start_at < ?
AND end_at > ?
${excludeSql}
`,
params,
);
return (rows[0]?.total ?? 0) > 0;
},
async list(query: ListShiftsQuery): Promise<{ items: Shift[]; total: number }> {
const { whereSql, params } = buildWhere(query);
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
ORDER BY sh.start_at ASC, sh.id ASC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toShift),
total: countRows[0]?.total ?? 0,
};
},
async findById(id: number): Promise<Shift | null> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.id = ? AND sh.deleted_at IS NULL
LIMIT 1
`,
[id],
);
return rows[0] ? toShift(rows[0]) : null;
},
async create(input: CreateShiftInput, user: AuthUser): Promise<number> {
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
const [result] = await pool.execute<ResultSetHeader>(
`
INSERT INTO shifts
(store_id, employee_id, role_name, start_at, end_at, status, creator_admin_id, creator_employee_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
input.storeId,
input.employeeId,
input.roleName ?? null,
new Date(input.startAt),
new Date(input.endAt),
input.status,
creatorAdminId,
creatorEmployeeId,
],
);
return result.insertId;
},
async update(id: number, input: UpdateShiftInput): Promise<void> {
const fieldMap: Array<[keyof UpdateShiftInput, string]> = [
["storeId", "store_id"],
["employeeId", "employee_id"],
["roleName", "role_name"],
["startAt", "start_at"],
["endAt", "end_at"],
["status", "status"],
];
const sets: string[] = [];
const params: SqlParam[] = [];
for (const [key, column] of fieldMap) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
sets.push(`${column} = ?`);
params.push((key === "startAt" || key === "endAt") && input[key] ? new Date(input[key]) : (input[key] as SqlParam));
}
}
if (sets.length === 0) {
return;
}
params.push(id);
await pool.execute(
`UPDATE shifts SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
params,
);
},
async cancel(id: number): Promise<void> {
await pool.execute(
`
UPDATE shifts
SET status = 'CANCELLED', deleted_at = CURRENT_TIMESTAMP(3)
WHERE id = ? AND deleted_at IS NULL
`,
[id],
);
},
async listTodayForEmployee(employeeId: number): Promise<Shift[]> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.employee_id = ?
AND sh.deleted_at IS NULL
AND sh.status = 'SCHEDULED'
AND DATE(sh.start_at) = CURRENT_DATE()
ORDER BY sh.start_at ASC
`,
[employeeId],
);
return rows.map(toShift);
},
};
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
import { SHIFT_STATUSES } from "./shift.types";
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const dateTimeSchema = z.string().trim().datetime({ offset: true });
const dateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 YYYY-MM-DD");
export const shiftIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const listShiftsQuerySchema = z.object({
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
employeeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
status: z.preprocess(emptyStringToUndefined, z.enum(SHIFT_STATUSES).optional()),
startDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
endDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
const shiftBodyBaseSchema = z.object({
storeId: z.coerce.number().int().positive(),
employeeId: z.coerce.number().int().positive(),
roleName: z.string().trim().max(50).nullable().optional(),
startAt: dateTimeSchema,
endAt: dateTimeSchema,
status: z.enum(SHIFT_STATUSES).default("SCHEDULED"),
});
export const createShiftBodySchema = shiftBodyBaseSchema.refine(
(value) => new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
{
message: "结束时间必须晚于开始时间",
},
);
export const updateShiftBodySchema = shiftBodyBaseSchema
.partial()
.refine((value) => Object.keys(value).length > 0, {
message: "至少需要提交一个要修改的字段",
})
.refine(
(value) =>
!value.startAt ||
!value.endAt ||
new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
{ message: "结束时间必须晚于开始时间" },
);
+95
View File
@@ -0,0 +1,95 @@
import { badRequest, conflict, notFound } from "../../shared/http-error";
import type { AuthUser } from "../auth/auth.types";
import { shiftRepository } from "./shift.repository";
import type { CreateShiftInput, ListShiftsQuery, UpdateShiftInput } from "./shift.types";
async function assertEmployeeStore(employeeId: number, storeId: number): Promise<void> {
const matched = await shiftRepository.employeeBelongsToStore(employeeId, storeId);
if (!matched) {
throw badRequest("排班员工不存在、已停用或不属于该门店");
}
}
function assertTimeRange(startAt: string, endAt: string): void {
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
throw badRequest("结束时间必须晚于开始时间");
}
}
async function assertNoOverlap(
employeeId: number,
startAt: string,
endAt: string,
excludeShiftId?: number,
): Promise<void> {
const overlapped = await shiftRepository.hasOverlappingShift(employeeId, startAt, endAt, excludeShiftId);
if (overlapped) {
throw conflict("该员工在该时间段已有排班");
}
}
export const shiftService = {
list(query: ListShiftsQuery) {
return shiftRepository.list(query);
},
async getById(id: number) {
const shift = await shiftRepository.findById(id);
if (!shift) {
throw notFound("排班不存在");
}
return shift;
},
async create(input: CreateShiftInput, user: AuthUser) {
await assertEmployeeStore(input.employeeId, input.storeId);
assertTimeRange(input.startAt, input.endAt);
if (input.status !== "CANCELLED") {
await assertNoOverlap(input.employeeId, input.startAt, input.endAt);
}
const id = await shiftRepository.create(input, user);
return this.getById(id);
},
async update(id: number, input: UpdateShiftInput) {
const current = await this.getById(id);
const employeeId = input.employeeId ?? current.employeeId;
const storeId = input.storeId ?? current.storeId;
await assertEmployeeStore(employeeId, storeId);
const startAt = input.startAt ?? current.startAt;
const endAt = input.endAt ?? current.endAt;
const status = input.status ?? current.status;
assertTimeRange(startAt, endAt);
if (status !== "CANCELLED") {
await assertNoOverlap(employeeId, startAt, endAt, id);
}
await shiftRepository.update(id, input);
return this.getById(id);
},
async cancel(id: number) {
await this.getById(id);
await shiftRepository.cancel(id);
},
listForEmployee(employee: AuthUser, query: ListShiftsQuery) {
return shiftRepository.list({
...query,
employeeId: employee.id,
storeId: undefined,
});
},
todayForEmployee(employee: AuthUser) {
return shiftRepository.listTodayForEmployee(employee.id);
},
};
+37
View File
@@ -0,0 +1,37 @@
export const SHIFT_STATUSES = ["SCHEDULED", "CANCELLED"] as const;
export type ShiftStatus = (typeof SHIFT_STATUSES)[number];
export interface Shift {
id: number;
storeId: number;
storeName: string;
employeeId: number;
employeeName: string;
roleName: string | null;
startAt: string;
endAt: string;
status: ShiftStatus;
createdAt: string;
updatedAt: string;
}
export interface ListShiftsQuery {
storeId?: number;
employeeId?: number;
status?: ShiftStatus;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
}
export interface CreateShiftInput {
storeId: number;
employeeId: number;
roleName?: string | null;
startAt: string;
endAt: string;
status: ShiftStatus;
}
export type UpdateShiftInput = Partial<CreateShiftInput>;