feat: 增加员工端工作台后端能力
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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: "结束时间必须晚于开始时间" },
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user