Commit 0001eec7a00d494f4a399db47a6949d2f013a574

Authored by zichun
1 parent 3545663a

feat(usr): 用户单据 API 与类型契约 create/update/detail/employees/permissions REQ-USR-001 REQ-USR-002

frontend/src/api/types.ts
... ... @@ -67,3 +67,56 @@ export interface UserListQuery {
67 67 pageNum: number;
68 68 pageSize: number;
69 69 }
  70 +
  71 +// === REQ-USR-001 / REQ-USR-002 用户单据契约(FE-04) ===
  72 +
  73 +/** 单据模式:新增 / 修改(由路由 :id 判定,spec § 3) */
  74 +export type UserDetailMode = 'create' | 'edit';
  75 +
  76 +/**
  77 + * 员工名下拉项(D1)。后端 GET /api/usr/employees 原始行
  78 + * `iIncrement`/`sEmployeeName`/`sEmployeeNo` 在 api 层归一为此结构。
  79 + */
  80 +export interface EmployeeOption {
  81 + value: number;
  82 + label: string;
  83 + sEmployeeNo: string | null;
  84 +}
  85 +
  86 +/**
  87 + * 权限项(D2/D3)。后端 GET /api/usr/permissions 原始行
  88 + * `iIncrement`/`sPermissionName`/`sPermissionCategory` 在 api 层归一为此结构。
  89 + */
  90 +export interface PermissionItem {
  91 + id: number;
  92 + name: string;
  93 + category: string;
  94 +}
  95 +
  96 +/**
  97 + * POST /api/usr/users 请求体(create,对齐 docs/05 § REQ-USR-001)。
  98 + * 密码 `initialPassword` 前端不传,由后端默认 666666(BR9)。
  99 + */
  100 +export interface UserCreateReq {
  101 + sUserName: string;
  102 + sUserNo?: string;
  103 + iEmployeeId?: number | null;
  104 + sUserType: string;
  105 + sLanguage: string;
  106 + iCanModifyBill?: 0 | 1;
  107 + permissionIds?: number[];
  108 +}
  109 +
  110 +/**
  111 + * PUT /api/usr/users/{id} 请求体(edit,对齐 docs/05 § REQ-USR-002)。
  112 + * `sUserName` 不可改不传、密码不在本接口(BR3/BR9);`permissionIds` 全量覆盖(BR11)。
  113 + */
  114 +export interface UserUpdateReq {
  115 + sUserNo?: string;
  116 + iEmployeeId?: number | null;
  117 + sUserType: string;
  118 + sLanguage: string;
  119 + iCanModifyBill?: 0 | 1;
  120 + iIsVoid?: 0 | 1;
  121 + permissionIds?: number[];
  122 +}
... ...
frontend/src/api/usrApi.ts
1 1 // REQ-USR-004: USR 模块 API 封装(登录 + 版本下拉取数)。页面只调本文件,不散用 axios。
2 2 // REQ-USR-003: 新增用户列表分页查询 listUsers(GET /api/usr/users,中文键归一 D-PLAN-2)。
  3 +// REQ-USR-001 / REQ-USR-002: 用户单据写/读封装(create/update/detail/employees/permissions,D1/D2/D4)。
