|
|
@@ -59,53 +59,31 @@ func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.Members
|
|
|
|
|
|
// AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
|
|
|
//
|
|
|
-// 语义:**全局钱包** + (可选)**指定校区账户**两段。金额全部 `money.Amount`(两位小数字符串输出)。
|
|
|
+// **形状:扁平**(契约 v1.5 裁决 —— 前端页面已按扁平读,改后端只动 DTO 与断言,改前端要动页面)。
|
|
|
+// 每字段的来源列(契约 §3.4 ③「字段准入条件」要求可追溯):
|
|
|
+//
|
|
|
+// totalBalance ← t_user_expense.balance (全局/跨校区)
|
|
|
+// campusBalance ← t_user_expense_school.balance (**指定校区**;未指定校区或无账户 → "0.00")
|
|
|
+// specialExpenses ← 对应表的 special_expenses (有校区账户取校区值,否则取全局值)
|
|
|
+// cumulativeIncome ← total_income (同上取法)
|
|
|
+//
|
|
|
+// `institutionId` / `campusId` 是**入参回显**,不是账户列
|
|
|
+// (`t_user_expense` 根本没有作用域列,§3.8-f 约束 2)⇒ **不得**从账户行里取。
|
|
|
type AccountSnapshot struct {
|
|
|
// UserID 用户(ULID)。
|
|
|
UserID string `json:"userId"`
|
|
|
- // Global 全局账户(跨校区钱包;`t_user_expense`)。
|
|
|
- Global AccountBalanceView `json:"global"`
|
|
|
- // Campus 指定校区账户;**未指定校区或账户不存在时为 null**(不是"零余额对象"——
|
|
|
- // 前端须用 null 判断"该校区还没建户",与余额 0 区分开)。
|
|
|
- Campus *CampusAccountBalanceView `json:"campus"`
|
|
|
-}
|
|
|
-
|
|
|
-// AccountBalanceView 全局账户余额视图(`t_user_expense` 的 6 个金额列)。
|
|
|
-type AccountBalanceView struct {
|
|
|
- // Balance 余额(元,±;负数=欠费)。
|
|
|
- Balance money.Amount `json:"balance"`
|
|
|
- // Spend 上课消费课费单价之和(元;只增)。
|
|
|
- Spend money.Amount `json:"spend"`
|
|
|
- // TotalIncome 累计充值(元)。
|
|
|
- TotalIncome money.Amount `json:"totalIncome"`
|
|
|
- // TotalExpenditure 累计消费(元)。
|
|
|
- TotalExpenditure money.Amount `json:"totalExpenditure"`
|
|
|
- // TotalRecommendedAmount 累计推荐金额(元)。
|
|
|
- TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
|
|
|
- // SpecialExpenses 特殊费用(元;坏账/清零/提前退费累计)。
|
|
|
- SpecialExpenses money.Amount `json:"specialExpenses"`
|
|
|
-}
|
|
|
-
|
|
|
-// CampusAccountBalanceView 校区账户余额视图(`t_user_expense_school` 的 6 个金额列 + 定位)。
|
|
|
-type CampusAccountBalanceView struct {
|
|
|
- // InstitutionID 机构 ID。
|
|
|
+ // InstitutionID 机构 ID(**入参回显**,不是账户列)。
|
|
|
InstitutionID int64 `json:"institutionId"`
|
|
|
- // CampusID 校区 ID(0=机构级账户)。
|
|
|
+ // CampusID 校区 ID(**入参回显**,不是账户列)。
|
|
|
CampusID int64 `json:"campusId"`
|
|
|
- // AccountID 账户行 ID(对账时比余额更精确的锚点)。
|
|
|
- AccountID int64 `json:"accountId"`
|
|
|
- // Balance 校区余额(元,±)。
|
|
|
- Balance money.Amount `json:"balance"`
|
|
|
- // Spend 校区上课消费课费单价之和(元)。
|
|
|
- Spend money.Amount `json:"spend"`
|
|
|
- // TotalIncome 校区累计充值(元)。
|
|
|
- TotalIncome money.Amount `json:"totalIncome"`
|
|
|
- // TotalExpenditure 校区累计消费(元)。
|
|
|
- TotalExpenditure money.Amount `json:"totalExpenditure"`
|
|
|
- // TotalRecommendedAmount 校区累计推荐金额(元)。
|
|
|
- TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
|
|
|
- // SpecialExpenses 校区特殊费用(元)。
|
|
|
+ // TotalBalance 全局(跨校区)余额(元)← `t_user_expense.balance`。
|
|
|
+ TotalBalance money.Amount `json:"totalBalance"`
|
|
|
+ // CampusBalance 指定校区余额(元)← `t_user_expense_school.balance`(未指定/无账户 → 0)。
|
|
|
+ CampusBalance money.Amount `json:"campusBalance"`
|
|
|
+ // SpecialExpenses 特殊费用(元):有校区账户取校区列,否则取全局列。
|
|
|
SpecialExpenses money.Amount `json:"specialExpenses"`
|
|
|
+ // CumulativeIncome 累计充值(元)← `total_income`(同上取法)。
|
|
|
+ CumulativeIncome money.Amount `json:"cumulativeIncome"`
|
|
|
}
|
|
|
|
|
|
// AccountSnapshot 读账户快照(端点 1)。
|
|
|
@@ -134,6 +112,14 @@ func (s *FinanceQueryService) AccountSnapshot(
|
|
|
}
|
|
|
out := &AccountSnapshot{UserID: userID}
|
|
|
|
|
|
+ // 入参回显(**不是**账户列:`t_user_expense` 无作用域列)
|
|
|
+ instID := institutionID
|
|
|
+ if instID <= 0 && s.sc != nil {
|
|
|
+ instID = s.sc.InstitutionID
|
|
|
+ }
|
|
|
+ out.InstitutionID = instID
|
|
|
+ out.CampusID = campusID
|
|
|
+
|
|
|
global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
|
|
|
if err != nil {
|
|
|
if isNotFoundErr(err) {
|
|
|
@@ -142,18 +128,27 @@ func (s *FinanceQueryService) AccountSnapshot(
|
|
|
}
|
|
|
return nil, err
|
|
|
}
|
|
|
- out.Global, err = buildAccountBalanceView(global)
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
+ // totalBalance ← t_user_expense.balance
|
|
|
+ globalBalance, berr := parseAccountAmount(global.Balance, "t_user_expense.balance")
|
|
|
+ if berr != nil {
|
|
|
+ return nil, berr
|
|
|
+ }
|
|
|
+ out.TotalBalance = globalBalance
|
|
|
+ // specialExpenses / cumulativeIncome 默认取**全局**列,有校区账户时被校区值覆盖
|
|
|
+ globalSpecial, serr := parseAccountAmount(global.SpecialExpenses, "t_user_expense.special_expenses")
|
|
|
+ if serr != nil {
|
|
|
+ return nil, serr
|
|
|
}
|
|
|
+ globalIncome, ierr := parseAccountAmount(global.TotalIncome, "t_user_expense.total_income")
|
|
|
+ if ierr != nil {
|
|
|
+ return nil, ierr
|
|
|
+ }
|
|
|
+ out.SpecialExpenses = globalSpecial
|
|
|
+ out.CumulativeIncome = globalIncome
|
|
|
|
|
|
if campusID <= 0 {
|
|
|
return out, nil
|
|
|
}
|
|
|
- instID := institutionID
|
|
|
- if instID <= 0 && s.sc != nil {
|
|
|
- instID = s.sc.InstitutionID
|
|
|
- }
|
|
|
if instID <= 0 {
|
|
|
return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
|
|
|
errString("service: 指定 campus_id 时还需要 institution_id(或作用域机构)"))
|
|
|
@@ -161,85 +156,29 @@ func (s *FinanceQueryService) AccountSnapshot(
|
|
|
campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
|
|
|
if cErr != nil {
|
|
|
if isNotFoundErr(cErr) {
|
|
|
- return out, nil // 该校区未建户 → campus = null(与"余额 0"区分)
|
|
|
+ // 该校区未建户 ⇒ campusBalance 保持 "0.00"(扁平形状下无法用 null 表达"未建户",
|
|
|
+ // 这是扁平化的**已知代价**:前端要区分"未建户"与"余额 0"需另加标志位,本期不加)
|
|
|
+ return out, nil
|
|
|
}
|
|
|
return nil, cErr
|
|
|
}
|
|
|
- view, vErr := buildCampusAccountBalanceView(campus)
|
|
|
- if vErr != nil {
|
|
|
- return nil, vErr
|
|
|
- }
|
|
|
- out.Campus = view
|
|
|
- return out, nil
|
|
|
-}
|
|
|
-
|
|
|
-// buildAccountBalanceView 把全局账户行转成金额视图(逐列定点解析)。
|
|
|
-//
|
|
|
-// 入参:row 全局账户行(非 nil)。出参:AccountBalanceView;列金额非法 → ErrDBError。
|
|
|
-func buildAccountBalanceView(row *model.UserExpense) (AccountBalanceView, error) {
|
|
|
- balance, err := parseAccountAmount(row.Balance, "t_user_expense.balance")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
- }
|
|
|
- spend, err := parseAccountAmount(row.Spend, "t_user_expense.spend")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
- }
|
|
|
- income, err := parseAccountAmount(row.TotalIncome, "t_user_expense.total_income")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
+ // campusBalance ← t_user_expense_school.balance;special/income 也改取校区列(校区视图口径)
|
|
|
+ campusBalance, cberr := parseAccountAmount(campus.Balance, "t_user_expense_school.balance")
|
|
|
+ if cberr != nil {
|
|
|
+ return nil, cberr
|
|
|
}
|
|
|
- expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense.total_expenditure")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
+ out.CampusBalance = campusBalance
|
|
|
+ campusSpecial, cserr := parseAccountAmount(campus.SpecialExpenses, "t_user_expense_school.special_expenses")
|
|
|
+ if cserr != nil {
|
|
|
+ return nil, cserr
|
|
|
}
|
|
|
- recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense.total_recommended_amount")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
- }
|
|
|
- special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense.special_expenses")
|
|
|
- if err != nil {
|
|
|
- return AccountBalanceView{}, err
|
|
|
+ campusIncome, cierr := parseAccountAmount(campus.TotalIncome, "t_user_expense_school.total_income")
|
|
|
+ if cierr != nil {
|
|
|
+ return nil, cierr
|
|
|
}
|
|
|
- return AccountBalanceView{
|
|
|
- Balance: balance, Spend: spend, TotalIncome: income,
|
|
|
- TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
|
|
|
- }, nil
|
|
|
-}
|
|
|
-
|
|
|
-// buildCampusAccountBalanceView 把校区账户行转成金额视图(逐列定点解析)。
|
|
|
-//
|
|
|
-// 入参:row 校区账户行(非 nil)。出参:*CampusAccountBalanceView;列金额非法 → ErrDBError。
|
|
|
-func buildCampusAccountBalanceView(row *model.UserExpenseSchool) (*CampusAccountBalanceView, error) {
|
|
|
- balance, err := parseAccountAmount(row.Balance, "t_user_expense_school.balance")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- spend, err := parseAccountAmount(row.Spend, "t_user_expense_school.spend")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- income, err := parseAccountAmount(row.TotalIncome, "t_user_expense_school.total_income")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense_school.total_expenditure")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense_school.total_recommended_amount")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense_school.special_expenses")
|
|
|
- if err != nil {
|
|
|
- return nil, err
|
|
|
- }
|
|
|
- return &CampusAccountBalanceView{
|
|
|
- InstitutionID: row.InstitutionID, CampusID: row.CampusID, AccountID: row.ID,
|
|
|
- Balance: balance, Spend: spend, TotalIncome: income,
|
|
|
- TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
|
|
|
- }, nil
|
|
|
+ out.SpecialExpenses = campusSpecial
|
|
|
+ out.CumulativeIncome = campusIncome
|
|
|
+ return out, nil
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------- 端点 11:流水列表
|
|
|
@@ -407,20 +346,22 @@ type RequestView struct {
|
|
|
Item string `json:"item"`
|
|
|
// Remark 备注(可空)。
|
|
|
Remark string `json:"remark"`
|
|
|
- // RequestUserID 申请人。
|
|
|
- RequestUserID string `json:"requestUserId"`
|
|
|
- // RequestTime 申请时间(可空)。
|
|
|
+ // ApplicantID 申请人(← `request_user_id`;契约名 `applicantId`,语义听 dump:`request_user_id`)。
|
|
|
+ ApplicantID string `json:"applicantId"`
|
|
|
+ // RequestTime 申请时间(← **`request_time`**,业务时间;可空)。
|
|
|
RequestTime *time.Time `json:"requestTime"`
|
|
|
- // ResponseUserID 审批人(可空)。
|
|
|
- ResponseUserID string `json:"responseUserId"`
|
|
|
- // ResponseTime 审批时间(可空)。
|
|
|
+ // ApproverID 审批人(← `response_user_id`;**未审批时为 null**,契约名 `approverId`)。
|
|
|
+ ApproverID *string `json:"approverId"`
|
|
|
+ // ResponseTime 审批时间(← **`response_time`**,业务时间;未审批为 null)。
|
|
|
ResponseTime *time.Time `json:"responseTime"`
|
|
|
// ResponseMsg 审批意见(可空)。
|
|
|
ResponseMsg string `json:"responseMsg"`
|
|
|
// Approved 审批状态(-2/-1/0/1)。
|
|
|
Approved int8 `json:"approved"`
|
|
|
- // CreatedAt 创建时间。
|
|
|
- CreatedAt time.Time `json:"createdAt"`
|
|
|
+ // ⚠ **不下发 `createdAt`**(契约 v1.5 裁决):
|
|
|
+ // `created_at` 是**迁移期新加的入库时间列**(`DEFAULT CURRENT_TIMESTAMP(3)`),
|
|
|
+ // 迁移后**所有历史工单的值都是"迁移执行那一刻"**——下发会让前端把全部历史工单排在同一天。
|
|
|
+ // 它不是业务时间,**任何端点都不得当业务时间下发**(要业务时间用 `requestTime`)。
|
|
|
}
|
|
|
|
|
|
// ListRequests 分页查工单(端点 12)。
|
|
|
@@ -461,8 +402,8 @@ func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
|
|
|
InstitutionID: row.InstitutionID, CampusID: row.CampusID,
|
|
|
ToInstitutionID: row.ToInstitutionID, ToCampusID: row.ToCampusID,
|
|
|
Money: amount, TransferAccountsType: row.TransferAccountsType,
|
|
|
- RequestUserID: row.RequestUserID, RequestTime: row.RequestTime,
|
|
|
- ResponseTime: row.ResponseTime, Approved: row.Approved, CreatedAt: row.CreatedAt,
|
|
|
+ ApplicantID: row.RequestUserID, ApproverID: row.ResponseUserID,
|
|
|
+ RequestTime: row.RequestTime, ResponseTime: row.ResponseTime, Approved: row.Approved,
|
|
|
}
|
|
|
if row.OrderNo != nil {
|
|
|
view.OrderNo = *row.OrderNo
|
|
|
@@ -473,9 +414,8 @@ func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
|
|
|
if row.Remark != nil {
|
|
|
view.Remark = *row.Remark
|
|
|
}
|
|
|
- if row.ResponseUserID != nil {
|
|
|
- view.ResponseUserID = *row.ResponseUserID
|
|
|
- }
|
|
|
+ // approverId ← response_user_id(未审批 → null,用指针保留"未审批"语义)
|
|
|
+ view.ApproverID = row.ResponseUserID
|
|
|
if row.ResponseMsg != nil {
|
|
|
view.ResponseMsg = *row.ResponseMsg
|
|
|
}
|
|
|
@@ -519,11 +459,23 @@ type DashboardResult struct {
|
|
|
SpecialExpenses money.Amount `json:"specialExpenses"`
|
|
|
// NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
|
|
|
NetReceivable money.Amount `json:"netReceivable"`
|
|
|
+ // BalanceDistribution 各校区余额分布(契约 v1.5 唯一可算项):
|
|
|
+ // ← `t_user_expense_school.balance` 按 `campus_id` 聚合(该表**有** schoolid/campusid)。
|
|
|
+ // 空 → `[]`。
|
|
|
+ BalanceDistribution []CampusBalanceItem `json:"balanceDistribution"`
|
|
|
// FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
|
|
|
// 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
|
|
|
FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
|
|
|
}
|
|
|
|
|
|
+// CampusBalanceItem 单校区余额(端点 21 的 `balanceDistribution` 元素)。
|
|
|
+type CampusBalanceItem struct {
|
|
|
+ // CampusID 校区 ID(← `t_user_expense_school.campus_id`;0 = 机构级账户)。
|
|
|
+ CampusID int64 `json:"campusId"`
|
|
|
+ // Balance 余额(元)← `t_user_expense_school.balance`。
|
|
|
+ Balance money.Amount `json:"balance"`
|
|
|
+}
|
|
|
+
|
|
|
// Dashboard 财务仪表盘(端点 21,P2)。
|
|
|
//
|
|
|
// 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
|
|
|
@@ -545,9 +497,18 @@ func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
+ rows, derr := s.expense.ListCampusBalances(ctx, s.sc.InstitutionID, in.CampusID)
|
|
|
+ if derr != nil {
|
|
|
+ return nil, derr
|
|
|
+ }
|
|
|
+ dist := make([]CampusBalanceItem, 0, len(rows))
|
|
|
+ for i := range rows {
|
|
|
+ dist = append(dist, CampusBalanceItem{CampusID: rows[i].CampusID, Balance: rows[i].Balance})
|
|
|
+ }
|
|
|
return &DashboardResult{
|
|
|
From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
|
|
|
- Balance: agg.Balance, TotalIncome: agg.TotalIncome,
|
|
|
+ BalanceDistribution: dist,
|
|
|
+ Balance: agg.Balance, TotalIncome: agg.TotalIncome,
|
|
|
TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
|
|
|
NetReceivable: agg.TotalIncome.Sub(agg.TotalExpenditure),
|
|
|
FlowsByCampusSupported: false,
|