2
This commit is contained in:
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<view class="report">
|
||||
<view class="modes">
|
||||
<view class="mode-tab" :class="{active: mode==='sale'}" @click="setMode('sale')">销售统计</view>
|
||||
<view class="mode-tab" :class="{active: mode==='purchase'}" @click="setMode('purchase')">进货统计</view>
|
||||
<view class="mode-tab" :class="{active: mode==='inventory'}" @click="setMode('inventory')">库存统计</view>
|
||||
<view class="mode-tab" :class="{active: mode==='arap'}" @click="setMode('arap')">应收/应付对账</view>
|
||||
</view>
|
||||
<view class="header">销售报表</view>
|
||||
|
||||
<view class="toolbar">
|
||||
<picker mode="date" :value="startDate" @change="onStartChange"><view class="date">{{ startDate }}</view></picker>
|
||||
@@ -13,39 +8,35 @@
|
||||
<picker mode="date" :value="endDate" @change="onEndChange"><view class="date">{{ endDate }}</view></picker>
|
||||
</view>
|
||||
|
||||
<view class="tabs" v-if="mode==='sale'">
|
||||
<view class="tab" :class="{active: dim==='customer'}" @click="dim='customer'; refresh()">按客户</view>
|
||||
<view class="tab" :class="{active: dim==='product'}" @click="dim='product'; refresh()">按货品</view>
|
||||
</view>
|
||||
<view class="tabs" v-else-if="mode==='purchase'">
|
||||
<view class="tab" :class="{active: dim==='supplier'}" @click="dim='supplier'; refresh()">按供应商</view>
|
||||
<view class="tab" :class="{active: dim==='product'}" @click="dim='product'; refresh()">按货品</view>
|
||||
</view>
|
||||
<view class="tabs" v-else-if="mode==='inventory'">
|
||||
<view class="tab" :class="{active: dim==='qty'}" @click="dim='qty'; refresh()">按数量</view>
|
||||
<view class="tab" :class="{active: dim==='amount'}" @click="dim='amount'; refresh()">按金额</view>
|
||||
</view>
|
||||
<view class="tabs" v-else-if="mode==='arap'">
|
||||
<view class="tab" :class="{active: dim==='ar'}" @click="dim='ar'; refresh()">应收对账</view>
|
||||
<view class="tab" :class="{active: dim==='ap'}" @click="dim='ap'; refresh()">应付对账</view>
|
||||
</view>
|
||||
|
||||
<view class="summary">
|
||||
<view class="item"><text class="label">销售额</text><text class="value">¥ {{ fmt(total.sales) }}</text></view>
|
||||
<view class="item"><text class="label">成本</text><text class="value">¥ {{ fmt(total.cost) }}</text></view>
|
||||
<view class="item"><text class="label">利润</text><text class="value">¥ {{ fmt(total.profit) }}</text></view>
|
||||
<view class="item"><text class="label">利润率</text><text class="value">{{ profitRate }}</text></view>
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{active: dim==='customer'}" @click="setDimension('customer')">按客户</view>
|
||||
<view class="tab" :class="{active: dim==='product'}" @click="setDimension('product')">按货品</view>
|
||||
</view>
|
||||
|
||||
<view v-for="(row, idx) in rows" :key="idx" class="card">
|
||||
<view class="row-head">
|
||||
<image v-if="row.avatar" class="thumb" :src="row.avatar" />
|
||||
<view class="title">{{ row.name }}</view>
|
||||
<view class="summary" v-if="summaryItems.length">
|
||||
<view class="summary-item" v-for="(item, ix) in summaryItems" :key="ix">
|
||||
<text class="label">{{ item.label }}</text>
|
||||
<text class="value">{{ item.value }}</text>
|
||||
</view>
|
||||
<view class="row-body">
|
||||
<text>销售额:¥ {{ fmt(row.sales) }}</text>
|
||||
<text style="margin-left: 18rpx;">成本:¥ {{ fmt(row.cost) }}</text>
|
||||
<text style="margin-left: 18rpx;">利润:¥ {{ fmt(row.profit) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-else-if="error" class="empty">{{ error }}</view>
|
||||
<view v-else-if="!rows.length" class="empty">暂无统计数据</view>
|
||||
<view v-else>
|
||||
<view v-for="(row, idx) in rows" :key="idx" class="card">
|
||||
<view class="row-head">
|
||||
<view class="row-title">
|
||||
<view class="title">{{ row.name }}</view>
|
||||
<view class="subtitle" v-if="showProductSpec(row)">{{ row.spec }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-body">
|
||||
<view class="metric" v-for="(metric, mIdx) in rowMetrics(row)" :key="mIdx">
|
||||
<text class="metric-label">{{ metric.label }}</text>
|
||||
<text class="metric-value">{{ metric.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -68,224 +59,113 @@ export default {
|
||||
return {
|
||||
startDate: formatDate(start),
|
||||
endDate: formatDate(now),
|
||||
mode: 'sale',
|
||||
dim: 'customer',
|
||||
rows: [],
|
||||
total: { sales: 0, cost: 0, profit: 0 }
|
||||
summary: { salesAmount: 0, costAmount: 0, profit: 0, profitRate: 0, itemCount: 0 },
|
||||
loading: false,
|
||||
error: ''
|
||||
}
|
||||
},
|
||||
onLoad(query) {
|
||||
try {
|
||||
const m = query && query.mode
|
||||
const d = query && query.dim
|
||||
if (m) this.mode = m
|
||||
if (d) this.dim = d
|
||||
if (d === 'product' || d === 'customer') this.dim = d
|
||||
} catch(e){}
|
||||
this.refresh()
|
||||
},
|
||||
computed: {
|
||||
profitRate() {
|
||||
const { sales, profit } = this.total
|
||||
if (!sales) return '0.00%'
|
||||
return ((profit / sales) * 100).toFixed(2) + '%'
|
||||
profitRateText() {
|
||||
const rate = Number(this.summary?.profitRate || 0)
|
||||
return rate.toFixed(2) + '%'
|
||||
},
|
||||
summaryItems() {
|
||||
if (!this.rows.length) return []
|
||||
return [
|
||||
{ label: '销售额', value: `¥ ${this.fmt(this.summary.salesAmount)}` },
|
||||
{ label: '成本', value: `¥ ${this.fmt(this.summary.costAmount)}` },
|
||||
{ label: '利润', value: `¥ ${this.fmt(this.summary.profit)}` },
|
||||
{ label: '利润率', value: this.profitRateText }
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fmt(n) { return Number(n || 0).toFixed(2) },
|
||||
setMode(m) {
|
||||
this.mode = m
|
||||
this.dim = m === 'sale' ? 'customer' : m === 'purchase' ? 'supplier' : m === 'inventory' ? 'qty' : 'ar'
|
||||
showProductSpec(row) { return this.dim === 'product' && row && row.spec },
|
||||
rowMetrics(row) {
|
||||
if (!row) return []
|
||||
return [
|
||||
{ label: '销售额', value: `¥ ${this.fmt(row.salesAmount)}` },
|
||||
{ label: '成本', value: `¥ ${this.fmt(row.costAmount)}` },
|
||||
{ label: '利润', value: `¥ ${this.fmt(row.profit)}` },
|
||||
{ label: '利润率', value: `${Number(row.profitRate || 0).toFixed(2)}%` }
|
||||
]
|
||||
},
|
||||
setDimension(d) {
|
||||
if (d !== 'customer' && d !== 'product') return
|
||||
if (this.dim === d) return
|
||||
this.dim = d
|
||||
this.refresh()
|
||||
},
|
||||
onStartChange(e) { this.startDate = e.detail.value; this.refresh() },
|
||||
onEndChange(e) { this.endDate = e.detail.value; this.refresh() },
|
||||
async refresh() {
|
||||
if (this.mode === 'sale') {
|
||||
if (this.dim === 'customer') return this.loadByCustomer()
|
||||
if (this.dim === 'product') return this.loadByProduct()
|
||||
}
|
||||
if (this.mode === 'purchase') {
|
||||
if (this.dim === 'supplier') return this.loadPurchaseBySupplier()
|
||||
if (this.dim === 'product') return this.loadPurchaseByProduct()
|
||||
}
|
||||
if (this.mode === 'inventory') {
|
||||
if (this.dim === 'qty') return this.loadInventoryByQty()
|
||||
if (this.dim === 'amount') return this.loadInventoryByAmount()
|
||||
}
|
||||
if (this.mode === 'arap') {
|
||||
if (this.dim === 'ar') return this.loadAR()
|
||||
if (this.dim === 'ap') return this.loadAP()
|
||||
}
|
||||
},
|
||||
async loadByCustomer() {
|
||||
// 数据来源:/api/orders?biz=sale&type=out 与 /api/products/{id} 获取成本(近似),或由订单明细返回单价与估算成本
|
||||
// 当前后端列表返回字段包含 amount、customerName,缺少明细成本;采用二段法:
|
||||
// 1) 列表聚合销售额;2) 如存在 productId 与单位进价可获取成本;暂以 0 成本占位,保留接口演进点。
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const listResp = await get('/api/orders', { biz: 'sale', type: 'out', startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (listResp && (listResp.list || listResp)) || []
|
||||
const map = new Map()
|
||||
let totalSales = 0
|
||||
for (const it of list) {
|
||||
const name = it.customerName || '未知客户'
|
||||
const amount = Number(it.amount || 0)
|
||||
totalSales += amount
|
||||
if (!map.has(name)) map.set(name, { name, sales: 0, cost: 0, profit: 0 })
|
||||
const row = map.get(name)
|
||||
row.sales += amount
|
||||
const resp = await get('/api/report/sales', {
|
||||
dimension: this.dim,
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate
|
||||
})
|
||||
const items = Array.isArray(resp?.items) ? resp.items : []
|
||||
this.rows = items.map(it => ({
|
||||
name: it?.name || (this.dim === 'product' ? '未命名商品' : '未指定客户'),
|
||||
spec: it?.spec || '',
|
||||
salesAmount: Number(it?.salesAmount || 0),
|
||||
costAmount: Number(it?.costAmount || 0),
|
||||
profit: Number(it?.profit || 0),
|
||||
profitRate: Number(it?.profitRate || 0)
|
||||
}))
|
||||
this.summary = {
|
||||
salesAmount: Number(resp?.summary?.salesAmount || 0),
|
||||
costAmount: Number(resp?.summary?.costAmount || 0),
|
||||
profit: Number(resp?.summary?.profit || 0),
|
||||
profitRate: Number(resp?.summary?.profitRate || 0),
|
||||
itemCount: Number(resp?.summary?.itemCount || this.rows.length)
|
||||
}
|
||||
const rows = Array.from(map.values()).map(r => ({ ...r, profit: r.sales - r.cost }))
|
||||
const total = { sales: totalSales, cost: 0, profit: totalSales }
|
||||
this.rows = rows
|
||||
this.total = total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
this.error = (e && e.message) || '报表加载失败'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadByProduct() {
|
||||
try {
|
||||
const listResp = await get('/api/orders', { biz: 'sale', type: 'out', startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (listResp && (listResp.list || listResp)) || []
|
||||
// 订单详情接口包含明细,逐单补拉详情聚合(规模较小时可接受;后端如提供汇总接口可替换)
|
||||
const agg = new Map()
|
||||
for (const it of list) {
|
||||
try {
|
||||
const d = await get(`/api/orders/${it.id}`)
|
||||
const items = d && d.items || []
|
||||
for (const m of items) {
|
||||
const key = String(m.productId || m.name)
|
||||
if (!agg.has(key)) agg.set(key, { name: m.name || ('#'+key), sales: 0, cost: 0, profit: 0 })
|
||||
const row = agg.get(key)
|
||||
const sales = Number(m.amount || 0)
|
||||
// 近似成本:缺后端返回进价,暂以 0(待后端扩展返回 purchasePrice)
|
||||
row.sales += sales
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
const rows = Array.from(agg.values()).map(r => ({ ...r, profit: r.sales - r.cost }))
|
||||
const totalSales = rows.reduce((s, r) => s + r.sales, 0)
|
||||
this.rows = rows
|
||||
this.total = { sales: totalSales, cost: 0, profit: totalSales }
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
async loadPurchaseBySupplier() {
|
||||
try {
|
||||
const listResp = await get('/api/purchase-orders', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (listResp && (listResp.list || listResp)) || []
|
||||
const map = new Map(); let total = 0
|
||||
for (const it of list) {
|
||||
const name = it.supplierName || '未知供应商'
|
||||
const amount = Number(it.amount || 0)
|
||||
total += amount
|
||||
if (!map.has(name)) map.set(name, { name, sales: 0, cost: 0, profit: 0 })
|
||||
const row = map.get(name)
|
||||
// 在进货统计语境里:sales 用来展示“进货额”,cost/profit 保持 0
|
||||
row.sales += amount
|
||||
}
|
||||
this.rows = Array.from(map.values())
|
||||
this.total = { sales: total, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
},
|
||||
async loadPurchaseByProduct() {
|
||||
try {
|
||||
const listResp = await get('/api/purchase-orders', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (listResp && (listResp.list || listResp)) || []
|
||||
const agg = new Map()
|
||||
for (const it of list) {
|
||||
try {
|
||||
const d = await get(`/api/purchase-orders/${it.id}`)
|
||||
for (const m of (d?.items || [])) {
|
||||
const key = String(m.productId || m.name)
|
||||
if (!agg.has(key)) agg.set(key, { name: m.name || ('#'+key), sales: 0, cost: 0, profit: 0 })
|
||||
const row = agg.get(key)
|
||||
row.sales += Number(m.amount || 0) // 这里的 sales 表示“进货额”
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
const rows = Array.from(agg.values())
|
||||
const total = rows.reduce((s, r)=> s + r.sales, 0)
|
||||
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
},
|
||||
async loadInventoryByQty() {
|
||||
try {
|
||||
const resp = await get('/api/inventories/logs', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (resp && (resp.list || resp)) || []
|
||||
const map = new Map(); let totalQty = 0
|
||||
for (const it of list) {
|
||||
const key = it.productId || '未知'
|
||||
if (!map.has(key)) map.set(key, { name: String(key), sales: 0, cost: 0, profit: 0 })
|
||||
const row = map.get(key)
|
||||
const q = Number(it.qtyDelta || 0)
|
||||
row.sales += q
|
||||
totalQty += q
|
||||
}
|
||||
this.rows = Array.from(map.values())
|
||||
this.total = { sales: totalQty, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
},
|
||||
async loadInventoryByAmount() {
|
||||
try {
|
||||
const resp = await get('/api/inventories/logs', { startDate: this.startDate, endDate: this.endDate, page: 1, size: 200 })
|
||||
const list = (resp && (resp.list || resp)) || []
|
||||
const map = new Map(); let totalAmt = 0
|
||||
for (const it of list) {
|
||||
const key = it.productId || '未知'
|
||||
if (!map.has(key)) map.set(key, { name: String(key), sales: 0, cost: 0, profit: 0 })
|
||||
const row = map.get(key)
|
||||
const a = Number(it.amount || it.amountDelta || 0)
|
||||
row.sales += a
|
||||
totalAmt += a
|
||||
}
|
||||
this.rows = Array.from(map.values())
|
||||
this.total = { sales: totalAmt, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
},
|
||||
async loadAR() {
|
||||
// 读取客户列表含 receivable 字段,作为对账口径(期末=期初+增加-收回-抹零);后端如提供期间变动接口再替换
|
||||
try {
|
||||
const res = await get('/api/customers', { page: 1, size: 100, debtOnly: false })
|
||||
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||
const rows = list.map(c => ({ name: c.name, sales: Number(c.receivable || 0), cost: 0, profit: 0 }))
|
||||
const total = rows.reduce((s, r)=> s + r.sales, 0)
|
||||
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
},
|
||||
async loadAP() {
|
||||
// 供应商暂未返回应付字段,先展示总览为0并提示后端扩展(遵循“不开假数据”)
|
||||
try {
|
||||
const res = await get('/api/suppliers', { page: 1, size: 100 })
|
||||
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||
const rows = list.map(s => ({ name: s.name, sales: Number(s.apPayable || 0), cost: 0, profit: 0 }))
|
||||
const total = rows.reduce((s, r)=> s + r.sales, 0)
|
||||
this.rows = rows; this.total = { sales: total, cost: 0, profit: 0 }
|
||||
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.report { padding: 20rpx; }
|
||||
.modes { display: flex; gap: 12rpx; margin-bottom: 14rpx; }
|
||||
.mode-tab { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 999rpx; background: $uni-bg-color-hover; color: $uni-text-color-grey; border: 1rpx solid $uni-border-color; }
|
||||
.mode-tab.active { background: $uni-color-primary; color: #fff; border-color: $uni-color-primary; font-weight: 700; }
|
||||
.toolbar { display: flex; align-items: center; gap: 8rpx; background: $uni-bg-color-grey; padding: 14rpx 16rpx; border-radius: 12rpx; }
|
||||
.date { padding: 10rpx 16rpx; border: 1rpx solid $uni-border-color; border-radius: 8rpx; color: $uni-text-color; }
|
||||
.tabs { display: flex; gap: 16rpx; margin-top: 14rpx; }
|
||||
.tab { padding: 12rpx 18rpx; border-radius: 999rpx; background: $uni-bg-color-hover; color: $uni-text-color-grey; }
|
||||
.tab.active { background: $uni-color-primary; color: #fff; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-top: 14rpx; }
|
||||
.summary .item { background: $uni-bg-color-grey; border-radius: 12rpx; padding: 16rpx; }
|
||||
.summary .label { font-size: 22rpx; color: $uni-text-color-grey; }
|
||||
.summary .value { display: block; margin-top: 8rpx; font-weight: 700; color: $uni-text-color; }
|
||||
.card { margin-top: 16rpx; background: $uni-bg-color-grey; border-radius: 12rpx; padding: 16rpx; }
|
||||
.row-head { display: flex; align-items: center; gap: 12rpx; }
|
||||
.thumb { width: 72rpx; height: 72rpx; border-radius: 8rpx; background: $uni-bg-color-hover; }
|
||||
.title { font-size: 28rpx; font-weight: 700; color: $uni-text-color; }
|
||||
.row-body { margin-top: 10rpx; color: $uni-text-color-grey; }
|
||||
.report { padding: 24rpx 20rpx 36rpx; display:flex; flex-direction:column; gap: 18rpx; }
|
||||
.header { font-size: 34rpx; font-weight: 700; color:#1f2a44; padding-left: 8rpx; }
|
||||
.toolbar { display:flex; align-items:center; justify-content:center; gap: 12rpx; background: #f7f9fc; border-radius: 16rpx; padding: 18rpx; }
|
||||
.date { min-width: 200rpx; padding: 12rpx 18rpx; border-radius: 12rpx; background: #fff; border: 1rpx solid rgba(91,107,139,0.16); text-align:center; color:#32445b; }
|
||||
.tabs { display:flex; gap: 12rpx; justify-content:center; }
|
||||
.tab { padding: 10rpx 20rpx; border-radius: 999rpx; background: #f0f4ff; color:#5b6b8b; transition: all .2s ease; }
|
||||
.tab.active { background: rgba(76,141,255,0.18); color:#3467d6; box-shadow: inset 0 0 0 2rpx rgba(76,141,255,0.45); }
|
||||
.summary { display:grid; grid-template-columns: repeat(auto-fill, minmax(240rpx,1fr)); gap: 12rpx; }
|
||||
.summary-item { background: #f7f9fc; border-radius: 16rpx; padding: 20rpx; display:flex; flex-direction:column; gap: 10rpx; }
|
||||
.summary-item .label { font-size: 24rpx; color:#6e7a96; }
|
||||
.summary-item .value { font-size: 32rpx; font-weight:700; color:#1f2a44; }
|
||||
.card { background:#fff; border-radius: 18rpx; padding: 20rpx; box-shadow: 0 8rpx 20rpx rgba(31,42,68,0.08); display:flex; flex-direction:column; gap: 14rpx; }
|
||||
.row-head { display:flex; justify-content:space-between; align-items:flex-start; }
|
||||
.row-title { display:flex; flex-direction:column; gap: 6rpx; }
|
||||
.title { font-size: 30rpx; font-weight:700; color:#1f2a44; }
|
||||
.subtitle { font-size: 24rpx; color:#6e7a96; }
|
||||
.row-body { display:flex; flex-wrap:wrap; gap: 12rpx 24rpx; }
|
||||
.metric { display:flex; gap: 8rpx; align-items:center; background:#f4f6fb; border-radius: 12rpx; padding: 10rpx 16rpx; }
|
||||
.metric-label { font-size: 24rpx; color:#6e7a96; }
|
||||
.metric-value { font-size: 28rpx; color:#1f2a44; font-weight:600; }
|
||||
.empty { text-align:center; padding: 80rpx 0; color:#9aa4be; font-size: 26rpx; }
|
||||
.loading { text-align:center; padding: 40rpx 0; color:#5b6b8b; font-size: 24rpx; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user