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.
 
 
 
 
 
 

290 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.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>产品</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 ? 11 : 10" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!rows.length">
<td :colspan="canManage ? 11 : 10" 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>{{ channelName(r.channelid) }}</td>
<!-- 批次为空 = 这些订单归不到任何批次(用户没绑过设备 / 批次已撤销),比例 0、全归平台 -->
<td class="font-mono text-xs">
<span v-if="r.batchno" class="text-indigo-600 font-medium">{{ r.batchno }}</span>
<span v-else class="badge badge-ghost badge-sm">无批次归属</span>
</td>
<td class="text-xs">{{ productName(r.productid) }}</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.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>订单实付金额合计。订单按下单时冻结的「来源设备」先归到渠道商,
再由设备反查出它属于哪个<b>生产批次</b>——分成比例是一批一谈的,取自该批次。<br />
渠道分成 = 基数 × 渠道比例;平台留存 = 基数 − 渠道分成。
标「无批次归属」的行是查不到来源设备的订单(用户从未绑过设备、或批次已被撤销),比例按 0 处理、全额归平台。<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
channelid: string
batchno: string // 生产批次号;空 = 这些订单归不到批次
productid: number
order_count: number
base_amount: number
channel_rate: number
channel_amount: number
platform_amount: number
status: number
gen_time: number
confirm_account: string
confirm_time: number
remark: 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 channels = ref<Channel[]>([])
const products = ref<Record<number, string>>({})
const loading = ref(false)
const generating = ref(false)
const totals = reactive({ base: 0, channel: 0, platform: 0 })
// 默认结算上一个自然月(月初看上月账最常见)
const filters = reactive({ month: prevMonthStr(), channelid: '', status: -1 })
// 渠道商已是全局实体(brandid 恒为 0),不再按品牌商收窄
const channelOptions = computed(() => channels.value)
const summaryCards = computed(() => [
{ label: '结算基数(订单实付)', value: totals.base, cls: 'text-slate-800' },
{ 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 productName(id?: number) {
const n = Number(id || 0)
if (!n) return '-'
return products.value[n] || ('产品#' + n.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 [cd, pd] = await Promise.all([
webApi<{ channels: Channel[] }>('getchannels', { status: -1 }),
webApi<{ products: { id: number; devicename: string }[] }>('getproducts', {}),
])
channels.value = cd?.channels ?? []
const m: Record<number, string> = {}
for (const p of pd?.products ?? []) m[p.id] = p.devicename
products.value = m
} catch {
channels.value = []
products.value = {}
}
}
async function loadData() {
loading.value = true
try {
if (!channels.value.length) await loadRefs()
const d = await webApi<{
items: Settlement[]; total_base: number; total_channel: number; total_platform: number
}>('getsettlements', {
period: monthToPeriod(filters.month),
channelid: filters.channelid,
status: Number(filters.status),
})
rows.value = d?.items ?? []
totals.base = d?.total_base ?? 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', {
// batchno 是结算行主键的一部分,不带的话服务端会定位到「无批次归属」那一行
period: r.period, channelid: r.channelid, batchno: r.batchno || '', 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), channelName(r.channelid), r.batchno || '无批次归属', productName(r.productid), r.order_count,
(r.base_amount / 100).toFixed(2),
ratePct(r.channel_rate), (r.channel_amount / 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>