You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
295 lines
13 KiB
295 lines
13 KiB
<template>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
|
|
<h2 class="text-lg font-semibold text-slate-800">
|
|
月结算<span class="text-xs text-slate-400 font-normal ml-2">按订单实付金额分成,渠道商从品牌商那份里再切</span>
|
|
</h2>
|
|
<div v-if="canManage" class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="generating" @click="genSettlement">
|
|
<span v-if="generating" class="loading loading-spinner loading-xs"></span>生成 / 重算本月账
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" :disabled="!rows.length" @click="exportCSV">导出 CSV</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 筛选 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4">
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">结算月</span></label>
|
|
<input v-model="filters.month" type="month" class="input input-bordered input-sm w-44" @change="loadData" />
|
|
</div>
|
|
<div v-if="!auth.isBrandAccount && !auth.isDealer" class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">品牌商</span></label>
|
|
<select v-model.number="filters.brandid" class="select select-bordered select-sm w-48" @change="loadData">
|
|
<option :value="0">全部品牌商</option>
|
|
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
|
|
</select>
|
|
</div>
|
|
<div v-if="!auth.isDealer" class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">渠道商</span></label>
|
|
<select v-model="filters.channelid" class="select select-bordered select-sm w-52" @change="loadData">
|
|
<option value="">全部渠道商</option>
|
|
<option v-for="ch in channelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label>
|
|
<select v-model.number="filters.status" class="select select-bordered select-sm w-32" @change="loadData">
|
|
<option :value="-1">全部状态</option>
|
|
<option :value="0">草稿</option>
|
|
<option :value="1">已确认</option>
|
|
<option :value="2">已支付</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-sm btn-primary text-white" @click="loadData">查询</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 汇总卡片 -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div v-for="card in summaryCards" :key="card.label" class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
|
|
<div class="text-xs text-slate-500">{{ card.label }}</div>
|
|
<div class="text-xl font-semibold mt-1" :class="card.cls">{{ fmtMoney(card.value) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 明细 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>结算月</th><th>品牌商</th><th>渠道商</th><th class="text-right">订单数</th>
|
|
<th class="text-right">结算基数</th><th class="text-right">品牌比例</th><th class="text-right">品牌实得</th>
|
|
<th class="text-right">渠道比例</th><th class="text-right">渠道分成</th><th class="text-right">平台留存</th>
|
|
<th>状态</th><th v-if="canManage">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td :colspan="canManage ? 12 : 11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
|
|
</tr>
|
|
<tr v-else-if="!rows.length">
|
|
<td :colspan="canManage ? 12 : 11" class="text-center text-slate-400 py-8">该月暂无结算数据</td>
|
|
</tr>
|
|
<tr v-for="(r, i) in rows" v-else :key="i">
|
|
<td class="font-mono">{{ fmtPeriod(r.period) }}</td>
|
|
<td>{{ brandName(r.brandid) }}</td>
|
|
<td>{{ channelName(r.channelid) }}</td>
|
|
<td class="text-right">{{ r.order_count }}</td>
|
|
<td class="text-right font-medium">{{ fmtMoney(r.base_amount) }}</td>
|
|
<td class="text-right text-slate-500">{{ ratePct(r.brand_rate) }}</td>
|
|
<td class="text-right text-emerald-600 font-medium">{{ fmtMoney(r.brand_net) }}</td>
|
|
<td class="text-right text-slate-500">{{ ratePct(r.channel_rate) }}</td>
|
|
<td class="text-right text-indigo-600 font-medium">{{ fmtMoney(r.channel_amount) }}</td>
|
|
<td class="text-right text-slate-500">{{ fmtMoney(r.platform_amount) }}</td>
|
|
<td>
|
|
<span class="badge badge-sm" :class="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
|
<div v-if="r.confirm_account" class="text-[11px] text-slate-400 mt-0.5">{{ r.confirm_account }}</div>
|
|
</td>
|
|
<td v-if="canManage">
|
|
<div class="flex flex-wrap gap-1">
|
|
<button v-if="r.status === 0" class="btn btn-ghost btn-xs text-emerald-600" @click="confirm(r, 1)">确认</button>
|
|
<button v-if="r.status === 1" class="btn btn-ghost btn-xs text-indigo-600" @click="confirm(r, 2)">标记已付</button>
|
|
<button v-if="r.status > 0" class="btn btn-ghost btn-xs text-amber-600" @click="confirm(r, 0)">退回草稿</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-xs text-slate-400 mt-3 leading-relaxed">
|
|
结算基数 = 该月<b>已支付</b>订单实付金额合计,订单按下单时冻结的「来源设备」归属到品牌商与渠道商。<br />
|
|
品牌商总提成 = 基数 × 品牌比例;渠道分成 = 品牌总提成 × 渠道比例;品牌实得 = 品牌总提成 − 渠道分成;平台留存 = 基数 − 品牌总提成。<br />
|
|
比例在生成结算单时快照冻结,之后调整比例不影响已生成的月份;<b>已确认</b>的行重算时会跳过,需先退回草稿才能重算。
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
|
|
definePageMeta({ title: '月结算' })
|
|
|
|
interface Settlement {
|
|
period: number
|
|
brandid: number
|
|
channelid: string
|
|
order_count: number
|
|
base_amount: number
|
|
brand_rate: number
|
|
brand_gross: number
|
|
channel_rate: number
|
|
channel_amount: number
|
|
brand_net: number
|
|
platform_amount: number
|
|
status: number
|
|
gen_time: number
|
|
confirm_account: string
|
|
confirm_time: number
|
|
remark: string
|
|
}
|
|
interface Brand { id: number; brand: string }
|
|
interface Channel { id: string; brandid: number; name: string }
|
|
|
|
const { webApi } = useApi()
|
|
const { success, error } = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
// 生成/确认仅超管、管理员;品牌商与渠道商只读自己的账单。
|
|
const canManage = computed(() => auth.isAdmin)
|
|
|
|
const rows = ref<Settlement[]>([])
|
|
const brands = ref<Brand[]>([])
|
|
const channels = ref<Channel[]>([])
|
|
const loading = ref(false)
|
|
const generating = ref(false)
|
|
const totals = reactive({ base: 0, brandNet: 0, channel: 0, platform: 0 })
|
|
|
|
// 默认结算上一个自然月(月初看上月账最常见)
|
|
const filters = reactive({ month: prevMonthStr(), brandid: 0, channelid: '', status: -1 })
|
|
|
|
const channelOptions = computed(() =>
|
|
filters.brandid ? channels.value.filter((ch) => ch.brandid === filters.brandid) : channels.value,
|
|
)
|
|
|
|
const summaryCards = computed(() => [
|
|
{ label: '结算基数(订单实付)', value: totals.base, cls: 'text-slate-800' },
|
|
{ label: '品牌商实得', value: totals.brandNet, cls: 'text-emerald-600' },
|
|
{ label: '渠道商分成', value: totals.channel, cls: 'text-indigo-600' },
|
|
{ label: '平台留存', value: totals.platform, cls: 'text-slate-600' },
|
|
])
|
|
|
|
function prevMonthStr(): string {
|
|
const d = new Date()
|
|
d.setDate(1)
|
|
d.setMonth(d.getMonth() - 1)
|
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
|
}
|
|
// <input type="month"> 的 "YYYY-MM" ↔ 后端的 YYYYMM 整数
|
|
function monthToPeriod(m: string): number {
|
|
const parts = (m || '').split('-')
|
|
if (parts.length !== 2) return 0
|
|
return parseInt(parts[0]) * 100 + parseInt(parts[1])
|
|
}
|
|
function fmtPeriod(p?: number) {
|
|
if (!p) return '-'
|
|
return Math.floor(p / 100) + '-' + String(p % 100).padStart(2, '0')
|
|
}
|
|
// 金额以「分」存储,展示成元
|
|
function fmtMoney(cents?: number) {
|
|
return '¥' + ((Number(cents) || 0) / 100).toFixed(2)
|
|
}
|
|
function ratePct(rate?: number) {
|
|
return ((Number(rate) || 0) / 100).toFixed(2) + '%'
|
|
}
|
|
function brandName(id?: number) {
|
|
if (!id) return '未归属品牌商'
|
|
const b = brands.value.find((x) => x.id === id)
|
|
return b ? b.brand : '品牌商#' + Number(id).toString(16).toUpperCase()
|
|
}
|
|
function channelName(id?: string) {
|
|
if (!id) return '未分配渠道'
|
|
const ch = channels.value.find((x) => x.id === id)
|
|
return ch ? ch.name + ' (' + ch.id + ')' : id
|
|
}
|
|
|
|
const STATUS_LABELS: Record<number, string> = { 0: '草稿', 1: '已确认', 2: '已支付' }
|
|
const STATUS_CLASS: Record<number, string> = { 0: 'badge-ghost', 1: 'badge-warning', 2: 'badge-success' }
|
|
function statusLabel(s: number) { return STATUS_LABELS[s] ?? String(s) }
|
|
function statusClass(s: number) { return STATUS_CLASS[s] ?? 'badge-ghost' }
|
|
|
|
async function loadRefs() {
|
|
try {
|
|
const [bd, cd] = await Promise.all([
|
|
webApi<{ brands: Brand[] }>('getbrands', {}),
|
|
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
|
|
])
|
|
brands.value = bd?.brands ?? []
|
|
channels.value = cd?.channels ?? []
|
|
} catch {
|
|
brands.value = []
|
|
channels.value = []
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
if (!brands.value.length) await loadRefs()
|
|
const d = await webApi<{
|
|
items: Settlement[]; total_base: number; total_brand_net: number; total_channel: number; total_platform: number
|
|
}>('getsettlements', {
|
|
period: monthToPeriod(filters.month),
|
|
brandid: Number(filters.brandid) || 0,
|
|
channelid: filters.channelid,
|
|
status: Number(filters.status),
|
|
})
|
|
rows.value = d?.items ?? []
|
|
totals.base = d?.total_base ?? 0
|
|
totals.brandNet = d?.total_brand_net ?? 0
|
|
totals.channel = d?.total_channel ?? 0
|
|
totals.platform = d?.total_platform ?? 0
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取结算数据失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function genSettlement() {
|
|
const period = monthToPeriod(filters.month)
|
|
if (!period) { error('请选择结算月'); return }
|
|
if (!window.confirm(`确定生成/重算 ${filters.month} 的结算单吗?已确认的行会被跳过。`)) return
|
|
generating.value = true
|
|
try {
|
|
const d = await webApi<{ period: number; rows: number; skipped: number }>('gensettlement', { period })
|
|
success(`已生成 ${d?.rows ?? 0} 行,跳过(已确认)${d?.skipped ?? 0} 行`)
|
|
loadData()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '生成失败')
|
|
} finally {
|
|
generating.value = false
|
|
}
|
|
}
|
|
|
|
async function confirm(r: Settlement, status: number) {
|
|
const actions: Record<number, string> = { 0: '退回草稿', 1: '确认', 2: '标记已支付' }
|
|
if (!window.confirm(`确定${actions[status]}这一行结算吗?`)) return
|
|
try {
|
|
await webApi('confirmsettlement', {
|
|
period: r.period, brandid: r.brandid, channelid: r.channelid, status, remark: r.remark || '',
|
|
})
|
|
success(actions[status] + '成功')
|
|
loadData()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '操作失败')
|
|
}
|
|
}
|
|
|
|
function exportCSV() {
|
|
const headers = ['结算月', '品牌商', '渠道商', '订单数', '结算基数(元)', '品牌比例', '品牌总提成(元)', '渠道比例', '渠道分成(元)', '品牌实得(元)', '平台留存(元)', '状态']
|
|
const lines = rows.value.map((r) => [
|
|
fmtPeriod(r.period), brandName(r.brandid), channelName(r.channelid), r.order_count,
|
|
(r.base_amount / 100).toFixed(2), ratePct(r.brand_rate), (r.brand_gross / 100).toFixed(2),
|
|
ratePct(r.channel_rate), (r.channel_amount / 100).toFixed(2), (r.brand_net / 100).toFixed(2),
|
|
(r.platform_amount / 100).toFixed(2), statusLabel(r.status),
|
|
])
|
|
const content = [headers.join(',')].concat(lines.map((l) => l.join(','))).join('\n')
|
|
const blob = new Blob(['' + content], { type: 'text/csv;charset=utf-8;' })
|
|
const a = document.createElement('a')
|
|
a.href = URL.createObjectURL(blob)
|
|
a.download = '月结算_' + filters.month + '.csv'
|
|
a.click()
|
|
URL.revokeObjectURL(a.href)
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|