支持自定义线下出款分类

This commit is contained in:
yml2213
2026-08-20 15:48:25 +08:00
parent f1b48f7921
commit 0b235d078d
9 changed files with 256 additions and 193 deletions
@@ -80,6 +80,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
ro.offline_settlement_remark AS remark, ro.offline_settlement_remark AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -125,6 +126,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name, COALESCE(NULLIF(operator.nickname, ''), operator.username, '') AS operator_name,
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark, CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category, '' AS category,
'' AS custom_category_name,
'' AS voucher_url, '' AS voucher_url,
NULL AS voided_at, NULL AS voided_at,
NULL AS voided_by, NULL AS voided_by,
@@ -160,6 +162,7 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name, COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
md.remark, md.remark,
md.category, md.category,
md.custom_category_name,
md.voucher_url, md.voucher_url,
md.voided_at, md.voided_at,
md.voided_by, md.voided_by,
@@ -202,37 +205,38 @@ func (r *Repository) applyDisbursementFilters(db *gorm.DB, query DisbursementQue
} }
type disbursementItemRow struct { type disbursementItemRow struct {
SourceType string SourceType string
SourceID uint64 SourceID uint64
BusinessNo string BusinessNo string
OrderID uint64 OrderID uint64
WithdrawalID uint64 WithdrawalID uint64
UserID uint64 UserID uint64
PayeeName string PayeeName string
PayeePhone string PayeePhone string
UploaderName string UploaderName string
SourceChannel string SourceChannel string
AccountType string AccountType string
AccountName string AccountName string
AccountNo string AccountNo string
BankName string BankName string
AmountCent int64 AmountCent int64
FeeCent int64 FeeCent int64
ActualAmountCent int64 ActualAmountCent int64
Status string Status string
RawStatus string RawStatus string
BusinessCreatedAt *time.Time BusinessCreatedAt *time.Time
PaidAt *time.Time PaidAt *time.Time
OperatorID *uint64 OperatorID *uint64
OperatorName string OperatorName string
Remark string Remark string
Category string Category string
VoucherURL string CustomCategoryName string
VoidedAt *time.Time VoucherURL string
VoidedBy *uint64 VoidedAt *time.Time
VoidedByName string VoidedBy *uint64
VoidReason string VoidedByName string
SourcePayload []byte VoidReason string
SourcePayload []byte
} }
func (r disbursementItemRow) toDTO() DisbursementItemDTO { func (r disbursementItemRow) toDTO() DisbursementItemDTO {
@@ -246,35 +250,36 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
} }
} }
return DisbursementItemDTO{ return DisbursementItemDTO{
SourceType: r.SourceType, SourceType: r.SourceType,
SourceID: r.SourceID, SourceID: r.SourceID,
BusinessNo: r.BusinessNo, BusinessNo: r.BusinessNo,
OrderID: r.OrderID, OrderID: r.OrderID,
WithdrawalID: r.WithdrawalID, WithdrawalID: r.WithdrawalID,
UserID: r.UserID, UserID: r.UserID,
PayeeName: r.PayeeName, PayeeName: r.PayeeName,
PayeePhone: phone, PayeePhone: phone,
UploaderName: r.UploaderName, UploaderName: r.UploaderName,
SourceChannel: r.SourceChannel, SourceChannel: r.SourceChannel,
AccountType: r.AccountType, AccountType: r.AccountType,
AccountName: r.AccountName, AccountName: r.AccountName,
AccountNo: r.AccountNo, AccountNo: r.AccountNo,
BankName: r.BankName, BankName: r.BankName,
AmountCent: r.AmountCent, AmountCent: r.AmountCent,
FeeCent: r.FeeCent, FeeCent: r.FeeCent,
ActualAmountCent: r.ActualAmountCent, ActualAmountCent: r.ActualAmountCent,
Status: r.Status, Status: r.Status,
RawStatus: r.RawStatus, RawStatus: r.RawStatus,
BusinessCreatedAt: r.BusinessCreatedAt, BusinessCreatedAt: r.BusinessCreatedAt,
PaidAt: r.PaidAt, PaidAt: r.PaidAt,
OperatorID: r.OperatorID, OperatorID: r.OperatorID,
OperatorName: r.OperatorName, OperatorName: r.OperatorName,
Remark: r.Remark, Remark: r.Remark,
Category: r.Category, Category: r.Category,
VoucherURL: r.VoucherURL, CustomCategoryName: r.CustomCategoryName,
VoidedAt: r.VoidedAt, VoucherURL: r.VoucherURL,
VoidedBy: r.VoidedBy, VoidedAt: r.VoidedAt,
VoidedByName: r.VoidedByName, VoidedBy: r.VoidedBy,
VoidReason: r.VoidReason, VoidedByName: r.VoidedByName,
VoidReason: r.VoidReason,
} }
} }
@@ -37,7 +37,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
`CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`, `CREATE TABLE users (id INTEGER PRIMARY KEY, nickname TEXT, phone TEXT)`,
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`, `CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, payee_name TEXT, id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT,
amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT, amount_cent INTEGER, paid_at DATETIME, remark TEXT, voucher_url TEXT,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME, status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME void_reason TEXT, created_at DATETIME
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
if err := db.Exec(`INSERT INTO manual_disbursements if err := db.Exec(`INSERT INTO manual_disbursements
(id, disbursement_no, category, payee_name, amount_cent, paid_at, remark, (id, disbursement_no, category, custom_category_name, payee_name, amount_cent, paid_at, remark,
voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at) voucher_url, status, created_by, voided_by, voided_at, void_reason, created_at)
VALUES VALUES
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?), (20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`, (21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil { at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
break break
} }
} }
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" { if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "custom" || manualItem.CustomCategoryName != "临时活动支出" || manualItem.OperatorName != "财务甲" {
t.Fatalf("其他线下出款信息不正确: %+v", manualItem) t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
} }
+55 -52
View File
@@ -102,45 +102,47 @@ type DisbursementListSummaryDTO struct {
} }
type DisbursementItemDTO struct { type DisbursementItemDTO struct {
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
SourceID uint64 `json:"source_id"` SourceID uint64 `json:"source_id"`
BusinessNo string `json:"business_no"` BusinessNo string `json:"business_no"`
OrderID uint64 `json:"order_id,omitempty"` OrderID uint64 `json:"order_id,omitempty"`
WithdrawalID uint64 `json:"withdrawal_id,omitempty"` WithdrawalID uint64 `json:"withdrawal_id,omitempty"`
UserID uint64 `json:"user_id"` UserID uint64 `json:"user_id"`
PayeeName string `json:"payee_name"` PayeeName string `json:"payee_name"`
PayeePhone string `json:"payee_phone"` PayeePhone string `json:"payee_phone"`
UploaderName string `json:"uploader_name"` UploaderName string `json:"uploader_name"`
SourceChannel string `json:"source_channel"` SourceChannel string `json:"source_channel"`
AccountType string `json:"account_type"` AccountType string `json:"account_type"`
AccountName string `json:"account_name"` AccountName string `json:"account_name"`
AccountNo string `json:"account_no"` AccountNo string `json:"account_no"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
AmountCent int64 `json:"amount_cent"` AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"` FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"` ActualAmountCent int64 `json:"actual_amount_cent"`
Status string `json:"status"` Status string `json:"status"`
RawStatus string `json:"raw_status"` RawStatus string `json:"raw_status"`
BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"` BusinessCreatedAt *time.Time `json:"business_created_at,omitempty"`
PaidAt *time.Time `json:"paid_at,omitempty"` PaidAt *time.Time `json:"paid_at,omitempty"`
OperatorID *uint64 `json:"operator_id,omitempty"` OperatorID *uint64 `json:"operator_id,omitempty"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Category string `json:"category"` Category string `json:"category"`
VoucherURL string `json:"voucher_url"` CustomCategoryName string `json:"custom_category_name"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoucherURL string `json:"voucher_url"`
VoidedBy *uint64 `json:"voided_by,omitempty"` VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedByName string `json:"voided_by_name"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidReason string `json:"void_reason"` VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"`
} }
type CreateManualDisbursementRequest struct { type CreateManualDisbursementRequest struct {
Category string `json:"category"` Category string `json:"category"`
PayeeName string `json:"payee_name"` CustomCategoryName string `json:"custom_category_name"`
AmountCent int64 `json:"amount_cent"` PayeeName string `json:"payee_name"`
PaidAt time.Time `json:"paid_at"` AmountCent int64 `json:"amount_cent"`
Remark string `json:"remark"` PaidAt time.Time `json:"paid_at"`
VoucherURL string `json:"voucher_url"` Remark string `json:"remark"`
VoucherURL string `json:"voucher_url"`
} }
type VoidManualDisbursementRequest struct { type VoidManualDisbursementRequest struct {
@@ -148,22 +150,23 @@ type VoidManualDisbursementRequest struct {
} }
type ManualDisbursementDTO struct { type ManualDisbursementDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"` DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"` Category string `json:"category"`
PayeeName string `json:"payee_name"` CustomCategoryName string `json:"custom_category_name"`
AmountCent int64 `json:"amount_cent"` PayeeName string `json:"payee_name"`
PaidAt time.Time `json:"paid_at"` AmountCent int64 `json:"amount_cent"`
Remark string `json:"remark"` PaidAt time.Time `json:"paid_at"`
VoucherURL string `json:"voucher_url"` Remark string `json:"remark"`
Status string `json:"status"` VoucherURL string `json:"voucher_url"`
CreatedBy uint64 `json:"created_by"` Status string `json:"status"`
CreatedByName string `json:"created_by_name"` CreatedBy uint64 `json:"created_by"`
VoidedBy *uint64 `json:"voided_by,omitempty"` CreatedByName string `json:"created_by_name"`
VoidedByName string `json:"voided_by_name"` VoidedBy *uint64 `json:"voided_by,omitempty"`
VoidedAt *time.Time `json:"voided_at,omitempty"` VoidedByName string `json:"voided_by_name"`
VoidReason string `json:"void_reason"` VoidedAt *time.Time `json:"voided_at,omitempty"`
CreatedAt time.Time `json:"created_at"` VoidReason string `json:"void_reason"`
CreatedAt time.Time `json:"created_at"`
} }
type FinanceSummaryDTO struct { type FinanceSummaryDTO struct {
@@ -16,21 +16,22 @@ import (
) )
type manualDisbursementRecord struct { type manualDisbursementRecord struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
PayeeName string CustomCategoryName string
AmountCent int64 PayeeName string
PaidAt time.Time AmountCent int64
Remark string PaidAt time.Time
VoucherURL string Remark string
Status string VoucherURL string
CreatedBy uint64 Status string
VoidedBy *uint64 CreatedBy uint64
VoidedAt *time.Time VoidedBy *uint64
VoidReason string VoidedAt *time.Time
CreatedAt time.Time VoidReason string
UpdatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
} }
func (manualDisbursementRecord) TableName() string { func (manualDisbursementRecord) TableName() string {
@@ -48,15 +49,16 @@ func (r *Repository) CreateManualDisbursement(
return nil, err return nil, err
} }
record := manualDisbursementRecord{ record := manualDisbursementRecord{
DisbursementNo: disbursementNo, DisbursementNo: disbursementNo,
Category: req.Category, Category: req.Category,
PayeeName: req.PayeeName, CustomCategoryName: req.CustomCategoryName,
AmountCent: req.AmountCent, PayeeName: req.PayeeName,
PaidAt: req.PaidAt, AmountCent: req.AmountCent,
Remark: req.Remark, PaidAt: req.PaidAt,
VoucherURL: req.VoucherURL, Remark: req.Remark,
Status: "paid", VoucherURL: req.VoucherURL,
CreatedBy: adminID, Status: "paid",
CreatedBy: adminID,
} }
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&record).Error; err != nil { if err := tx.Create(&record).Error; err != nil {
@@ -71,12 +73,13 @@ func (r *Repository) CreateManualDisbursement(
BizID: &id, BizID: &id,
Meta: meta, Meta: meta,
Detail: map[string]any{ Detail: map[string]any{
"disbursement_no": disbursementNo, "disbursement_no": disbursementNo,
"category": req.Category, "category": req.Category,
"payee_name": req.PayeeName, "custom_category_name": req.CustomCategoryName,
"amount_cent": req.AmountCent, "payee_name": req.PayeeName,
"paid_at": req.PaidAt, "amount_cent": req.AmountCent,
"has_voucher": req.VoucherURL != "", "paid_at": req.PaidAt,
"has_voucher": req.VoucherURL != "",
}, },
}) })
}) })
@@ -153,42 +156,44 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
return nil, err return nil, err
} }
return &ManualDisbursementDTO{ return &ManualDisbursementDTO{
ID: row.ID, ID: row.ID,
DisbursementNo: row.DisbursementNo, DisbursementNo: row.DisbursementNo,
Category: row.Category, Category: row.Category,
PayeeName: row.PayeeName, CustomCategoryName: row.CustomCategoryName,
AmountCent: row.AmountCent, PayeeName: row.PayeeName,
PaidAt: row.PaidAt, AmountCent: row.AmountCent,
Remark: row.Remark, PaidAt: row.PaidAt,
VoucherURL: row.VoucherURL, Remark: row.Remark,
Status: row.Status, VoucherURL: row.VoucherURL,
CreatedBy: row.CreatedBy, Status: row.Status,
CreatedByName: row.CreatedByName, CreatedBy: row.CreatedBy,
VoidedBy: row.VoidedBy, CreatedByName: row.CreatedByName,
VoidedByName: row.VoidedByName, VoidedBy: row.VoidedBy,
VoidedAt: row.VoidedAt, VoidedByName: row.VoidedByName,
VoidReason: row.VoidReason, VoidedAt: row.VoidedAt,
CreatedAt: row.CreatedAt, VoidReason: row.VoidReason,
CreatedAt: row.CreatedAt,
}, nil }, nil
} }
type manualDisbursementDetailRow struct { type manualDisbursementDetailRow struct {
ID uint64 ID uint64
DisbursementNo string DisbursementNo string
Category string Category string
PayeeName string CustomCategoryName string
AmountCent int64 PayeeName string
PaidAt time.Time AmountCent int64
Remark string PaidAt time.Time
VoucherURL string Remark string
Status string VoucherURL string
CreatedBy uint64 Status string
CreatedByName string CreatedBy uint64
VoidedBy *uint64 CreatedByName string
VoidedByName string VoidedBy *uint64
VoidedAt *time.Time VoidedByName string
VoidReason string VoidedAt *time.Time
CreatedAt time.Time VoidReason string
CreatedAt time.Time
} }
func newManualDisbursementNo() (string, error) { func newManualDisbursementNo() (string, error) {
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
statements := []string{ statements := []string{
`CREATE TABLE manual_disbursements ( `CREATE TABLE manual_disbursements (
id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE, id INTEGER PRIMARY KEY AUTOINCREMENT, disbursement_no TEXT NOT NULL UNIQUE,
category TEXT NOT NULL, payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL, category TEXT NOT NULL, custom_category_name TEXT NOT NULL DEFAULT '', payee_name TEXT NOT NULL, amount_cent INTEGER NOT NULL,
paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL, paid_at DATETIME NOT NULL, remark TEXT NOT NULL, voucher_url TEXT NOT NULL,
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER, status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME, voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
@@ -47,17 +47,18 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
repo := NewRepository(db) repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation()) paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "operating_expense", Category: "custom",
PayeeName: "测试供应商", CustomCategoryName: "临时活动支出",
AmountCent: 8800, PayeeName: "测试供应商",
PaidAt: paidAt, AmountCent: 8800,
Remark: "测试运营支出", PaidAt: paidAt,
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp", Remark: "测试运营支出",
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
}, 7, auditlog.Meta{RequestID: "req-create"}) }, 7, auditlog.Meta{RequestID: "req-create"})
if err != nil { if err != nil {
t.Fatalf("创建其他线下出款失败: %v", err) t.Fatalf("创建其他线下出款失败: %v", err)
} }
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" { if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" || created.CustomCategoryName != "临时活动支出" {
t.Fatalf("创建结果不正确: %+v", created) t.Fatalf("创建结果不正确: %+v", created)
} }
@@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
} }
func TestManualDisbursementValidation(t *testing.T) { func TestManualDisbursementValidation(t *testing.T) {
if !validManualDisbursementCategory("duoduo_deposit_refund") {
t.Fatal("多多退押金分类应为有效分类")
}
if !validManualDisbursementCustomCategoryName("custom", "临时活动支出") || validManualDisbursementCustomCategoryName("custom", "") {
t.Fatal("自定义分类名称校验不正确")
}
service := NewService(&Repository{}) service := NewService(&Repository{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{ _, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown", Category: "unknown",
@@ -74,10 +74,12 @@ func (s *Service) CreateManualDisbursement(
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
} }
req.Category = strings.TrimSpace(req.Category) req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName) req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark) req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL) req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) || if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 || utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() || req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 || utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
@@ -106,13 +108,20 @@ func (s *Service) VoidManualDisbursement(
func validManualDisbursementCategory(category string) bool { func validManualDisbursementCategory(category string) bool {
switch category { switch category {
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other": case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "duoduo_deposit_refund", "other", "custom":
return true return true
default: default:
return false return false
} }
} }
func validManualDisbursementCustomCategoryName(category, name string) bool {
if category != "custom" {
return name == ""
}
return utf8.RuneCountInString(name) >= 1 && utf8.RuneCountInString(name) <= 50
}
func validManualVoucherURL(raw string) bool { func validManualVoucherURL(raw string) bool {
if raw == "" { if raw == "" {
return true return true
@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE manual_disbursements
ADD COLUMN custom_category_name VARCHAR(50) NOT NULL DEFAULT '' COMMENT '自定义出款分类名称';
-- +goose Down
ALTER TABLE manual_disbursements
DROP COLUMN custom_category_name;
@@ -144,6 +144,7 @@ export interface FinanceDisbursementItem {
operator_name: string operator_name: string
remark: string remark: string
category: string category: string
custom_category_name: string
voucher_url: string voucher_url: string
voided_at?: string voided_at?: string
voided_by?: number voided_by?: number
@@ -153,6 +154,7 @@ export interface FinanceDisbursementItem {
export interface CreateManualDisbursementPayload { export interface CreateManualDisbursementPayload {
category: string category: string
custom_category_name?: string
payee_name: string payee_name: string
amount_cent: number amount_cent: number
paid_at: string paid_at: string
@@ -164,6 +166,7 @@ export interface ManualDisbursement {
id: number id: number
disbursement_no: string disbursement_no: string
category: string category: string
custom_category_name: string
payee_name: string payee_name: string
amount_cent: number amount_cent: number
paid_at: string paid_at: string
@@ -42,10 +42,13 @@ const manualCategories = [
{ value: 'seller_supplement', label: '卖家补款' }, { value: 'seller_supplement', label: '卖家补款' },
{ value: 'operating_expense', label: '运营支出' }, { value: 'operating_expense', label: '运营支出' },
{ value: 'channel_fee', label: '渠道费用' }, { value: 'channel_fee', label: '渠道费用' },
{ value: 'duoduo_deposit_refund', label: '多多退押金' },
{ value: 'other', label: '其他' }, { value: 'other', label: '其他' },
{ value: 'custom', label: '自定义分类' },
] ]
const createForm = reactive({ const createForm = reactive({
category: '', category: '',
custom_category_name: '',
payee_name: '', payee_name: '',
amount_yuan: 0, amount_yuan: 0,
paid_at: new Date(), paid_at: new Date(),
@@ -130,6 +133,7 @@ function openCreate() {
function resetCreateForm() { function resetCreateForm() {
createForm.category = '' createForm.category = ''
createForm.custom_category_name = ''
createForm.payee_name = '' createForm.payee_name = ''
createForm.amount_yuan = 0 createForm.amount_yuan = 0
createForm.paid_at = new Date() createForm.paid_at = new Date()
@@ -169,10 +173,16 @@ async function submitManualDisbursement() {
ElMessage.warning('请填写出款用途') ElMessage.warning('请填写出款用途')
return return
} }
if (createForm.category === 'custom' && !createForm.custom_category_name.trim()) {
ElMessage.warning('请填写自定义分类名称')
return
}
createSaving.value = true createSaving.value = true
try { try {
await createManualDisbursement({ await createManualDisbursement({
category: createForm.category, category: createForm.category,
custom_category_name:
createForm.category === 'custom' ? createForm.custom_category_name.trim() : undefined,
payee_name: createForm.payee_name.trim(), payee_name: createForm.payee_name.trim(),
amount_cent: amountCent, amount_cent: amountCent,
paid_at: createForm.paid_at.toISOString(), paid_at: createForm.paid_at.toISOString(),
@@ -283,7 +293,8 @@ function itemStatusLabel(row: FinanceDisbursementItem) {
return statusLabel(row.status) return statusLabel(row.status)
} }
function categoryLabel(category: string) { function categoryLabel(category: string, customCategoryName = '') {
if (category === 'custom' && customCategoryName) return customCategoryName
return manualCategories.find(item => item.value === category)?.label || category || '-' return manualCategories.find(item => item.value === category)?.label || category || '-'
} }
@@ -429,7 +440,7 @@ function accountTypeLabel(type: string) {
<small v-if="row.payee_phone">{{ row.payee_phone }}</small> <small v-if="row.payee_phone">{{ row.payee_phone }}</small>
<small v-else-if="row.source_type === 'withdrawal'">用户 ID {{ row.user_id }}</small> <small v-else-if="row.source_type === 'withdrawal'">用户 ID {{ row.user_id }}</small>
<small v-else-if="row.source_type === 'manual_offline'"> <small v-else-if="row.source_type === 'manual_offline'">
{{ categoryLabel(row.category) }} {{ categoryLabel(row.category, row.custom_category_name) }}
</small> </small>
<small v-else>联系方式未登记</small> <small v-else>联系方式未登记</small>
<small v-if="row.uploader_name">上传{{ row.uploader_name }}</small> <small v-if="row.uploader_name">上传{{ row.uploader_name }}</small>
@@ -445,7 +456,9 @@ function accountTypeLabel(type: string) {
<small v-if="row.bank_name">{{ row.bank_name }}</small> <small v-if="row.bank_name">{{ row.bank_name }}</small>
</div> </div>
<div v-else-if="row.source_type === 'manual_offline'" class="stack-cell"> <div v-else-if="row.source_type === 'manual_offline'" class="stack-cell">
<el-tag size="small" effect="plain">{{ categoryLabel(row.category) }}</el-tag> <el-tag size="small" effect="plain">
{{ categoryLabel(row.category, row.custom_category_name) }}
</el-tag>
<small>{{ row.voucher_url ? '已上传付款凭证' : '未上传付款凭证' }}</small> <small>{{ row.voucher_url ? '已上传付款凭证' : '未上传付款凭证' }}</small>
</div> </div>
<span v-else class="muted-text">线下约定</span> <span v-else class="muted-text">线下约定</span>
@@ -545,6 +558,14 @@ function accountTypeLabel(type: string) {
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="createForm.category === 'custom'" label="自定义分类名称" required>
<el-input
v-model="createForm.custom_category_name"
maxlength="50"
show-word-limit
placeholder="请输入分类名称"
/>
</el-form-item>
<el-form-item label="收款人" required> <el-form-item label="收款人" required>
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" /> <el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
</el-form-item> </el-form-item>
@@ -634,7 +655,7 @@ function accountTypeLabel(type: string) {
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="出款分类"> <el-descriptions-item label="出款分类">
{{ categoryLabel(selectedManual.category) }} {{ categoryLabel(selectedManual.category, selectedManual.custom_category_name) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="收款人">{{ selectedManual.payee_name }}</el-descriptions-item> <el-descriptions-item label="收款人">{{ selectedManual.payee_name }}</el-descriptions-item>
<el-descriptions-item label="出款金额"> <el-descriptions-item label="出款金额">