feat: add media supply billing center
This commit is contained in:
@@ -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<OpsMediaSupplyResourcesResponse>(
|
||||
@@ -107,6 +161,12 @@ export const opsMediaSupplyApi = {
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
listBillings(params: OpsMediaSupplyBillingsParams) {
|
||||
return http.get<OpsMediaSupplyBillingsResponse>(
|
||||
'/media-supply/billings',
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
adjustWallet(payload: {
|
||||
tenant_id: number
|
||||
user_id: number
|
||||
|
||||
@@ -214,7 +214,162 @@
|
||||
{{ formatDate(record.updated_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button type="link" @click="openWalletModal(record)">调整余额</a-button>
|
||||
<div class="wallet-actions">
|
||||
<a-button type="link" @click="openUserBillings(record)">账单</a-button>
|
||||
<a-button type="link" @click="openWalletModal(record)">调整余额</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="billings" tab="账单中心">
|
||||
<div class="ops-card media-ops-card billing-card">
|
||||
<div class="billing-summary">
|
||||
<div class="billing-summary__item">
|
||||
<span>销售额</span>
|
||||
<strong>{{ formatMoney(billingSummary.total_sales_cents) }}</strong>
|
||||
</div>
|
||||
<div class="billing-summary__item">
|
||||
<span>成本</span>
|
||||
<strong>{{ formatMoney(billingSummary.total_cost_cents) }}</strong>
|
||||
</div>
|
||||
<div class="billing-summary__item billing-summary__item--profit">
|
||||
<span>毛利</span>
|
||||
<strong>{{ formatMoney(billingSummary.gross_profit_cents) }}</strong>
|
||||
<small>毛利率 {{ formatPercent(grossProfitRate) }}</small>
|
||||
</div>
|
||||
<div class="billing-summary__item">
|
||||
<span>账单数</span>
|
||||
<strong>{{ billingSummary.total_entries }}</strong>
|
||||
</div>
|
||||
<div class="billing-summary__item">
|
||||
<span>净账变</span>
|
||||
<strong>{{ formatMoney(billingSummary.net_wallet_delta_cents) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-toolbar media-ops-toolbar billing-toolbar">
|
||||
<a-input
|
||||
v-model:value="billingFilter.keyword"
|
||||
allow-clear
|
||||
placeholder="客户 / 手机 / 租户 / 订单 / 备注"
|
||||
style="width: 280px"
|
||||
@press-enter="resetBillings"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="billingFilter.tenantId"
|
||||
allow-clear
|
||||
placeholder="租户 ID"
|
||||
style="width: 120px"
|
||||
@press-enter="resetBillings"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="billingFilter.userId"
|
||||
allow-clear
|
||||
placeholder="用户 ID"
|
||||
style="width: 120px"
|
||||
@press-enter="resetBillings"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="billingFilter.orderId"
|
||||
allow-clear
|
||||
placeholder="订单 ID"
|
||||
style="width: 120px"
|
||||
@press-enter="resetBillings"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="billingFilter.reason"
|
||||
allow-clear
|
||||
placeholder="账单类型"
|
||||
style="width: 140px"
|
||||
:options="billingReasonOptions"
|
||||
@change="resetBillings"
|
||||
/>
|
||||
<a-range-picker
|
||||
v-model:value="billingDateRange"
|
||||
show-time
|
||||
style="width: 360px"
|
||||
@change="resetBillings"
|
||||
/>
|
||||
<a-button @click="setBillingThisMonth">本月</a-button>
|
||||
<a-button @click="showAllBillings">全部</a-button>
|
||||
<a-button type="primary" @click="resetBillings">查询</a-button>
|
||||
<a-button @click="clearBillingFilters">重置</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="billingColumns"
|
||||
:data-source="billingRows"
|
||||
:loading="billingLoading"
|
||||
:pagination="billingPagination"
|
||||
row-key="billing_key"
|
||||
:scroll="{ x: 1780 }"
|
||||
@change="onBillingTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
<div class="mono-line">{{ formatDate(record.created_at) }}</div>
|
||||
<div class="muted">#{{ record.id }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'user'">
|
||||
<div class="resource-main">
|
||||
<strong>
|
||||
{{ record.user_name || record.user_phone || `用户 ${record.user_id}` }}
|
||||
</strong>
|
||||
<div class="resource-sub">
|
||||
<code>U{{ record.user_id }}</code>
|
||||
<span>{{ record.user_phone || '未绑定手机' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
<div>{{ record.tenant_name || `租户 ${record.tenant_id}` }}</div>
|
||||
<div class="muted">T{{ record.tenant_id }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'order'">
|
||||
<div class="resource-main">
|
||||
<strong>{{ record.order_title || record.note || '--' }}</strong>
|
||||
<div class="resource-sub">
|
||||
<code v-if="record.order_id">O{{ record.order_id }}</code>
|
||||
<span v-if="record.external_order_code">{{ record.external_order_code }}</span>
|
||||
<a-tag v-if="record.order_status" :color="orderStatusColor(record.order_status)">
|
||||
{{ orderStatusLabel(record.order_status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reason'">
|
||||
<a-tag :color="billingReasonColor(record.reason)">
|
||||
{{ billingReasonLabel(record.reason) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'delta_cents'">
|
||||
<strong :class="amountClass(record.delta_cents)">
|
||||
{{ formatSignedMoney(record.delta_cents) }}
|
||||
</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'sales_cents'">
|
||||
<strong :class="amountClass(record.sales_cents)">
|
||||
{{ formatMoney(record.sales_cents) }}
|
||||
</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'cost_cents'">
|
||||
<strong :class="amountClass(record.cost_cents)">
|
||||
{{ formatMoney(record.cost_cents) }}
|
||||
</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'gross_profit_cents'">
|
||||
<div class="price-stack">
|
||||
<strong :class="amountClass(record.gross_profit_cents)">
|
||||
{{ formatMoney(record.gross_profit_cents) }}
|
||||
</strong>
|
||||
<span>{{ formatPercent(profitRate(record)) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'balance_after_cents'">
|
||||
<strong>{{ formatMoney(record.balance_after_cents) }}</strong>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -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<BillingRow[]>([])
|
||||
const billingSummary = ref<OpsMediaSupplyBillingSummary>({ ...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<OpsMediaSupplyResource> = [
|
||||
{ title: '媒体名称', key: 'name', width: 320, fixed: 'left' },
|
||||
{ title: '售价 / 成本', key: 'price', width: 150, align: 'right' },
|
||||
@@ -375,7 +574,20 @@ const walletColumns: TableColumnsType<WalletRow> = [
|
||||
{ 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<BillingRow> = [
|
||||
{ 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<TablePaginationConfig>(() => ({
|
||||
@@ -394,10 +606,27 @@ const walletPagination = computed<TablePaginationConfig>(() => ({
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}))
|
||||
|
||||
const billingPagination = computed<TablePaginationConfig>(() => ({
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBillings(): Promise<void> {
|
||||
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<void> {
|
||||
syncLoading.value = true
|
||||
try {
|
||||
@@ -587,7 +891,29 @@ async function submitWalletAdjustment(): Promise<void> {
|
||||
|
||||
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'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -676,11 +1033,67 @@ function statusColor(status: string): string {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.billing-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.media-ops-toolbar {
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.billing-toolbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.billing-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(5, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.billing-summary__item {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 86px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.billing-summary__item span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.billing-summary__item strong {
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.billing-summary__item small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.billing-summary__item--profit {
|
||||
background: #ecfdf5;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.billing-summary__item--profit strong {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.resource-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -741,16 +1154,54 @@ function statusColor(status: string): string {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.wallet-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mono-line,
|
||||
.amount-positive,
|
||||
.amount-negative,
|
||||
.amount-neutral,
|
||||
.wallet-balance,
|
||||
.price-stack strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.amount-neutral {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.edit-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.billing-summary {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.media-ops-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.billing-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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", "媒体投稿账单创建失败")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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", "媒体投稿账单创建失败")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_created_id;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_created;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_tenant_user_created_cover;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_media_supply_wallet_ledgers_reason_created;
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user