新增其他线下出款记账
This commit is contained in:
@@ -290,8 +290,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
|||||||
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
|
item.OfflineSettlementPaidCount = row.OfflineSettlementPaidCount
|
||||||
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
|
item.WithdrawalPaidAmountCent = row.WithdrawalPaidAmountCent
|
||||||
item.WithdrawalPaidCount = row.WithdrawalPaidCount
|
item.WithdrawalPaidCount = row.WithdrawalPaidCount
|
||||||
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent
|
item.ManualPaidAmountCent = row.ManualPaidAmountCent
|
||||||
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount
|
item.ManualPaidCount = row.ManualPaidCount
|
||||||
|
item.DisbursementPaidAmountCent = row.OfflineSettlementPaidAmountCent + row.WithdrawalPaidAmountCent + row.ManualPaidAmountCent
|
||||||
|
item.DisbursementPaidCount = row.OfflineSettlementPaidCount + row.WithdrawalPaidCount + row.ManualPaidCount
|
||||||
itemsByDate[date] = item
|
itemsByDate[date] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,21 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manualPaid struct {
|
||||||
|
AmountCent int64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
if err := db.Table("manual_disbursements").
|
||||||
|
Select(`COALESCE(SUM(amount_cent), 0) AS amount_cent, COUNT(id) AS count`).
|
||||||
|
Where("status = ?", "paid").
|
||||||
|
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
|
||||||
|
Scan(&manualPaid).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &DisbursementSummaryDTO{
|
return &DisbursementSummaryDTO{
|
||||||
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent,
|
PaidAmountCent: offlinePaid.AmountCent + withdrawalPaid.ActualAmountCent + manualPaid.AmountCent,
|
||||||
PaidCount: offlinePaid.Count + withdrawalPaid.Count,
|
PaidCount: offlinePaid.Count + withdrawalPaid.Count + manualPaid.Count,
|
||||||
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
|
PendingPaymentAmountCent: offlinePending.AmountCent + withdrawalPending.ActualAmountCent,
|
||||||
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
|
PendingPaymentCount: offlinePending.Count + withdrawalPending.Count,
|
||||||
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
|
OfflineSettlementPaidAmountCent: offlinePaid.AmountCent,
|
||||||
@@ -88,6 +100,8 @@ func (r *Repository) disbursementSummary(ctx context.Context, query DashboardQue
|
|||||||
WithdrawalPendingCount: withdrawalPending.Count,
|
WithdrawalPendingCount: withdrawalPending.Count,
|
||||||
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
|
WithdrawalReviewAmountCent: withdrawalReview.ActualAmountCent,
|
||||||
WithdrawalReviewCount: withdrawalReview.Count,
|
WithdrawalReviewCount: withdrawalReview.Count,
|
||||||
|
ManualPaidAmountCent: manualPaid.AmountCent,
|
||||||
|
ManualPaidCount: manualPaid.Count,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +132,19 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows))
|
manualRows := make([]dailyDisbursementSourceRow, 0)
|
||||||
|
if err := db.Table("manual_disbursements").
|
||||||
|
Select(`DATE(paid_at) AS date,
|
||||||
|
COALESCE(SUM(amount_cent), 0) AS amount_cent,
|
||||||
|
COUNT(id) AS count`).
|
||||||
|
Where("status = ?", "paid").
|
||||||
|
Where("paid_at >= ? AND paid_at <= ?", query.StartDate, query.EndDate).
|
||||||
|
Group("DATE(paid_at)").
|
||||||
|
Scan(&manualRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsByDate := make(map[string]dailyDisbursementRow, len(offlineRows)+len(withdrawalRows)+len(manualRows))
|
||||||
for _, row := range offlineRows {
|
for _, row := range offlineRows {
|
||||||
date := dailyDateKey(row.Date)
|
date := dailyDateKey(row.Date)
|
||||||
item := rowsByDate[date]
|
item := rowsByDate[date]
|
||||||
@@ -135,6 +161,14 @@ func (r *Repository) dailyDisbursements(ctx context.Context, query DashboardQuer
|
|||||||
item.WithdrawalPaidCount = row.Count
|
item.WithdrawalPaidCount = row.Count
|
||||||
rowsByDate[date] = item
|
rowsByDate[date] = item
|
||||||
}
|
}
|
||||||
|
for _, row := range manualRows {
|
||||||
|
date := dailyDateKey(row.Date)
|
||||||
|
item := rowsByDate[date]
|
||||||
|
item.Date = date
|
||||||
|
item.ManualPaidAmountCent = row.AmountCent
|
||||||
|
item.ManualPaidCount = row.Count
|
||||||
|
rowsByDate[date] = item
|
||||||
|
}
|
||||||
|
|
||||||
rows := make([]dailyDisbursementRow, 0, len(rowsByDate))
|
rows := make([]dailyDisbursementRow, 0, len(rowsByDate))
|
||||||
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
for day := dayStart(query.StartDate); !day.After(query.EndDate); day = day.AddDate(0, 0, 1) {
|
||||||
@@ -157,4 +191,6 @@ type dailyDisbursementRow struct {
|
|||||||
OfflineSettlementPaidCount int64
|
OfflineSettlementPaidCount int64
|
||||||
WithdrawalPaidAmountCent int64
|
WithdrawalPaidAmountCent int64
|
||||||
WithdrawalPaidCount int64
|
WithdrawalPaidCount int64
|
||||||
|
ManualPaidAmountCent int64
|
||||||
|
ManualPaidCount int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
|||||||
ro.offline_settled_by AS operator_id,
|
ro.offline_settled_by AS operator_id,
|
||||||
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 voucher_url,
|
||||||
|
NULL AS voided_at,
|
||||||
|
NULL AS voided_by,
|
||||||
|
'' AS voided_by_name,
|
||||||
|
'' AS void_reason,
|
||||||
lu.parsed_payload AS source_payload`).
|
lu.parsed_payload AS source_payload`).
|
||||||
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
||||||
SELECT MAX(lu2.id) FROM listing_uploads AS lu2 WHERE lu2.listing_id = ro.listing_id
|
SELECT MAX(lu2.id) FROM listing_uploads AS lu2 WHERE lu2.listing_id = ro.listing_id
|
||||||
@@ -118,11 +124,52 @@ func (r *Repository) disbursementListBaseQuery(ctx context.Context) *gorm.DB {
|
|||||||
COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id,
|
COALESCE(wr.paid_by, wr.reviewed_by) AS operator_id,
|
||||||
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 voucher_url,
|
||||||
|
NULL AS voided_at,
|
||||||
|
NULL AS voided_by,
|
||||||
|
'' AS voided_by_name,
|
||||||
|
'' AS void_reason,
|
||||||
NULL AS source_payload`).
|
NULL AS source_payload`).
|
||||||
Joins("LEFT JOIN users AS u ON u.id = wr.user_id").
|
Joins("LEFT JOIN users AS u ON u.id = wr.user_id").
|
||||||
Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)")
|
Joins("LEFT JOIN admin_users AS operator ON operator.id = COALESCE(wr.paid_by, wr.reviewed_by)")
|
||||||
|
|
||||||
union := db.Raw("? UNION ALL ?", platformManaged, withdrawal)
|
manualOffline := db.Table("manual_disbursements AS md").
|
||||||
|
Select(`'manual_offline' AS source_type,
|
||||||
|
md.id AS source_id,
|
||||||
|
md.disbursement_no AS business_no,
|
||||||
|
0 AS order_id,
|
||||||
|
0 AS withdrawal_id,
|
||||||
|
0 AS user_id,
|
||||||
|
md.payee_name,
|
||||||
|
'' AS payee_phone,
|
||||||
|
'' AS uploader_name,
|
||||||
|
'' AS source_channel,
|
||||||
|
'' AS account_type,
|
||||||
|
'' AS account_name,
|
||||||
|
'' AS account_no,
|
||||||
|
'' AS bank_name,
|
||||||
|
md.amount_cent,
|
||||||
|
0 AS fee_cent,
|
||||||
|
md.amount_cent AS actual_amount_cent,
|
||||||
|
md.status,
|
||||||
|
md.status AS raw_status,
|
||||||
|
md.created_at AS business_created_at,
|
||||||
|
md.paid_at,
|
||||||
|
md.created_by AS operator_id,
|
||||||
|
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS operator_name,
|
||||||
|
md.remark,
|
||||||
|
md.category,
|
||||||
|
md.voucher_url,
|
||||||
|
md.voided_at,
|
||||||
|
md.voided_by,
|
||||||
|
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name,
|
||||||
|
md.void_reason,
|
||||||
|
NULL AS source_payload`).
|
||||||
|
Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by").
|
||||||
|
Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by")
|
||||||
|
|
||||||
|
union := db.Raw("? UNION ALL ? UNION ALL ?", platformManaged, withdrawal, manualOffline)
|
||||||
return db.Table("(?) AS d", union)
|
return db.Table("(?) AS d", union)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +226,12 @@ type disbursementItemRow struct {
|
|||||||
OperatorID *uint64
|
OperatorID *uint64
|
||||||
OperatorName string
|
OperatorName string
|
||||||
Remark string
|
Remark string
|
||||||
|
Category string
|
||||||
|
VoucherURL string
|
||||||
|
VoidedAt *time.Time
|
||||||
|
VoidedBy *uint64
|
||||||
|
VoidedByName string
|
||||||
|
VoidReason string
|
||||||
SourcePayload []byte
|
SourcePayload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,5 +270,11 @@ func (r disbursementItemRow) toDTO() DisbursementItemDTO {
|
|||||||
OperatorID: r.OperatorID,
|
OperatorID: r.OperatorID,
|
||||||
OperatorName: r.OperatorName,
|
OperatorName: r.OperatorName,
|
||||||
Remark: r.Remark,
|
Remark: r.Remark,
|
||||||
|
Category: r.Category,
|
||||||
|
VoucherURL: r.VoucherURL,
|
||||||
|
VoidedAt: r.VoidedAt,
|
||||||
|
VoidedBy: r.VoidedBy,
|
||||||
|
VoidedByName: r.VoidedByName,
|
||||||
|
VoidReason: r.VoidReason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ 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 (
|
||||||
|
id INTEGER PRIMARY KEY, disbursement_no TEXT, category TEXT, 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
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
for _, statement := range statements {
|
for _, statement := range statements {
|
||||||
if err := db.Exec(statement).Error; err != nil {
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
@@ -50,7 +56,7 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
|
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
|
||||||
(7, '财务甲', 'finance_a')`).Error; err != nil {
|
(7, '财务甲', 'finance_a'), (8, '财务乙', 'finance_b')`).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.Exec(`INSERT INTO listing_uploads
|
if err := db.Exec(`INSERT INTO listing_uploads
|
||||||
@@ -80,6 +86,15 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
|||||||
at(5), at(6), at(7), at(8)).Error; err != nil {
|
at(5), at(6), at(7), at(8)).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO manual_disbursements
|
||||||
|
(id, disbursement_no, category, 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, ?, '重复记录', ?)`,
|
||||||
|
at(6), at(5), at(7), at(8), at(5)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
repo := NewRepository(db)
|
repo := NewRepository(db)
|
||||||
query := DisbursementQuery{
|
query := DisbursementQuery{
|
||||||
@@ -93,11 +108,11 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Disbursements() error = %v", err)
|
t.Fatalf("Disbursements() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.Total != 5 || len(result.Items) != 5 {
|
if result.Total != 7 || len(result.Items) != 7 {
|
||||||
t.Fatalf("记录数 = %d/%d, want 5/5", result.Total, len(result.Items))
|
t.Fatalf("记录数 = %d/%d, want 7/7", result.Total, len(result.Items))
|
||||||
}
|
}
|
||||||
if result.Summary.PaidAmountCent != 21900 || result.Summary.PaidCount != 2 {
|
if result.Summary.PaidAmountCent != 24400 || result.Summary.PaidCount != 3 {
|
||||||
t.Fatalf("已出款 = %d/%d, want 21900/2", result.Summary.PaidAmountCent, result.Summary.PaidCount)
|
t.Fatalf("已出款 = %d/%d, want 24400/3", result.Summary.PaidAmountCent, result.Summary.PaidCount)
|
||||||
}
|
}
|
||||||
if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 {
|
if result.Summary.PaymentPendingAmountCent != 11000 || result.Summary.PaymentPendingCount != 2 {
|
||||||
t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount)
|
t.Fatalf("待打款 = %d/%d, want 11000/2", result.Summary.PaymentPendingAmountCent, result.Summary.PaymentPendingCount)
|
||||||
@@ -120,6 +135,17 @@ func TestDisbursementListCombinesPlatformSettlementsAndWithdrawals(t *testing.T)
|
|||||||
t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName)
|
t.Fatalf("平台收款人/上传人 = %s/%s/%s", platformItem.PayeeName, platformItem.PayeePhone, platformItem.UploaderName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manualItem *DisbursementItemDTO
|
||||||
|
for index := range result.Items {
|
||||||
|
if result.Items[index].BusinessNo == "OD202607010001" {
|
||||||
|
manualItem = &result.Items[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manualItem == nil || manualItem.SourceType != "manual_offline" || manualItem.Category != "user_compensation" || manualItem.OperatorName != "财务甲" {
|
||||||
|
t.Fatalf("其他线下出款信息不正确: %+v", manualItem)
|
||||||
|
}
|
||||||
|
|
||||||
paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{
|
paidWithdrawals, err := repo.Disbursements(t.Context(), DisbursementQuery{
|
||||||
SourceType: "withdrawal",
|
SourceType: "withdrawal",
|
||||||
Status: "paid",
|
Status: "paid",
|
||||||
|
|||||||
@@ -35,10 +35,19 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
)`).Error; err != nil {
|
)`).Error; err != nil {
|
||||||
t.Fatalf("创建提现表失败: %v", err)
|
t.Fatalf("创建提现表失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Exec(`CREATE TABLE manual_disbursements (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
amount_cent INTEGER NOT NULL,
|
||||||
|
paid_at DATETIME NOT NULL
|
||||||
|
)`).Error; err != nil {
|
||||||
|
t.Fatalf("创建其他线下出款表失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
loc := timeutil.ShanghaiLocation()
|
loc := timeutil.ShanghaiLocation()
|
||||||
inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc)
|
inRangeOffline := time.Date(2026, 7, 1, 10, 0, 0, 0, loc)
|
||||||
inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc)
|
inRangeWithdrawal := time.Date(2026, 7, 2, 11, 0, 0, 0, loc)
|
||||||
|
inRangeManual := time.Date(2026, 7, 3, 12, 0, 0, 0, loc)
|
||||||
outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc)
|
outOfRange := time.Date(2026, 6, 30, 23, 59, 0, 0, loc)
|
||||||
|
|
||||||
for _, args := range [][]any{
|
for _, args := range [][]any{
|
||||||
@@ -54,6 +63,16 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
t.Fatalf("写入订单失败: %v", err)
|
t.Fatalf("写入订单失败: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, args := range [][]any{
|
||||||
|
{1, "paid", 2500, inRangeManual},
|
||||||
|
{2, "paid", 1000, outOfRange},
|
||||||
|
{3, "voided", 6000, inRangeManual},
|
||||||
|
} {
|
||||||
|
if err := db.Exec(`INSERT INTO manual_disbursements
|
||||||
|
(id, status, amount_cent, paid_at) VALUES (?, ?, ?, ?)`, args...).Error; err != nil {
|
||||||
|
t.Fatalf("写入其他线下出款失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, args := range [][]any{
|
for _, args := range [][]any{
|
||||||
{1, "completed", 10000, 100, 9900, inRangeWithdrawal},
|
{1, "completed", 10000, 100, 9900, inRangeWithdrawal},
|
||||||
{2, "completed", 5000, 0, 5000, outOfRange},
|
{2, "completed", 5000, 0, 5000, outOfRange},
|
||||||
@@ -77,8 +96,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("disbursementSummary() error = %v", err)
|
t.Fatalf("disbursementSummary() error = %v", err)
|
||||||
}
|
}
|
||||||
if summary.PaidAmountCent != 21900 || summary.PaidCount != 2 {
|
if summary.PaidAmountCent != 24400 || summary.PaidCount != 3 {
|
||||||
t.Fatalf("实际出款 = %d/%d, want 21900/2", summary.PaidAmountCent, summary.PaidCount)
|
t.Fatalf("实际出款 = %d/%d, want 24400/3", summary.PaidAmountCent, summary.PaidCount)
|
||||||
}
|
}
|
||||||
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
|
if summary.OfflineSettlementPaidAmountCent != 12000 || summary.OfflineSettlementPaidCount != 1 {
|
||||||
t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
|
t.Fatalf("代管打款 = %d/%d, want 12000/1", summary.OfflineSettlementPaidAmountCent, summary.OfflineSettlementPaidCount)
|
||||||
@@ -86,6 +105,9 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 {
|
if summary.WithdrawalAmountCent != 10000 || summary.WithdrawalFeeAmountCent != 100 || summary.WithdrawalPaidAmountCent != 9900 {
|
||||||
t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent)
|
t.Fatalf("提现申请/手续费/实付 = %d/%d/%d, want 10000/100/9900", summary.WithdrawalAmountCent, summary.WithdrawalFeeAmountCent, summary.WithdrawalPaidAmountCent)
|
||||||
}
|
}
|
||||||
|
if summary.ManualPaidAmountCent != 2500 || summary.ManualPaidCount != 1 {
|
||||||
|
t.Fatalf("其他线下出款 = %d/%d, want 2500/1", summary.ManualPaidAmountCent, summary.ManualPaidCount)
|
||||||
|
}
|
||||||
if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 {
|
if summary.PendingPaymentAmountCent != 12800 || summary.PendingPaymentCount != 2 {
|
||||||
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
|
t.Fatalf("待出款 = %d/%d, want 12800/2", summary.PendingPaymentAmountCent, summary.PendingPaymentCount)
|
||||||
}
|
}
|
||||||
@@ -97,8 +119,8 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("dailyDisbursements() error = %v", err)
|
t.Fatalf("dailyDisbursements() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(daily) != 2 {
|
if len(daily) != 3 {
|
||||||
t.Fatalf("每日出款行数 = %d, want 2", len(daily))
|
t.Fatalf("每日出款行数 = %d, want 3", len(daily))
|
||||||
}
|
}
|
||||||
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
|
if daily[0].Date != "2026-07-01" || daily[0].OfflineSettlementPaidAmountCent != 12000 {
|
||||||
t.Fatalf("第一日代管出款 = %+v", daily[0])
|
t.Fatalf("第一日代管出款 = %+v", daily[0])
|
||||||
@@ -106,4 +128,7 @@ func TestDisbursementStatisticsUseActualPaymentTimeAndAmount(t *testing.T) {
|
|||||||
if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 {
|
if daily[1].Date != "2026-07-02" || daily[1].WithdrawalPaidAmountCent != 9900 {
|
||||||
t.Fatalf("第二日提现出款 = %+v", daily[1])
|
t.Fatalf("第二日提现出款 = %+v", daily[1])
|
||||||
}
|
}
|
||||||
|
if daily[2].Date != "2026-07-03" || daily[2].ManualPaidAmountCent != 2500 {
|
||||||
|
t.Fatalf("第三日其他线下出款 = %+v", daily[2])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ type DisbursementSummaryDTO struct {
|
|||||||
WithdrawalPendingCount int64 `json:"withdrawal_pending_count"`
|
WithdrawalPendingCount int64 `json:"withdrawal_pending_count"`
|
||||||
WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"`
|
WithdrawalReviewAmountCent int64 `json:"withdrawal_review_amount_cent"`
|
||||||
WithdrawalReviewCount int64 `json:"withdrawal_review_count"`
|
WithdrawalReviewCount int64 `json:"withdrawal_review_count"`
|
||||||
|
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
||||||
|
ManualPaidCount int64 `json:"manual_paid_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisbursementListDTO struct {
|
type DisbursementListDTO struct {
|
||||||
@@ -115,6 +117,44 @@ type DisbursementItemDTO struct {
|
|||||||
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"`
|
||||||
|
VoucherURL string `json:"voucher_url"`
|
||||||
|
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||||
|
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||||
|
VoidedByName string `json:"voided_by_name"`
|
||||||
|
VoidReason string `json:"void_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateManualDisbursementRequest struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
PayeeName string `json:"payee_name"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PaidAt time.Time `json:"paid_at"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
VoucherURL string `json:"voucher_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoidManualDisbursementRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualDisbursementDTO struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
DisbursementNo string `json:"disbursement_no"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
PayeeName string `json:"payee_name"`
|
||||||
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
PaidAt time.Time `json:"paid_at"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
VoucherURL string `json:"voucher_url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedBy uint64 `json:"created_by"`
|
||||||
|
CreatedByName string `json:"created_by_name"`
|
||||||
|
VoidedBy *uint64 `json:"voided_by,omitempty"`
|
||||||
|
VoidedByName string `json:"voided_by_name"`
|
||||||
|
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
||||||
|
VoidReason string `json:"void_reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FinanceSummaryDTO struct {
|
type FinanceSummaryDTO struct {
|
||||||
@@ -166,6 +206,8 @@ type FinanceDailyDTO struct {
|
|||||||
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
|
OfflineSettlementPaidCount int64 `json:"offline_settlement_paid_count"`
|
||||||
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
|
WithdrawalPaidAmountCent int64 `json:"withdrawal_paid_amount_cent"`
|
||||||
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
|
WithdrawalPaidCount int64 `json:"withdrawal_paid_count"`
|
||||||
|
ManualPaidAmountCent int64 `json:"manual_paid_amount_cent"`
|
||||||
|
ManualPaidCount int64 `json:"manual_paid_count"`
|
||||||
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
|
DisbursementPaidAmountCent int64 `json:"disbursement_paid_amount_cent"`
|
||||||
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
DisbursementPaidCount int64 `json:"disbursement_paid_count"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
|
"hfb_sys/backend/internal/middleware"
|
||||||
"hfb_sys/backend/internal/timeutil"
|
"hfb_sys/backend/internal/timeutil"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
@@ -60,6 +62,49 @@ func (h *Handler) Disbursements(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateManualDisbursement(c *gin.Context) {
|
||||||
|
adminID, ok := financeAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CreateManualDisbursementRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "线下出款信息不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.CreateManualDisbursement(c.Request.Context(), req, adminID, financeAuditMeta(c))
|
||||||
|
if err != nil {
|
||||||
|
writeFinanceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) VoidManualDisbursement(c *gin.Context) {
|
||||||
|
adminID, ok := financeAdminID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少管理员上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "线下出款记录 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req VoidManualDisbursementRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "请填写作废原因")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.VoidManualDisbursement(c.Request.Context(), id, req.Reason, adminID, financeAuditMeta(c))
|
||||||
|
if err != nil {
|
||||||
|
writeFinanceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
|
func parseDashboardQuery(c *gin.Context) (DashboardQuery, bool) {
|
||||||
start, end, ok := parseDateRange(c, 6)
|
start, end, ok := parseDateRange(c, 6)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -124,11 +169,11 @@ func parseDisbursementQuery(c *gin.Context) (DisbursementQuery, bool) {
|
|||||||
StartDate: start,
|
StartDate: start,
|
||||||
EndDate: end,
|
EndDate: end,
|
||||||
}
|
}
|
||||||
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" {
|
if query.SourceType != "" && query.SourceType != "platform_managed" && query.SourceType != "withdrawal" && query.SourceType != "manual_offline" {
|
||||||
response.BadRequest(c, "出款类型不正确")
|
response.BadRequest(c, "出款类型不正确")
|
||||||
return query, false
|
return query, false
|
||||||
}
|
}
|
||||||
if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" {
|
if query.Status != "" && query.Status != "review_pending" && query.Status != "payment_pending" && query.Status != "paid" && query.Status != "rejected" && query.Status != "cancelled" && query.Status != "voided" {
|
||||||
response.BadRequest(c, "出款状态不正确")
|
response.BadRequest(c, "出款状态不正确")
|
||||||
return query, false
|
return query, false
|
||||||
}
|
}
|
||||||
@@ -186,7 +231,30 @@ func writeFinanceError(c *gin.Context, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrDependencyUnavailable):
|
case errors.Is(err, ErrDependencyUnavailable):
|
||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
|
case errors.Is(err, ErrInvalidManualDisbursement):
|
||||||
|
response.BadRequest(c, "线下出款信息不正确")
|
||||||
|
case errors.Is(err, ErrManualDisbursementNotFound):
|
||||||
|
response.NotFound(c, "线下出款记录不存在")
|
||||||
|
case errors.Is(err, ErrManualDisbursementNotPaid):
|
||||||
|
response.BadRequest(c, "该线下出款记录已作废")
|
||||||
default:
|
default:
|
||||||
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
|
response.Error(c, http.StatusInternalServerError, "finance_error", "财务数据暂时不可用")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func financeAdminID(c *gin.Context) (uint64, bool) {
|
||||||
|
value, ok := c.Get(middleware.ContextAdminID)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
adminID, ok := value.(uint64)
|
||||||
|
return adminID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func financeAuditMeta(c *gin.Context) auditlog.Meta {
|
||||||
|
return auditlog.Meta{
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
RequestID: middleware.GetRequestID(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package adminfinance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manualDisbursementRecord struct {
|
||||||
|
ID uint64
|
||||||
|
DisbursementNo string
|
||||||
|
Category string
|
||||||
|
PayeeName string
|
||||||
|
AmountCent int64
|
||||||
|
PaidAt time.Time
|
||||||
|
Remark string
|
||||||
|
VoucherURL string
|
||||||
|
Status string
|
||||||
|
CreatedBy uint64
|
||||||
|
VoidedBy *uint64
|
||||||
|
VoidedAt *time.Time
|
||||||
|
VoidReason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manualDisbursementRecord) TableName() string {
|
||||||
|
return "manual_disbursements"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateManualDisbursement(
|
||||||
|
ctx context.Context,
|
||||||
|
req CreateManualDisbursementRequest,
|
||||||
|
adminID uint64,
|
||||||
|
meta auditlog.Meta,
|
||||||
|
) (*ManualDisbursementDTO, error) {
|
||||||
|
disbursementNo, err := newManualDisbursementNo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := manualDisbursementRecord{
|
||||||
|
DisbursementNo: disbursementNo,
|
||||||
|
Category: req.Category,
|
||||||
|
PayeeName: req.PayeeName,
|
||||||
|
AmountCent: req.AmountCent,
|
||||||
|
PaidAt: req.PaidAt,
|
||||||
|
Remark: req.Remark,
|
||||||
|
VoucherURL: req.VoucherURL,
|
||||||
|
Status: "paid",
|
||||||
|
CreatedBy: adminID,
|
||||||
|
}
|
||||||
|
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := record.ID
|
||||||
|
return auditlog.Append(tx, auditlog.Entry{
|
||||||
|
ActorType: "admin",
|
||||||
|
ActorID: adminID,
|
||||||
|
Action: "manual_disbursement_create",
|
||||||
|
BizType: "manual_disbursement",
|
||||||
|
BizID: &id,
|
||||||
|
Meta: meta,
|
||||||
|
Detail: map[string]any{
|
||||||
|
"disbursement_no": disbursementNo,
|
||||||
|
"category": req.Category,
|
||||||
|
"payee_name": req.PayeeName,
|
||||||
|
"amount_cent": req.AmountCent,
|
||||||
|
"paid_at": req.PaidAt,
|
||||||
|
"has_voucher": req.VoucherURL != "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.findManualDisbursement(ctx, record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) VoidManualDisbursement(
|
||||||
|
ctx context.Context,
|
||||||
|
id uint64,
|
||||||
|
reason string,
|
||||||
|
adminID uint64,
|
||||||
|
meta auditlog.Meta,
|
||||||
|
) (*ManualDisbursementDTO, error) {
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var record manualDisbursementRecord
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrManualDisbursementNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.Status != "paid" {
|
||||||
|
return ErrManualDisbursementNotPaid
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timeutil.ShanghaiNow()
|
||||||
|
if err := tx.Model(&record).Updates(map[string]any{
|
||||||
|
"status": "voided",
|
||||||
|
"voided_by": adminID,
|
||||||
|
"voided_at": now,
|
||||||
|
"void_reason": reason,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bizID := record.ID
|
||||||
|
return auditlog.Append(tx, auditlog.Entry{
|
||||||
|
ActorType: "admin",
|
||||||
|
ActorID: adminID,
|
||||||
|
Action: "manual_disbursement_void",
|
||||||
|
BizType: "manual_disbursement",
|
||||||
|
BizID: &bizID,
|
||||||
|
Meta: meta,
|
||||||
|
Detail: map[string]any{
|
||||||
|
"disbursement_no": record.DisbursementNo,
|
||||||
|
"amount_cent": record.AmountCent,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.findManualDisbursement(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findManualDisbursement(ctx context.Context, id uint64) (*ManualDisbursementDTO, error) {
|
||||||
|
var row manualDisbursementDetailRow
|
||||||
|
err := r.db.WithContext(ctx).Table("manual_disbursements AS md").
|
||||||
|
Select(`md.*,
|
||||||
|
COALESCE(NULLIF(creator.nickname, ''), creator.username, '') AS created_by_name,
|
||||||
|
COALESCE(NULLIF(voider.nickname, ''), voider.username, '') AS voided_by_name`).
|
||||||
|
Joins("LEFT JOIN admin_users AS creator ON creator.id = md.created_by").
|
||||||
|
Joins("LEFT JOIN admin_users AS voider ON voider.id = md.voided_by").
|
||||||
|
Where("md.id = ?", id).
|
||||||
|
Take(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrManualDisbursementNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ManualDisbursementDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
DisbursementNo: row.DisbursementNo,
|
||||||
|
Category: row.Category,
|
||||||
|
PayeeName: row.PayeeName,
|
||||||
|
AmountCent: row.AmountCent,
|
||||||
|
PaidAt: row.PaidAt,
|
||||||
|
Remark: row.Remark,
|
||||||
|
VoucherURL: row.VoucherURL,
|
||||||
|
Status: row.Status,
|
||||||
|
CreatedBy: row.CreatedBy,
|
||||||
|
CreatedByName: row.CreatedByName,
|
||||||
|
VoidedBy: row.VoidedBy,
|
||||||
|
VoidedByName: row.VoidedByName,
|
||||||
|
VoidedAt: row.VoidedAt,
|
||||||
|
VoidReason: row.VoidReason,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualDisbursementDetailRow struct {
|
||||||
|
ID uint64
|
||||||
|
DisbursementNo string
|
||||||
|
Category string
|
||||||
|
PayeeName string
|
||||||
|
AmountCent int64
|
||||||
|
PaidAt time.Time
|
||||||
|
Remark string
|
||||||
|
VoucherURL string
|
||||||
|
Status string
|
||||||
|
CreatedBy uint64
|
||||||
|
CreatedByName string
|
||||||
|
VoidedBy *uint64
|
||||||
|
VoidedByName string
|
||||||
|
VoidedAt *time.Time
|
||||||
|
VoidReason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManualDisbursementNo() (string, error) {
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("OD%s%s", timeutil.ShanghaiNow().Format("20060102150405"), hex.EncodeToString(buf)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package adminfinance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManualDisbursementCreateAndVoid(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("打开测试数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE admin_users (id INTEGER PRIMARY KEY, nickname TEXT, username TEXT)`,
|
||||||
|
`CREATE TABLE audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_type TEXT, actor_id INTEGER,
|
||||||
|
action TEXT, biz_type TEXT, biz_id INTEGER, ip TEXT, user_agent TEXT,
|
||||||
|
detail BLOB, created_at DATETIME
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatalf("创建测试表失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Exec(`INSERT INTO admin_users (id, nickname, username) VALUES
|
||||||
|
(7, '财务甲', 'finance_a'), (8, '财务乙', 'finance_b')`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
PayeeName: "测试供应商",
|
||||||
|
AmountCent: 8800,
|
||||||
|
PaidAt: paidAt,
|
||||||
|
Remark: "测试运营支出",
|
||||||
|
VoucherURL: "/api/files/object?key=manual-disbursement%2Fvoucher.webp",
|
||||||
|
}, 7, auditlog.Meta{RequestID: "req-create"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建其他线下出款失败: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == 0 || created.DisbursementNo == "" || created.Status != "paid" || created.CreatedByName != "财务甲" {
|
||||||
|
t.Fatalf("创建结果不正确: %+v", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
voided, err := repo.VoidManualDisbursement(t.Context(), created.ID, "录入金额有误", 8, auditlog.Meta{RequestID: "req-void"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("作废其他线下出款失败: %v", err)
|
||||||
|
}
|
||||||
|
if voided.Status != "voided" || voided.VoidedByName != "财务乙" || voided.VoidReason != "录入金额有误" || voided.VoidedAt == nil {
|
||||||
|
t.Fatalf("作废结果不正确: %+v", voided)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.VoidManualDisbursement(t.Context(), created.ID, "重复作废", 8, auditlog.Meta{}); !errors.Is(err, ErrManualDisbursementNotPaid) {
|
||||||
|
t.Fatalf("重复作废错误 = %v, want ErrManualDisbursementNotPaid", err)
|
||||||
|
}
|
||||||
|
var auditCount int64
|
||||||
|
if err := db.Table("audit_logs").Where("biz_type = ?", "manual_disbursement").Count(&auditCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auditCount != 2 {
|
||||||
|
t.Fatalf("审计日志数量 = %d, want 2", auditCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualDisbursementValidation(t *testing.T) {
|
||||||
|
service := NewService(&Repository{})
|
||||||
|
_, err := service.CreateManualDisbursement(t.Context(), CreateManualDisbursementRequest{
|
||||||
|
Category: "unknown",
|
||||||
|
PayeeName: "测试收款人",
|
||||||
|
AmountCent: 100,
|
||||||
|
PaidAt: timeutil.ShanghaiNow(),
|
||||||
|
Remark: "测试",
|
||||||
|
}, 1, auditlog.Meta{})
|
||||||
|
if !errors.Is(err, ErrInvalidManualDisbursement) {
|
||||||
|
t.Fatalf("无效分类错误 = %v, want ErrInvalidManualDisbursement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,19 @@ package adminfinance
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrDependencyUnavailable = errors.New("dependency unavailable")
|
var (
|
||||||
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
|
ErrInvalidManualDisbursement = errors.New("invalid manual disbursement")
|
||||||
|
ErrManualDisbursementNotFound = errors.New("manual disbursement not found")
|
||||||
|
ErrManualDisbursementNotPaid = errors.New("manual disbursement is not paid")
|
||||||
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
@@ -53,3 +63,66 @@ func (s *Service) Disbursements(ctx context.Context, query DisbursementQuery) (*
|
|||||||
}
|
}
|
||||||
return s.repo.Disbursements(ctx, query)
|
return s.repo.Disbursements(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateManualDisbursement(
|
||||||
|
ctx context.Context,
|
||||||
|
req CreateManualDisbursementRequest,
|
||||||
|
adminID uint64,
|
||||||
|
meta auditlog.Meta,
|
||||||
|
) (*ManualDisbursementDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
req.Category = strings.TrimSpace(req.Category)
|
||||||
|
req.PayeeName = strings.TrimSpace(req.PayeeName)
|
||||||
|
req.Remark = strings.TrimSpace(req.Remark)
|
||||||
|
req.VoucherURL = strings.TrimSpace(req.VoucherURL)
|
||||||
|
if !validManualDisbursementCategory(req.Category) ||
|
||||||
|
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 ||
|
||||||
|
!validManualVoucherURL(req.VoucherURL) {
|
||||||
|
return nil, ErrInvalidManualDisbursement
|
||||||
|
}
|
||||||
|
return s.repo.CreateManualDisbursement(ctx, req, adminID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) VoidManualDisbursement(
|
||||||
|
ctx context.Context,
|
||||||
|
id uint64,
|
||||||
|
reason string,
|
||||||
|
adminID uint64,
|
||||||
|
meta auditlog.Meta,
|
||||||
|
) (*ManualDisbursementDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if id == 0 || utf8.RuneCountInString(reason) < 1 || utf8.RuneCountInString(reason) > 255 {
|
||||||
|
return nil, ErrInvalidManualDisbursement
|
||||||
|
}
|
||||||
|
return s.repo.VoidManualDisbursement(ctx, id, reason, adminID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validManualDisbursementCategory(category string) bool {
|
||||||
|
switch category {
|
||||||
|
case "user_compensation", "seller_supplement", "operating_expense", "channel_fee", "other":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validManualVoucherURL(raw string) bool {
|
||||||
|
if raw == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(raw) > 500 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || parsed.IsAbs() || parsed.Path != "/api/files/object" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(parsed.Query().Get("key"), "manual-disbursement/")
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
|
|||||||
func normalizeScene(scene string) string {
|
func normalizeScene(scene string) string {
|
||||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||||
switch scene {
|
switch scene {
|
||||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle":
|
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "manual-disbursement":
|
||||||
return scene
|
return scene
|
||||||
default:
|
default:
|
||||||
return "misc"
|
return "misc"
|
||||||
|
|||||||
@@ -639,6 +639,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard)
|
adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard)
|
||||||
adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details)
|
adminRoutes.GET("/finance/details", requirePerm("wallet:view"), adminFinanceHandler.Details)
|
||||||
adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements)
|
adminRoutes.GET("/finance/disbursements", requirePerm("wallet:view"), adminFinanceHandler.Disbursements)
|
||||||
|
adminRoutes.POST("/finance/manual-disbursements", requirePerm("finance:manual_disbursement"), adminFinanceHandler.CreateManualDisbursement)
|
||||||
|
adminRoutes.POST("/finance/manual-disbursements/:id/void", requirePerm("finance:manual_disbursement"), adminFinanceHandler.VoidManualDisbursement)
|
||||||
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
adminRoutes.GET("/wallet/ledger", requirePerm("wallet:view"), walletHandler.AdminLedger)
|
||||||
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
|
adminRoutes.GET("/payments", requirePerm("wallet:view"), paymentHandler.AdminList)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
CREATE TABLE manual_disbursements (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
disbursement_no VARCHAR(64) NOT NULL COMMENT '其他线下出款单号',
|
||||||
|
category VARCHAR(32) NOT NULL COMMENT '出款分类',
|
||||||
|
payee_name VARCHAR(100) NOT NULL COMMENT '收款人或单位名称',
|
||||||
|
amount_cent BIGINT NOT NULL COMMENT '实际出款金额(分)',
|
||||||
|
paid_at DATETIME NOT NULL COMMENT '实际出款时间',
|
||||||
|
remark VARCHAR(500) NOT NULL COMMENT '出款用途备注',
|
||||||
|
voucher_url VARCHAR(500) NOT NULL DEFAULT '' COMMENT '付款凭证地址',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'paid' COMMENT '状态: paid已出款/voided已作废',
|
||||||
|
created_by BIGINT UNSIGNED NOT NULL COMMENT '录入管理员ID',
|
||||||
|
voided_by BIGINT UNSIGNED NULL COMMENT '作废管理员ID',
|
||||||
|
voided_at DATETIME NULL COMMENT '作废时间',
|
||||||
|
void_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '作废原因',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_manual_disbursements_no (disbursement_no),
|
||||||
|
KEY idx_manual_disbursements_paid (status, paid_at),
|
||||||
|
KEY idx_manual_disbursements_created (created_at),
|
||||||
|
KEY idx_manual_disbursements_category (category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='其他线下出款记账';
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||||
|
('finance:manual_disbursement', '管理其他线下出款', 'finance', 'manual_disbursement')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
resource = VALUES(resource),
|
||||||
|
action = VALUES(action);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id FROM roles r, permissions p
|
||||||
|
WHERE r.code IN ('super_admin', 'finance') AND p.code = 'finance:manual_disbursement';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DELETE rp FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code = 'finance:manual_disbursement';
|
||||||
|
|
||||||
|
DELETE FROM permissions WHERE code = 'finance:manual_disbursement';
|
||||||
|
DROP TABLE IF EXISTS manual_disbursements;
|
||||||
@@ -54,6 +54,8 @@ export interface FinanceDailyItem {
|
|||||||
offline_settlement_paid_count: number
|
offline_settlement_paid_count: number
|
||||||
withdrawal_paid_amount_cent: number
|
withdrawal_paid_amount_cent: number
|
||||||
withdrawal_paid_count: number
|
withdrawal_paid_count: number
|
||||||
|
manual_paid_amount_cent: number
|
||||||
|
manual_paid_count: number
|
||||||
disbursement_paid_amount_cent: number
|
disbursement_paid_amount_cent: number
|
||||||
disbursement_paid_count: number
|
disbursement_paid_count: number
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,8 @@ export interface FinanceDisbursementSummary {
|
|||||||
withdrawal_pending_count: number
|
withdrawal_pending_count: number
|
||||||
withdrawal_review_amount_cent: number
|
withdrawal_review_amount_cent: number
|
||||||
withdrawal_review_count: number
|
withdrawal_review_count: number
|
||||||
|
manual_paid_amount_cent: number
|
||||||
|
manual_paid_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceDisbursementListSummary {
|
export interface FinanceDisbursementListSummary {
|
||||||
@@ -96,7 +100,7 @@ export interface FinanceDisbursementListSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceDisbursementItem {
|
export interface FinanceDisbursementItem {
|
||||||
source_type: 'platform_managed' | 'withdrawal'
|
source_type: 'platform_managed' | 'withdrawal' | 'manual_offline'
|
||||||
source_id: number
|
source_id: number
|
||||||
business_no: string
|
business_no: string
|
||||||
order_id?: number
|
order_id?: number
|
||||||
@@ -113,13 +117,47 @@ export interface FinanceDisbursementItem {
|
|||||||
amount_cent: number
|
amount_cent: number
|
||||||
fee_cent: number
|
fee_cent: number
|
||||||
actual_amount_cent: number
|
actual_amount_cent: number
|
||||||
status: 'review_pending' | 'payment_pending' | 'paid' | 'rejected' | 'cancelled'
|
status: 'review_pending' | 'payment_pending' | 'paid' | 'rejected' | 'cancelled' | 'voided'
|
||||||
raw_status: string
|
raw_status: string
|
||||||
business_created_at?: string
|
business_created_at?: string
|
||||||
paid_at?: string
|
paid_at?: string
|
||||||
operator_id?: number
|
operator_id?: number
|
||||||
operator_name: string
|
operator_name: string
|
||||||
remark: string
|
remark: string
|
||||||
|
category: string
|
||||||
|
voucher_url: string
|
||||||
|
voided_at?: string
|
||||||
|
voided_by?: number
|
||||||
|
voided_by_name: string
|
||||||
|
void_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateManualDisbursementPayload {
|
||||||
|
category: string
|
||||||
|
payee_name: string
|
||||||
|
amount_cent: number
|
||||||
|
paid_at: string
|
||||||
|
remark: string
|
||||||
|
voucher_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualDisbursement {
|
||||||
|
id: number
|
||||||
|
disbursement_no: string
|
||||||
|
category: string
|
||||||
|
payee_name: string
|
||||||
|
amount_cent: number
|
||||||
|
paid_at: string
|
||||||
|
remark: string
|
||||||
|
voucher_url: string
|
||||||
|
status: 'paid' | 'voided'
|
||||||
|
created_by: number
|
||||||
|
created_by_name: string
|
||||||
|
voided_by?: number
|
||||||
|
voided_by_name: string
|
||||||
|
voided_at?: string
|
||||||
|
void_reason: string
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceDisbursementQuery extends FinanceDateQuery {
|
export interface FinanceDisbursementQuery extends FinanceDateQuery {
|
||||||
@@ -239,6 +277,8 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
|
|||||||
withdrawal_pending_count: 0,
|
withdrawal_pending_count: 0,
|
||||||
withdrawal_review_amount_cent: 0,
|
withdrawal_review_amount_cent: 0,
|
||||||
withdrawal_review_count: 0,
|
withdrawal_review_count: 0,
|
||||||
|
manual_paid_amount_cent: 0,
|
||||||
|
manual_paid_count: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,3 +320,19 @@ export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createManualDisbursement(payload: CreateManualDisbursementPayload) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
|
||||||
|
'/admin/finance/manual-disbursements',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function voidManualDisbursement(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ManualDisbursement>>(
|
||||||
|
`/admin/finance/manual-disbursements/${id}/void`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Finance Dashboard</p>
|
<p class="eyebrow">Finance Dashboard</p>
|
||||||
<h1>财务仪表盘</h1>
|
<h1>财务仪表盘</h1>
|
||||||
<p>按天查看收付款、平台收入、号主结算、平台代管打款和用户提现。</p>
|
<p>按天查看收付款、平台收入、号主结算及全部资金出款。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -157,7 +157,7 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
|||||||
|
|
||||||
<div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid">
|
<div v-if="dashboard?.disbursement_summary" class="metric-grid disbursement-grid">
|
||||||
<div class="metric-card disbursement-card">
|
<div class="metric-card disbursement-card">
|
||||||
<span>代管及提现出款</span>
|
<span>全部实际出款</span>
|
||||||
<strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong>
|
<strong>{{ moneyCent(dashboard.disbursement_summary.paid_amount_cent) }}</strong>
|
||||||
<small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small>
|
<small>{{ dashboard.disbursement_summary.paid_count }} 笔实际出款合计</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +177,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
|||||||
{{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
|
{{ moneyCent(dashboard.disbursement_summary.withdrawal_fee_amount_cent) }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric-card disbursement-card">
|
||||||
|
<span>其他线下出款</span>
|
||||||
|
<strong>{{ moneyCent(dashboard.disbursement_summary.manual_paid_amount_cent) }}</strong>
|
||||||
|
<small>{{ dashboard.disbursement_summary.manual_paid_count }} 笔有效记账</small>
|
||||||
|
</div>
|
||||||
<div class="metric-card pending-card">
|
<div class="metric-card pending-card">
|
||||||
<span>当前待出款</span>
|
<span>当前待出款</span>
|
||||||
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
|
<strong>{{ moneyCent(dashboard.disbursement_summary.pending_payment_amount_cent) }}</strong>
|
||||||
@@ -262,6 +267,11 @@ function rowOfflinePendingClass(row: FinanceDailyItem) {
|
|||||||
{{ moneyCent(row.withdrawal_paid_amount_cent) }}
|
{{ moneyCent(row.withdrawal_paid_amount_cent) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="其他线下" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ moneyCent(row.manual_paid_amount_cent) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="待线下" width="130">
|
<el-table-column label="待线下" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span :class="rowOfflinePendingClass(row)">
|
<span :class="rowOfflinePendingClass(row)">
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Search, View } from '@element-plus/icons-vue'
|
import { CircleClose, Plus, Search, UploadFilled, View } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createManualDisbursement,
|
||||||
fetchFinanceDisbursements,
|
fetchFinanceDisbursements,
|
||||||
|
voidManualDisbursement,
|
||||||
type FinanceDisbursementItem,
|
type FinanceDisbursementItem,
|
||||||
type FinanceDisbursementListSummary,
|
type FinanceDisbursementListSummary,
|
||||||
} from '@/features/admin/api/adminFinance'
|
} from '@/features/admin/api/adminFinance'
|
||||||
import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
|
import { fetchAdminWithdrawal, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
|
||||||
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
|
||||||
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
|
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
|
||||||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
|
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
|
||||||
@@ -26,7 +30,28 @@ const currentPage = ref(1)
|
|||||||
const currentPageSize = ref(20)
|
const currentPageSize = ref(20)
|
||||||
const showWithdrawalDetail = ref(false)
|
const showWithdrawalDetail = ref(false)
|
||||||
const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
|
const selectedWithdrawal = ref<WithdrawalDetail | null>(null)
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const createSaving = ref(false)
|
||||||
|
const voucherUploading = ref(false)
|
||||||
|
const manualActionID = ref(0)
|
||||||
|
const selectedManual = ref<FinanceDisbursementItem | null>(null)
|
||||||
const summary = ref<FinanceDisbursementListSummary>(emptySummary())
|
const summary = ref<FinanceDisbursementListSummary>(emptySummary())
|
||||||
|
const canManageManual = computed(() => adminSession.hasPermission('finance:manual_disbursement'))
|
||||||
|
const manualCategories = [
|
||||||
|
{ value: 'user_compensation', label: '用户补偿' },
|
||||||
|
{ value: 'seller_supplement', label: '卖家补款' },
|
||||||
|
{ value: 'operating_expense', label: '运营支出' },
|
||||||
|
{ value: 'channel_fee', label: '渠道费用' },
|
||||||
|
{ value: 'other', label: '其他' },
|
||||||
|
]
|
||||||
|
const createForm = reactive({
|
||||||
|
category: '',
|
||||||
|
payee_name: '',
|
||||||
|
amount_yuan: 0,
|
||||||
|
paid_at: new Date(),
|
||||||
|
remark: '',
|
||||||
|
voucher_url: '',
|
||||||
|
})
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
date_type: 'created',
|
date_type: 'created',
|
||||||
start_date: defaultStartDate(),
|
start_date: defaultStartDate(),
|
||||||
@@ -98,6 +123,103 @@ function onWithdrawalSaved() {
|
|||||||
void loadItems()
|
void loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetCreateForm()
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
createForm.category = ''
|
||||||
|
createForm.payee_name = ''
|
||||||
|
createForm.amount_yuan = 0
|
||||||
|
createForm.paid_at = new Date()
|
||||||
|
createForm.remark = ''
|
||||||
|
createForm.voucher_url = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVoucher(options: { file: File }) {
|
||||||
|
if (!options.file.type.startsWith('image/')) {
|
||||||
|
ElMessage.warning('付款凭证仅支持图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voucherUploading.value = true
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadAdminFile(options.file, 'manual-disbursement')
|
||||||
|
createForm.voucher_url = uploaded.url
|
||||||
|
ElMessage.success('凭证已上传')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '凭证上传失败'))
|
||||||
|
} finally {
|
||||||
|
voucherUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualDisbursement() {
|
||||||
|
const amountCent = yuanToCent(createForm.amount_yuan)
|
||||||
|
if (
|
||||||
|
!createForm.category ||
|
||||||
|
!createForm.payee_name.trim() ||
|
||||||
|
amountCent <= 0 ||
|
||||||
|
!createForm.paid_at
|
||||||
|
) {
|
||||||
|
ElMessage.warning('请填写完整的出款信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!createForm.remark.trim()) {
|
||||||
|
ElMessage.warning('请填写出款用途')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createSaving.value = true
|
||||||
|
try {
|
||||||
|
await createManualDisbursement({
|
||||||
|
category: createForm.category,
|
||||||
|
payee_name: createForm.payee_name.trim(),
|
||||||
|
amount_cent: amountCent,
|
||||||
|
paid_at: createForm.paid_at.toISOString(),
|
||||||
|
remark: createForm.remark.trim(),
|
||||||
|
voucher_url: createForm.voucher_url || undefined,
|
||||||
|
})
|
||||||
|
createVisible.value = false
|
||||||
|
ElMessage.success('线下出款已记账')
|
||||||
|
await loadItems()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '线下出款记账失败'))
|
||||||
|
} finally {
|
||||||
|
createSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualDetail(row: FinanceDisbursementItem) {
|
||||||
|
selectedManual.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoidManual(row: FinanceDisbursementItem) {
|
||||||
|
if (manualActionID.value) return
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt(
|
||||||
|
`确认作废出款单 ${row.business_no}?作废后将从财务统计中排除。`,
|
||||||
|
'作废线下出款',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认作废',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputPlaceholder: '请输入作废原因',
|
||||||
|
inputValidator: value => !!value.trim() || '请输入作废原因',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manualActionID.value = row.source_id
|
||||||
|
await voidManualDisbursement(row.source_id, value.trim())
|
||||||
|
selectedManual.value = null
|
||||||
|
ElMessage.success('线下出款已作废')
|
||||||
|
await loadItems()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(readError(error, '作废线下出款失败'))
|
||||||
|
} finally {
|
||||||
|
manualActionID.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function emptySummary(): FinanceDisbursementListSummary {
|
function emptySummary(): FinanceDisbursementListSummary {
|
||||||
return {
|
return {
|
||||||
record_count: 0,
|
record_count: 0,
|
||||||
@@ -122,11 +244,18 @@ function money(value: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceLabel(source: string) {
|
function sourceLabel(source: string) {
|
||||||
return source === 'platform_managed' ? '平台代管' : '用户提现'
|
const labels: Record<string, string> = {
|
||||||
|
platform_managed: '平台代管',
|
||||||
|
withdrawal: '用户提现',
|
||||||
|
manual_offline: '其他线下出款',
|
||||||
|
}
|
||||||
|
return labels[source] || source
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceType(source: string) {
|
function sourceType(source: string) {
|
||||||
return source === 'platform_managed' ? 'primary' : 'info'
|
if (source === 'platform_managed') return 'primary'
|
||||||
|
if (source === 'manual_offline') return 'warning'
|
||||||
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
@@ -136,6 +265,7 @@ function statusLabel(status: string) {
|
|||||||
paid: '已打款',
|
paid: '已打款',
|
||||||
rejected: '已拒绝',
|
rejected: '已拒绝',
|
||||||
cancelled: '已取消',
|
cancelled: '已取消',
|
||||||
|
voided: '已作废',
|
||||||
}
|
}
|
||||||
return labels[status] || status
|
return labels[status] || status
|
||||||
}
|
}
|
||||||
@@ -144,9 +274,19 @@ function statusType(status: string) {
|
|||||||
if (status === 'paid') return 'success'
|
if (status === 'paid') return 'success'
|
||||||
if (status === 'review_pending' || status === 'payment_pending') return 'warning'
|
if (status === 'review_pending' || status === 'payment_pending') return 'warning'
|
||||||
if (status === 'rejected') return 'danger'
|
if (status === 'rejected') return 'danger'
|
||||||
|
if (status === 'voided') return 'danger'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function itemStatusLabel(row: FinanceDisbursementItem) {
|
||||||
|
if (row.source_type === 'manual_offline' && row.status === 'paid') return '已出款'
|
||||||
|
return statusLabel(row.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabel(category: string) {
|
||||||
|
return manualCategories.find(item => item.value === category)?.label || category || '-'
|
||||||
|
}
|
||||||
|
|
||||||
function accountTypeLabel(type: string) {
|
function accountTypeLabel(type: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
alipay: '支付宝',
|
alipay: '支付宝',
|
||||||
@@ -163,9 +303,12 @@ function accountTypeLabel(type: string) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Disbursements</p>
|
<p class="eyebrow">Disbursements</p>
|
||||||
<h1>资金出款</h1>
|
<h1>资金出款</h1>
|
||||||
<p>查询平台代管线下结算与用户提现的应付、待办和实际打款记录。</p>
|
<p>统一查询平台代管、用户提现和其他线下出款记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
<el-button v-if="canManageManual" type="success" :icon="Plus" @click="openCreate">
|
||||||
|
新增线下出款
|
||||||
|
</el-button>
|
||||||
<el-button @click="resetFilters">重置</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
<el-button type="primary" :icon="Search" :loading="loading" @click="search">
|
<el-button type="primary" :icon="Search" :loading="loading" @click="search">
|
||||||
查询
|
查询
|
||||||
@@ -233,6 +376,7 @@ function accountTypeLabel(type: string) {
|
|||||||
>
|
>
|
||||||
<el-option label="平台代管" value="platform_managed" />
|
<el-option label="平台代管" value="platform_managed" />
|
||||||
<el-option label="用户提现" value="withdrawal" />
|
<el-option label="用户提现" value="withdrawal" />
|
||||||
|
<el-option label="其他线下出款" value="manual_offline" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="出款状态">
|
<el-form-item label="出款状态">
|
||||||
@@ -242,10 +386,15 @@ function accountTypeLabel(type: string) {
|
|||||||
<el-option label="已打款" value="paid" />
|
<el-option label="已打款" value="paid" />
|
||||||
<el-option label="已拒绝" value="rejected" />
|
<el-option label="已拒绝" value="rejected" />
|
||||||
<el-option label="已取消" value="cancelled" />
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
<el-option label="已作废" value="voided" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="业务单号">
|
<el-form-item label="业务单号">
|
||||||
<el-input v-model="filters.business_no" clearable placeholder="订单号或提现单号" />
|
<el-input
|
||||||
|
v-model="filters.business_no"
|
||||||
|
clearable
|
||||||
|
placeholder="订单号、提现单号或出款单号"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="收款人">
|
<el-form-item label="收款人">
|
||||||
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
|
<el-input v-model="filters.keyword" clearable placeholder="姓名、手机号或账号" />
|
||||||
@@ -279,19 +428,26 @@ function accountTypeLabel(type: string) {
|
|||||||
<strong>{{ row.payee_name || '-' }}</strong>
|
<strong>{{ row.payee_name || '-' }}</strong>
|
||||||
<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'">
|
||||||
|
{{ categoryLabel(row.category) }}
|
||||||
|
</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>
|
||||||
<small v-if="row.source_channel">渠道:{{ row.source_channel }}</small>
|
<small v-if="row.source_channel">渠道:{{ row.source_channel }}</small>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="收款账户" min-width="205">
|
<el-table-column label="出款信息" min-width="205">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.source_type === 'withdrawal'" class="stack-cell">
|
<div v-if="row.source_type === 'withdrawal'" class="stack-cell">
|
||||||
<span>{{ accountTypeLabel(row.account_type) }} · {{ row.account_name || '-' }}</span>
|
<span>{{ accountTypeLabel(row.account_type) }} · {{ row.account_name || '-' }}</span>
|
||||||
<small>{{ row.account_no || '-' }}</small>
|
<small>{{ row.account_no || '-' }}</small>
|
||||||
<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">
|
||||||
|
<el-tag size="small" effect="plain">{{ categoryLabel(row.category) }}</el-tag>
|
||||||
|
<small>{{ row.voucher_url ? '已上传付款凭证' : '未上传付款凭证' }}</small>
|
||||||
|
</div>
|
||||||
<span v-else class="muted-text">线下约定</span>
|
<span v-else class="muted-text">线下约定</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -307,7 +463,7 @@ function accountTypeLabel(type: string) {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="105" align="center">
|
<el-table-column label="状态" width="105" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusType(row.status)">{{ itemStatusLabel(row) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="形成时间" min-width="175">
|
<el-table-column label="形成时间" min-width="175">
|
||||||
@@ -322,7 +478,7 @@ function accountTypeLabel(type: string) {
|
|||||||
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
|
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.remark || '-' }}</template>
|
<template #default="{ row }">{{ row.remark || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90" fixed="right">
|
<el-table-column label="操作" width="155" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')"
|
v-if="row.withdrawal_id && adminSession.hasPermission('withdrawal:detail')"
|
||||||
@@ -337,6 +493,21 @@ function accountTypeLabel(type: string) {
|
|||||||
<RouterLink v-else-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">
|
<RouterLink v-else-if="row.order_id" :to="adminPath(`orders/${row.order_id}`)">
|
||||||
<el-button link type="primary" :icon="View">详情</el-button>
|
<el-button link type="primary" :icon="View">详情</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<template v-else-if="row.source_type === 'manual_offline'">
|
||||||
|
<el-button link type="primary" :icon="View" @click="openManualDetail(row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'paid' && canManageManual"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:icon="CircleClose"
|
||||||
|
:loading="manualActionID === row.source_id"
|
||||||
|
@click="handleVoidManual(row)"
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -356,6 +527,159 @@ function accountTypeLabel(type: string) {
|
|||||||
:withdrawal="selectedWithdrawal"
|
:withdrawal="selectedWithdrawal"
|
||||||
@saved="onWithdrawalSaved"
|
@saved="onWithdrawalSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="createVisible"
|
||||||
|
title="新增其他线下出款"
|
||||||
|
width="560px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form class="manual-form" label-position="top" @submit.prevent="submitManualDisbursement">
|
||||||
|
<el-form-item label="出款分类" required>
|
||||||
|
<el-select v-model="createForm.category" class="full-control" placeholder="请选择分类">
|
||||||
|
<el-option
|
||||||
|
v-for="item in manualCategories"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="收款人" required>
|
||||||
|
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="出款金额" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="createForm.amount_yuan"
|
||||||
|
class="full-control"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
:step="100"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="出款时间" required>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="createForm.paid_at"
|
||||||
|
class="full-control"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择出款时间"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="manual-form-wide" label="备注" required>
|
||||||
|
<el-input
|
||||||
|
v-model="createForm.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="填写出款用途"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="manual-form-wide" label="付款凭证(选填)">
|
||||||
|
<div v-if="createForm.voucher_url" class="voucher-preview">
|
||||||
|
<AuthImage
|
||||||
|
:source="createForm.voucher_url"
|
||||||
|
:admin="true"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="[createForm.voucher_url]"
|
||||||
|
:image-style="{ width: '104px', height: '104px', borderRadius: '6px' }"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
class="voucher-remove"
|
||||||
|
circle
|
||||||
|
plain
|
||||||
|
type="danger"
|
||||||
|
:icon="CircleClose"
|
||||||
|
title="移除凭证"
|
||||||
|
@click="createForm.voucher_url = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
v-else
|
||||||
|
:show-file-list="false"
|
||||||
|
:http-request="uploadVoucher"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
>
|
||||||
|
<el-button :icon="UploadFilled" :loading="voucherUploading">上传凭证</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="createSaving" @click="createVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="createSaving"
|
||||||
|
:disabled="voucherUploading"
|
||||||
|
@click="submitManualDisbursement"
|
||||||
|
>
|
||||||
|
确认记账
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:model-value="!!selectedManual"
|
||||||
|
title="其他线下出款详情"
|
||||||
|
width="620px"
|
||||||
|
@update:model-value="selectedManual = null"
|
||||||
|
>
|
||||||
|
<el-descriptions v-if="selectedManual" :column="2" border>
|
||||||
|
<el-descriptions-item label="出款单号">{{
|
||||||
|
selectedManual.business_no
|
||||||
|
}}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="statusType(selectedManual.status)">
|
||||||
|
{{ itemStatusLabel(selectedManual) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="出款分类">
|
||||||
|
{{ categoryLabel(selectedManual.category) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="收款人">{{ selectedManual.payee_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="出款金额">
|
||||||
|
<strong>{{ money(selectedManual.actual_amount_cent) }}</strong>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="出款时间">
|
||||||
|
{{ formatDateTime(selectedManual.paid_at) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="录入人">
|
||||||
|
{{ selectedManual.operator_name || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="录入时间">
|
||||||
|
{{ formatDateTime(selectedManual.business_created_at) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">
|
||||||
|
{{ selectedManual.remark || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="selectedManual.voucher_url" label="付款凭证" :span="2">
|
||||||
|
<AuthImage
|
||||||
|
:source="selectedManual.voucher_url"
|
||||||
|
:admin="true"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="[selectedManual.voucher_url]"
|
||||||
|
:image-style="{ width: '140px', height: '140px', borderRadius: '6px' }"
|
||||||
|
/>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="selectedManual.status === 'voided'" label="作废信息" :span="2">
|
||||||
|
{{ selectedManual.voided_by_name || '-' }} ·
|
||||||
|
{{ formatDateTime(selectedManual.voided_at) }} ·
|
||||||
|
{{ selectedManual.void_reason || '-' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="selectedManual = null">关闭</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="selectedManual?.status === 'paid' && canManageManual"
|
||||||
|
type="danger"
|
||||||
|
:icon="CircleClose"
|
||||||
|
:loading="manualActionID === selectedManual.source_id"
|
||||||
|
@click="handleVoidManual(selectedManual)"
|
||||||
|
>
|
||||||
|
作废记录
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -432,6 +756,28 @@ function accountTypeLabel(type: string) {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-form-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1440px) {
|
@media (max-width: 1440px) {
|
||||||
.disbursement-metrics {
|
.disbursement-metrics {
|
||||||
grid-template-columns: repeat(3, minmax(170px, 1fr));
|
grid-template-columns: repeat(3, minmax(170px, 1fr));
|
||||||
@@ -444,8 +790,13 @@ function accountTypeLabel(type: string) {
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.disbursement-metrics,
|
.disbursement-metrics,
|
||||||
.disbursement-filters {
|
.disbursement-filters,
|
||||||
|
.manual-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-form-wide {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user