|
@@ -14,7 +14,7 @@
|
|
|
import { fail, ok, page } from '@/api/mock/envelope';
|
|
import { fail, ok, page } from '@/api/mock/envelope';
|
|
|
import { isMoney, toCents, fmt } from '@/api/mock/money';
|
|
import { isMoney, toCents, fmt } from '@/api/mock/money';
|
|
|
import { rfc3339 } from '@/api/mock/time';
|
|
import { rfc3339 } from '@/api/mock/time';
|
|
|
-import { inScope, findUser, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
|
|
|
|
|
|
|
+import { inScope, findUser, findCampus, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
|
|
|
import type { ExpenseLog } from '@/api/mock/data';
|
|
import type { ExpenseLog } from '@/api/mock/data';
|
|
|
import type { MockCtx, MockRoute } from '@/api/mock/adapter';
|
|
import type { MockCtx, MockRoute } from '@/api/mock/adapter';
|
|
|
import type { R } from '@/types/api';
|
|
import type { R } from '@/types/api';
|
|
@@ -35,8 +35,10 @@ function moneyParam(s: unknown): { cents: number } | { err: R<undefined> } {
|
|
|
return { cents };
|
|
return { cents };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 幂等包装:同 requestId 重复 → 回放首次结果并置 repeated=true。入参:key/首次构造器。出参:R。 */
|
|
|
|
|
|
|
+/** 幂等包装:同 requestId 重复 → 回放首次结果并置 repeated=true。入参:key/首次构造器。出参:R。
|
|
|
|
|
+ * ⚠ key 为空(未传 requestId)时**不享幂等**:不存、不放回,直接执行(对齐后端:不传 = 重复提交会二次入账)。 */
|
|
|
function withIdempotent(key: string, build: () => { data: Record<string, unknown>; repeated?: boolean }): R<unknown> {
|
|
function withIdempotent(key: string, build: () => { data: Record<string, unknown>; repeated?: boolean }): R<unknown> {
|
|
|
|
|
+ if (!key || key.endsWith(':')) return ok(build().data);
|
|
|
const prev = takeIdempotent(key);
|
|
const prev = takeIdempotent(key);
|
|
|
if (prev) return ok({ ...prev, repeated: true });
|
|
if (prev) return ok({ ...prev, repeated: true });
|
|
|
const first = build();
|
|
const first = build();
|
|
@@ -84,7 +86,7 @@ export const financeRoutes: MockRoute[] = [
|
|
|
});
|
|
});
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
- /* 2. GET /api/admin/finance/accounts/:userId/refundable —— {refundableAmount} */
|
|
|
|
|
|
|
+ /* 2. GET /api/admin/finance/accounts/:userId/refundable —— {refundableAmount}。⚠ institutionId/campusId 均必填(缺 → 2001)。 */
|
|
|
{
|
|
{
|
|
|
method: 'GET',
|
|
method: 'GET',
|
|
|
path: '/api/admin/finance/accounts/:userId/refundable',
|
|
path: '/api/admin/finance/accounts/:userId/refundable',
|
|
@@ -93,7 +95,8 @@ export const financeRoutes: MockRoute[] = [
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
const u = findUser(ctx.params.userId);
|
|
const u = findUser(ctx.params.userId);
|
|
|
if (!u) return fail(3001, '用户不存在');
|
|
if (!u) return fail(3001, '用户不存在');
|
|
|
- const campusId = ctx.query.campusId ? Number(ctx.query.campusId) : u.campusId;
|
|
|
|
|
|
|
+ if (!ctx.query.campusId || Number(ctx.query.campusId) <= 0) return fail(2001, 'campusId 必填');
|
|
|
|
|
+ const campusId = Number(ctx.query.campusId);
|
|
|
const refundable = getAccount(ctx.params.userId, campusId).campus;
|
|
const refundable = getAccount(ctx.params.userId, campusId).campus;
|
|
|
return ok({ refundableAmount: fmt(Math.max(refundable, 0)) });
|
|
return ok({ refundableAmount: fmt(Math.max(refundable, 0)) });
|
|
|
},
|
|
},
|
|
@@ -117,7 +120,7 @@ export const financeRoutes: MockRoute[] = [
|
|
|
});
|
|
});
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
- /* 4. POST /api/admin/finance/expenditures —— 消费 {logId,balance} */
|
|
|
|
|
|
|
+ /* 4. POST /api/admin/finance/expenditures —— 消费 {logId,balance,repeated}。⚠ type 必填且须在白名单内(否则 2002)。 */
|
|
|
{
|
|
{
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
path: '/api/admin/finance/expenditures',
|
|
path: '/api/admin/finance/expenditures',
|
|
@@ -126,15 +129,25 @@ export const financeRoutes: MockRoute[] = [
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
const userId = ctx.body?.userId as string;
|
|
const userId = ctx.body?.userId as string;
|
|
|
const campusId = Number(ctx.body?.campusId ?? 0);
|
|
const campusId = Number(ctx.body?.campusId ?? 0);
|
|
|
- const type = Number(ctx.body?.type ?? 101);
|
|
|
|
|
|
|
+ const type = Number(ctx.body?.type);
|
|
|
const m = moneyParam(ctx.body?.money);
|
|
const m = moneyParam(ctx.body?.money);
|
|
|
if ('err' in m) return m.err;
|
|
if ('err' in m) return m.err;
|
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
|
- const log = appendLog(userId, campusId, type, -m.cents, '消费');
|
|
|
|
|
- return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total) });
|
|
|
|
|
|
|
+ // 白名单:101/104/105/200/201/204/205(与后端 FundService.Expenditure 一致;不在白名单 → 2002)
|
|
|
|
|
+ const EXPENDITURE_TYPES = [101, 104, 105, 200, 201, 204, 205];
|
|
|
|
|
+ if (!EXPENDITURE_TYPES.includes(type)) return fail(2002, 'type 不在白名单(101/104/105/200/201/204/205)');
|
|
|
|
|
+ // 幂等:与后端一致(requestId 可空,空则不享幂等)
|
|
|
|
|
+ const key = idempotentKey('expenditure', String(ctx.body?.requestId ?? ''));
|
|
|
|
|
+ return withIdempotent(key, () => {
|
|
|
|
|
+ // 3101 零接线:余额不足不校验,直接扣成负数(复刻旧 SP;前端提交前自行查余额拦截)
|
|
|
|
|
+ const log = appendLog(userId, campusId, type, -m.cents, '消费');
|
|
|
|
|
+ return { data: { logId: log.id, balance: fmt(getAccount(userId, campusId).total), repeated: false } };
|
|
|
|
|
+ });
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
- /* 5. POST /api/admin/finance/refunds —— 退费 {logId,balance,changed} */
|
|
|
|
|
|
|
+ /* 5. POST /api/admin/finance/refunds —— 退费 {logId,balance,changed,repeated}。
|
|
|
|
|
+ * ⚠ type 必填且须为已知流水类型(否则 2002);**不做 3102/余额校验**(对齐 Go FundService.Refund:
|
|
|
|
|
+ * 3102 只在自定义退费/清零返回,本端点余额≤0 也直接动账扣成负数);changed 恒 true(契约标注但实际恒 true)。 */
|
|
|
{
|
|
{
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
path: '/api/admin/finance/refunds',
|
|
path: '/api/admin/finance/refunds',
|
|
@@ -143,14 +156,18 @@ export const financeRoutes: MockRoute[] = [
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
const userId = ctx.body?.userId as string;
|
|
const userId = ctx.body?.userId as string;
|
|
|
const campusId = Number(ctx.body?.campusId ?? 0);
|
|
const campusId = Number(ctx.body?.campusId ?? 0);
|
|
|
|
|
+ const type = Number(ctx.body?.type);
|
|
|
const m = moneyParam(ctx.body?.money);
|
|
const m = moneyParam(ctx.body?.money);
|
|
|
if ('err' in m) return m.err;
|
|
if ('err' in m) return m.err;
|
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
|
- const before = getAccount(userId, campusId).campus;
|
|
|
|
|
- if (before <= 0) return fail(3102, '账户余额为 0,不可退费');
|
|
|
|
|
- const changed = m.cents > before;
|
|
|
|
|
- const log = appendLog(userId, campusId, 207, -Math.min(m.cents, before), '退费');
|
|
|
|
|
- return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total), changed });
|
|
|
|
|
|
|
+ // 对齐 Go isKnownLogType 白名单(model/models_fund.go §10.2 全集;退费通常 207;不在白名单 → 2002)
|
|
|
|
|
+ const KNOWN_LOG_TYPES = [100, 101, 104, 105, 106, 107, 108, 200, 201, 204, 205, 206, 207, 208, 209];
|
|
|
|
|
+ if (!KNOWN_LOG_TYPES.includes(type)) return fail(2002, 'type 不在流水类型白名单内');
|
|
|
|
|
+ const key = idempotentKey('refund', String(ctx.body?.requestId ?? ''));
|
|
|
|
|
+ return withIdempotent(key, () => {
|
|
|
|
|
+ const log = appendLog(userId, campusId, type, -m.cents, '退费');
|
|
|
|
|
+ return { data: { logId: log.id, balance: fmt(getAccount(userId, campusId).total), changed: true, repeated: false } };
|
|
|
|
|
+ });
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
/* 6. POST /api/admin/finance/refunds/custom —— CustomRefundResult(§3.1,核心分支) */
|
|
/* 6. POST /api/admin/finance/refunds/custom —— CustomRefundResult(§3.1,核心分支) */
|
|
@@ -200,7 +217,8 @@ export const financeRoutes: MockRoute[] = [
|
|
|
});
|
|
});
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
- /* 7. POST /api/admin/finance/transfers —— 转结 {logId106,logId206} */
|
|
|
|
|
|
|
+ /* 7. POST /api/admin/finance/transfers —— 转结 {logId106,logId206,fromBalance,toBalance,repeated}。
|
|
|
|
|
+ * ⚠ 四个作用域参数全必填;转出=转入校区 → 3140;不校验余额(3101 零接线,可扣成负数,前端自行前置拦截)。 */
|
|
|
{
|
|
{
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
path: '/api/admin/finance/transfers',
|
|
path: '/api/admin/finance/transfers',
|
|
@@ -208,18 +226,36 @@ export const financeRoutes: MockRoute[] = [
|
|
|
const denied = checkScope(ctx);
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
const userId = ctx.body?.userId as string;
|
|
const userId = ctx.body?.userId as string;
|
|
|
|
|
+ const fromInstitutionId = Number(ctx.body?.fromInstitutionId ?? 0);
|
|
|
const fromCampus = Number(ctx.body?.fromCampusId ?? 0);
|
|
const fromCampus = Number(ctx.body?.fromCampusId ?? 0);
|
|
|
|
|
+ const toInstitutionId = Number(ctx.body?.toInstitutionId ?? 0);
|
|
|
const toCampus = Number(ctx.body?.toCampusId ?? 0);
|
|
const toCampus = Number(ctx.body?.toCampusId ?? 0);
|
|
|
const m = moneyParam(ctx.body?.money);
|
|
const m = moneyParam(ctx.body?.money);
|
|
|
if ('err' in m) return m.err;
|
|
if ('err' in m) return m.err;
|
|
|
- if (!userId || !findUser(userId) || fromCampus === 0 || toCampus === 0) return fail(2001, '参数缺失');
|
|
|
|
|
- // dump 口径:106 转结出 = +money,206 转结入 = −money(★以 dump 为准)
|
|
|
|
|
- const log106 = appendLog(userId, fromCampus, 106, m.cents, '转结出');
|
|
|
|
|
- const log206 = appendLog(userId, toCampus, 206, -m.cents, '转结入');
|
|
|
|
|
- return ok({ logId106: log106.id, logId206: log206.id });
|
|
|
|
|
|
|
+ if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
|
|
|
+ if (!fromInstitutionId || !fromCampus || !toInstitutionId || !toCampus) return fail(2001, 'fromInstitutionId/fromCampusId/toInstitutionId/toCampusId 均必填');
|
|
|
|
|
+ if (!findCampus(fromCampus) || !findCampus(toCampus)) return fail(3104, '校区不存在或未归属该机构');
|
|
|
|
|
+ if (fromCampus === toCampus && fromInstitutionId === toInstitutionId) return fail(3140, '转出与转入校区相同');
|
|
|
|
|
+ const key = idempotentKey('transfer', String(ctx.body?.requestId ?? ''));
|
|
|
|
|
+ return withIdempotent(key, () => {
|
|
|
|
|
+ const fromBefore = getAccount(userId, fromCampus).campus;
|
|
|
|
|
+ const toBefore = getAccount(userId, toCampus).campus;
|
|
|
|
|
+ // ★ dump 口径(Go FundService.Transfer 同款,不得"修正"):106 转出 = **+money**、206 转入 = **−money**,不动全局 balance
|
|
|
|
|
+ const log106 = appendLog(userId, fromCampus, 106, m.cents, '转结出');
|
|
|
|
|
+ const log206 = appendLog(userId, toCampus, 206, -m.cents, '转结入');
|
|
|
|
|
+ return {
|
|
|
|
|
+ data: {
|
|
|
|
|
+ logId106: log106.id,
|
|
|
|
|
+ logId206: log206.id,
|
|
|
|
|
+ fromBalance: fmt(fromBefore - m.cents),
|
|
|
|
|
+ toBalance: fmt(toBefore + m.cents),
|
|
|
|
|
+ repeated: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
- /* 8. POST /api/admin/finance/bad-debts —— 坏账 {logId,specialExpenses} */
|
|
|
|
|
|
|
+ /* 8. POST /api/admin/finance/bad-debts —— 坏账 {logId,specialExpenses,balance,repeated}。⚠ 口径:balance 与 specialExpenses 双增。 */
|
|
|
{
|
|
{
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
path: '/api/admin/finance/bad-debts',
|
|
path: '/api/admin/finance/bad-debts',
|
|
@@ -233,8 +269,17 @@ export const financeRoutes: MockRoute[] = [
|
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
|
const key = idempotentKey('bad-debt', String(ctx.body?.requestId ?? ''));
|
|
const key = idempotentKey('bad-debt', String(ctx.body?.requestId ?? ''));
|
|
|
return withIdempotent(key, () => {
|
|
return withIdempotent(key, () => {
|
|
|
|
|
+ // 口径:坏账 = balance 与 special_expenses 双增(响应 balance 已含本次增量,前端直接展示)
|
|
|
|
|
+ const before = getAccount(userId, campusId);
|
|
|
const log = appendLog(userId, campusId, 108, m.cents, '坏账处理');
|
|
const log = appendLog(userId, campusId, 108, m.cents, '坏账处理');
|
|
|
- return { data: { logId: log.id, specialExpenses: fmt(m.cents), repeated: false } };
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ data: {
|
|
|
|
|
+ logId: log.id,
|
|
|
|
|
+ specialExpenses: fmt(before.campus + m.cents),
|
|
|
|
|
+ balance: fmt(before.campus + m.cents),
|
|
|
|
|
+ repeated: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
});
|
|
});
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
@@ -293,13 +338,14 @@ export const financeRoutes: MockRoute[] = [
|
|
|
handler: (ctx) => {
|
|
handler: (ctx) => {
|
|
|
const denied = checkScope(ctx);
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
|
|
+ // 对齐真实:流水表 t_user_expense_log 无 institution_id/campus_id 列,**无法按校区过滤**(传 campusId 也不生效)。
|
|
|
|
|
+ // 故此处只传 userId/type/from/to(作用域校验已由 checkScope 完成),campusId 刻意不传。
|
|
|
const rows = queryLogs({
|
|
const rows = queryLogs({
|
|
|
userId: ctx.query.userId,
|
|
userId: ctx.query.userId,
|
|
|
type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
|
|
type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
|
|
|
from: ctx.query.from,
|
|
from: ctx.query.from,
|
|
|
to: ctx.query.to,
|
|
to: ctx.query.to,
|
|
|
institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
|
|
institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
|
|
|
- campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
|
|
|
|
|
});
|
|
});
|
|
|
return page(rows.map(logRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
|
|
return page(rows.map(logRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
|
|
|
},
|
|
},
|
|
@@ -325,6 +371,9 @@ export const financeRoutes: MockRoute[] = [
|
|
|
userId: r.userId,
|
|
userId: r.userId,
|
|
|
institutionId: r.institutionId,
|
|
institutionId: r.institutionId,
|
|
|
campusId: r.campusId,
|
|
campusId: r.campusId,
|
|
|
|
|
+ // 仅 type=300(转结)有目标校区,其余 0(对齐真实:非转结为 null,前端 DTO 用 number|null,0 等价)
|
|
|
|
|
+ toInstitutionId: r.toInstitutionId || null,
|
|
|
|
|
+ toCampusId: r.toCampusId || null,
|
|
|
money: fmt(r.money),
|
|
money: fmt(r.money),
|
|
|
approved: r.approved,
|
|
approved: r.approved,
|
|
|
applicantId: r.applicantId,
|
|
applicantId: r.applicantId,
|
|
@@ -350,7 +399,19 @@ export const financeRoutes: MockRoute[] = [
|
|
|
const m = moneyParam(ctx.body?.money);
|
|
const m = moneyParam(ctx.body?.money);
|
|
|
if ('err' in m) return m.err;
|
|
if ('err' in m) return m.err;
|
|
|
if (!type || !userId || !findUser(userId)) return fail(2001, 'type/userId 缺失或非法');
|
|
if (!type || !userId || !findUser(userId)) return fail(2001, 'type/userId 缺失或非法');
|
|
|
- const r = createRequest(type, userId, campusId, m.cents, String(ctx.body?.remark ?? ''), userId);
|
|
|
|
|
|
|
+ // 白名单 100/101/202/203/300(对齐真实;非白名单 → 2002)
|
|
|
|
|
+ const REQUEST_TYPES = [100, 101, 202, 203, 300];
|
|
|
|
|
+ if (!REQUEST_TYPES.includes(type)) return fail(2002, 'type 不在白名单(100/101/202/203/300)');
|
|
|
|
|
+ const r = createRequest(
|
|
|
|
|
+ type,
|
|
|
|
|
+ userId,
|
|
|
|
|
+ campusId,
|
|
|
|
|
+ m.cents,
|
|
|
|
|
+ String(ctx.body?.remark ?? ''),
|
|
|
|
|
+ userId,
|
|
|
|
|
+ Number(ctx.body?.toInstitutionId ?? 0),
|
|
|
|
|
+ Number(ctx.body?.toCampusId ?? 0),
|
|
|
|
|
+ );
|
|
|
return ok({ id: r.id, bizNo: r.bizNo });
|
|
return ok({ id: r.id, bizNo: r.bizNo });
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
@@ -377,13 +438,17 @@ export const financeRoutes: MockRoute[] = [
|
|
|
handler: (ctx) => {
|
|
handler: (ctx) => {
|
|
|
const denied = checkScope(ctx);
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
if (denied) return denied;
|
|
|
|
|
+ // 对齐真实:responseMsg 必填(驳回必须留理由,缺 → 2001)
|
|
|
|
|
+ const msg = String(ctx.body?.responseMsg ?? '').trim();
|
|
|
|
|
+ if (!msg) return fail(2001, 'responseMsg 必填(驳回必须留理由)');
|
|
|
const r = findRequest(Number(ctx.params.id));
|
|
const r = findRequest(Number(ctx.params.id));
|
|
|
if (!r) return fail(3001, '工单不存在');
|
|
if (!r) return fail(3001, '工单不存在');
|
|
|
if (r.approved !== 0) return fail(3140, '工单已处理,不可重复操作');
|
|
if (r.approved !== 0) return fail(3140, '工单已处理,不可重复操作');
|
|
|
r.approved = -1;
|
|
r.approved = -1;
|
|
|
r.approverId = '01J2M' + '302'.padStart(21, '0');
|
|
r.approverId = '01J2M' + '302'.padStart(21, '0');
|
|
|
|
|
+ r.responseMsg = msg;
|
|
|
r.responseTime = rfc3339(Date.now());
|
|
r.responseTime = rfc3339(Date.now());
|
|
|
- return ok({ approved: -1 });
|
|
|
|
|
|
|
+ return ok({ approved: -1, id: r.id });
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
/* 16. POST /api/admin/finance/requests/:id/cancel —— 撤销(仅 approved=0) */
|
|
/* 16. POST /api/admin/finance/requests/:id/cancel —— 撤销(仅 approved=0) */
|