3 4 import request from './request';
4 5 import type {
5 6 LoginPayload,
... ... @@ -8,6 +9,10 @@ import type {
8 9 UserVO,
9 10 PageResult,
10 11 UserListQuery,
  12 + UserCreateReq,
  13 + UserUpdateReq,
  14 + EmployeeOption,
  15 + PermissionItem,
11 16 } from './types';
12 17  
13 18 // 响应拦截器已拆 Result.data,故此处返回类型即业务数据本体。
... ... @@ -82,3 +87,71 @@ export async function listUsers(query: UserListQuery): Promise<PageResult<UserVO
82 87 pageSize: page.pageSize,
83 88 };
84 89 }
  90 +
  91 +// === REQ-USR-001 / REQ-USR-002 用户单据写/读封装(FE-04) ===
  92 +
  93 +/** POST /api/usr/users —— 新增用户(body 不含密码/审计字段,BR9),返回新建主键 id */
  94 +export function createUser(body: UserCreateReq): Promise<{ id: number }> {
  95 + return request.post('/usr/users', body) as unknown as Promise<{ id: number }>;
  96 +}
  97 +
  98 +/** PUT /api/usr/users/{id} —— 修改用户(body 不含 sUserName,BR3;permissionIds 全量覆盖,BR11) */
  99 +export function updateUser(id: number, body: UserUpdateReq): Promise<{ id: number }> {
  100 + return request.put('/usr/users/' + id, body) as unknown as Promise<{ id: number }>;
  101 +}
  102 +
  103 +/**
  104 + * edit 预填:复用 GET /api/usr/users 列表端点(「等于」匹配 + pageSize=1)定位单条(D4)。
  105 + * 复用 listUsers 的中文键归一,返回 records[0] ?? null。
  106 + */
  107 +export async function getUserDetail(params: {
  108 + queryField: string;
  109 + queryValue: string;
  110 +}): Promise<UserVO | null> {
  111 + const page = await listUsers({
  112 + queryField: params.queryField,
  113 + matchType: '等于',
  114 + queryValue: params.queryValue,
  115 + pageNum: 1,
  116 + pageSize: 1,
  117 + });
  118 + return page.records[0] ?? null;
  119 +}
  120 +
  121 +/** 后端原始员工行(GET /api/usr/employees,D1) */
  122 +interface RawEmployeeRecord {
  123 + iIncrement: number;
  124 + sEmployeeName: string;
  125 + sEmployeeNo: string | null;
  126 +}
  127 +
  128 +/** GET /api/usr/employees —— 员工名下拉数据源,归一为 EmployeeOption(D1) */
  129 +export async function listEmployees(): Promise<EmployeeOption[]> {
  130 + const rows = (await (request.get('/usr/employees') as unknown as Promise<
  131 + RawEmployeeRecord[]
  132 + >)) ?? [];
  133 + return rows.map((r) => ({
  134 + value: r.iIncrement,
  135 + label: r.sEmployeeName,
  136 + sEmployeeNo: r.sEmployeeNo ?? null,
  137 + }));
  138 +}
  139 +
  140 +/** 后端原始权限行(GET /api/usr/permissions,D2/D3) */
  141 +interface RawPermissionRecord {
  142 + iIncrement: number;
  143 + sPermissionName: string;
  144 + sPermissionCategory: string;
  145 +}
  146 +
  147 +/** GET /api/usr/permissions —— 权限分类列表数据源,归一为 PermissionItem(D2/D3) */
  148 +export async function listPermissions(): Promise<PermissionItem[]> {
  149 + const rows = (await (request.get('/usr/permissions') as unknown as Promise<
  150 + RawPermissionRecord[]
  151 + >)) ?? [];
  152 + return rows.map((r) => ({
  153 + id: r.iIncrement,
  154 + name: r.sPermissionName,
  155 + category: r.sPermissionCategory,
  156 + }));
  157 +}
