支持自定义线下出款分类

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,
ro.offline_settlement_remark AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
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,
CASE WHEN wr.payment_remark <> '' THEN wr.payment_remark ELSE wr.review_remark END AS remark,
'' AS category,
'' AS custom_category_name,
'' AS voucher_url,
NULL AS voided_at,
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,
md.remark,
md.category,
md.custom_category_name,
md.voucher_url,
md.voided_at,
md.voided_by,
@@ -227,6 +230,7 @@ type disbursementItemRow struct {
OperatorName string
Remark string
Category string
CustomCategoryName string
VoucherURL string
VoidedAt *time.Time
VoidedBy *uint64
@@ -271,6 +275,7 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
OperatorName: r.OperatorName,
Remark: r.Remark,
Category: r.Category,
CustomCategoryName: r.CustomCategoryName,
VoucherURL: r.VoucherURL,
VoidedAt: r.VoidedAt,
VoidedBy: r.VoidedBy,
@@ -37,7 +37,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
`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 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,
status TEXT, created_by INTEGER, voided_by INTEGER, voided_at DATETIME,
void_reason TEXT, created_at DATETIME
@@ -87,11 +87,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
t.Fatal(err)
}
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)
VALUES
(20, 'OD202607010001', 'user_compensation', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
(20, 'OD202607010001', 'custom', '临时活动支出', '李四', 2500, ?, '用户补偿', '', 'paid', 7, NULL, NULL, '', ?),
(21, 'OD202607010002', 'other', '', '测试单位', 6000, ?, '重复录入', '', 'voided', 7, 8, ?, '重复记录', ?)`,
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
t.Fatal(err)
}
@@ -142,7 +142,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
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)
}
@@ -127,6 +127,7 @@ type DisbursementItemDTO struct {
OperatorName string `json:"operator_name"`
Remark string `json:"remark"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
VoucherURL string `json:"voucher_url"`
VoidedAt *time.Time `json:"voided_at,omitempty"`
VoidedBy *uint64 `json:"voided_by,omitempty"`
@@ -136,6 +137,7 @@ type DisbursementItemDTO struct {
type CreateManualDisbursementRequest struct {
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
@@ -151,6 +153,7 @@ type ManualDisbursementDTO struct {
ID uint64 `json:"id"`
DisbursementNo string `json:"disbursement_no"`
Category string `json:"category"`
CustomCategoryName string `json:"custom_category_name"`
PayeeName string `json:"payee_name"`
AmountCent int64 `json:"amount_cent"`
PaidAt time.Time `json:"paid_at"`
@@ -19,6 +19,7 @@ type manualDisbursementRecord struct {
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt time.Time
@@ -50,6 +51,7 @@ func (r *Repository) CreateManualDisbursement(
record := manualDisbursementRecord{
DisbursementNo: disbursementNo,
Category: req.Category,
CustomCategoryName: req.CustomCategoryName,
PayeeName: req.PayeeName,
AmountCent: req.AmountCent,
PaidAt: req.PaidAt,
@@ -73,6 +75,7 @@ func (r *Repository) CreateManualDisbursement(
Detail: map[string]any{
"disbursement_no": disbursementNo,
"category": req.Category,
"custom_category_name": req.CustomCategoryName,
"payee_name": req.PayeeName,
"amount_cent": req.AmountCent,
"paid_at": req.PaidAt,
@@ -156,6 +159,7 @@ func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*Ma
ID: row.ID,
DisbursementNo: row.DisbursementNo,
Category: row.Category,
CustomCategoryName: row.CustomCategoryName,
PayeeName: row.PayeeName,
AmountCent: row.AmountCent,
PaidAt: row.PaidAt,
@@ -176,6 +180,7 @@ type manualDisbursementDetailRow struct {
ID uint64
DisbursementNo string
Category string
CustomCategoryName string
PayeeName string
AmountCent int64
PaidAt time.Time
@@ -21,7 +21,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
statements := []string{
`CREATE TABLE manual_disbursements (
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,
status TEXT NOT NULL, created_by INTEGER NOT NULL, voided_by INTEGER,
voided_at DATETIME, void_reason TEXT NOT NULL DEFAULT '', created_at DATETIME,
@@ -47,7 +47,8 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
repo := NewRepository(db)
paidAt := time.Date(2026, 7, 20, 9, 30, 0, 0, timeutil.ShanghaiLocation())
created, err := repo.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "operating_expense",
Category: "custom",
CustomCategoryName: "临时活动支出",
PayeeName: "测试供应商",
AmountCent: 8800,
PaidAt: paidAt,
@@ -57,7 +58,7 @@ func TestManualDisbursementCreateAndVoid(t *testing.T) {
if err != nil {
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)
}
@@ -82,6 +83,13 @@ func TestManualDisbursementCreateAndVoid(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{})
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
Category: "unknown",
@@ -74,10 +74,12 @@ func (s *Service) CreateManualDisbursement(
return nil, ErrDependencyUnavailable
}
req.Category = strings.TrimSpace(req.Category)
req.CustomCategoryName = strings.TrimSpace(req.CustomCategoryName)
req.PayeeName = strings.TrimSpace(req.PayeeName)
req.Remark = strings.TrimSpace(req.Remark)
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
if !validManualDisbursementCategory(req.Category) ||
!validManualDisbursementCustomCategoryName(req.Category, req.CustomCategoryName) ||
utf8.RuneCountInString(req.PayeeName) < 1 || utf8.RuneCountInString(req.PayeeName) > 100 ||
req.AmountCent <= 0 || req.PaidAt.IsZero() ||
utf8.RuneCountInString(req.Remark) < 1 || utf8.RuneCountInString(req.Remark) > 500 ||
@@ -106,13 +108,20 @@ func (s *Service) VoidManualDisbursement(
func validManualDisbursementCategory(category string) bool {
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
default:
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 {
if raw == "" {
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
remark: string
category: string
custom_category_name: string
voucher_url: string
voided_at?: string
voided_by?: number
@@ -153,6 +154,7 @@ export interface FinanceDisbursementItem {
export interface CreateManualDisbursementPayload {
category: string
custom_category_name?: string
payee_name: string
amount_cent: number
paid_at: string
@@ -164,6 +166,7 @@ export interface ManualDisbursement {
id: number
disbursement_no: string
category: string
custom_category_name: string
payee_name: string
amount_cent: number
paid_at: string
@@ -42,10 +42,13 @@ const manualCategories = [
{ value: 'seller_supplement', label: '卖家补款' },
{ value: 'operating_expense', label: '运营支出' },
{ value: 'channel_fee', label: '渠道费用' },
{ value: 'duoduo_deposit_refund', label: '多多退押金' },
{ value: 'other', label: '其他' },
{ value: 'custom', label: '自定义分类' },
]
const createForm = reactive({
category: '',
custom_category_name: '',
payee_name: '',
amount_yuan: 0,
paid_at: new Date(),
@@ -130,6 +133,7 @@ function openCreate() {
function resetCreateForm() {
createForm.category = ''
createForm.custom_category_name = ''
createForm.payee_name = ''
createForm.amount_yuan = 0
createForm.paid_at = new Date()
@@ -169,10 +173,16 @@ async function submitManualDisbursement() {
ElMessage.warning('请填写出款用途')
return
}
if (createForm.category === 'custom' && !createForm.custom_category_name.trim()) {
ElMessage.warning('请填写自定义分类名称')
return
}
createSaving.value = true
try {
await createManualDisbursement({
category: createForm.category,
custom_category_name:
createForm.category === 'custom' ? createForm.custom_category_name.trim() : undefined,
payee_name: createForm.payee_name.trim(),
amount_cent: amountCent,
paid_at: createForm.paid_at.toISOString(),
@@ -283,7 +293,8 @@ function itemStatusLabel(row: FinanceDisbursementItem) {
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 || '-'
}
@@ -429,7 +440,7 @@ function accountTypeLabel(type: string) {
<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 === 'manual_offline'">
{{ categoryLabel(row.category) }}
{{ categoryLabel(row.category, row.custom_category_name) }}
</small>
<small v-else>联系方式未登记</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>
</div>
<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>
</div>
<span v-else class="muted-text">线下约定</span>
@@ -545,6 +558,14 @@ function accountTypeLabel(type: string) {
/>
</el-select>
</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-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
</el-form-item>
@@ -634,7 +655,7 @@ function accountTypeLabel(type: string) {
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="出款分类">
{{ categoryLabel(selectedManual.category) }}
{{ categoryLabel(selectedManual.category, selectedManual.custom_category_name) }}
</el-descriptions-item>
<el-descriptions-item label="收款人">{{ selectedManual.payee_name }}</el-descriptions-item>
<el-descriptions-item label="出款金额">