From 31811b07d4fb7c3c2674a9786bd64f9111eb3302 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 29 Jun 2026 23:13:33 +0800 Subject: [PATCH] feat: add media supply billing center --- apps/ops-web/src/lib/media-supply.ts | 60 +++ apps/ops-web/src/views/MediaSupplyOpsView.vue | 459 +++++++++++++++++- server/internal/ops/app/media_supply.go | 241 ++++++++- .../ops/transport/media_supply_handler.go | 65 +++ server/internal/ops/transport/router.go | 1 + .../tenant/app/media_supply_service.go | 25 +- ...media_supply_billing_fact_columns.down.sql | 5 + ...d_media_supply_billing_fact_columns.up.sql | 34 ++ ..._add_media_supply_billing_indexes.down.sql | 1 + ...00_add_media_supply_billing_indexes.up.sql | 12 + ...media_supply_billing_tenant_index.down.sql | 1 + ...d_media_supply_billing_tenant_index.up.sql | 11 + ..._supply_billing_tenant_user_index.down.sql | 1 + ...ia_supply_billing_tenant_user_index.up.sql | 10 + ...media_supply_billing_reason_index.down.sql | 1 + ...d_media_supply_billing_reason_index.up.sql | 11 + 16 files changed, 926 insertions(+), 12 deletions(-) create mode 100644 server/migrations/20260629110000_add_media_supply_billing_fact_columns.down.sql create mode 100644 server/migrations/20260629110000_add_media_supply_billing_fact_columns.up.sql create mode 100644 server/migrations/20260629110100_add_media_supply_billing_indexes.down.sql create mode 100644 server/migrations/20260629110100_add_media_supply_billing_indexes.up.sql create mode 100644 server/migrations/20260629110101_add_media_supply_billing_tenant_index.down.sql create mode 100644 server/migrations/20260629110101_add_media_supply_billing_tenant_index.up.sql create mode 100644 server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.down.sql create mode 100644 server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.up.sql create mode 100644 server/migrations/20260629110103_add_media_supply_billing_reason_index.down.sql create mode 100644 server/migrations/20260629110103_add_media_supply_billing_reason_index.up.sql diff --git a/apps/ops-web/src/lib/media-supply.ts b/apps/ops-web/src/lib/media-supply.ts index 374e6ea..8ffd9bd 100644 --- a/apps/ops-web/src/lib/media-supply.ts +++ b/apps/ops-web/src/lib/media-supply.ts @@ -70,6 +70,60 @@ export interface OpsMediaSupplyWalletsParams { page_size?: number } +export interface OpsMediaSupplyBillingEntry { + id: number + tenant_id: number + tenant_name?: string | null + user_id: number + user_name?: string | null + user_phone?: string | null + order_id?: number | null + order_title?: string | null + order_status?: string | null + external_order_code?: string | null + delta_cents: number + balance_after_cents: number + reason: string + note?: string | null + sales_cents: number + cost_cents: number + gross_profit_cents: number + created_by?: number | null + created_at: string +} + +export interface OpsMediaSupplyBillingSummary { + total_entries: number + total_sales_cents: number + total_cost_cents: number + gross_profit_cents: number + net_wallet_delta_cents: number + order_debit_cents: number + order_refund_cents: number + recharge_cents: number + manual_adjustment_cents: number +} + +export interface OpsMediaSupplyBillingsResponse { + items: OpsMediaSupplyBillingEntry[] + total: number + page: number + size: number + summary: OpsMediaSupplyBillingSummary +} + +export interface OpsMediaSupplyBillingsParams { + keyword?: string + tenant_id?: number + user_id?: number + order_id?: number + reason?: string + start_at?: string + end_at?: string + page?: number + page_size?: number +} + export const opsMediaSupplyApi = { listResources(params: OpsMediaSupplyResourcesParams) { return http.get( @@ -107,6 +161,12 @@ export const opsMediaSupplyApi = { params as Record, ) }, + listBillings(params: OpsMediaSupplyBillingsParams) { + return http.get( + '/media-supply/billings', + params as Record, + ) + }, adjustWallet(payload: { tenant_id: number user_id: number diff --git a/apps/ops-web/src/views/MediaSupplyOpsView.vue b/apps/ops-web/src/views/MediaSupplyOpsView.vue index 94e957f..aeed565 100644 --- a/apps/ops-web/src/views/MediaSupplyOpsView.vue +++ b/apps/ops-web/src/views/MediaSupplyOpsView.vue @@ -214,7 +214,162 @@ {{ formatDate(record.updated_at) }} + + + + + + +
+
+
+ 销售额 + {{ formatMoney(billingSummary.total_sales_cents) }} +
+
+ 成本 + {{ formatMoney(billingSummary.total_cost_cents) }} +
+
+ 毛利 + {{ formatMoney(billingSummary.gross_profit_cents) }} + 毛利率 {{ formatPercent(grossProfitRate) }} +
+
+ 账单数 + {{ billingSummary.total_entries }} +
+
+ 净账变 + {{ formatMoney(billingSummary.net_wallet_delta_cents) }} +
+
+ +
+ + + + + + + 本月 + 全部 + 查询 + 重置 +
+ + + @@ -287,12 +442,14 @@ import { EditOutlined, ReloadOutlined } from '@ant-design/icons-vue' import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue' import { message } from 'ant-design-vue' -import dayjs from 'dayjs' +import dayjs, { type Dayjs } from 'dayjs' import { computed, onMounted, reactive, ref, watch } from 'vue' import { showOpsError } from '@/lib/errors' import { opsMediaSupplyApi, + type OpsMediaSupplyBillingEntry, + type OpsMediaSupplyBillingSummary, type OpsMediaSupplyResource, type OpsMediaSupplyWallet, } from '@/lib/media-supply' @@ -301,9 +458,25 @@ interface WalletRow extends OpsMediaSupplyWallet { wallet_key: string } +interface BillingRow extends OpsMediaSupplyBillingEntry { + billing_key: string +} + const MODEL_ID_AUTHORITY_NEWS = 1 const PAGE_SIZE = 20 +const emptyBillingSummary: OpsMediaSupplyBillingSummary = { + total_entries: 0, + total_sales_cents: 0, + total_cost_cents: 0, + gross_profit_cents: 0, + net_wallet_delta_cents: 0, + order_debit_cents: 0, + order_refund_cents: 0, + recharge_cents: 0, + manual_adjustment_cents: 0, +} + const activeTab = ref('resources') const resourcePage = ref(1) const resourcePageSize = ref(PAGE_SIZE) @@ -332,6 +505,17 @@ const walletForm = reactive({ note: '', }) +const billingPage = ref(1) +const billingPageSize = ref(PAGE_SIZE) +const billingLoading = ref(false) +const billingTotal = ref(0) +const billingRows = ref([]) +const billingSummary = ref({ ...emptyBillingSummary }) +const billingDateRange = ref<[Dayjs, Dayjs] | null>([ + dayjs().startOf('month'), + dayjs().endOf('day'), +]) + const resourceFilter = reactive({ keyword: '', remarkKeyword: '', @@ -347,11 +531,26 @@ const walletFilter = reactive({ tenantId: '', }) +const billingFilter = reactive({ + keyword: '', + tenantId: '', + userId: '', + orderId: '', + reason: undefined as string | undefined, +}) + const visibilityOptions = [ { label: '客户可见', value: 'visible' }, { label: '客户隐藏', value: 'hidden' }, ] +const billingReasonOptions = [ + { label: '投稿扣费', value: 'order_debit' }, + { label: '订单退款', value: 'order_refund' }, + { label: '充值', value: 'recharge' }, + { label: '手动调整', value: 'adjustment' }, +] + const resourceColumns: TableColumnsType = [ { title: '媒体名称', key: 'name', width: 320, fixed: 'left' }, { title: '售价 / 成本', key: 'price', width: 150, align: 'right' }, @@ -375,7 +574,20 @@ const walletColumns: TableColumnsType = [ { title: '余额', key: 'balance_cents', width: 160, align: 'right' }, { title: '账单', key: 'ledger', width: 180 }, { title: '更新时间', key: 'updated_at', width: 180 }, - { title: '操作', key: 'actions', width: 130, fixed: 'right', align: 'center' }, + { title: '操作', key: 'actions', width: 160, fixed: 'right', align: 'center' }, +] + +const billingColumns: TableColumnsType = [ + { title: '时间', key: 'created_at', width: 180, fixed: 'left' }, + { title: '客户', key: 'user', width: 240 }, + { title: '租户', key: 'tenant', width: 200 }, + { title: '订单 / 备注', key: 'order', width: 320 }, + { title: '类型', key: 'reason', width: 120, align: 'center' }, + { title: '账变', key: 'delta_cents', width: 140, align: 'right' }, + { title: '销售额', key: 'sales_cents', width: 140, align: 'right' }, + { title: '成本', key: 'cost_cents', width: 140, align: 'right' }, + { title: '毛利', key: 'gross_profit_cents', width: 150, align: 'right' }, + { title: '余额快照', key: 'balance_after_cents', width: 150, align: 'right' }, ] const resourcePagination = computed(() => ({ @@ -394,10 +606,27 @@ const walletPagination = computed(() => ({ showTotal: (total) => `共 ${total} 条`, })) +const billingPagination = computed(() => ({ + current: billingPage.value, + pageSize: billingPageSize.value, + total: billingTotal.value, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, +})) + +const grossProfitRate = computed(() => + billingSummary.value.total_sales_cents === 0 + ? null + : billingSummary.value.gross_profit_cents / billingSummary.value.total_sales_cents, +) + watch(activeTab, (key) => { if (key === 'wallets' && walletRows.value.length === 0) { void loadWallets() } + if (key === 'billings' && billingRows.value.length === 0) { + void loadBillings() + } }) onMounted(() => { @@ -449,6 +678,33 @@ async function loadWallets(): Promise { } } +async function loadBillings(): Promise { + billingLoading.value = true + try { + const result = await opsMediaSupplyApi.listBillings({ + keyword: billingFilter.keyword.trim() || undefined, + tenant_id: parsePositiveNumber(billingFilter.tenantId), + user_id: parsePositiveNumber(billingFilter.userId), + order_id: parsePositiveNumber(billingFilter.orderId), + reason: billingFilter.reason, + start_at: billingDateRange.value?.[0]?.toISOString(), + end_at: billingDateRange.value?.[1]?.toISOString(), + page: billingPage.value, + page_size: billingPageSize.value, + }) + billingRows.value = result.items.map((item) => ({ + ...item, + billing_key: String(item.id), + })) + billingTotal.value = result.total + billingSummary.value = result.summary + } catch (error) { + showOpsError(error) + } finally { + billingLoading.value = false + } +} + function resetResources(): void { resourcePage.value = 1 void loadResources() @@ -470,12 +726,37 @@ function resetWallets(): void { void loadWallets() } +function resetBillings(): void { + billingPage.value = 1 + void loadBillings() +} + function clearWalletFilters(): void { walletFilter.keyword = '' walletFilter.tenantId = '' resetWallets() } +function clearBillingFilters(): void { + billingFilter.keyword = '' + billingFilter.tenantId = '' + billingFilter.userId = '' + billingFilter.orderId = '' + billingFilter.reason = undefined + billingDateRange.value = [dayjs().startOf('month'), dayjs().endOf('day')] + resetBillings() +} + +function setBillingThisMonth(): void { + billingDateRange.value = [dayjs().startOf('month'), dayjs().endOf('day')] + resetBillings() +} + +function showAllBillings(): void { + billingDateRange.value = null + resetBillings() +} + function onResourceTableChange(pagination: TablePaginationConfig): void { resourcePage.value = pagination.current ?? 1 resourcePageSize.value = pagination.pageSize ?? PAGE_SIZE @@ -488,6 +769,29 @@ function onWalletTableChange(pagination: TablePaginationConfig): void { void loadWallets() } +function onBillingTableChange(pagination: TablePaginationConfig): void { + billingPage.value = pagination.current ?? 1 + billingPageSize.value = pagination.pageSize ?? PAGE_SIZE + void loadBillings() +} + +function openUserBillings(row: WalletRow): void { + billingFilter.keyword = '' + billingFilter.tenantId = String(row.tenant_id) + billingFilter.userId = String(row.user_id) + billingFilter.orderId = '' + billingFilter.reason = undefined + billingPage.value = 1 + if (activeTab.value === 'billings') { + void loadBillings() + return + } + activeTab.value = 'billings' + if (billingRows.value.length > 0) { + void loadBillings() + } +} + async function queueSync(): Promise { syncLoading.value = true try { @@ -587,7 +891,29 @@ async function submitWalletAdjustment(): Promise { function formatMoney(cents?: number | null): string { if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--' - return `¥${(cents / 100).toFixed(2)}` + const prefix = cents < 0 ? '-' : '' + return `${prefix}¥${(Math.abs(cents) / 100).toFixed(2)}` +} + +function formatSignedMoney(cents?: number | null): string { + if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--' + const prefix = cents > 0 ? '+' : cents < 0 ? '-' : '' + return `${prefix}¥${(Math.abs(cents) / 100).toFixed(2)}` +} + +function formatPercent(value: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '--' + return `${(value * 100).toFixed(1)}%` +} + +function profitRate(row: OpsMediaSupplyBillingEntry): number | null { + if (!row.sales_cents) return null + return row.gross_profit_cents / row.sales_cents +} + +function amountClass(cents?: number | null): string { + if (typeof cents !== 'number' || cents === 0) return 'amount-neutral' + return cents > 0 ? 'amount-positive' : 'amount-negative' } function formatSellPrice(cents?: number | null): string { @@ -643,6 +969,37 @@ function statusColor(status: string): string { if (status === 'disabled') return 'default' return 'blue' } + +function billingReasonLabel(reason: string): string { + if (reason === 'order_debit') return '投稿扣费' + if (reason === 'order_refund') return '订单退款' + if (reason === 'recharge') return '充值' + if (reason === 'adjustment') return '调整' + return reason || '--' +} + +function billingReasonColor(reason: string): string { + if (reason === 'order_debit') return 'blue' + if (reason === 'order_refund') return 'orange' + if (reason === 'recharge') return 'green' + if (reason === 'adjustment') return 'purple' + return 'default' +} + +function orderStatusLabel(status: string): string { + if (status === 'queued') return '排队' + if (status === 'submitting') return '提交中' + if (status === 'submitted') return '已提交' + if (status === 'failed') return '失败' + return status || '--' +} + +function orderStatusColor(status: string): string { + if (status === 'failed') return 'red' + if (status === 'submitted') return 'green' + if (status === 'submitting') return 'gold' + return 'blue' +} diff --git a/server/internal/ops/app/media_supply.go b/server/internal/ops/app/media_supply.go index 389c3ff..1731e88 100644 --- a/server/internal/ops/app/media_supply.go +++ b/server/internal/ops/app/media_supply.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "math" + "strconv" "strings" "time" @@ -97,6 +98,40 @@ type MediaSupplyWalletStatus struct { TenantName *string `json:"tenant_name,omitempty"` } +type MediaSupplyBillingEntry struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + TenantName *string `json:"tenant_name,omitempty"` + UserID int64 `json:"user_id"` + UserName *string `json:"user_name,omitempty"` + UserPhone *string `json:"user_phone,omitempty"` + OrderID *int64 `json:"order_id,omitempty"` + OrderTitle *string `json:"order_title,omitempty"` + OrderStatus *string `json:"order_status,omitempty"` + ExternalOrderCode *string `json:"external_order_code,omitempty"` + DeltaCents int64 `json:"delta_cents"` + BalanceAfterCents int64 `json:"balance_after_cents"` + Reason string `json:"reason"` + Note *string `json:"note,omitempty"` + SalesCents int64 `json:"sales_cents"` + CostCents int64 `json:"cost_cents"` + GrossProfitCents int64 `json:"gross_profit_cents"` + CreatedBy *int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type MediaSupplyBillingSummary struct { + TotalEntries int64 `json:"total_entries"` + TotalSalesCents int64 `json:"total_sales_cents"` + TotalCostCents int64 `json:"total_cost_cents"` + GrossProfitCents int64 `json:"gross_profit_cents"` + NetWalletDeltaCents int64 `json:"net_wallet_delta_cents"` + OrderDebitCents int64 `json:"order_debit_cents"` + OrderRefundCents int64 `json:"order_refund_cents"` + RechargeCents int64 `json:"recharge_cents"` + ManualAdjustmentCents int64 `json:"manual_adjustment_cents"` +} + type ListMediaSupplyWalletsInput struct { Keyword string TenantID int64 @@ -111,6 +146,26 @@ type ListMediaSupplyWalletsResult struct { Size int `json:"size"` } +type ListMediaSupplyBillingsInput struct { + Keyword string + TenantID int64 + UserID int64 + OrderID int64 + Reason string + StartAt *time.Time + EndAt *time.Time + Page int + Size int +} + +type ListMediaSupplyBillingsResult struct { + Items []MediaSupplyBillingEntry `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Summary MediaSupplyBillingSummary `json:"summary"` +} + type AdjustMediaSupplyWalletInput struct { TenantID int64 UserID int64 @@ -371,6 +426,187 @@ func (s *MediaSupplyService) ListWallets(ctx context.Context, input ListMediaSup return &ListMediaSupplyWalletsResult{Items: items, Total: total, Page: page, Size: size}, nil } +func (s *MediaSupplyService) ListBillings(ctx context.Context, input ListMediaSupplyBillingsInput) (*ListMediaSupplyBillingsResult, error) { + if s == nil || s.pool == nil { + return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size) + args := make([]any, 0, 8) + where := []string{"1=1"} + needsSearchJoin := false + if input.TenantID > 0 { + args = append(args, input.TenantID) + where = append(where, fmt.Sprintf("l.tenant_id = $%d", len(args))) + } + if input.UserID > 0 { + args = append(args, input.UserID) + where = append(where, fmt.Sprintf("l.user_id = $%d", len(args))) + } + if input.OrderID > 0 { + args = append(args, input.OrderID) + where = append(where, fmt.Sprintf("l.order_id = $%d", len(args))) + } + if reason := strings.TrimSpace(input.Reason); reason != "" { + args = append(args, reason) + where = append(where, fmt.Sprintf("l.reason = $%d", len(args))) + } + if input.StartAt != nil { + args = append(args, *input.StartAt) + where = append(where, fmt.Sprintf("l.created_at >= $%d", len(args))) + } + if input.EndAt != nil { + args = append(args, *input.EndAt) + where = append(where, fmt.Sprintf("l.created_at < $%d", len(args))) + } + if keyword := strings.TrimSpace(input.Keyword); keyword != "" { + needsSearchJoin = true + args = append(args, "%"+keyword+"%") + keywordArg := len(args) + predicates := []string{ + fmt.Sprintf("COALESCE(u.phone, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(u.email, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(u.name, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(t.name, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(o.title, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(o.external_order_code, '') ILIKE $%d", keywordArg), + fmt.Sprintf("COALESCE(l.note, '') ILIKE $%d", keywordArg), + } + if id, err := strconv.ParseInt(keyword, 10, 64); err == nil && id > 0 { + args = append(args, id) + idArg := len(args) + predicates = append(predicates, fmt.Sprintf("(l.tenant_id = $%d OR l.user_id = $%d OR l.order_id = $%d)", idArg, idArg, idArg)) + } + where = append(where, "("+strings.Join(predicates, " OR ")+")") + } + whereSQL := strings.Join(where, " AND ") + summarySQL := ` + SELECT COUNT(*), + COALESCE(SUM(l.sales_cents), 0), + COALESCE(SUM(l.cost_cents), 0), + COALESCE(SUM(l.gross_profit_cents), 0), + COALESCE(SUM(l.delta_cents), 0), + COALESCE(SUM(CASE WHEN l.reason = 'order_debit' THEN -l.delta_cents ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN l.reason = 'order_refund' THEN l.delta_cents ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN l.reason = 'recharge' THEN l.delta_cents ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN l.reason = 'adjustment' THEN l.delta_cents ELSE 0 END), 0) + FROM media_supply_wallet_ledgers l` + if needsSearchJoin { + summarySQL += ` + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN tenants t ON t.id = l.tenant_id + LEFT JOIN media_supply_orders o ON o.id = l.order_id` + } + summarySQL += ` + WHERE ` + whereSQL + var summary MediaSupplyBillingSummary + if err := s.pool.QueryRow(ctx, summarySQL, args...).Scan( + &summary.TotalEntries, + &summary.TotalSalesCents, + &summary.TotalCostCents, + &summary.GrossProfitCents, + &summary.NetWalletDeltaCents, + &summary.OrderDebitCents, + &summary.OrderRefundCents, + &summary.RechargeCents, + &summary.ManualAdjustmentCents, + ); err != nil { + return nil, response.ErrInternal(50091, "media_supply_billing_summary_failed", "媒体投稿账单汇总失败") + } + listArgs := append([]any{}, args...) + listArgs = append(listArgs, size, (page-1)*size) + rows, err := s.pool.Query(ctx, ` + SELECT l.id, l.tenant_id, t.name, l.user_id, u.name, u.phone, l.order_id, + o.title, o.status, o.external_order_code, l.delta_cents, + l.balance_after_cents, l.reason, l.note, l.sales_cents, l.cost_cents, + l.gross_profit_cents, l.created_by, l.created_at + FROM media_supply_wallet_ledgers l + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN tenants t ON t.id = l.tenant_id + LEFT JOIN media_supply_orders o ON o.id = l.order_id + WHERE `+whereSQL+` + ORDER BY l.created_at DESC, l.id DESC + LIMIT $`+fmt.Sprint(len(listArgs)-1)+` OFFSET $`+fmt.Sprint(len(listArgs))+` + `, listArgs...) + if err != nil { + return nil, response.ErrInternal(50092, "media_supply_billing_query_failed", "媒体投稿账单读取失败") + } + defer rows.Close() + items := make([]MediaSupplyBillingEntry, 0, size) + for rows.Next() { + var item MediaSupplyBillingEntry + var tenantName sql.NullString + var userName sql.NullString + var userPhone sql.NullString + var orderID sql.NullInt64 + var orderTitle sql.NullString + var orderStatus sql.NullString + var externalOrderCode sql.NullString + var note sql.NullString + var createdBy sql.NullInt64 + if err := rows.Scan( + &item.ID, + &item.TenantID, + &tenantName, + &item.UserID, + &userName, + &userPhone, + &orderID, + &orderTitle, + &orderStatus, + &externalOrderCode, + &item.DeltaCents, + &item.BalanceAfterCents, + &item.Reason, + ¬e, + &item.SalesCents, + &item.CostCents, + &item.GrossProfitCents, + &createdBy, + &item.CreatedAt, + ); err != nil { + return nil, response.ErrInternal(50093, "media_supply_billing_scan_failed", "媒体投稿账单解析失败") + } + if tenantName.Valid { + item.TenantName = &tenantName.String + } + if userName.Valid { + item.UserName = &userName.String + } + if userPhone.Valid { + item.UserPhone = &userPhone.String + } + if orderID.Valid { + item.OrderID = &orderID.Int64 + } + if orderTitle.Valid { + item.OrderTitle = &orderTitle.String + } + if orderStatus.Valid { + item.OrderStatus = &orderStatus.String + } + if externalOrderCode.Valid { + item.ExternalOrderCode = &externalOrderCode.String + } + if note.Valid { + item.Note = ¬e.String + } + if createdBy.Valid { + item.CreatedBy = &createdBy.Int64 + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50093, "media_supply_billing_scan_failed", "媒体投稿账单解析失败") + } + return &ListMediaSupplyBillingsResult{ + Items: items, + Total: summary.TotalEntries, + Page: page, + Size: size, + Summary: summary, + }, nil +} + func (s *MediaSupplyService) QueueSync(ctx context.Context, actor *Actor, modelID int) (int64, error) { if s == nil || s.pool == nil { return 0, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") @@ -455,9 +691,10 @@ func (s *MediaSupplyService) AdjustWallet(ctx context.Context, actor *Actor, inp } if _, err := tx.Exec(ctx, ` INSERT INTO media_supply_wallet_ledgers ( - tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by + tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by, + sales_cents, cost_cents, gross_profit_cents ) - VALUES ($1, $2, $3, $4, $5, $6, NULL) + VALUES ($1, $2, $3, $4, $5, $6, NULL, 0, 0, 0) `, input.TenantID, input.UserID, input.DeltaCents, balanceAfter, reason, nullableOpsMediaSupplyString(note)); err != nil { return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") } diff --git a/server/internal/ops/transport/media_supply_handler.go b/server/internal/ops/transport/media_supply_handler.go index 325c888..8798999 100644 --- a/server/internal/ops/transport/media_supply_handler.go +++ b/server/internal/ops/transport/media_supply_handler.go @@ -2,6 +2,7 @@ package transport import ( "strconv" + "time" "github.com/gin-gonic/gin" @@ -157,6 +158,58 @@ func listMediaSupplyWalletsHandler(svc *app.MediaSupplyService) gin.HandlerFunc } } +func listMediaSupplyBillingsHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + tenantID, err := parseOptionalOpsInt64Query(c, "tenant_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_tenant_id", "租户参数必须是数字")) + return + } + userID, err := parseOptionalOpsInt64Query(c, "user_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_user_id", "用户参数必须是数字")) + return + } + orderID, err := parseOptionalOpsInt64Query(c, "order_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_order_id", "订单参数必须是数字")) + return + } + startAt, err := parseOptionalOpsTimeQuery(c, "start_at") + if err != nil { + response.Error(c, response.ErrBadRequest(40003, "invalid_start_at", "start_at 必须是 RFC3339")) + return + } + endAt, err := parseOptionalOpsTimeQuery(c, "end_at") + if err != nil { + response.Error(c, response.ErrBadRequest(40004, "invalid_end_at", "end_at 必须是 RFC3339")) + return + } + if startAt != nil && endAt != nil && !startAt.Before(*endAt) { + response.Error(c, response.ErrBadRequest(40005, "invalid_billing_time_range", "结束时间必须晚于开始时间")) + return + } + page, _ := parseOptionalOpsIntQuery(c, "page") + size, _ := parseOptionalOpsIntQuery(c, "page_size") + result, err := svc.ListBillings(c.Request.Context(), app.ListMediaSupplyBillingsInput{ + Keyword: c.Query("keyword"), + TenantID: tenantID, + UserID: userID, + OrderID: orderID, + Reason: c.Query("reason"), + StartAt: startAt, + EndAt: endAt, + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + func adjustMediaSupplyWalletHandler(svc *app.MediaSupplyService) gin.HandlerFunc { return func(c *gin.Context) { var body adjustMediaSupplyWalletRequest @@ -199,3 +252,15 @@ func parseOptionalOpsInt64Query(c *gin.Context, key string) (int64, error) { } return strconv.ParseInt(raw, 10, 64) } + +func parseOptionalOpsTimeQuery(c *gin.Context, key string) (*time.Time, error) { + raw := c.Query(key) + if raw == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil, err + } + return &parsed, nil +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index a01c4c6..aa9614d 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -139,6 +139,7 @@ func RegisterRoutes(d Deps) { authed.PUT("/media-supply/resources/:id/visibility", setMediaSupplyResourceVisibilityHandler(d.MediaSupply)) authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply)) authed.GET("/media-supply/wallets", listMediaSupplyWalletsHandler(d.MediaSupply)) + authed.GET("/media-supply/billings", listMediaSupplyBillingsHandler(d.MediaSupply)) authed.POST("/media-supply/wallets/adjustments", adjustMediaSupplyWalletHandler(d.MediaSupply)) authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler)) diff --git a/server/internal/tenant/app/media_supply_service.go b/server/internal/tenant/app/media_supply_service.go index 3706c46..b70e569 100644 --- a/server/internal/tenant/app/media_supply_service.go +++ b/server/internal/tenant/app/media_supply_service.go @@ -763,9 +763,10 @@ func (s *MediaSupplyService) AdjustWallet(ctx context.Context, req AdjustMediaSu } if _, err := tx.Exec(ctx, ` INSERT INTO media_supply_wallet_ledgers ( - tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by + tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by, + sales_cents, cost_cents, gross_profit_cents ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, 0) `, actor.TenantID, req.UserID, req.DeltaCents, balanceAfter, reason, nullableTrimmedString(req.Note), actor.UserID); err != nil { return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") } @@ -848,9 +849,15 @@ func (s *MediaSupplyService) debitMediaSupplyWallet(ctx context.Context, tx pgx. } if _, err := tx.Exec(ctx, ` INSERT INTO media_supply_wallet_ledgers ( - tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by + tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by, + sales_cents, cost_cents, gross_profit_cents + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + (SELECT sell_total_cents FROM media_supply_orders WHERE id = $3), + (SELECT cost_total_cents FROM media_supply_orders WHERE id = $3), + (SELECT sell_total_cents - cost_total_cents FROM media_supply_orders WHERE id = $3) ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, tenantID, userID, orderID, -amountCents, balanceAfter, "order_debit", nullableTrimmedString(orderTitle), createdBy); err != nil { return 0, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") } @@ -900,9 +907,15 @@ func (s *MediaSupplyService) refundMediaSupplyWalletForOrder(ctx context.Context } if _, err := tx.Exec(ctx, ` INSERT INTO media_supply_wallet_ledgers ( - tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by + tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by, + sales_cents, cost_cents, gross_profit_cents + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + (SELECT -sell_total_cents FROM media_supply_orders WHERE id = $3), + (SELECT -cost_total_cents FROM media_supply_orders WHERE id = $3), + (SELECT -(sell_total_cents - cost_total_cents) FROM media_supply_orders WHERE id = $3) ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, tenantID, userID, orderID, debitCents, balanceAfter, "order_refund", nullableTrimmedString(ledgerNote), userID); err != nil { return response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") } diff --git a/server/migrations/20260629110000_add_media_supply_billing_fact_columns.down.sql b/server/migrations/20260629110000_add_media_supply_billing_fact_columns.down.sql new file mode 100644 index 0000000..c1491d5 --- /dev/null +++ b/server/migrations/20260629110000_add_media_supply_billing_fact_columns.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE media_supply_wallet_ledgers + DROP CONSTRAINT IF EXISTS chk_media_supply_wallet_ledger_fact_non_negative, + DROP COLUMN IF EXISTS gross_profit_cents, + DROP COLUMN IF EXISTS cost_cents, + DROP COLUMN IF EXISTS sales_cents; diff --git a/server/migrations/20260629110000_add_media_supply_billing_fact_columns.up.sql b/server/migrations/20260629110000_add_media_supply_billing_fact_columns.up.sql new file mode 100644 index 0000000..a9f8e0a --- /dev/null +++ b/server/migrations/20260629110000_add_media_supply_billing_fact_columns.up.sql @@ -0,0 +1,34 @@ +ALTER TABLE media_supply_wallet_ledgers + ADD COLUMN IF NOT EXISTS sales_cents BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cost_cents BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS gross_profit_cents BIGINT NOT NULL DEFAULT 0; + +UPDATE media_supply_wallet_ledgers l +SET sales_cents = CASE + WHEN l.reason = 'order_debit' THEN o.sell_total_cents + WHEN l.reason = 'order_refund' THEN -o.sell_total_cents + ELSE 0 + END, + cost_cents = CASE + WHEN l.reason = 'order_debit' THEN o.cost_total_cents + WHEN l.reason = 'order_refund' THEN -o.cost_total_cents + ELSE 0 + END, + gross_profit_cents = CASE + WHEN l.reason = 'order_debit' THEN o.sell_total_cents - o.cost_total_cents + WHEN l.reason = 'order_refund' THEN -(o.sell_total_cents - o.cost_total_cents) + ELSE 0 + END +FROM media_supply_orders o +WHERE l.order_id = o.id + AND l.reason IN ('order_debit', 'order_refund'); + +ALTER TABLE media_supply_wallet_ledgers + ADD CONSTRAINT chk_media_supply_wallet_ledger_fact_non_negative + CHECK ( + reason = 'order_refund' + OR (sales_cents >= 0 AND cost_cents >= 0 AND gross_profit_cents >= 0) + ) NOT VALID; + +ALTER TABLE media_supply_wallet_ledgers + VALIDATE CONSTRAINT chk_media_supply_wallet_ledger_fact_non_negative; diff --git a/server/migrations/20260629110100_add_media_supply_billing_indexes.down.sql b/server/migrations/20260629110100_add_media_supply_billing_indexes.down.sql new file mode 100644 index 0000000..2f0861f --- /dev/null +++ b/server/migrations/20260629110100_add_media_supply_billing_indexes.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_created_id; diff --git a/server/migrations/20260629110100_add_media_supply_billing_indexes.up.sql b/server/migrations/20260629110100_add_media_supply_billing_indexes.up.sql new file mode 100644 index 0000000..33254bc --- /dev/null +++ b/server/migrations/20260629110100_add_media_supply_billing_indexes.up.sql @@ -0,0 +1,12 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_created_id + ON media_supply_wallet_ledgers (created_at DESC, id DESC) + INCLUDE ( + tenant_id, + user_id, + order_id, + reason, + delta_cents, + sales_cents, + cost_cents, + gross_profit_cents + ); diff --git a/server/migrations/20260629110101_add_media_supply_billing_tenant_index.down.sql b/server/migrations/20260629110101_add_media_supply_billing_tenant_index.down.sql new file mode 100644 index 0000000..7fdc0a7 --- /dev/null +++ b/server/migrations/20260629110101_add_media_supply_billing_tenant_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_created; diff --git a/server/migrations/20260629110101_add_media_supply_billing_tenant_index.up.sql b/server/migrations/20260629110101_add_media_supply_billing_tenant_index.up.sql new file mode 100644 index 0000000..a5bd6cc --- /dev/null +++ b/server/migrations/20260629110101_add_media_supply_billing_tenant_index.up.sql @@ -0,0 +1,11 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_tenant_created + ON media_supply_wallet_ledgers (tenant_id, created_at DESC, id DESC) + INCLUDE ( + user_id, + order_id, + reason, + delta_cents, + sales_cents, + cost_cents, + gross_profit_cents + ); diff --git a/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.down.sql b/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.down.sql new file mode 100644 index 0000000..b733eac --- /dev/null +++ b/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_user_created_cover; diff --git a/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.up.sql b/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.up.sql new file mode 100644 index 0000000..8d4db04 --- /dev/null +++ b/server/migrations/20260629110102_add_media_supply_billing_tenant_user_index.up.sql @@ -0,0 +1,10 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_tenant_user_created_cover + ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC, id DESC) + INCLUDE ( + order_id, + reason, + delta_cents, + sales_cents, + cost_cents, + gross_profit_cents + ); diff --git a/server/migrations/20260629110103_add_media_supply_billing_reason_index.down.sql b/server/migrations/20260629110103_add_media_supply_billing_reason_index.down.sql new file mode 100644 index 0000000..c981fb8 --- /dev/null +++ b/server/migrations/20260629110103_add_media_supply_billing_reason_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_reason_created; diff --git a/server/migrations/20260629110103_add_media_supply_billing_reason_index.up.sql b/server/migrations/20260629110103_add_media_supply_billing_reason_index.up.sql new file mode 100644 index 0000000..4b557cf --- /dev/null +++ b/server/migrations/20260629110103_add_media_supply_billing_reason_index.up.sql @@ -0,0 +1,11 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_supply_wallet_ledgers_reason_created + ON media_supply_wallet_ledgers (reason, created_at DESC, id DESC) + INCLUDE ( + tenant_id, + user_id, + order_id, + delta_cents, + sales_cents, + cost_cents, + gross_profit_cents + );