... ...
frontend/tests/unit/usrApi.userdetail.test.ts 0 → 100644
  1 +// REQ-USR-001 / REQ-USR-002: 用户单据 API 封装单测
  2 +// createUser / updateUser / getUserDetail / listEmployees / listPermissions 透传与归一(D1/D2/D4)
  3 +import { describe, it, expect, vi, beforeEach } from 'vitest';
  4 +
  5 +// 桩底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.userlist.test.ts 模式
  6 +vi.mock('../../src/api/request', () => {
  7 + return {
  8 + default: {
  9 + post: vi.fn(),
  10 + put: vi.fn(),
  11 + get: vi.fn(),
  12 + },
  13 + };
  14 +});
  15 +
  16 +import request from '../../src/api/request';
  17 +import {
  18 + createUser,
  19 + updateUser,
  20 + getUserDetail,
  21 + listEmployees,
  22 + listPermissions,
  23 +} from '../../src/api/usrApi';
  24 +
  25 +const mockedRequest = request as unknown as {
  26 + post: ReturnType<typeof vi.fn>;
  27 + put: ReturnType<typeof vi.fn>;
  28 + get: ReturnType<typeof vi.fn>;
  29 +};
  30 +
  31 +describe('usrApi 用户单据封装', () => {
  32 + beforeEach(() => {
  33 + vi.clearAllMocks();
  34 + });
  35 +
  36 + it('createUser posts /usr/users with body (no password/creator/createDate)', async () => {
  37 + mockedRequest.post.mockResolvedValue({ id: 9 });
  38 + const body = {
  39 + sUserName: 'zhangsan',
  40 + sUserNo: 'zs',
  41 + iEmployeeId: 3,
  42 + sUserType: '普通用户',
  43 + sLanguage: '中文',
  44 + iCanModifyBill: 0 as const,
  45 + permissionIds: [1, 2],
  46 + };
  47 + const ret = await createUser(body);
  48 + expect(mockedRequest.post).toHaveBeenCalledTimes(1);
  49 + const [url, payload] = mockedRequest.post.mock.calls[0];
  50 + expect(url).toBe('/usr/users');
  51 + expect(payload).toEqual(body);
  52 + expect(payload).not.toHaveProperty('initialPassword');
  53 + expect(payload).not.toHaveProperty('sCreator');
  54 + expect(payload).not.toHaveProperty('tCreateDate');
  55 + expect(ret).toEqual({ id: 9 });
  56 + });
  57 +
  58 + it('updateUser puts /usr/users/{id} with body (no sUserName)', async () => {
  59 + mockedRequest.put.mockResolvedValue({ id: 7 });
  60 + const body = {
  61 + sUserType: '超级管理员',
  62 + sLanguage: '英文',
  63 + iCanModifyBill: 1 as const,
  64 + iIsVoid: 0 as const,
  65 + permissionIds: [2],
  66 + };
  67 + const ret = await updateUser(7, body);
  68 + expect(mockedRequest.put).toHaveBeenCalledTimes(1);
  69 + const [url, payload] = mockedRequest.put.mock.calls[0];
  70 + expect(url).toBe('/usr/users/7');
  71 + expect(payload).toEqual(body);
  72 + expect(payload).not.toHaveProperty('sUserName');
  73 + expect(ret).toEqual({ id: 7 });
  74 + });
  75 +
  76 + it('getUserDetail queries equals match pageSize 1 and returns records[0]', async () => {
  77 + mockedRequest.get.mockResolvedValue({
  78 + records: [
  79 + {
  80 + id: 7,
  81 + sUserName: 'zhangsan',
  82 + 员工名: '张三',
  83 + sUserNo: 'zs',
  84 + 部门: null,
  85 + sUserType: '普通用户',
  86 + sLanguage: '中文',
  87 + iIsVoid: 0,
  88 + tLastLoginDate: null,
  89 + sCreator: 'admin',
  90 + tCreateDate: '2026-01-01T00:00:00',
  91 + },
  92 + ],
  93 + total: 1,
  94 + pageNum: 1,
  95 + pageSize: 1,
  96 + });
  97 + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'zhangsan' });
  98 + expect(mockedRequest.get).toHaveBeenCalledTimes(1);
  99 + const [url, config] = mockedRequest.get.mock.calls[0];
  100 + expect(url).toBe('/usr/users');
  101 + expect(config.params).toMatchObject({
  102 + queryField: '用户名',
  103 + matchType: '等于',
  104 + queryValue: 'zhangsan',
  105 + pageNum: 1,
  106 + pageSize: 1,
  107 + });
  108 + expect(ret).not.toBeNull();
  109 + expect(ret!.id).toBe(7);
  110 + expect(ret!.employeeName).toBe('张三');
  111 + });
  112 +
  113 + it('getUserDetail returns null when records empty', async () => {
  114 + mockedRequest.get.mockResolvedValue({ records: [], total: 0, pageNum: 1, pageSize: 1 });
  115 + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'none' });
  116 + expect(ret).toBeNull();
  117 + });
  118 +
  119 + it('listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption', async () => {
  120 + mockedRequest.get.mockResolvedValue([
  121 + { iIncrement: 3, sEmployeeName: '张三', sEmployeeNo: 'zs' },
  122 + ]);
  123 + const ret = await listEmployees();
  124 + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/employees');
  125 + expect(ret).toEqual([{ value: 3, label: '张三', sEmployeeNo: 'zs' }]);
  126 + });
  127 +
  128 + it('listPermissions normalizes to PermissionItem', async () => {
  129 + mockedRequest.get.mockResolvedValue([
  130 + { iIncrement: 1, sPermissionName: '默认显示', sPermissionCategory: '基础' },
  131 + ]);
  132 + const ret = await listPermissions();
  133 + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/permissions');
  134 + expect(ret).toEqual([{ id: 1, name: '默认显示', category: '基础' }]);
  135 + });
  136 +});
... ...