feat: add media supply billing center
This commit is contained in:
@@ -70,6 +70,60 @@ export interface OpsMediaSupplyWalletsParams {
|
|||||||
page_size?: number
|
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 = {
|
export const opsMediaSupplyApi = {
|
||||||
listResources(params: OpsMediaSupplyResourcesParams) {
|
listResources(params: OpsMediaSupplyResourcesParams) {
|
||||||
return http.get<OpsMediaSupplyResourcesResponse>(
|
return http.get<OpsMediaSupplyResourcesResponse>(
|
||||||
@@ -107,6 +161,12 @@ export const opsMediaSupplyApi = {
|
|||||||
params as Record<string, unknown>,
|
params as Record<string, unknown>,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
listBillings(params: OpsMediaSupplyBillingsParams) {
|
||||||
|
return http.get<OpsMediaSupplyBillingsResponse>(
|
||||||
|
'/media-supply/billings',
|
||||||
|
params as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
},
|
||||||
adjustWallet(payload: {
|
adjustWallet(payload: {
|
||||||
tenant_id: number
|
tenant_id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
|
|||||||
@@ -214,7 +214,162 @@
|
|||||||
{{ formatDate(record.updated_at) }}
|
{{ formatDate(record.updated_at) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<div class="wallet-actions">
|
||||||
|
<a-button type="link" @click="openUserBillings(record)">账单</a-button>
|
||||||
<a-button type="link" @click="openWalletModal(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>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -287,12 +442,14 @@
|
|||||||
import { EditOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
import { EditOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
||||||
import { message } 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 { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { showOpsError } from '@/lib/errors'
|
import { showOpsError } from '@/lib/errors'
|
||||||
import {
|
import {
|
||||||
opsMediaSupplyApi,
|
opsMediaSupplyApi,
|
||||||
|
type OpsMediaSupplyBillingEntry,
|
||||||
|
type OpsMediaSupplyBillingSummary,
|
||||||
type OpsMediaSupplyResource,
|
type OpsMediaSupplyResource,
|
||||||
type OpsMediaSupplyWallet,
|
type OpsMediaSupplyWallet,
|
||||||
} from '@/lib/media-supply'
|
} from '@/lib/media-supply'
|
||||||
@@ -301,9 +458,25 @@ interface WalletRow extends OpsMediaSupplyWallet {
|
|||||||
wallet_key: string
|
wallet_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BillingRow extends OpsMediaSupplyBillingEntry {
|
||||||
|
billing_key: string
|
||||||
|
}
|
||||||
|
|
||||||
const MODEL_ID_AUTHORITY_NEWS = 1
|
const MODEL_ID_AUTHORITY_NEWS = 1
|
||||||
const PAGE_SIZE = 20
|
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 activeTab = ref('resources')
|
||||||
const resourcePage = ref(1)
|
const resourcePage = ref(1)
|
||||||
const resourcePageSize = ref(PAGE_SIZE)
|
const resourcePageSize = ref(PAGE_SIZE)
|
||||||
@@ -332,6 +505,17 @@ const walletForm = reactive({
|
|||||||
note: '',
|
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({
|
const resourceFilter = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
remarkKeyword: '',
|
remarkKeyword: '',
|
||||||
@@ -347,11 +531,26 @@ const walletFilter = reactive({
|
|||||||
tenantId: '',
|
tenantId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const billingFilter = reactive({
|
||||||
|
keyword: '',
|
||||||
|
tenantId: '',
|
||||||
|
userId: '',
|
||||||
|
orderId: '',
|
||||||
|
reason: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
const visibilityOptions = [
|
const visibilityOptions = [
|
||||||
{ label: '客户可见', value: 'visible' },
|
{ label: '客户可见', value: 'visible' },
|
||||||
{ label: '客户隐藏', value: 'hidden' },
|
{ label: '客户隐藏', value: 'hidden' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const billingReasonOptions = [
|
||||||
|
{ label: '投稿扣费', value: 'order_debit' },
|
||||||
|
{ label: '订单退款', value: 'order_refund' },
|
||||||
|
{ label: '充值', value: 'recharge' },
|
||||||
|
{ label: '手动调整', value: 'adjustment' },
|
||||||
|
]
|
||||||
|
|
||||||
const resourceColumns: TableColumnsType<OpsMediaSupplyResource> = [
|
const resourceColumns: TableColumnsType<OpsMediaSupplyResource> = [
|
||||||
{ title: '媒体名称', key: 'name', width: 320, fixed: 'left' },
|
{ title: '媒体名称', key: 'name', width: 320, fixed: 'left' },
|
||||||
{ title: '售价 / 成本', key: 'price', width: 150, align: 'right' },
|
{ 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: 'balance_cents', width: 160, align: 'right' },
|
||||||
{ title: '账单', key: 'ledger', width: 180 },
|
{ title: '账单', key: 'ledger', width: 180 },
|
||||||
{ title: '更新时间', key: 'updated_at', 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>(() => ({
|
const resourcePagination = computed<TablePaginationConfig>(() => ({
|
||||||
@@ -394,10 +606,27 @@ const walletPagination = computed<TablePaginationConfig>(() => ({
|
|||||||
showTotal: (total) => `共 ${total} 条`,
|
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) => {
|
watch(activeTab, (key) => {
|
||||||
if (key === 'wallets' && walletRows.value.length === 0) {
|
if (key === 'wallets' && walletRows.value.length === 0) {
|
||||||
void loadWallets()
|
void loadWallets()
|
||||||
}
|
}
|
||||||
|
if (key === 'billings' && billingRows.value.length === 0) {
|
||||||
|
void loadBillings()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
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 {
|
function resetResources(): void {
|
||||||
resourcePage.value = 1
|
resourcePage.value = 1
|
||||||
void loadResources()
|
void loadResources()
|
||||||
@@ -470,12 +726,37 @@ function resetWallets(): void {
|
|||||||
void loadWallets()
|
void loadWallets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetBillings(): void {
|
||||||
|
billingPage.value = 1
|
||||||
|
void loadBillings()
|
||||||
|
}
|
||||||
|
|
||||||
function clearWalletFilters(): void {
|
function clearWalletFilters(): void {
|
||||||
walletFilter.keyword = ''
|
walletFilter.keyword = ''
|
||||||
walletFilter.tenantId = ''
|
walletFilter.tenantId = ''
|
||||||
resetWallets()
|
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 {
|
function onResourceTableChange(pagination: TablePaginationConfig): void {
|
||||||
resourcePage.value = pagination.current ?? 1
|
resourcePage.value = pagination.current ?? 1
|
||||||
resourcePageSize.value = pagination.pageSize ?? PAGE_SIZE
|
resourcePageSize.value = pagination.pageSize ?? PAGE_SIZE
|
||||||
@@ -488,6 +769,29 @@ function onWalletTableChange(pagination: TablePaginationConfig): void {
|
|||||||
void loadWallets()
|
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> {
|
async function queueSync(): Promise<void> {
|
||||||
syncLoading.value = true
|
syncLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -587,7 +891,29 @@ async function submitWalletAdjustment(): Promise<void> {
|
|||||||
|
|
||||||
function formatMoney(cents?: number | null): string {
|
function formatMoney(cents?: number | null): string {
|
||||||
if (typeof cents !== 'number' || !Number.isFinite(cents)) return '--'
|
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 {
|
function formatSellPrice(cents?: number | null): string {
|
||||||
@@ -643,6 +969,37 @@ function statusColor(status: string): string {
|
|||||||
if (status === 'disabled') return 'default'
|
if (status === 'disabled') return 'default'
|
||||||
return 'blue'
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -676,11 +1033,67 @@ function statusColor(status: string): string {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.media-ops-toolbar {
|
.media-ops-toolbar {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
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 {
|
.resource-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -741,16 +1154,54 @@ function statusColor(status: string): string {
|
|||||||
color: #0f766e;
|
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 {
|
.edit-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.billing-summary {
|
||||||
|
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.media-ops-header {
|
.media-ops-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -97,6 +98,40 @@ type MediaSupplyWalletStatus struct {
|
|||||||
TenantName *string `json:"tenant_name,omitempty"`
|
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 {
|
type ListMediaSupplyWalletsInput struct {
|
||||||
Keyword string
|
Keyword string
|
||||||
TenantID int64
|
TenantID int64
|
||||||
@@ -111,6 +146,26 @@ type ListMediaSupplyWalletsResult struct {
|
|||||||
Size int `json:"size"`
|
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 {
|
type AdjustMediaSupplyWalletInput struct {
|
||||||
TenantID int64
|
TenantID int64
|
||||||
UserID 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
|
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) {
|
func (s *MediaSupplyService) QueueSync(ctx context.Context, actor *Actor, modelID int) (int64, error) {
|
||||||
if s == nil || s.pool == nil {
|
if s == nil || s.pool == nil {
|
||||||
return 0, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
|
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, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO media_supply_wallet_ledgers (
|
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 {
|
`, input.TenantID, input.UserID, input.DeltaCents, balanceAfter, reason, nullableOpsMediaSupplyString(note)); err != nil {
|
||||||
return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
|
return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package transport
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func adjustMediaSupplyWalletHandler(svc *app.MediaSupplyService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body adjustMediaSupplyWalletRequest
|
var body adjustMediaSupplyWalletRequest
|
||||||
@@ -199,3 +252,15 @@ func parseOptionalOpsInt64Query(c *gin.Context, key string) (int64, error) {
|
|||||||
}
|
}
|
||||||
return strconv.ParseInt(raw, 10, 64)
|
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.PUT("/media-supply/resources/:id/visibility", setMediaSupplyResourceVisibilityHandler(d.MediaSupply))
|
||||||
authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply))
|
authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply))
|
||||||
authed.GET("/media-supply/wallets", listMediaSupplyWalletsHandler(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.POST("/media-supply/wallets/adjustments", adjustMediaSupplyWalletHandler(d.MediaSupply))
|
||||||
|
|
||||||
authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler))
|
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, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO media_supply_wallet_ledgers (
|
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 {
|
`, 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", "媒体投稿账单创建失败")
|
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, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO media_supply_wallet_ledgers (
|
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 {
|
`, tenantID, userID, orderID, -amountCents, balanceAfter, "order_debit", nullableTrimmedString(orderTitle), createdBy); err != nil {
|
||||||
return 0, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
|
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, `
|
if _, err := tx.Exec(ctx, `
|
||||||
INSERT INTO media_supply_wallet_ledgers (
|
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 {
|
`, tenantID, userID, orderID, debitCents, balanceAfter, "order_refund", nullableTrimmedString(ledgerNote), userID); err != nil {
|
||||||
return response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败")
|
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