This commit is contained in:
hero920103 committed 2026-10-19 01:13:17 +08:00
1 parent 5718a00e31
commit 1850ac640e
226 files changed
+37772 -12071

No files matched your search

@@ -0,0 +1,658 @@
<template>
<div class="product-page">
<div class="page-header">
<div>
<h2>商品档案</h2>
<p>多规格多单位、五级价格体系、安全库存与批次保质期设置</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="openCreate">新增商品</el-button>
</div>
</div>
<div class="body">
<div class="cat-panel">
<div class="cat-head">
<span>商品分类</span>
<el-button link type="primary" size="small" :icon="Plus" @click="openCatEdit(null)">新增</el-button>
</div>
<el-input v-model="catFilter" size="small" placeholder="搜索分类" clearable :prefix-icon="Search" class="cat-search" />
<el-tree
ref="catTree"
:data="categoryTree"
:props="{ label: 'category_name', children: 'children' }"
node-key="id"
highlight-current
:filter-node-method="filterCat"
:expand-on-click-node="false"
default-expand-all
@node-click="onCatClick"
>
<template #default="{ data }">
<span class="cat-node">
<span>{{ data.category_name }}</span>
<span class="cat-count">{{ data.product_count || 0 }}</span>
<el-button link type="primary" size="small" @click.stop="openCatEdit(data)">改名</el-button>
<el-button link type="danger" size="small" @click.stop="removeCat(data)">删除</el-button>
</span>
</template>
</el-tree>
<div class="cat-all" @click="clearCat">全部商品</div>
</div>
<div class="main-panel">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item>
<el-input v-model="filters.keyword" clearable placeholder="搜索名称/编码/条码/品牌" :prefix-icon="Search" style="width: 230px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px" @change="handleSearch">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="loading" class="table-container">
<el-table :data="rows" stripe border size="small">
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column label="商品名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="link" @click="openEdit(row)">{{ row.product_name }}</span>
<el-tag v-if="row.enable_batch === 1" size="small" effect="plain" type="warning" class="mini-tag">批次</el-tag>
</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="category_name" label="分类" width="110" show-overflow-tooltip />
<el-table-column label="单位" width="120" align="center">
<template #default="{ row }">
{{ row.base_unit }}<span v-if="row.aux_unit" class="text-muted"> / {{ row.aux_unit }}×{{ row.aux_ratio }}</span>
</template>
</el-table-column>
<el-table-column label="采购价" width="100" align="right">
<template #default="{ row }">{{ money(row.purchase_price) }}</template>
</el-table-column>
<el-table-column label="零售价" width="100" align="right">
<template #default="{ row }">{{ money(row.sale_price) }}</template>
</el-table-column>
<el-table-column label="最低价" width="100" align="right">
<template #default="{ row }">
<span :class="{ 'text-muted': !row.min_price }">{{ money(row.min_price) }}</span>
</template>
</el-table-column>
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">
<span :class="stockClass(row.safety_state)">{{ qty(row.total_stock) }}</span>
</template>
</el-table-column>
<el-table-column label="安全/最高" width="110" align="right">
<template #default="{ row }">
<span class="text-muted">{{ qty(row.safety_qty) }} / {{ qty(row.max_qty) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="success" size="small" @click="toggle(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
<el-button link type="danger" size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page.page"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
</div>
</div>
<el-drawer v-model="editVisible" :title="editForm.id ? '编辑商品' : '新增商品'" direction="rtl" size="52%">
<el-form :model="editForm" label-width="100px" class="edit-form">
<div class="form-group">基本信息</div>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="商品名称" required><el-input v-model="editForm.product_name" maxlength="200" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品编码"><el-input v-model="editForm.product_code" placeholder="留空自动生成" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="分类">
<el-tree-select
v-model="editForm.category_id"
:data="categoryTree"
:props="{ label: 'category_name', children: 'children', value: 'id' }"
check-strictly
clearable
placeholder="请选择分类"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="品牌"><el-input v-model="editForm.brand" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="型号"><el-input v-model="editForm.model" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="规格"><el-input v-model="editForm.spec" placeholder="如 500g/袋" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="条码"><el-input v-model="editForm.barcode" /></el-form-item></el-col>
</el-row>
<div class="form-group">单位与换算</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="基本单位" required><el-input v-model="editForm.base_unit" placeholder="个/箱/kg" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="辅助单位"><el-input v-model="editForm.aux_unit" placeholder="如 箱" /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="换算系数">
<el-input-number v-model="editForm.aux_ratio" :min="0" :precision="4" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<div class="form-hint">库存与销售一律按基本单位记账,换算系数表示 1 个辅助单位等于多少基本单位。</div>
<div class="form-group">价格体系</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="采购价"><el-input-number v-model="editForm.purchase_price" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="零售价"><el-input-number v-model="editForm.sale_price" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="批发价"><el-input-number v-model="editForm.wholesale_price" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="会员价"><el-input-number v-model="editForm.member_price" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="最低售价">
<el-input-number v-model="editForm.min_price" :min="0" :precision="4" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率(%)">
<el-input-number v-model="editForm.tax_rate" :min="0" :max="100" :precision="2" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<div class="form-hint">最低售价用于销售出库的限价校验,低于该价需有审核权限。</div>
<div class="form-group">库存与批次</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="安全库存"><el-input-number v-model="editForm.safety_qty" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="最高库存"><el-input-number v-model="editForm.max_qty" :min="0" :precision="4" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="保质期(天)"><el-input-number v-model="editForm.shelf_life_days" :min="0" :precision="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="批次管理">
<el-switch v-model="editForm.enable_batch" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="状态">
<el-switch v-model="editForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" :rows="2" maxlength="500" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="catVisible" :title="catForm.id ? '编辑分类' : '新增分类'" width="420px">
<el-form :model="catForm" label-width="90px">
<el-form-item label="上级">
<el-tree-select
v-model="catForm.parent_id"
:data="categoryTree"
:props="{ label: 'category_name', children: 'children', value: 'id' }"
check-strictly
clearable
placeholder="不选为一级分类"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="名称" required><el-input v-model="catForm.category_name" maxlength="50" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0" :precision="0" controls-position="right" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catVisible = false">取消</el-button>
<el-button type="primary" :loading="catSaving" @click="submitCat">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import {
createProduct,
deleteCategory,
deleteProduct,
getCategoryList,
getProductList,
saveCategory,
toggleProductStatus,
updateProduct
} from '@/api/erpProduct'
import { money, qty } from '../document/dict'
const loading = ref(false)
const saving = ref(false)
const rows = ref([])
const categoryTree = ref([])
const catFilter = ref('')
const catTree = ref(null)
const filters = reactive({ keyword: '', status: '', category_id: '' })
const page = reactive({ page: 1, pageSize: 20, total: 0 })
const editVisible = ref(false)
const editForm = reactive(emptyForm())
const catVisible = ref(false)
const catSaving = ref(false)
const catForm = reactive({ id: 0, category_name: '', parent_id: undefined, sort_order: 0 })
function emptyForm() {
return {
id: 0,
product_code: '',
product_name: '',
category_id: undefined,
brand: '',
spec: '',
model: '',
base_unit: '个',
aux_unit: '',
aux_ratio: 1,
barcode: '',
extra_barcodes: '',
purchase_price: 0,
sale_price: 0,
wholesale_price: 0,
member_price: 0,
min_price: 0,
tax_rate: 0,
safety_qty: 0,
max_qty: 0,
shelf_life_days: 0,
enable_batch: 0,
status: 1,
remark: ''
}
}
const stockClass = (state) => (state === 'low' ? 'danger' : state === 'over' ? 'warning' : '')
watch(catFilter, (v) => catTree.value && catTree.value.filter(v))
function filterCat(value, data) {
if (!value) return true
return String(data.category_name || '').includes(value)
}
async function fetchCategories() {
try {
const res = await getCategoryList()
if (res.code === 200) categoryTree.value = res.data.tree || []
} catch (e) {
categoryTree.value = []
}
}
async function fetchList() {
loading.value = true
try {
const params = { page: page.page, pageSize: page.pageSize }
if (filters.keyword) params.keyword = filters.keyword
if (filters.status !== '') params.status = filters.status
if (filters.category_id) params.category_id = filters.category_id
const res = await getProductList(params)
if (res.code === 200) {
rows.value = res.data.list || []
page.total = Number(res.data.total) || 0
} else {
ElMessage.error(res.msg || '商品加载失败')
}
} catch (e) {
ElMessage.error(e.message || '商品加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.page = 1
fetchList()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
handleSearch()
}
function onCatClick(node) {
filters.category_id = node.id
handleSearch()
}
function clearCat() {
filters.category_id = ''
if (catTree.value) catTree.value.setCurrentKey(null)
handleSearch()
}
function openCreate() {
Object.assign(editForm, emptyForm())
if (filters.category_id) editForm.category_id = filters.category_id
editVisible.value = true
}
function openEdit(row) {
Object.assign(editForm, emptyForm(), {
id: row.id,
product_code: row.product_code || '',
product_name: row.product_name || '',
category_id: row.category_id || undefined,
brand: row.brand || '',
spec: row.spec || '',
model: row.model || '',
base_unit: row.base_unit || '个',
aux_unit: row.aux_unit || '',
aux_ratio: Number(row.aux_ratio) || 1,
barcode: row.barcode || '',
extra_barcodes: row.extra_barcodes || '',
purchase_price: Number(row.purchase_price) || 0,
sale_price: Number(row.sale_price) || 0,
wholesale_price: Number(row.wholesale_price) || 0,
member_price: Number(row.member_price) || 0,
min_price: Number(row.min_price) || 0,
tax_rate: Number(row.tax_rate) || 0,
safety_qty: Number(row.safety_qty) || 0,
max_qty: Number(row.max_qty) || 0,
shelf_life_days: Number(row.shelf_life_days) || 0,
enable_batch: Number(row.enable_batch) || 0,
status: Number(row.status) || 0,
remark: row.remark || ''
})
editVisible.value = true
}
async function submit() {
if (!editForm.product_name.trim()) {
ElMessage.warning('请填写商品名称')
return
}
if (!String(editForm.base_unit || '').trim()) {
ElMessage.warning('请填写基本单位')
return
}
const payload = { ...editForm }
delete payload.id
delete payload.total_stock
delete payload.safety_state
delete payload.category_name
payload.category_id = Number(payload.category_id) || 0
saving.value = true
try {
const res = editForm.id ? await updateProduct(editForm.id, payload) : await createProduct(payload)
if (res.code === 200) {
ElMessage.success('已保存')
editVisible.value = false
fetchList()
fetchCategories()
}
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function toggle(row) {
try {
await toggleProductStatus(row.id, row.status === 1 ? 0 : 1)
ElMessage.success('状态已更新')
} catch (e) {
ElMessage.error(e.message || '操作失败')
}
fetchList()
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除商品「${row.product_name}」?`, '删除确认', { type: 'warning' })
} catch (e) {
return
}
try {
await deleteProduct(row.id)
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e.message || '删除失败')
return
}
fetchList()
fetchCategories()
}
function openCatEdit(data) {
if (data) {
catForm.id = data.id
catForm.category_name = data.category_name
catForm.parent_id = data.parent_id || undefined
catForm.sort_order = Number(data.sort_order) || 0
} else {
catForm.id = 0
catForm.category_name = ''
catForm.parent_id = filters.category_id || undefined
catForm.sort_order = 0
}
catVisible.value = true
}
async function submitCat() {
if (!catForm.category_name.trim()) {
ElMessage.warning('请填写分类名称')
return
}
catSaving.value = true
try {
const res = await saveCategory({
id: catForm.id,
category_name: catForm.category_name,
parent_id: Number(catForm.parent_id) || 0,
sort_order: catForm.sort_order
})
if (res.code === 200) {
ElMessage.success('分类已保存')
catVisible.value = false
fetchCategories()
}
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
catSaving.value = false
}
}
async function removeCat(data) {
try {
await ElMessageBox.confirm(`确认删除分类「${data.category_name}」?`, '删除确认', { type: 'warning' })
} catch (e) {
return
}
try {
await deleteCategory(data.id)
ElMessage.success('已删除')
if (filters.category_id === data.id) clearCat()
fetchCategories()
fetchList()
} catch (e) {
ElMessage.error(e.message || '删除失败')
}
}
onMounted(() => {
fetchCategories()
fetchList()
})
</script>
<style lang="less" scoped>
.product-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.body {
display: flex;
gap: 16px;
align-items: flex-start;
}
.cat-panel {
width: 230px;
flex: 0 0 230px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 12px;
}
.cat-head {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.cat-search {
margin-bottom: 10px;
}
.cat-node {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font-size: 13px;
}
.cat-count {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.cat-all {
margin-top: 10px;
padding: 6px 8px;
font-size: 13px;
color: var(--el-color-primary);
cursor: pointer;
border-radius: 4px;
&:hover {
background: var(--el-color-primary-light-9);
}
}
.main-panel {
flex: 1;
min-width: 0;
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.link {
cursor: pointer;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.mini-tag {
margin-left: 6px;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.danger {
color: var(--el-color-danger);
}
.warning {
color: var(--el-color-warning);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-group {
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
margin: 6px 0 12px;
padding-left: 8px;
border-left: 3px solid var(--el-color-primary);
}
.form-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin: -6px 0 12px;
}
</style>
@@ -0,0 +1,247 @@
<template>
<div class="warehouse-page">
<div class="page-header">
<div>
<h2>仓库档案</h2>
<p>多仓管理、默认仓库设置,列表显示各仓在库品种数与货值</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="openCreate">新增仓库</el-button>
</div>
</div>
<div v-loading="loading" class="table-container">
<el-table :data="rows" stripe border size="small">
<el-table-column prop="warehouse_code" label="仓库编号" width="140" />
<el-table-column label="仓库名称" min-width="160">
<template #default="{ row }">
<span class="link" @click="openEdit(row)">{{ row.warehouse_name }}</span>
<el-tag v-if="row.is_default === 1" type="success" size="small" effect="plain" class="mini-tag">默认仓</el-tag>
</template>
</el-table-column>
<el-table-column prop="manager" label="负责人" width="110" />
<el-table-column prop="phone" label="联系电话" width="130" />
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column label="在库品种" width="100" align="right">
<template #default="{ row }">{{ row.stock_kinds || 0 }}</template>
</el-table-column>
<el-table-column label="库存货值" width="130" align="right">
<template #default="{ row }">{{ money(row.stock_value) }}</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.is_default !== 1" link type="success" size="small" @click="setDefault(row)">设为默认</el-button>
<el-button link type="danger" size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-drawer v-model="editVisible" :title="form.id ? '编辑仓库' : '新增仓库'" direction="rtl" size="420px">
<el-form :model="form" label-width="90px">
<el-form-item label="仓库名称" required><el-input v-model="form.warehouse_name" maxlength="100" /></el-form-item>
<el-form-item label="仓库编号"><el-input v-model="form.warehouse_code" placeholder="留空自动生成" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="form.manager" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.phone" maxlength="20" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="默认仓库">
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
<span class="hint">开单时默认带出该仓</span>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createWarehouse, deleteWarehouse, getWarehouseList, updateWarehouse } from '@/api/erpWarehouse'
import { money } from '../document/dict'
const loading = ref(false)
const saving = ref(false)
const editVisible = ref(false)
const rows = ref([])
const form = reactive({
id: 0,
warehouse_code: '',
warehouse_name: '',
address: '',
manager: '',
phone: '',
is_default: 0,
status: 1,
remark: ''
})
async function fetchList() {
loading.value = true
try {
const res = await getWarehouseList({})
if (res.code === 200) rows.value = res.data.list || []
else ElMessage.error(res.msg || '仓库加载失败')
} catch (e) {
ElMessage.error(e.message || '仓库加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, {
id: 0,
warehouse_code: '',
warehouse_name: '',
address: '',
manager: '',
phone: '',
is_default: rows.value.length === 0 ? 1 : 0,
status: 1,
remark: ''
})
editVisible.value = true
}
function openEdit(row) {
Object.assign(form, {
id: row.id,
warehouse_code: row.warehouse_code || '',
warehouse_name: row.warehouse_name || '',
address: row.address || '',
manager: row.manager || '',
phone: row.phone || '',
is_default: Number(row.is_default) || 0,
status: Number(row.status) || 0,
remark: row.remark || ''
})
editVisible.value = true
}
async function submit() {
if (!form.warehouse_name.trim()) {
ElMessage.warning('请填写仓库名称')
return
}
const payload = { ...form }
delete payload.id
saving.value = true
try {
const res = form.id ? await updateWarehouse(form.id, payload) : await createWarehouse(payload)
if (res.code === 200) {
ElMessage.success('已保存')
editVisible.value = false
fetchList()
}
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function setDefault(row) {
try {
await updateWarehouse(row.id, {
warehouse_code: row.warehouse_code,
warehouse_name: row.warehouse_name,
address: row.address,
manager: row.manager,
phone: row.phone,
is_default: 1,
status: row.status,
remark: row.remark
})
ElMessage.success('已设为默认仓库')
} catch (e) {
ElMessage.error(e.message || '操作失败')
return
}
fetchList()
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除仓库「${row.warehouse_name}」?`, '删除确认', { type: 'warning' })
} catch (e) {
return
}
try {
await deleteWarehouse(row.id)
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e.message || '删除失败')
return
}
fetchList()
}
onMounted(fetchList)
</script>
<style lang="less" scoped>
.warehouse-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.link {
cursor: pointer;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.mini-tag {
margin-left: 6px;
}
.hint {
margin-left: 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -148,6 +148,37 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="信用额度">
<el-input-number
v-model="form.credit_limit"
:min="0"
:precision="2"
:step="1000"
controls-position="right"
placeholder="0 表示不限制"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账期(天)">
<el-input-number
v-model="form.payment_days"
:min="0"
:precision="0"
:step="10"
controls-position="right"
placeholder="0 表示现结"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="credit-tip">
信用额度用于销售出库审核时的超限拦截,账期决定应收单的到期日与逾期起算。
</div>
</el-col>
<el-col :span="24">
<el-form-item label="经营范围" prop="business_scope">
<el-input
@@ -348,6 +379,8 @@ const defaultForm = () => ({
address: "",
status: "1",
is_draft: 0,
credit_limit: 0,
payment_days: 0,
remark: "",
invoice_title: "",
tax_number: "",
@@ -499,6 +532,12 @@ function handleSaveDraft() {
padding: 0 20px;
}
.credit-tip {
margin: -8px 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
display: flex;
justify-content: space-between;
+75 -71
View File
@@ -1,5 +1,5 @@
<template>
<div class="erp-dashboard">
<div v-loading="loading" class="erp-dashboard">
<!-- 第一行统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :span="4" :xs="12" v-for="(item, index) in firstRowStats" :key="'first-' + index">
@@ -9,8 +9,8 @@
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
<span v-if="item.prefix" class="currency">{{ item.prefix }}</span>
<span class="number">{{ item.text }}</span>
</div>
</div>
</el-col>
@@ -25,8 +25,8 @@
<el-icon class="stat-icon"><Info-Filled /></el-icon>
</div>
<div class="stat-value">
<span class="currency">¥</span>
<span class="number">{{ formatMoney(item.value) }}</span>
<span v-if="item.prefix" class="currency">{{ item.prefix }}</span>
<span class="number">{{ item.text }}</span>
</div>
</div>
</el-col>
@@ -36,19 +36,19 @@
<el-row :gutter="16" class="chart-row" style="margin-top: 16px;">
<el-col :span="8" :xs="24">
<div class="chart-card">
<div class="chart-title">销售统计</div>
<div class="chart-title">销售趋势</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8" :xs="24">
<div class="chart-card">
<div class="chart-title">零售统计</div>
<div ref="retailChartRef" class="chart-container"></div>
<div class="chart-title">购销差额</div>
<div ref="netChartRef" class="chart-container"></div>
</div>
</el-col>
<el-col :span="8" :xs="24">
<div class="chart-card">
<div class="chart-title">采购统计</div>
<div class="chart-title">采购趋势</div>
<div ref="purchaseChartRef" class="chart-container"></div>
</div>
</el-col>
@@ -60,39 +60,32 @@
import { ref, onMounted, onUnmounted, shallowRef, nextTick } from 'vue';
import * as echarts from 'echarts';
import { InfoFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getErpDashboard } from '@/api/erpReport';
// 统计卡片数据 - 第一行
const firstRowStats = ref([
{ label: '今日销售', value: 0 },
{ label: '今日零售', value: 0 },
{ label: '今日采购', value: 0 },
{ label: '本月累计销售', value: 0 },
{ label: '本月累计零售', value: 0 },
{ label: '本月累计采购', value: 0 },
]);
type Stat = { label: string; text: string; prefix?: string };
// 统计卡片数据 - 第二行
const secondRowStats = ref([
{ label: '昨日销售', value: 0 },
{ label: '昨日零售', value: 0 },
{ label: '昨日采购', value: 0 },
{ label: '今年累计销售', value: 0 },
{ label: '今年累计零售', value: 0 },
{ label: '今年累计采购', value: 0 },
]);
const loading = ref(false);
// 统计卡片数据 - 第一行(金额)
const firstRowStats = ref<Stat[]>([]);
// 统计卡片数据 - 第二行(成本、往来与预警)
const secondRowStats = ref<Stat[]>([]);
// 图表实例
const salesChartRef = ref<HTMLElement | null>(null);
const retailChartRef = ref<HTMLElement | null>(null);
const netChartRef = ref<HTMLElement | null>(null);
const purchaseChartRef = ref<HTMLElement | null>(null);
const salesChartInstance = shallowRef<echarts.ECharts | null>(null);
const retailChartInstance = shallowRef<echarts.ECharts | null>(null);
const netChartInstance = shallowRef<echarts.ECharts | null>(null);
const purchaseChartInstance = shallowRef<echarts.ECharts | null>(null);
// 格式化金额
const formatMoney = (value: number): string => {
return value.toLocaleString();
};
const money = (value: unknown): string =>
Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const plain = (value: unknown): string => String(Number(value || 0));
// 生成最近6个月的月份标签
const getMonthLabels = (): string[] => {
@@ -170,69 +163,80 @@ const initCharts = () => {
if (salesChartRef.value) {
salesChartInstance.value = initChart(salesChartRef.value, '销售', '#409EFF');
}
if (retailChartRef.value) {
retailChartInstance.value = initChart(retailChartRef.value, '零售', '#67C23A');
if (netChartRef.value) {
netChartInstance.value = initChart(netChartRef.value, '购销差额', '#67C23A');
}
if (purchaseChartRef.value) {
purchaseChartInstance.value = initChart(purchaseChartRef.value, '采购', '#E6A23C');
}
};
// 加载数据(模拟接口调用)
// 加载数据:口径与报表中心完全一致(只统计已生效单据与库存流水)
const loadData = async () => {
// TODO: 调用接口获取真实数据
// const res = await getErpDashboard();
// 模拟数据更新
firstRowStats.value = [
{ label: '今日销售', value: 12580 },
{ label: '今日零售', value: 8560 },
{ label: '今日采购', value: 23400 },
{ label: '本月累计销售', value: 358600 },
{ label: '本月累计零售', value: 245800 },
{ label: '本月累计采购', value: 568900 },
];
secondRowStats.value = [
{ label: '昨日销售', value: 15230 },
{ label: '昨日零售', value: 9870 },
{ label: '昨日采购', value: 18900 },
{ label: '今年累计销售', value: 2856000 },
{ label: '今年累计零售', value: 1985000 },
{ label: '今年累计采购', value: 4568000 },
];
// 更新图表数据
updateCharts();
loading.value = true;
try {
const res = await getErpDashboard({});
if (res.code !== 200) {
throw new Error(res.msg || '看板数据加载失败');
}
const d = res.data || {};
const m = d.month || {};
const t = d.today || {};
const s = d.stock || {};
const b = d.partner_bill || {};
const w = d.warning || {};
firstRowStats.value = [
{ label: '今日销售额', text: money(t.sales_amount), prefix: '¥' },
{ label: '本月销售额', text: money(m.sales_amount), prefix: '¥' },
{ label: '本月采购额', text: money(m.purchase_amount), prefix: '¥' },
{ label: '本月毛利', text: money(m.profit), prefix: '¥' },
{ label: '库存金额', text: money(s.value), prefix: '¥' },
{ label: '未结应收', text: money(b.receivable), prefix: '¥' },
];
secondRowStats.value = [
{ label: '本月销售成本', text: money(m.cost), prefix: '¥' },
{ label: '本月退货额', text: money(m.return_amount), prefix: '¥' },
{ label: '未结应付', text: money(b.payable), prefix: '¥' },
{ label: '往来净额', text: money(b.net), prefix: '¥' },
{ label: '本月毛利率', text: `${Number(m.profit_rate || 0).toFixed(2)}%` },
{ label: '缺货 / 低于安全库存', text: `${plain(w.out)} / ${plain(w.low)}` },
];
updateCharts(d.trend || []);
} catch (e: any) {
ElMessage.error(e?.message || '看板数据加载失败');
} finally {
loading.value = false;
}
};
// 更新图表数据
const updateCharts = () => {
const salesData = [28000, 32000, 35000, 38000, 42000, 358600];
const retailData = [18000, 21000, 22000, 25000, 28000, 245800];
const purchaseData = [45000, 48000, 52000, 55000, 58000, 568900];
const updateCharts = (trend: Array<{ sale?: number; buy?: number }>) => {
const sale = trend.map((x) => Number(x.sale || 0));
const buy = trend.map((x) => Number(x.buy || 0));
const net = trend.map((x) => Number(x.sale || 0) - Number(x.buy || 0));
if (salesChartInstance.value) {
salesChartInstance.value.setOption({ series: [{ data: salesData }] });
salesChartInstance.value.setOption({ series: [{ data: sale }] });
}
if (retailChartInstance.value) {
retailChartInstance.value.setOption({ series: [{ data: retailData }] });
if (netChartInstance.value) {
netChartInstance.value.setOption({ series: [{ data: net }] });
}
if (purchaseChartInstance.value) {
purchaseChartInstance.value.setOption({ series: [{ data: purchaseData }] });
purchaseChartInstance.value.setOption({ series: [{ data: buy }] });
}
};
// 自适应窗口大小
const handleResize = () => {
salesChartInstance.value?.resize();
retailChartInstance.value?.resize();
netChartInstance.value?.resize();
purchaseChartInstance.value?.resize();
};
onMounted(async () => {
initCharts();
await nextTick();
initCharts();
await loadData();
window.addEventListener('resize', handleResize);
});
@@ -240,7 +244,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
salesChartInstance.value?.dispose();
retailChartInstance.value?.dispose();
netChartInstance.value?.dispose();
purchaseChartInstance.value?.dispose();
});
</script>
@@ -0,0 +1,185 @@
<template>
<el-drawer
:model-value="visible"
:title="title"
direction="rtl"
size="60%"
@open="load"
@close="close"
>
<div v-loading="loading" class="doc-detail">
<template v-if="doc.id">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="单据编号">{{ doc.doc_no }}</el-descriptions-item>
<el-descriptions-item label="业务日期">{{ dateOnly(doc.biz_date) }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="docStatusTag(doc.status)" size="small">{{ docStatusText(doc.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="往来单位">{{ doc.partner_name || '-' }}</el-descriptions-item>
<el-descriptions-item :label="doc.to_warehouse_name ? '调出仓库' : '仓库'">
{{ doc.warehouse_name || '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="doc.to_warehouse_name" label="调入仓库">
{{ doc.to_warehouse_name }}
</el-descriptions-item>
<el-descriptions-item label="来源单据">{{ doc.source_doc_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="制单">{{ doc.create_user_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审核">{{ doc.auditor_name || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="showPrice" label="单据金额">{{ money(doc.total_amount) }}</el-descriptions-item>
<el-descriptions-item v-if="showPrice" label="整单折扣">{{ money(doc.discount_amount) }}</el-descriptions-item>
<el-descriptions-item v-if="showPrice" label="成交金额">
<span class="amount">{{ money(doc.actual_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="合计数量">{{ qty(doc.total_qty) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{ doc.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">明细行</div>
<el-table :data="doc.items || []" border size="small">
<el-table-column type="index" label="#" width="46" align="center" />
<el-table-column prop="product_code" label="商品编码" width="120" />
<el-table-column prop="product_name" label="商品名称" min-width="160" />
<el-table-column prop="spec" label="规格" width="110" />
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="数量" width="96" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column v-if="isStockTake" label="账面数" width="96" align="right">
<template #default="{ row }">{{ qty(row.book_qty) }}</template>
</el-table-column>
<el-table-column v-if="hasExecuted" label="已执行" width="96" align="right">
<template #default="{ row }">
<span :class="{ 'text-muted': !row.executed_qty }">{{ qty(row.executed_qty) }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasExecuted" label="未执行" width="96" align="right">
<template #default="{ row }">{{ qty(row.quantity - row.executed_qty) }}</template>
</el-table-column>
<el-table-column v-if="showPrice" label="单价" width="100" align="right">
<template #default="{ row }">{{ money(row.unit_price) }}</template>
</el-table-column>
<el-table-column v-if="showPrice" label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
<el-table-column prop="batch_no" label="批次" width="110" />
<el-table-column v-if="showBatchDates" label="到期日" width="110">
<template #default="{ row }">{{ dateOnly(row.expire_date) }}</template>
</el-table-column>
<el-table-column prop="remark" label="行备注" min-width="110" />
</el-table>
<template v-if="doc.instance">
<div class="section-title">审批进度</div>
<div class="inst-head">
<span>{{ doc.instance.flow_name || '审批流程' }}</span>
<el-tag size="small" :type="doc.instance.status === 2 ? 'success' : doc.instance.status === 3 ? 'danger' : 'warning'">
{{ doc.instance.status_label }}
</el-tag>
<span class="text-muted">提交人:{{ doc.instance.submit_name || '-' }}</span>
<span class="text-muted">当前节点:{{ doc.instance.current_node || '-' }}</span>
</div>
<el-timeline class="inst-line">
<el-timeline-item
v-for="t in doc.instance.tasks || []"
:key="t.task_id"
:timestamp="dateOnly(t.finish_time || t.create_time)"
:type="t.status === 2 ? 'success' : t.status === 3 ? 'danger' : 'primary'"
size="small"
>
<b>{{ t.node_name }}</b> · {{ t.approver_name || '-' }} · {{ t.status_label }}
<div v-if="t.comment" class="text-muted">{{ t.comment }}</div>
</el-timeline-item>
</el-timeline>
</template>
</template>
<el-empty v-else-if="!loading" description="单据未找到" />
</div>
</el-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getDocumentDetail } from '@/api/erpDocument'
import {
dateOnly,
docStatusTag,
docStatusText,
docTypeLabel,
money,
qty
} from './dict'
const props = defineProps({
visible: { type: Boolean, default: false },
docId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['update:visible'])
const doc = ref({})
const loading = ref(false)
const title = computed(() => (doc.value.doc_no ? `${docTypeLabel(doc.value.doc_type)} ${doc.value.doc_no}` : '单据详情'))
const isStockTake = computed(() => doc.value.doc_type === 'stock_take')
const showPrice = computed(() => String(doc.value.doc_type || '').startsWith('purchase')
|| String(doc.value.doc_type || '').startsWith('sales')
|| doc.value.doc_type === 'other_in')
const showBatchDates = computed(() => String(doc.value.doc_type || '').startsWith('purchase')
|| doc.value.doc_type === 'other_in')
const hasExecuted = computed(() => (doc.value.items || []).some((it) => Number(it.executed_qty) > 0))
watch(() => props.docId, () => { doc.value = {} })
async function load() {
if (!props.docId) return
loading.value = true
try {
const res = await getDocumentDetail(props.docId)
if (res.code === 200) doc.value = res.data || {}
else ElMessage.error(res.msg || '单据加载失败')
} catch (e) {
ElMessage.error(e.message || '单据加载失败')
} finally {
loading.value = false
}
}
function close() {
emit('update:visible', false)
}
</script>
<style lang="less" scoped>
.doc-detail {
padding-bottom: 8px;
}
.section-title {
margin: 18px 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.amount {
color: var(--el-color-danger);
font-weight: 600;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.inst-head {
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
margin-bottom: 12px;
}
.inst-line {
padding-left: 4px;
}
</style>
@@ -0,0 +1,719 @@
<template>
<el-drawer
:model-value="visible"
:title="drawerTitle"
direction="rtl"
size="70%"
:close-on-click-modal="false"
@open="onOpen"
@close="close"
>
<div class="doc-editor">
<el-form :model="form" label-width="96px" class="head-form">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="单据编号">
<el-input :model-value="form.doc_no || '保存后自动生成'" disabled />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="业务日期" required>
<el-date-picker
v-model="form.biz_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="isTransfer ? '调出仓库' : '仓库'" required>
<el-select v-model="form.warehouse_id" placeholder="请选择仓库" style="width: 100%" @change="onWarehouseChange">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="isTransfer" :span="8">
<el-form-item label="调入仓库" required>
<el-select v-model="form.to_warehouse_id" placeholder="请选择调入仓库" style="width: 100%">
<el-option
v-for="w in warehouses.filter((x) => x.id !== form.warehouse_id)"
:key="w.id"
:label="w.warehouse_name"
:value="w.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col v-if="partnerType" :span="8">
<el-form-item :label="partnerType === 'supplier' ? '供应商' : '客户'" required>
<el-select
v-model="form.partner_id"
filterable
clearable
placeholder="搜索名称选择"
style="width: 100%"
@change="onPartnerChange"
>
<el-option v-for="p in partners" :key="p.id" :label="partnerLabel(p)" :value="p.id" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="partnerType && creditTip" :span="8">
<el-form-item label="信用占用">
<span class="credit-tip">{{ creditTip }}</span>
</el-form-item>
</el-col>
<el-col v-if="sourceTypes.length" :span="8">
<el-form-item label="来源单据">
<el-select
v-model="form.source_doc_id"
filterable
clearable
placeholder="可不选,选择后按行带入"
style="width: 100%"
@change="onSourceChange"
>
<el-option
v-for="d in sourceDocs"
:key="d.id"
:label="d.doc_no + ' · ' + (d.partner_name || '')"
:value="d.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col v-if="flows.length" :span="8">
<el-form-item label="审批流程">
<el-select v-model="form.flow_id" placeholder="提交时使用的审批流" style="width: 100%">
<el-option v-for="f in flows" :key="f.flow_id" :label="f.flow_name" :value="f.flow_id" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="!isStockTake && !isTransfer" :span="8">
<el-form-item label="整单折扣">
<el-input-number v-model="form.discount_amount" :min="0" :precision="2" :step="10" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="sourceTypes.length ? 8 : 16">
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="备注(可选)" maxlength="200" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="items-bar">
<el-button type="primary" :icon="Plus" size="small" @click="addRow">添加明细行</el-button>
<el-button v-if="form.source_doc_id" size="small" :icon="Download" @click="importFromSource">
按来源单据带入
</el-button>
<el-button v-if="isStockTake && form.warehouse_id" size="small" :icon="DocumentCopy" @click="loadTakeSheet">
载入盘点表
</el-button>
<span class="items-tip">{{ itemsTip }}</span>
</div>
<el-table :data="items" border size="small" class="items-table" v-loading="importing">
<el-table-column type="index" label="#" width="46" align="center" />
<el-table-column label="商品" min-width="220">
<template #default="{ row }">
<ProductSelect v-model="row.product_id" @pick="(p) => onPickProduct(row, p)" />
</template>
</el-table-column>
<el-table-column label="规格" width="110">
<template #default="{ row }">
<span>{{ row.spec || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="单位" width="70" align="center">
<template #default="{ row }">
<el-input v-model="row.unit" size="small" placeholder="单位" />
</template>
</el-table-column>
<el-table-column v-if="isStockTake" label="账面数" width="100" align="right">
<template #default="{ row }">
<span>{{ fmtQty(row.book_qty) }}</span>
</template>
</el-table-column>
<el-table-column :label="isStockTake ? '实盘数' : '数量'" width="130" align="right">
<template #default="{ row }">
<el-input-number v-model="row.quantity" :min="0" :precision="4" :step="1" size="small" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="showPrice" label="单价" width="130" align="right">
<template #default="{ row }">
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :step="1" size="small" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="showPrice" label="金额" width="110" align="right">
<template #default="{ row }">
<span>{{ fmtMoney(round2(row.quantity * row.unit_price)) }}</span>
</template>
</el-table-column>
<el-table-column v-if="showCost && !showPrice" label="参考成本" width="110" align="right">
<template #default="{ row }">
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :step="1" size="small" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="批次" width="130">
<template #default="{ row }">
<el-input v-model="row.batch_no" size="small" :placeholder="row.enable_batch ? '必填' : '可选'" />
</template>
</el-table-column>
<el-table-column v-if="showBatchDates" label="生产日期" width="150">
<template #default="{ row }">
<el-date-picker v-model="row.production_date" type="date" value-format="YYYY-MM-DD" size="small" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="showBatchDates" label="到期日" width="150">
<template #default="{ row }">
<el-date-picker v-model="row.expire_date" type="date" value-format="YYYY-MM-DD" size="small" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="!isTransfer" label="行备注" min-width="120">
<template #default="{ row }">
<el-input v-model="row.remark" size="small" placeholder="可选" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="totals">
<span>合计数量:<b>{{ fmtQty(totalQty) }}</b></span>
<span v-if="showPrice">合计金额:<b>{{ fmtMoney(totalAmount) }}</b></span>
<span v-if="showPrice && form.discount_amount">折扣:<b>-{{ fmtMoney(form.discount_amount) }}</b></span>
<span v-if="showPrice">成交金额:<b class="amount">{{ fmtMoney(totalAmount - form.discount_amount) }}</b></span>
</div>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="close">取消</el-button>
<el-button :loading="saving" @click="save('draft')">保存草稿</el-button>
<el-button v-if="flows.length" type="warning" :loading="saving" @click="save('submit')">保存并提交审批</el-button>
<el-button type="primary" :loading="saving" @click="save('approve')">保存并审核生效</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Download, DocumentCopy } from '@element-plus/icons-vue'
import ProductSelect from './ProductSelect.vue'
import {
dateOnly,
docTypeLabel,
docTypePartner,
docTypeSources,
money as fmtMoney,
pickPrice,
qty as fmtQty,
todayStr
} from './dict'
import { getWarehouseList } from '@/api/erpWarehouse'
import { getCustomerList } from '@/api/customer'
import { getSupplierList } from '@/api/supplier'
import { getProductDetail } from '@/api/erpProduct'
import { getDocumentList, getDocumentFlows, getDocumentMeta, getExecutableItems, getTakeSheet, saveDocument, submitDocument, approveDocument } from '@/api/erpDocument'
import { getPartnerSummary } from '@/api/erpFinance'
const props = defineProps({
visible: { type: Boolean, default: false },
docType: { type: String, required: true },
editData: { type: Object, default: null },
/** 下推入参:{ doc, type },打开时自动带上来源单与明细 */
pushFrom: { type: Object, default: null },
/** 预填明细(补货建议转采购单用):[{ product_id, product_name, spec, unit, quantity, unit_price }] */
presetItems: { type: Array, default: null }
})
const emit = defineEmits(['update:visible', 'success'])
let rowSeq = 0
const warehouses = ref([])
const partners = ref([])
const sourceDocs = ref([])
const flows = ref([])
const items = ref([])
const saving = ref(false)
const importing = ref(false)
const salesPriceType = ref('sale')
const creditUsage = ref(null)
const form = reactive({
id: 0,
doc_no: '',
biz_date: todayStr(),
warehouse_id: 0,
to_warehouse_id: 0,
partner_type: '',
partner_id: 0,
partner_name: '',
source_doc_id: 0,
discount_amount: 0,
flow_id: 0,
remark: ''
})
const partnerType = computed(() => docTypePartner(props.docType))
const sourceTypes = computed(() => docTypeSources(props.docType))
const isTransfer = computed(() => props.docType === 'transfer')
const isStockTake = computed(() => props.docType === 'stock_take')
const isPurchase = computed(() => props.docType.startsWith('purchase'))
const isSales = computed(() => props.docType.startsWith('sales'))
const showPrice = computed(() => isPurchase.value || isSales.value || props.docType === 'other_in')
const showCost = computed(() => props.docType === 'stock_take' || props.docType === 'other_out')
const showBatchDates = computed(() => isPurchase.value || props.docType === 'other_in')
const drawerTitle = computed(() => (props.editData ? '编辑' : '新建') + docTypeLabel(props.docType))
const itemsTip = computed(() => {
if (isStockTake.value) return '选择仓库后「载入盘点表」带出账面数,实盘数与账面数的差额自动生成盘盈盘亏流水。'
if (isTransfer.value) return '调拨按加权成本在两个仓之间平移,不产生损益。'
if (sourceTypes.value.length) return '可关联来源单据按行带入,超出可执行量会在保存时拦截。'
return ''
})
const totalQty = computed(() => items.value.reduce((s, r) => s + (Number(r.quantity) || 0), 0))
const totalAmount = computed(() => items.value.reduce((s, r) => s + round2((Number(r.quantity) || 0) * (Number(r.unit_price) || 0)), 0))
function round2(v) {
return Math.round((Number(v) || 0) * 100) / 100
}
function partnerLabel(p) {
return props.docType.startsWith('sales') || partnerType.value === 'customer' ? p.customer_name : p.supplier_name
}
// 抽屉打开时才加载基础数据:列表页可能同时挂着多个类型的编辑器实例
async function onOpen() {
resetForm()
await loadBasics()
}
async function loadBasics() {
try {
const [whRes, metaRes] = await Promise.all([getWarehouseList({ pageSize: 100 }), getDocumentMeta()])
if (whRes.code === 200) warehouses.value = whRes.data.list || []
if (metaRes.code === 200) {
salesPriceType.value = metaRes.data.sales_price_type || 'sale'
if (!form.warehouse_id && metaRes.data.default_warehouse_id) form.warehouse_id = metaRes.data.default_warehouse_id
}
} catch (e) {
ElMessage.error(e.message || '基础数据加载失败')
}
if (partnerType.value) loadPartners()
if (sourceTypes.value.length) loadSourceDocs()
loadFlows()
}
async function loadPartners() {
try {
const res = partnerType.value === 'customer'
? await getCustomerList({ pageSize: 100, is_draft: 0, status: '1' })
: await getSupplierList({ pageSize: 100, is_draft: 0, status: '1' })
if (res.code === 200) partners.value = res.data.list || []
} catch (e) {
ElMessage.error(e.message || '往来单位加载失败')
}
}
async function loadSourceDocs() {
try {
const res = await getDocumentList({ doc_types: sourceTypes.value.join(','), status: 2, pageSize: 50 })
if (res.code === 200) sourceDocs.value = res.data.list || []
} catch (e) {
sourceDocs.value = []
}
}
async function loadFlows() {
try {
const res = await getDocumentFlows(props.docType)
flows.value = res.code === 200 ? res.data.list || [] : []
} catch (e) {
flows.value = []
}
form.flow_id = flows.value.length ? flows.value[0].flow_id : 0
}
function resetForm() {
const src = props.editData
form.id = src ? Number(src.id) || 0 : 0
form.doc_no = src ? src.doc_no || '' : ''
form.biz_date = src ? dateOnly(src.biz_date) : todayStr()
form.warehouse_id = src ? Number(src.warehouse_id) || 0 : form.warehouse_id || 0
form.to_warehouse_id = src ? Number(src.to_warehouse_id) || 0 : 0
form.partner_type = partnerType.value
form.partner_id = src ? Number(src.partner_id) || 0 : 0
form.partner_name = src ? src.partner_name || '' : ''
form.source_doc_id = src ? Number(src.source_doc_id) || 0 : 0
form.discount_amount = src ? Number(src.discount_amount) || 0 : 0
form.flow_id = 0
form.remark = src ? src.remark || '' : ''
if (src && src.items) {
items.value = src.items.map((it) => ({
key: ++rowSeq,
product_id: it.product_id,
product_name: it.product_name,
spec: it.spec,
unit: it.unit || it.base_unit,
quantity: Number(it.quantity) || 0,
book_qty: Number(it.book_qty) || 0,
unit_price: Number(it.unit_price) || 0,
batch_no: it.batch_no || '',
production_date: dateOnlyOrEmpty(it.production_date),
expire_date: dateOnlyOrEmpty(it.expire_date),
ref_item_id: Number(it.ref_item_id) || 0,
remark: it.remark || '',
enable_batch: false
}))
syncBatchFlags(items.value)
} else if (props.pushFrom) {
applyPushFrom(props.pushFrom)
} else if (props.presetItems && props.presetItems.length) {
items.value = props.presetItems.map((it) => ({
key: ++rowSeq,
product_id: Number(it.product_id) || 0,
product_name: it.product_name || '',
spec: it.spec || '',
unit: it.unit || '',
quantity: Number(it.quantity) || 0,
book_qty: 0,
unit_price: Number(it.unit_price) || 0,
batch_no: '',
production_date: '',
expire_date: '',
ref_item_id: 0,
remark: it.remark || '',
enable_batch: false
}))
syncBatchFlags(items.value)
} else {
items.value = []
addRow()
}
if (form.partner_id) loadCredit()
}
function dateOnlyOrEmpty(v) {
if (!v) return ''
const d = new Date(v)
return isNaN(d.getTime()) ? '' : dateOnly(v)
}
/** 编辑已有单据时回查商品是否启用批次,让批次列显示「必填」提示 */
async function syncBatchFlags(rows) {
const ids = [...new Set(rows.map((r) => Number(r.product_id)).filter(Boolean))]
if (!ids.length) return
const results = await Promise.allSettled(ids.map((id) => getProductDetail(id)))
const byId = {}
results.forEach((r) => {
if (r.status === 'fulfilled' && r.value?.code === 200) byId[r.value.data.id] = r.value.data
})
rows.forEach((r) => {
const p = byId[r.product_id]
if (p) r.enable_batch = Number(p.enable_batch) === 1
})
}
function applyPushFrom(pf) {
const src = pf.doc || {}
form.partner_type = src.partner_type || partnerType.value
form.partner_id = Number(src.partner_id) || 0
form.partner_name = src.partner_name || ''
form.warehouse_id = Number(src.warehouse_id) || form.warehouse_id
form.source_doc_id = Number(src.id) || 0
items.value = []
importFromSource()
}
function addRow() {
items.value.push({
key: ++rowSeq,
product_id: 0,
product_name: '',
spec: '',
unit: '',
quantity: 1,
book_qty: 0,
unit_price: 0,
batch_no: '',
production_date: '',
expire_date: '',
ref_item_id: 0,
remark: '',
enable_batch: false
})
}
function removeRow(index) {
items.value.splice(index, 1)
}
function onPickProduct(row, p) {
row.product_name = p.product_name
row.spec = p.spec || ''
row.unit = p.base_unit || ''
row.enable_batch = Number(p.enable_batch) === 1
const price = pickPrice(p, props.docType, salesPriceType.value)
if (price) row.unit_price = price
}
function onWarehouseChange() {
if (isTransfer.value && form.to_warehouse_id === form.warehouse_id) form.to_warehouse_id = 0
}
function onPartnerChange(id) {
const p = partners.value.find((x) => String(x.id) === String(id))
form.partner_name = p ? partnerLabel(p) : ''
loadSourceDocs()
loadCredit()
}
/** 客户信用额度与未结应收:开单时就提示,避免审核时才被信用控制拦下 */
async function loadCredit() {
creditUsage.value = null
if (partnerType.value !== 'customer' || !form.partner_id) return
const p = partners.value.find((x) => String(x.id) === String(form.partner_id))
if (!p) return
try {
const res = await getPartnerSummary({ partner_type: 'customer' })
if (res.code !== 200) return
const row = (res.data.list || []).find((x) => Number(x.partner_id) === Number(form.partner_id))
const limit = Number(p.credit_limit) || 0
const open = row ? Number(row.recv_open) || 0 : 0
if (!limit) {
creditUsage.value = `未收 ${fmtMoney(open)}(不设额度上限)`
return
}
const rest = limit - open
creditUsage.value = rest < 0
? `额度 ${fmtMoney(limit)},已超 ${fmtMoney(-rest)}`
: `额度 ${fmtMoney(limit)},剩余 ${fmtMoney(rest)}`
} catch (e) {
creditUsage.value = null
}
}
async function onSourceChange(id) {
if (!id) {
items.value = []
addRow()
return
}
await importFromSource()
}
async function importFromSource() {
if (!form.source_doc_id) return
importing.value = true
try {
const res = await getExecutableItems(form.source_doc_id)
if (res.code !== 200) return
const rows = (res.data.items || []).filter((it) => Number(it.remain_qty) > 0)
if (!rows.length) {
ElMessage.warning('来源单据已无待执行数量')
return
}
items.value = rows.map((it) => ({
key: ++rowSeq,
product_id: it.product_id,
product_name: it.product_name,
spec: it.spec || '',
unit: it.unit || '',
quantity: Number(it.remain_qty) || 0,
book_qty: 0,
unit_price: Number(it.unit_price) || 0,
batch_no: it.batch_no || '',
production_date: '',
expire_date: '',
ref_item_id: it.item_id,
remark: '',
enable_batch: !!it.batch_no
}))
syncBatchFlags(items.value)
} catch (e) {
ElMessage.error(e.message || '来源单据明细加载失败')
} finally {
importing.value = false
}
}
async function loadTakeSheet() {
importing.value = true
try {
const res = await getTakeSheet(form.warehouse_id)
if (res.code !== 200) return
const rows = res.data.list || []
if (!rows.length) {
ElMessage.warning('该仓库暂无账面库存')
return
}
items.value = rows.map((r) => ({
key: ++rowSeq,
product_id: r.product_id,
product_name: r.product_name,
spec: r.spec || '',
unit: r.unit || '',
quantity: Number(r.book_qty) || 0,
book_qty: Number(r.book_qty) || 0,
unit_price: round2(r.cost_price) || 0,
batch_no: r.batch_no || '',
production_date: '',
expire_date: '',
ref_item_id: 0,
remark: '',
enable_batch: !!r.batch_no
}))
} catch (e) {
ElMessage.error(e.message || '盘点表加载失败')
} finally {
importing.value = false
}
}
function validate() {
if (!form.biz_date) return '请选择业务日期'
if (!form.warehouse_id) return isTransfer.value ? '请选择调出仓库' : '请选择仓库'
if (isTransfer.value && !form.to_warehouse_id) return '请选择调入仓库'
if (partnerType.value && !form.partner_id) return `请选择${partnerType.value === 'supplier' ? '供应商' : '客户'}`
const rows = items.value.filter((r) => r.product_id)
if (!rows.length) return '请至少添加一行明细'
for (const r of rows) {
if (!(Number(r.quantity) > 0)) return `商品「${r.product_name}」的数量必须大于 0`
if (r.enable_batch && !r.batch_no) return `商品「${r.product_name}」启用批次管理,请填写批次号`
}
return ''
}
function buildPayload() {
return {
id: form.id || 0,
doc_type: props.docType,
biz_date: form.biz_date,
warehouse_id: form.warehouse_id,
to_warehouse_id: form.to_warehouse_id,
partner_type: partnerType.value,
partner_id: form.partner_id,
source_doc_id: form.source_doc_id,
// 调拨/盘点等不涉及成交价的单据不允许有折扣,否则会被后端计入金额
discount_amount: showPrice.value ? form.discount_amount : 0,
remark: form.remark,
items: items.value
.filter((r) => r.product_id)
.map((r, i) => ({
product_id: r.product_id,
product_name: r.product_name,
spec: r.spec,
unit: r.unit,
quantity: r.quantity,
book_qty: r.book_qty,
unit_price: r.unit_price,
batch_no: r.batch_no,
production_date: r.production_date,
expire_date: r.expire_date,
ref_item_id: r.ref_item_id,
remark: r.remark,
sort_order: i + 1
}))
}
}
async function save(mode) {
const msg = validate()
if (msg) {
ElMessage.warning(msg)
return
}
saving.value = true
try {
const res = await saveDocument(buildPayload())
if (res.code !== 200) return
const id = res.data.id
if (mode === 'submit') {
await submitDocument(id, form.flow_id ? { flow_id: form.flow_id } : {})
ElMessage.success('已提交审批')
} else if (mode === 'approve') {
await approveDocument(id)
ElMessage.success('单据已审核生效,库存与往来账已更新')
} else {
ElMessage.success('草稿已保存')
}
emit('success')
close()
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function close() {
emit('update:visible', false)
}
</script>
<style lang="less" scoped>
.doc-editor {
padding-bottom: 8px;
}
.head-form {
margin-bottom: 8px;
}
.credit-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.items-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.items-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.items-table {
width: 100%;
}
.totals {
display: flex;
gap: 24px;
justify-content: flex-end;
margin-top: 14px;
font-size: 13px;
color: var(--el-text-color-regular);
.amount {
color: var(--el-color-danger);
font-size: 16px;
}
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,432 @@
<template>
<div class="doc-page">
<div class="page-header">
<div>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
</div>
<div class="header-actions">
<el-dropdown v-if="typeOptions.length > 1" @command="openCreate">
<el-button type="primary" :icon="Plus">
新建单据<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="t in typeOptions" :key="t" :command="t">{{ docTypeLabel(t) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="primary" :icon="Plus" @click="openCreate(typeOptions[0])">新建{{ docTypeLabel(typeOptions[0]) }}</el-button>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item v-if="typeOptions.length > 1" label="单据类型">
<el-select v-model="filters.doc_type" clearable placeholder="全部" style="width: 140px" @change="handleSearch">
<el-option v-for="t in typeOptions" :key="t" :label="docTypeLabel(t)" :value="t" />
</el-select>
</el-form-item>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索单号/往来单位/备注"
:prefix-icon="Search"
style="width: 220px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px" @change="handleSearch">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="仓库">
<el-select v-model="filters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="handleSearch">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
@change="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="loading" class="table-container">
<el-table :data="rows" stripe border size="small">
<el-table-column v-if="typeOptions.length > 1" label="类型" width="116" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.doc_type_label || docTypeLabel(row.doc_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="单据编号" min-width="150">
<template #default="{ row }">
<span class="doc-no doc-link" @click="openDetail(row)">{{ row.doc_no }}</span>
</template>
</el-table-column>
<el-table-column label="业务日期" width="110" align="center">
<template #default="{ row }">{{ dateOnly(row.biz_date) }}</template>
</el-table-column>
<el-table-column label="往来单位" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.partner_name || '-' }}</template>
</el-table-column>
<el-table-column label="仓库" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.warehouse_name || '-' }}<span v-if="row.to_warehouse_name"> → {{ row.to_warehouse_name }}</span>
</template>
</el-table-column>
<el-table-column label="数量" width="92" align="right">
<template #default="{ row }">{{ qty(row.total_qty) }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span :class="{ 'text-muted': !amountOf(row) }">{{ money(amountOf(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="docStatusTag(row.status)" size="small">{{ docStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_user_name" label="制单" width="100" show-overflow-tooltip />
<el-table-column label="操作" width="230" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button v-if="canEdit(row)" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canEdit(row)" link type="warning" size="small" @click="handleSubmit(row)">提交审批</el-button>
<el-button v-if="canApprove(row)" link type="success" size="small" @click="handleApprove(row)">审核</el-button>
<el-dropdown v-if="nextTypes(row).length" trigger="click" @command="(t) => openPush(row, t)">
<el-button link type="primary" size="small">下推<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="n in nextTypes(row)" :key="n.type" :command="n.type">{{ n.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="row.status !== 2 && row.status !== 4" link type="danger" size="small" @click="handleCancel(row)">作废</el-button>
<el-button v-if="canEdit(row)" link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
<DocEditor
v-model:visible="editorVisible"
:doc-type="editorType"
:edit-data="editData"
:push-from="pushFrom"
@success="fetchList"
/>
<DocDetailDrawer v-model:visible="detailVisible" :doc-id="detailId" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import DocEditor from './DocEditor.vue'
import DocDetailDrawer from './DocDetailDrawer.vue'
import {
DOC_NEXT_TYPES,
DOC_STATUS,
dateOnly,
docStatusTag,
docStatusText,
docTypeLabel,
money,
qty
} from './dict'
import {
approveDocument,
cancelDocument,
deleteDocument,
getDocumentDetail,
getDocumentList,
submitDocument
} from '@/api/erpDocument'
import { getWarehouseList } from '@/api/erpWarehouse'
const props = defineProps({
/** 本页面覆盖的单据类型;单个即专用列表,多个即混合列表 */
types: { type: Array, required: true },
title: { type: String, default: '单据管理' },
subtitle: { type: String, default: '' }
})
const loading = ref(false)
const rows = ref([])
const warehouses = ref([])
const dateRange = ref([])
const statusOptions = Object.keys(DOC_STATUS).map((k) => ({ value: k, label: DOC_STATUS[k].label }))
const filters = reactive({ keyword: '', status: '', doc_type: '', warehouse_id: '' })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const editorVisible = ref(false)
const editorType = ref(props.types[0])
const editData = ref(null)
const pushFrom = ref(null)
const detailVisible = ref(false)
const detailId = ref(0)
const typeOptions = computed(() => props.types)
/** 金额列口径:采购销售取成交金额,库存类单据无金额概念只显示数量 */
function amountOf(row) {
const t = row.doc_type
if (String(t).startsWith('purchase') || String(t).startsWith('sales') || t === 'other_in') {
return Number(row.actual_amount) || 0
}
return 0
}
function canEdit(row) {
return row.status === 0 || row.status === 3
}
function canApprove(row) {
return row.status === 0 || row.status === 3 || row.status === 1
}
function nextTypes(row) {
return DOC_NEXT_TYPES[row.doc_type] || []
}
async function fetchList() {
loading.value = true
try {
const params = {
page: pagination.page,
pageSize: pagination.pageSize,
doc_types: typeOptions.value.join(',')
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status !== '') params.status = filters.status
if (filters.doc_type) params.doc_type = filters.doc_type
if (filters.warehouse_id) params.warehouse_id = filters.warehouse_id
if (dateRange.value && dateRange.value.length === 2) {
params.date_from = dateRange.value[0]
params.date_to = dateRange.value[1]
}
const res = await getDocumentList(params)
if (res.code === 200) {
rows.value = res.data.list || []
pagination.total = Number(res.data.total) || 0
} else {
ElMessage.error(res.msg || '单据加载失败')
}
} catch (e) {
ElMessage.error(e.message || '单据加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.doc_type = ''
filters.warehouse_id = ''
dateRange.value = []
handleSearch()
}
function openCreate(type) {
editData.value = null
pushFrom.value = null
editorType.value = type
editorVisible.value = true
}
async function openEdit(row) {
// 列表接口不含明细行,编辑前先取完整单据回填
try {
const res = await getDocumentDetail(row.id)
editData.value = res.code === 200 ? res.data : row
} catch (e) {
editData.value = row
}
pushFrom.value = null
editorType.value = row.doc_type
editorVisible.value = true
}
function openPush(row, targetType) {
editData.value = null
pushFrom.value = { doc: row, type: targetType }
editorType.value = targetType
editorVisible.value = true
}
function openDetail(row) {
detailId.value = row.id
detailVisible.value = true
}
async function handleSubmit(row) {
try {
const res = await submitDocument(row.id)
if (res.code === 200) ElMessage.success('已提交审批')
} catch (e) {
ElMessage.error(e.message || '提交失败')
return
}
fetchList()
}
async function handleApprove(row) {
try {
await ElMessageBox.confirm(
`确认审核「${row.doc_no}」?审核通过后将立即产生库存流水与往来账。`,
'审核确认',
{ type: 'warning' }
)
} catch (e) {
return
}
try {
await approveDocument(row.id)
ElMessage.success('单据已生效')
} catch (e) {
ElMessage.error(e.message || '审核失败')
return
}
fetchList()
}
async function handleCancel(row) {
try {
await ElMessageBox.confirm(`确认作废「${row.doc_no}」?作废后不可恢复。`, '作废确认', { type: 'warning' })
} catch (e) {
return
}
try {
await cancelDocument(row.id)
ElMessage.success('已作废')
} catch (e) {
ElMessage.error(e.message || '作废失败')
return
}
fetchList()
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除草稿「${row.doc_no}」?`, '删除确认', { type: 'warning' })
} catch (e) {
return
}
try {
await deleteDocument(row.id)
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e.message || '删除失败')
return
}
fetchList()
}
onMounted(async () => {
try {
const res = await getWarehouseList({ pageSize: 100 })
if (res.code === 200) warehouses.value = res.data.list || []
} catch (e) {
warehouses.value = []
}
fetchList()
})
</script>
<style lang="less" scoped>
.doc-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.doc-no {
font-weight: 500;
}
.doc-link {
cursor: pointer;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.text-muted {
color: var(--el-text-color-secondary);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,93 @@
<template>
<el-select
v-model="selected"
filterable
clearable
remote
reserve-keyword
:remote-method="search"
:loading="loading"
:placeholder="placeholder"
@change="onChange"
>
<el-option
v-for="p in options"
:key="p.id"
:value="p.id"
:label="p.product_name + (p.spec ? ' ' + p.spec : '')"
>
<div class="product-option">
<span class="po-name">{{ p.product_name }}</span>
<span class="po-meta">{{ p.product_code }}{{ p.spec ? ' · ' + p.spec : '' }} · 库存 {{ fmtQty(p.total_stock) }}{{ p.base_unit }}</span>
</div>
</el-option>
</el-select>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { qty as fmtQty } from './dict'
import { getProductList } from '@/api/erpProduct'
const props = defineProps({
modelValue: { type: [Number, String], default: 0 },
placeholder: { type: String, default: '搜索商品名称/编码/条码' }
})
const emit = defineEmits(['update:modelValue', 'pick'])
const selected = ref(props.modelValue || undefined)
const options = ref([])
const loading = ref(false)
watch(() => props.modelValue, (v) => {
selected.value = v || undefined
})
async function search(keyword) {
loading.value = true
try {
const res = await getProductList({
keyword: keyword || '',
only_enabled: 1,
page: 1,
pageSize: 30
})
if (res.code === 200) {
options.value = res.data.list || []
}
} catch (e) {
ElMessage.error(e.message || '商品加载失败')
} finally {
loading.value = false
}
}
function onChange(id) {
emit('update:modelValue', id || 0)
const p = options.value.find((x) => String(x.id) === String(id))
if (p) emit('pick', p)
}
// 首次展开即带出一批常用商品,避免用户以为下拉是空的
search('')
</script>
<style lang="less" scoped>
.product-option {
display: flex;
flex-direction: column;
line-height: 1.4;
padding: 2px 0;
.po-name {
font-size: 13px;
color: var(--el-text-color-primary);
}
.po-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+104
View File
@@ -0,0 +1,104 @@
/**
* 进销存单据字典与格式化工具。
*
* 十类单据共用一套接口与界面,类型文案、状态色、往来单位要求都在这里收口,
* 页面只按 doc_type 取差异,避免各处硬编码。
*/
export const DOC_TYPES = {
purchase_order: { label: '采购订单', partner: 'supplier', affectsStock: false, prefix: 'CGDD' },
purchase_in: { label: '采购入库单', partner: 'supplier', affectsStock: true, source: ['purchase_order'] },
purchase_return: { label: '采购退货单', partner: 'supplier', affectsStock: true, source: ['purchase_order', 'purchase_in'] },
sales_order: { label: '销售订单', partner: 'customer', affectsStock: false },
sales_out: { label: '销售出库单', partner: 'customer', affectsStock: true, source: ['sales_order'] },
sales_return: { label: '销售退货单', partner: 'customer', affectsStock: true, source: ['sales_order', 'sales_out'] },
other_in: { label: '其他入库单', partner: '', affectsStock: true },
other_out: { label: '其他出库单', partner: '', affectsStock: true },
transfer: { label: '库存调拨单', partner: '', affectsStock: true, toWarehouse: true },
stock_take: { label: '库存盘点单', partner: '', affectsStock: true, takeSheet: true }
}
export const DOC_TYPE_ORDER = [
'purchase_order', 'purchase_in', 'purchase_return',
'sales_order', 'sales_out', 'sales_return',
'other_in', 'other_out', 'transfer', 'stock_take'
]
export const DOC_STATUS = {
0: { label: '草稿', tag: 'info' },
1: { label: '审批中', tag: 'warning' },
2: { label: '已生效', tag: 'success' },
3: { label: '已驳回', tag: 'danger' },
4: { label: '已作废', tag: 'info' }
}
/** 下游单据:从已生效单据「下推」时用的目标类型 */
export const DOC_NEXT_TYPES = {
purchase_order: [
{ type: 'purchase_in', label: '采购入库' },
{ type: 'purchase_return', label: '采购退货' }
],
purchase_in: [{ type: 'purchase_return', label: '采购退货' }],
sales_order: [
{ type: 'sales_out', label: '销售出库' },
{ type: 'sales_return', label: '销售退货' }
],
sales_out: [{ type: 'sales_return', label: '销售退货' }]
}
export function docTypeLabel(docType) {
return DOC_TYPES[docType]?.label || docType
}
export function docTypePartner(docType) {
return DOC_TYPES[docType]?.partner || ''
}
export function docTypeSources(docType) {
return DOC_TYPES[docType]?.source || []
}
export function docStatusText(status) {
return DOC_STATUS[status]?.label || '未知'
}
export function docStatusTag(status) {
return DOC_STATUS[status]?.tag || 'info'
}
export function money(v) {
const n = Number(v || 0)
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function qty(v) {
const n = Number(v || 0)
// 数量按 4 位小数存储,尾部多余的 0 不显示
return String(Number(n.toFixed(4)))
}
/**
* 价格体系取价:采购类取采购价,销售类按基础设置的售价口径(零售/批发/会员)。
* 其他出入库、调拨、盘点不带价,由后端按移动加权成本结转。
*/
export function pickPrice(product, docType, salesPriceType) {
if (!product) return 0
if (docType.startsWith('purchase')) return Number(product.purchase_price) || 0
if (docType.startsWith('sales')) {
const field = { sale: 'sale_price', wholesale: 'wholesale_price', member: 'member_price' }[salesPriceType] || 'sale_price'
return Number(product[field]) || 0
}
return 0
}
export function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function dateOnly(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return String(val)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
@@ -0,0 +1,413 @@
<template>
<div class="bill-page">
<div class="page-header">
<div>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="$emit('fund', billType)">新建{{ fundLabel }}</el-button>
</div>
</div>
<div class="kpi-row">
<div class="kpi">
<span class="kpi-label">未收应收合计</span>
<b class="danger">{{ money(summary.open_receivable) }}</b>
</div>
<div class="kpi">
<span class="kpi-label">未付应付合计</span>
<b class="warning">{{ money(summary.open_payable) }}</b>
</div>
<div class="kpi">
<span class="kpi-label">{{ title }}未结</span>
<b>{{ money(ownOpen) }}</b>
</div>
</div>
<el-tabs v-model="tab">
<el-tab-pane label="往来账" name="bill">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item>
<el-input v-model="filters.keyword" clearable placeholder="搜索往来单位/来源单号" :prefix-icon="Search" style="width: 220px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="核销状态">
<el-select v-model="filters.settle_state" clearable placeholder="全部" style="width: 120px" @change="handleSearch">
<el-option label="未结清" value="open" />
<el-option label="部分核销" value="part" />
<el-option label="已结清" value="done" />
</el-select>
</el-form-item>
<el-form-item label="账期">
<el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="loading" class="table-container">
<el-table :data="rows" stripe border size="small">
<el-table-column label="来源单据" min-width="150">
<template #default="{ row }">
<span class="link" @click="openDetail(row)">{{ row.bill.source_doc_no || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="往来单位" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.bill.partner_name || '-' }}</template>
</el-table-column>
<el-table-column label="记账日期" width="110" align="center">
<template #default="{ row }">{{ dateOnly(row.bill.bill_date) }}</template>
</el-table-column>
<el-table-column label="到期日" width="110" align="center">
<template #default="{ row }">
<span :class="{ 'text-muted': !row.bill.due_date }">{{ row.bill.due_date ? dateOnly(row.bill.due_date) : '现结' }}</span>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">{{ money(row.bill.amount) }}</template>
</el-table-column>
<el-table-column label="已核销" width="110" align="right">
<template #default="{ row }">{{ money(row.bill.settled_amount) }}</template>
</el-table-column>
<el-table-column label="未结余额" width="120" align="right">
<template #default="{ row }">
<b :class="row.balance > 0 ? 'danger' : 'text-muted'">{{ money(row.balance) }}</b>
</template>
</el-table-column>
<el-table-column label="逾期" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.overdue_days > 0" type="danger" size="small">{{ row.overdue_days }} 天</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.bill.status === 2 ? 'success' : row.bill.status === 1 ? 'warning' : 'info'" size="small">
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page.page"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="账龄分析" name="aging">
<div v-loading="agingLoading" class="table-container">
<el-alert
:title="`按到期日逾期天数分桶,未到期表示尚未逾期;合计 ${money(agingTotal)}`"
type="info"
:closable="false"
class="aging-alert"
/>
<el-table :data="agingRows" stripe border size="small">
<el-table-column prop="partner_name" label="往来单位" min-width="160" show-overflow-tooltip />
<el-table-column label="性质" width="90" align="center">
<template #default="{ row }">{{ row.bill_type === 1 ? '应收' : '应付' }}</template>
</el-table-column>
<el-table-column label="未结合计" width="130" align="right">
<template #default="{ row }"><b>{{ money(row.total) }}</b></template>
</el-table-column>
<el-table-column v-for="b in bucketLabels" :key="b" :label="b" min-width="110" align="right">
<template #default="{ row }">
<span :class="{ 'danger': b !== '未到期' && Number(row.buckets[b] || 0) > 0 }">
{{ money(row.buckets[b] || 0) }}
</span>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!agingLoading && !agingRows.length" description="当前没有未结往来账" />
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="detailVisible" title="往来账详情" direction="rtl" size="46%">
<div v-if="detail.bill">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="往来单位">{{ detail.bill.partner_name }}</el-descriptions-item>
<el-descriptions-item label="性质">{{ detail.bill.bill_type === 1 ? '应收' : '应付' }}</el-descriptions-item>
<el-descriptions-item label="来源单据">{{ detail.bill.source_doc_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.bill.status === 2 ? '已结清' : detail.bill.status === 1 ? '部分核销' : '未核销' }}</el-descriptions-item>
<el-descriptions-item label="记账日期">{{ dateOnly(detail.bill.bill_date) }}</el-descriptions-item>
<el-descriptions-item label="到期日">{{ detail.bill.due_date ? dateOnly(detail.bill.due_date) : '现结' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ money(detail.bill.amount) }}</el-descriptions-item>
<el-descriptions-item label="未结余额">
<b class="danger">{{ money(detail.balance) }}</b>
</el-descriptions-item>
</el-descriptions>
<div class="section-title">核销记录</div>
<el-table :data="detail.settles || []" border size="small" empty-text="尚无核销记录">
<el-table-column label="收付款单" min-width="140">
<template #default="{ row }">{{ (detail.funds && detail.funds[row.fund_bill_id] ? detail.funds[row.fund_bill_id].fund_no : row.fund_bill_id) }}</template>
</el-table-column>
<el-table-column label="核销金额" width="120" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
<el-table-column label="时间" width="170" align="center">
<template #default="{ row }">{{ dateOnly(row.create_time) }}</template>
</el-table-column>
</el-table>
<template v-if="detail.doc_items && detail.doc_items.length">
<div class="section-title">来源单据明细</div>
<el-table :data="detail.doc_items" border size="small">
<el-table-column prop="product_name" label="商品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="100" />
<el-table-column label="数量" width="90" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">{{ money(row.unit_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { getAging, getBillDetail, getBillList } from '@/api/erpFinance'
import { dateOnly, money, qty } from '../../document/dict'
const props = defineProps({
/** 1=应收(客户) 2=应付(供应商) */
billType: { type: [Number, String], required: true },
title: { type: String, default: '应收账款' },
subtitle: { type: String, default: '' }
})
defineEmits(['fund'])
const billTypeNum = computed(() => Number(props.billType))
const fundLabel = computed(() => (billTypeNum.value === 1 ? '收款单' : '付款单'))
const loading = ref(false)
const rows = ref([])
const summary = ref({ open_receivable: 0, open_payable: 0 })
const tab = ref('bill')
const filters = reactive({ keyword: '', settle_state: '' })
const dates = ref([])
const page = reactive({ page: 1, pageSize: 20, total: 0 })
const agingRows = ref([])
const agingLoading = ref(false)
const bucketLabels = ref(['未到期', '1-30天', '31-60天', '61-90天', '90天以上'])
const detailVisible = ref(false)
const detail = ref({})
const ownOpen = computed(() =>
billTypeNum.value === 1 ? Number(summary.value.open_receivable) || 0 : Number(summary.value.open_payable) || 0
)
const agingTotal = computed(() => agingRows.value.reduce((s, r) => s + (Number(r.total) || 0), 0))
async function fetchList() {
loading.value = true
try {
const params = { page: page.page, pageSize: page.pageSize, bill_type: billTypeNum.value }
if (filters.keyword) params.keyword = filters.keyword
if (filters.settle_state) params.settle_state = filters.settle_state
if (dates.value && dates.value.length === 2) {
params.date_from = dates.value[0]
params.date_to = dates.value[1]
}
const res = await getBillList(params)
if (res.code === 200) {
rows.value = res.data.list || []
page.total = Number(res.data.total) || 0
summary.value = res.data.summary || { open_receivable: 0, open_payable: 0 }
} else {
ElMessage.error(res.msg || '往来账加载失败')
}
} catch (e) {
ElMessage.error(e.message || '往来账加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.page = 1
fetchList()
}
function resetFilters() {
filters.keyword = ''
filters.settle_state = ''
dates.value = []
handleSearch()
}
async function fetchAging() {
agingLoading.value = true
try {
const res = await getAging({ bill_type: billTypeNum.value })
if (res.code === 200) {
const list = (res.data.list || []).filter((r) => Number(r.bill_type) === billTypeNum.value)
list.sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0))
agingRows.value = list
if (Array.isArray(res.data.buckets) && res.data.buckets.length) {
bucketLabels.value = res.data.buckets.map((b) => b.label)
}
} else {
ElMessage.error(res.msg || '账龄加载失败')
}
} catch (e) {
ElMessage.error(e.message || '账龄加载失败')
} finally {
agingLoading.value = false
}
}
async function openDetail(row) {
try {
const res = await getBillDetail(row.bill.id)
if (res.code === 200) {
detail.value = res.data || {}
detailVisible.value = true
} else {
ElMessage.error(res.msg || '详情加载失败')
}
} catch (e) {
ElMessage.error(e.message || '详情加载失败')
}
}
watch(tab, (v) => {
if (v === 'aging' && !agingRows.value.length) fetchAging()
})
onMounted(fetchList)
</script>
<style lang="less" scoped>
.bill-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.kpi-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.kpi {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 170px;
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-bg-color);
.kpi-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
b {
font-size: 20px;
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.aging-alert {
margin-bottom: 12px;
}
.section-title {
margin: 18px 0 10px;
font-size: 14px;
font-weight: 600;
}
.link {
cursor: pointer;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.danger {
color: var(--el-color-danger);
}
.warning {
color: var(--el-color-warning);
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,19 @@
<template>
<BillListPage
:bill-type="2"
title="应付账款"
subtitle="采购入库与退货自动生成应付,按供应商账期计算到期日,支持按单核销"
@fund="toPayment"
/>
</template>
<script setup>
import { useRouter } from 'vue-router'
import BillListPage from './components/BillListPage.vue'
const router = useRouter()
function toPayment() {
router.push('/apps/erp/finance/payment')
}
</script>
@@ -0,0 +1,644 @@
<template>
<div class="fund-page">
<div class="page-header">
<div>
<h2>收付款管理</h2>
<p>收款自动核销应收、付款自动核销应付;不指定往来账时按到期日 FIFO 冲抵</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="openCreate">新建收付款</el-button>
</div>
</div>
<el-tabs v-model="tab">
<el-tab-pane label="收付款单" name="fund">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="方式">
<el-select v-model="filters.fund_type" clearable placeholder="全部" style="width: 130px" @change="handleSearch">
<el-option v-for="f in fundTypes" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px" @change="handleSearch">
<el-option label="草稿" value="0" />
<el-option label="已确认" value="1" />
<el-option label="已作废" value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="filters.keyword" clearable placeholder="搜索单号/往来单位" :prefix-icon="Search" style="width: 200px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="loading" class="table-container">
<el-table :data="rows" stripe border size="small">
<el-table-column prop="fund_no" label="单号" min-width="150" />
<el-table-column label="方式" width="100" align="center">
<template #default="{ row }">
<el-tag :type="fundTag(row.fund_type)" size="small">{{ fundLabel(row.fund_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="往来单位" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.partner_name || '-' }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<b :class="row.fund_type === 1 || row.fund_type === 3 ? 'in' : 'out'">{{ money(row.amount) }}</b>
</template>
</el-table-column>
<el-table-column label="已核销" width="110" align="right">
<template #default="{ row }">{{ money(row.settled_amount) }}</template>
</el-table-column>
<el-table-column label="收款方式" width="110" align="center">
<template #default="{ row }">{{ methodLabel(row.pay_method) }}</template>
</el-table-column>
<el-table-column prop="pay_account" label="账户" width="130" show-overflow-tooltip />
<el-table-column label="业务日期" width="110" align="center">
<template #default="{ row }">{{ dateOnly(row.biz_date) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : row.status === 4 ? 'info' : 'warning'" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status === 0" link type="success" size="small" @click="openConfirm(row)">确认</el-button>
<el-button link type="primary" size="small" @click="openDetail(row)">核销明细</el-button>
<el-button v-if="row.status === 1" link type="danger" size="small" @click="handleVoid(row)">作废</el-button>
<el-button v-if="row.status === 0" link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page.page"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="资金流水" name="cash">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="区间">
<el-date-picker v-model="cashDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="loadCash" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadCash">查询</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="cashLoading" class="table-container">
<div class="kpi-row">
<div class="kpi"><span class="kpi-label">收入合计</span><b class="in">{{ money(cash.total_in) }}</b></div>
<div class="kpi"><span class="kpi-label">支出合计</span><b class="out">{{ money(cash.total_out) }}</b></div>
<div class="kpi"><span class="kpi-label">净额</span><b>{{ money(cash.net) }}</b></div>
</div>
<el-table :data="cash.list" stripe border size="small" max-height="480">
<el-table-column prop="date" label="日期" width="130" align="center" />
<el-table-column label="收入" width="140" align="right">
<template #default="{ row }"><span class="in">{{ money(row.in) }}</span></template>
</el-table-column>
<el-table-column label="支出" width="140" align="right">
<template #default="{ row }"><span class="out">{{ money(row.out) }}</span></template>
</el-table-column>
<el-table-column label="当日净额" width="140" align="right">
<template #default="{ row }">{{ money(row.net) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!cashLoading && !(cash.list || []).length" description="区间内没有已确认的收付款" />
</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" :title="form.id ? '编辑收付款单' : '新建收付款单'" width="520px">
<el-form :model="form" label-width="92px">
<el-form-item label="方式">
<el-select v-model="form.fund_type" style="width: 100%" @change="onFundTypeChange">
<el-option v-for="f in fundTypes" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
</el-form-item>
<el-form-item v-if="needPartner" :label="form.fund_type === 1 ? '客户' : '供应商'" required>
<el-select v-model="form.partner_id" filterable clearable placeholder="搜索名称选择" style="width: 100%">
<el-option v-for="p in partners" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="金额" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="收款方式">
<el-select v-model="form.pay_method" style="width: 100%">
<el-option v-for="m in payMethods" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="账户">
<el-input v-model="form.pay_account" placeholder="如 工商银行 6222****" />
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker v-model="form.biz_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="200" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button :loading="saving" @click="submit(0)">仅保存草稿</el-button>
<el-button type="primary" :loading="saving" @click="submit(1)">保存并确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="confirmVisible" title="确认收付款并核销" width="720px">
<div class="confirm-head">
<span>{{ current.fund_no }} · {{ fundLabel(current.fund_type) }} {{ money(current.amount) }}</span>
<span class="text-muted">不勾选任何往来账时,按到期日最早的单据自动冲抵</span>
</div>
<el-table :data="openBills" border size="small" max-height="320" @selection-change="onSettleSelect">
<el-table-column type="selection" width="46" />
<el-table-column prop="source_doc_no" label="来源单据" min-width="150" />
<el-table-column label="记账日" width="110" align="center">
<template #default="{ row }">{{ dateOnly(row.bill_date) }}</template>
</el-table-column>
<el-table-column label="到期日" width="110" align="center">
<template #default="{ row }">{{ row.due_date ? dateOnly(row.due_date) : '现结' }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
<el-table-column label="未结" width="110" align="right">
<template #default="{ row }">{{ money(row.balance) }}</template>
</el-table-column>
<el-table-column label="本次核销" width="150" align="right">
<template #default="{ row }">
<el-input-number v-model="row._settle" :min="0" :max="row.balance" :precision="2" :step="100" size="small" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
</el-table>
<div class="confirm-foot">
<span>本次核销合计:<b>{{ money(settleTotal) }}</b></span>
<span :class="settleTotal > current.amount ? 'out' : 'text-muted'">
{{ settleTotal > current.amount ? '核销金额超出收付款金额' : `剩余 ${money(current.amount - settleTotal)} 将作为预收/预付挂账` }}
</span>
</div>
<template #footer>
<el-button @click="confirmVisible = false">取消</el-button>
<el-button type="primary" :loading="confirming" :disabled="settleTotal > current.amount" @click="doConfirm">
确认收付款
</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailVisible" title="核销明细" direction="rtl" size="420px">
<div v-if="fundDetail.fund">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="单号">{{ fundDetail.fund.fund_no }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ money(fundDetail.fund.amount) }}</el-descriptions-item>
<el-descriptions-item label="已核销">{{ money(fundDetail.fund.settled_amount) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(fundDetail.fund.status) }}</el-descriptions-item>
</el-descriptions>
<div class="section-title">核销的往来账</div>
<el-table :data="fundDetail.fund.settles || []" border size="small" empty-text="未核销任何往来账">
<el-table-column prop="partner_bill_no" label="往来账单号" min-width="130" />
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
</el-table>
<div class="section-title">关联单据</div>
<el-table :data="fundDetail.bills || []" border size="small" empty-text="无">
<el-table-column prop="source_doc_no" label="来源单据" min-width="140" />
<el-table-column prop="partner_name" label="往来单位" min-width="120" />
<el-table-column label="未结" width="110" align="right">
<template #default="{ row }">{{ money(row.amount - row.settled_amount) }}</template>
</el-table-column>
</el-table>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import {
confirmFund,
deleteFund,
getCashFlow,
getFundDetail,
getFundList,
getFundOpenBills,
saveFund,
voidFund
} from '@/api/erpFinance'
import { getCustomerList } from '@/api/customer'
import { getSupplierList } from '@/api/supplier'
import { dateOnly, money, todayStr } from '../document/dict'
const fundTypes = [
{ value: 1, label: '收款' },
{ value: 2, label: '付款' },
{ value: 3, label: '其他收入' },
{ value: 4, label: '费用支出' }
]
const payMethods = [
{ value: 'cash', label: '现金' },
{ value: 'bank', label: '银行转账' },
{ value: 'wechat', label: '微信' },
{ value: 'alipay', label: '支付宝' },
{ value: 'draft', label: '票据' },
{ value: 'other', label: '其他' }
]
const tab = ref('fund')
const loading = ref(false)
const saving = ref(false)
const confirming = ref(false)
const rows = ref([])
const filters = reactive({ fund_type: '', status: '', keyword: '' })
const dates = ref([])
const page = reactive({ page: 1, pageSize: 20, total: 0 })
const editVisible = ref(false)
const form = reactive(emptyForm())
const partners = ref([])
const confirmVisible = ref(false)
const current = ref({ amount: 0 })
const openBills = ref([])
const settleRows = ref([])
const detailVisible = ref(false)
const fundDetail = ref({})
const cash = ref({ list: [], total_in: 0, total_out: 0, net: 0 })
const cashDates = ref([])
const cashLoading = ref(false)
const needPartner = computed(() => form.fund_type === 1 || form.fund_type === 2)
const settleTotal = computed(() =>
(settleRows.value || []).reduce((s, r) => s + (Number(r._settle) || 0), 0)
)
function emptyForm() {
return { id: 0, fund_type: 1, partner_id: 0, amount: 0, pay_method: 'bank', pay_account: '', biz_date: todayStr(), remark: '' }
}
const fundLabelOf = (v) => fundTypes.find((f) => f.value === Number(v))?.label || '收付款'
const fundLabel = fundLabelOf
const fundTag = (v) => ({ 1: 'success', 2: 'danger', 3: 'success', 4: 'warning' }[Number(v)] || 'info')
const statusLabel = (s) => ({ 0: '草稿', 1: '已确认', 4: '已作废' }[Number(s)] || '草稿')
const methodLabel = (m) => payMethods.find((x) => x.value === m)?.label || m || '-'
async function fetchList() {
loading.value = true
try {
const params = { page: page.page, pageSize: page.pageSize }
if (filters.fund_type !== '') params.fund_type = filters.fund_type
if (filters.status !== '') params.status = filters.status
if (filters.keyword) params.keyword = filters.keyword
if (dates.value && dates.value.length === 2) {
params.date_from = dates.value[0]
params.date_to = dates.value[1]
}
const res = await getFundList(params)
if (res.code === 200) {
rows.value = res.data.list || []
page.total = Number(res.data.total) || 0
} else {
ElMessage.error(res.msg || '收付款加载失败')
}
} catch (e) {
ElMessage.error(e.message || '收付款加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.page = 1
fetchList()
}
function resetFilters() {
filters.fund_type = ''
filters.status = ''
filters.keyword = ''
dates.value = []
handleSearch()
}
async function loadPartners() {
try {
const res = form.fund_type === 2
? await getSupplierList({ pageSize: 100, is_draft: 0, status: '1' })
: await getCustomerList({ pageSize: 100, is_draft: 0, status: '1' })
const list = res.code === 200 ? res.data.list || [] : []
partners.value = list.map((p) => ({ id: p.id, name: p.customer_name || p.supplier_name }))
} catch (e) {
partners.value = []
}
}
function onFundTypeChange() {
form.partner_id = 0
if (needPartner.value) loadPartners()
}
function openCreate() {
Object.assign(form, emptyForm())
loadPartners()
editVisible.value = true
}
function openEdit(row) {
Object.assign(form, emptyForm(), {
id: row.id,
fund_type: Number(row.fund_type) || 1,
partner_id: Number(row.partner_id) || 0,
amount: Number(row.amount) || 0,
pay_method: row.pay_method || 'bank',
pay_account: row.pay_account || '',
biz_date: dateOnly(row.biz_date),
remark: row.remark || ''
})
if (needPartner.value) loadPartners()
editVisible.value = true
}
/** after 为 1 时保存后立即确认(草稿状态直接生效并核销) */
async function submit(after) {
if (!(Number(form.amount) > 0)) {
ElMessage.warning('请填写大于 0 的金额')
return
}
if (needPartner.value && !form.partner_id) {
ElMessage.warning(form.fund_type === 1 ? '请选择客户' : '请选择供应商')
return
}
saving.value = true
try {
const res = await saveFund({ ...form })
if (res.code !== 200) return
if (after === 1) {
await confirmFund(res.data.id, {})
ElMessage.success('已确认并核销')
} else {
ElMessage.success('草稿已保存')
}
editVisible.value = false
fetchList()
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function openConfirm(row) {
current.value = row
try {
const res = await getFundOpenBills({ fund_type: row.fund_type, partner_id: row.partner_id })
if (res.code === 200) {
openBills.value = (res.data.list || []).map((b) => ({ ...b, _settle: 0 }))
} else {
openBills.value = []
}
} catch (e) {
openBills.value = []
}
settleRows.value = []
confirmVisible.value = true
}
/** 勾选即填满未结余额(不超过本单剩余可核销额),取消勾选清零 */
function onSettleSelect(sel) {
const ids = new Set(sel.map((r) => r.id))
let left = Number(current.value.amount) || 0
openBills.value.forEach((r) => {
if (!ids.has(r.id)) {
r._settle = 0
return
}
const want = Math.min(Number(r.balance) || 0, round2(left))
r._settle = want
left = round2(left - want)
})
settleRows.value = openBills.value.filter((r) => ids.has(r.id))
}
function round2(v) {
return Math.round((Number(v) || 0) * 100) / 100
}
async function doConfirm() {
const settles = openBills.value
.filter((r) => Number(r._settle) > 0)
.map((r) => ({ partner_bill_id: r.id, amount: round2(r._settle) }))
confirming.value = true
try {
await confirmFund(current.value.id, settles.length ? { settles } : {})
ElMessage.success('已确认,库存往来账同步完成')
confirmVisible.value = false
fetchList()
} catch (e) {
ElMessage.error(e.message || '确认失败')
} finally {
confirming.value = false
}
}
async function openDetail(row) {
try {
const res = await getFundDetail(row.id)
if (res.code === 200) {
fundDetail.value = res.data || {}
detailVisible.value = true
}
} catch (e) {
ElMessage.error(e.message || '明细加载失败')
}
}
async function handleVoid(row) {
try {
await ElMessageBox.confirm(`确认作废「${row.fund_no}」?作废将回滚其核销记录。`, '作废确认', { type: 'warning' })
} catch (e) {
return
}
try {
await voidFund(row.id)
ElMessage.success('已作废')
} catch (e) {
ElMessage.error(e.message || '作废失败')
return
}
fetchList()
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除草稿「${row.fund_no}」?`, '删除确认', { type: 'warning' })
} catch (e) {
return
}
try {
await deleteFund(row.id)
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e.message || '删除失败')
return
}
fetchList()
}
async function loadCash() {
cashLoading.value = true
try {
const params = {}
if (cashDates.value && cashDates.value.length === 2) {
params.date_from = cashDates.value[0]
params.date_to = cashDates.value[1]
}
const res = await getCashFlow(params)
if (res.code === 200) {
cash.value = res.data || { list: [] }
}
} catch (e) {
ElMessage.error(e.message || '资金流水加载失败')
} finally {
cashLoading.value = false
}
}
watch(tab, (v) => {
if (v === 'cash' && !(cash.value.list || []).length) loadCash()
})
onMounted(fetchList)
</script>
<style lang="less" scoped>
.fund-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.kpi-row {
display: flex;
gap: 12px;
margin-bottom: 14px;
}
.kpi {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 160px;
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
.kpi-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
b {
font-size: 20px;
}
}
.confirm-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
}
.confirm-foot {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 13px;
}
.section-title {
margin: 18px 0 10px;
font-size: 14px;
font-weight: 600;
}
.in {
color: var(--el-color-success);
}
.out {
color: var(--el-color-danger);
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,19 @@
<template>
<BillListPage
:bill-type="1"
title="应收账款"
subtitle="销售出库与退货自动生成应收,按账期计算到期日,支持按单核销与逾期跟踪"
@fund="toPayment"
/>
</template>
<script setup>
import { useRouter } from 'vue-router'
import BillListPage from './components/BillListPage.vue'
const router = useRouter()
function toPayment() {
router.push('/apps/erp/finance/payment')
}
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['purchase_in']" title="采购入库单" subtitle="审核生效即增加库存并按采购价生成应付账款" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['purchase_order']" title="采购订单" subtitle="先下单后入库,按行记录执行进度,未执行量可下推为入库单或退货单" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['purchase_return']" title="采购退货单" subtitle="红字冲减库存与应付,退货数量按原单剩余可退量校验" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,69 @@
<template>
<div class="sum-table">
<div class="mini-summary">
<span>合计数量 <b>{{ qty(data.summary?.qty) }}</b></span>
<span>合计金额 <b>{{ money(data.summary?.amount) }}</b></span>
<span>{{ (data.list || []).length }} 行</span>
</div>
<el-table :data="data.list" stripe border size="small">
<el-table-column type="index" label="#" width="55" align="center" />
<el-table-column prop="label" :label="dimLabel" min-width="200" show-overflow-tooltip />
<el-table-column label="数量" width="120" align="right">
<template #default="{ row }">{{ qty(row.qty) }}</template>
</el-table-column>
<el-table-column :label="amountLabel" width="140" align="right">
<template #default="{ row }">
<b :class="Number(row.amount) < 0 ? 'out' : ''">{{ money(row.amount) }}</b>
</template>
</el-table-column>
<el-table-column label="单据行数" width="100" align="right">
<template #default="{ row }">{{ row.lines }}</template>
</el-table-column>
<el-table-column label="金额占比" min-width="160">
<template #default="{ row }">
<el-progress :percentage="pct(row.amount)" :stroke-width="10" :show-text="false" />
</template>
</el-table-column>
</el-table>
<el-empty v-if="!(data.list || []).length" description="区间内没有已生效单据" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { money, qty } from '../document/dict'
const props = defineProps({
data: { type: Object, default: () => ({ list: [], summary: {} }) },
amountLabel: { type: String, default: '金额' },
dimLabel: { type: String, default: '名称' }
})
const total = computed(() =>
(props.data.list || []).reduce((s, r) => s + Math.abs(Number(r.amount) || 0), 0)
)
function pct(v) {
if (!total.value) return 0
return Math.round((Math.abs(Number(v) || 0) / total.value) * 1000) / 10
}
</script>
<style lang="less" scoped>
.mini-summary {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
b {
color: var(--el-text-color-primary);
}
}
.out {
color: var(--el-color-danger);
}
</style>
+795
View File
@@ -0,0 +1,795 @@
<template>
<div class="erp-report">
<div class="page-header">
<div>
<h2>报表中心</h2>
<p>统计口径只含已生效单据与库存流水,因此报表数字与库存结存、往来账永远一致</p>
</div>
<el-form :inline="true" class="range-form" @submit.prevent>
<el-form-item v-if="tab !== 'dashboard' && tab !== 'rfm'" label="统计区间">
<el-date-picker
v-model="dates"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px"
@change="reloadCurrent"
/>
</el-form-item>
<el-form-item>
<el-button :icon="Refresh" @click="reloadCurrent">刷新</el-button>
</el-form-item>
</el-form>
</div>
<el-tabs v-model="tab" class="report-tabs" @tab-change="onTabChange">
<!-- 经营看板 -->
<el-tab-pane label="经营看板" name="dashboard">
<div v-loading="dashLoading" class="tab-body">
<div class="kpi-grid">
<div v-for="k in kpiCards" :key="k.label" class="kpi-card">
<span class="kpi-label">{{ k.label }}</span>
<b :class="k.cls">{{ k.text }}</b>
<span class="kpi-sub">{{ k.sub }}</span>
</div>
</div>
<div class="panel-row">
<div class="panel">
<div class="panel-title">近 6 个月采购 / 销售趋势</div>
<div ref="trendRef" class="chart"></div>
</div>
<div class="panel side">
<div class="panel-title">库存预警与待办</div>
<div class="warn-grid">
<div v-for="w in warnCards" :key="w.label" class="warn-item" @click="w.to && router.push(w.to)">
<b :class="w.cls">{{ w.value }}</b>
<span>{{ w.label }}</span>
</div>
</div>
<div class="todo-line">
<span>待我审批 <b>{{ dash.todo?.approval_pending || 0 }}</b> 单</span>
<span>草稿 <b>{{ dash.todo?.draft_docs || 0 }}</b> 单</span>
<span>今日销售 <b>{{ money(dash.today?.sales_amount) }}</b> / {{ dash.today?.sales_count || 0 }} 单</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- 采购汇总 -->
<el-tab-pane label="采购汇总" name="purchase">
<div v-loading="sumLoading.purchase" class="tab-body">
<div class="dim-bar">
<el-radio-group v-model="sumDim.purchase" size="small" @change="() => loadSummary('purchase')">
<el-radio-button value="product">按商品</el-radio-button>
<el-radio-button value="supplier">按供应商</el-radio-button>
<el-radio-button value="month">按月</el-radio-button>
</el-radio-group>
<span class="sum-tip">净额已扣除采购退货</span>
</div>
<SummaryTable :data="sumData.purchase" amount-label="采购金额" :dim-label="dimLabel(sumDim.purchase)" />
</div>
</el-tab-pane>
<!-- 销售汇总 -->
<el-tab-pane label="销售汇总" name="sales">
<div v-loading="sumLoading.sales" class="tab-body">
<div class="dim-bar">
<el-radio-group v-model="sumDim.sales" size="small" @change="() => loadSummary('sales')">
<el-radio-button value="product">按商品</el-radio-button>
<el-radio-button value="customer">按客户</el-radio-button>
<el-radio-button value="month">按月</el-radio-button>
</el-radio-group>
<span class="sum-tip">净额已扣除销售退货</span>
</div>
<SummaryTable :data="sumData.sales" amount-label="销售金额" :dim-label="dimLabel(sumDim.sales)" />
</div>
</el-tab-pane>
<!-- 销售排行 -->
<el-tab-pane label="热销/滞销" name="rank">
<div v-loading="rankLoading" class="tab-body">
<div class="dim-bar">
<el-radio-group v-model="rankOrder" size="small" @change="loadRank">
<el-radio-button value="desc">热销榜</el-radio-button>
<el-radio-button value="asc">滞销榜</el-radio-button>
</el-radio-group>
<el-select v-model="rankTop" size="small" style="width: 110px" @change="loadRank">
<el-option v-for="n in [10, 20, 30, 50]" :key="n" :label="`Top ${n}`" :value="n" />
</el-select>
<span class="sum-tip">{{ rankOrder === 'asc' ? '滞销榜含区间内一件未卖的在售商品' : '' }}</span>
</div>
<el-table :data="rankList" stripe border size="small">
<el-table-column type="index" label="#" width="55" align="center" />
<el-table-column prop="product_code" label="商品编码" width="140" />
<el-table-column prop="product_name" label="商品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="数量" width="110" align="right">
<template #default="{ row }">{{ qty(row.qty) }}</template>
</el-table-column>
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }"><b>{{ money(row.amount) }}</b></template>
</el-table-column>
<el-table-column label="金额占比" min-width="160">
<template #default="{ row }">
<el-progress :percentage="pct(row.amount, rankTotal)" :stroke-width="10" :show-text="false" />
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- 收发存 -->
<el-tab-pane label="收发存" name="stockmove">
<div v-loading="moveLoading" class="tab-body">
<div class="dim-bar">
<el-select v-model="moveFilter.warehouse_id" size="small" clearable placeholder="全部仓库" style="width: 160px" @change="loadMove(1)">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
<el-input
v-model="moveFilter.keyword"
size="small"
clearable
placeholder="商品名称/编码"
style="width: 200px"
:prefix-icon="Search"
@keyup.enter="loadMove(1)"
/>
<el-button size="small" type="primary" :icon="Search" @click="loadMove(1)">查询</el-button>
</div>
<div class="mini-summary">
<span>本期入库 <b>{{ money(moveSummary.in_amount) }}</b>({{ qty(moveSummary.in_qty) }})</span>
<span>本期出库 <b>{{ money(moveSummary.out_amount) }}</b>({{ qty(moveSummary.out_qty) }})</span>
<span>期末库存金额 <b>{{ money(moveSummary.end_amount) }}</b></span>
</div>
<el-table :data="moveList" stripe border size="small">
<el-table-column prop="product_code" label="编码" width="130" fixed="left" />
<el-table-column prop="product_name" label="商品" min-width="150" fixed="left" show-overflow-tooltip />
<el-table-column prop="warehouse_name" label="仓库" width="110" />
<el-table-column prop="category_name" label="分类" width="110" show-overflow-tooltip />
<el-table-column label="期初" align="right">
<el-table-column prop="open_qty" label="数量" width="95" align="right">
<template #default="{ row }">{{ qty(row.open_qty) }}</template>
</el-table-column>
<el-table-column prop="open_amount" label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.open_amount) }}</template>
</el-table-column>
</el-table-column>
<el-table-column label="本期收入" align="right">
<el-table-column prop="in_qty" label="数量" width="95" align="right">
<template #default="{ row }"><span class="in">{{ qty(row.in_qty) }}</span></template>
</el-table-column>
<el-table-column prop="in_amount" label="金额" width="110" align="right">
<template #default="{ row }"><span class="in">{{ money(row.in_amount) }}</span></template>
</el-table-column>
</el-table-column>
<el-table-column label="本期发出" align="right">
<el-table-column prop="out_qty" label="数量" width="95" align="right">
<template #default="{ row }"><span class="out">{{ qty(row.out_qty) }}</span></template>
</el-table-column>
<el-table-column prop="out_amount" label="金额" width="110" align="right">
<template #default="{ row }"><span class="out">{{ money(row.out_amount) }}</span></template>
</el-table-column>
</el-table-column>
<el-table-column label="期末结存" align="right">
<el-table-column prop="end_qty" label="数量" width="95" align="right">
<template #default="{ row }"><b>{{ qty(row.end_qty) }}</b></template>
</el-table-column>
<el-table-column prop="end_cost" label="加权成本" width="100" align="right">
<template #default="{ row }">{{ money(row.end_cost) }}</template>
</el-table-column>
<el-table-column prop="end_amount" label="金额" width="120" align="right">
<template #default="{ row }"><b>{{ money(row.end_amount) }}</b></template>
</el-table-column>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="movePage.page"
v-model:page-size="movePage.pageSize"
:total="movePage.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="loadMove()"
@current-change="loadMove()"
/>
</div>
</div>
</el-tab-pane>
<!-- 毛利分析 -->
<el-tab-pane label="毛利分析" name="profit">
<div v-loading="profitLoading" class="tab-body">
<div class="kpi-grid compact">
<div class="kpi-card"><span class="kpi-label">销售收入</span><b>{{ money(profitSummary.revenue) }}</b><span class="kpi-sub">已扣退货</span></div>
<div class="kpi-card"><span class="kpi-label">销售成本</span><b>{{ money(profitSummary.cost) }}</b><span class="kpi-sub">出库流水实际成本</span></div>
<div class="kpi-card"><span class="kpi-label">毛利额</span><b :class="profitSummary.profit >= 0 ? 'in' : 'out'">{{ money(profitSummary.profit) }}</b><span class="kpi-sub">收入 − 成本</span></div>
<div class="kpi-card"><span class="kpi-label">毛利率</span><b>{{ Number(profitSummary.profit_rate || 0).toFixed(2) }}%</b><span class="kpi-sub">毛利额 / 收入</span></div>
</div>
<el-table :data="profitList" stripe border size="small">
<el-table-column type="index" label="#" width="55" align="center" />
<el-table-column prop="product_code" label="编码" width="130" />
<el-table-column prop="product_name" label="商品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="销量" width="100" align="right">
<template #default="{ row }">{{ qty(row.qty) }}</template>
</el-table-column>
<el-table-column label="收入" width="120" align="right">
<template #default="{ row }">{{ money(row.revenue) }}</template>
</el-table-column>
<el-table-column label="成本" width="120" align="right">
<template #default="{ row }">{{ money(row.cost) }}</template>
</el-table-column>
<el-table-column label="毛利" width="120" align="right">
<template #default="{ row }"><b :class="row.profit >= 0 ? 'in' : 'out'">{{ money(row.profit) }}</b></template>
</el-table-column>
<el-table-column label="毛利率" min-width="170">
<template #default="{ row }">
<div class="rate-cell">
<el-progress
:percentage="Math.min(100, Math.max(0, Number(row.profit_rate) || 0))"
:stroke-width="10"
:color="row.profit_rate < 0 ? 'var(--el-color-danger)' : 'var(--el-color-success)'"
/>
<span :class="row.profit_rate < 0 ? 'out' : ''">{{ Number(row.profit_rate || 0).toFixed(2) }}%</span>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="profitNegativeCount" class="neg-tip">
有 {{ profitNegativeCount }} 个商品毛利为负,通常是售价低于移动加权成本或退货冲减,建议核查售价与采购成本。
</div>
</div>
</el-tab-pane>
<!-- 客户价值 RFM -->
<el-tab-pane label="客户价值" name="rfm">
<div v-loading="rfmLoading" class="tab-body">
<div class="seg-bar">
<el-tag v-for="(n, seg) in rfmSegments" :key="seg" :type="segTag(seg)" effect="light">
{{ seg }} {{ n }}
</el-tag>
<span class="sum-tip">R={{ rfmMedian.r ?? '-' }}天 / F={{ rfmMedian.f ?? '-' }}次 / M={{ money(rfmMedian.m) }}(中位数切分,取全部已生效销售出库)</span>
</div>
<el-table :data="rfmList" stripe border size="small">
<el-table-column type="index" label="#" width="55" align="center" />
<el-table-column prop="customer_name" label="客户" min-width="160" show-overflow-tooltip />
<el-table-column label="最近购买" width="120" align="center">
<template #default="{ row }">{{ row.last_date || '-' }}</template>
</el-table-column>
<el-table-column label="R(天)" width="90" align="right">
<template #default="{ row }">{{ row.r_days >= 99999 ? '-' : row.r_days }}</template>
</el-table-column>
<el-table-column prop="f_count" label="F(次)" width="80" align="right" />
<el-table-column label="M(金额)" width="130" align="right">
<template #default="{ row }"><b>{{ money(row.m_amount) }}</b></template>
</el-table-column>
<el-table-column label="得分" width="110" align="center">
<template #default="{ row }">{{ row.r_score }} / {{ row.f_score }} / {{ row.m_score }}</template>
</el-table-column>
<el-table-column label="分层" width="130" align="center">
<template #default="{ row }">
<el-tag :type="segTag(row.segment)" size="small">{{ row.segment }}</el-tag>
</template>
</el-table-column>
<el-table-column label="金额贡献" min-width="160">
<template #default="{ row }">
<el-progress :percentage="pct(row.m_amount, rfmTotal)" :stroke-width="10" :show-text="false" />
</template>
</el-table-column>
</el-table>
<el-empty v-if="!rfmLoading && !rfmList.length" description="还没有已生效的销售出库单" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import {
getErpDashboard,
getProfitReport,
getPurchaseSummary,
getRfmReport,
getSalesRank,
getSalesSummary,
getStockMoveReport
} from '@/api/erpReport'
import { getWarehouseList } from '@/api/erpWarehouse'
import { money, qty } from '../document/dict'
import SummaryTable from './SummaryTable.vue'
const route = useRoute()
const router = useRouter()
const tab = ref(route.query.tab || 'dashboard')
const dates = ref([])
const loaded = reactive({})
const dashLoading = ref(false)
const sumLoading = reactive({ purchase: false, sales: false })
const rankLoading = ref(false)
const moveLoading = ref(false)
const profitLoading = ref(false)
const rfmLoading = ref(false)
const dash = ref({})
const trendRef = ref(null)
const trendChart = shallowRef(null)
const sumDim = reactive({ purchase: 'product', sales: 'product' })
const sumData = reactive({
purchase: { list: [], summary: {} },
sales: { list: [], summary: {} }
})
const rankOrder = ref('desc')
const rankTop = ref(10)
const rankList = ref([])
const rankTotal = computed(() => rankList.value.reduce((s, r) => s + Math.abs(r.amount || 0), 0))
const warehouses = ref([])
const moveFilter = reactive({ warehouse_id: '', keyword: '' })
const moveList = ref([])
const moveSummary = ref({})
const movePage = reactive({ page: 1, pageSize: 20, total: 0 })
const profitList = ref([])
const profitSummary = ref({})
const profitNegativeCount = computed(() => profitList.value.filter((r) => Number(r.profit) < 0).length)
const rfmList = ref([])
const rfmSegments = ref({})
const rfmMedian = ref({})
const rfmTotal = computed(() => rfmList.value.reduce((s, r) => s + Math.max(0, r.m_amount || 0), 0))
const kpiCards = computed(() => {
const m = dash.value.month || {}
const s = dash.value.stock || {}
const b = dash.value.partner_bill || {}
return [
{ label: '本月采购额', text: money(m.purchase_amount), sub: `${m.purchase_count || 0} 张入库单`, cls: '' },
{ label: '本月销售额', text: money(m.sales_amount), sub: `退货 ${money(m.return_amount)}`, cls: 'in' },
{ label: '本月毛利', text: money(m.profit), sub: `成本 ${money(m.cost)}`, cls: Number(m.profit) >= 0 ? 'in' : 'out' },
{ label: '毛利率', text: `${Number(m.profit_rate || 0).toFixed(2)}%`, sub: '毛利 / 净收入', cls: '' },
{ label: '库存金额', text: money(s.value), sub: `结存数量 ${qty(s.qty)}`, cls: '' },
{ label: '未结应收', text: money(b.receivable), sub: `未结应付 ${money(b.payable)}`, cls: 'out' }
]
})
const warnCards = computed(() => {
const w = dash.value.warning || {}
return [
{ label: '低于安全库存', value: w.low || 0, cls: 'warn', to: '/apps/erp/stock/warning' },
{ label: '缺货', value: w.out || 0, cls: 'danger', to: '/apps/erp/stock/warning' },
{ label: '超储', value: w.over || 0, cls: 'muted', to: '/apps/erp/stock/warning' },
{ label: '30 天内到期批次', value: w.expiring || 0, cls: 'warn', to: '/apps/erp/stock/query' }
]
})
function pct(v, total) {
if (!total) return 0
return Math.round((Math.abs(Number(v) || 0) / total) * 1000) / 10
}
const dimLabels = { product: '商品', supplier: '供应商', customer: '客户', month: '月份' }
const dimLabel = (d) => dimLabels[d] || '名称'
function segTag(seg) {
return {
重要价值客户: 'success',
重要发展客户: 'warning',
重要保持客户: '',
重要挽留客户: 'danger',
一般客户: 'info'
}[seg] || 'info'
}
function rangeParams() {
const p = {}
if (dates.value && dates.value.length === 2) {
p.date_from = dates.value[0]
p.date_to = dates.value[1]
}
return p
}
async function loadDashboard() {
dashLoading.value = true
try {
const res = await getErpDashboard({})
if (res.code === 200) {
dash.value = res.data || {}
nextTick(renderTrend)
}
} catch (e) {
ElMessage.error(e.message || '看板加载失败')
} finally {
dashLoading.value = false
}
}
function renderTrend() {
const el = trendRef.value
if (!el) return
if (!trendChart.value) {
trendChart.value = echarts.getInstanceByDom(el) || echarts.init(el)
}
const list = dash.value.trend || []
trendChart.value.setOption({
tooltip: { trigger: 'axis', valueFormatter: (v) => money(v) },
legend: { data: ['采购', '销售'], right: 0, top: 0 },
grid: { left: 8, right: 8, top: 34, bottom: 4, containLabel: true },
xAxis: { type: 'category', data: list.map((t) => t.month) },
yAxis: { type: 'value', axisLabel: { formatter: (v) => (v >= 10000 ? `${v / 10000}万` : v) } },
series: [
{ name: '采购', type: 'bar', data: list.map((t) => t.buy), itemStyle: { color: '#e6a23c', borderRadius: [3, 3, 0, 0] } },
{ name: '销售', type: 'bar', data: list.map((t) => t.sale), itemStyle: { color: '#409eff', borderRadius: [3, 3, 0, 0] } }
]
})
trendChart.value.resize()
}
async function loadSummary(kind) {
sumLoading[kind] = true
try {
const fn = kind === 'purchase' ? getPurchaseSummary : getSalesSummary
const res = await fn({ dimension: sumDim[kind], ...rangeParams() })
if (res.code === 200) {
sumData[kind] = { list: res.data.list || [], summary: res.data.summary || {} }
}
} catch (e) {
ElMessage.error(e.message || '汇总加载失败')
} finally {
sumLoading[kind] = false
}
}
async function loadRank() {
rankLoading.value = true
try {
const res = await getSalesRank({ order: rankOrder.value, top: rankTop.value, ...rangeParams() })
if (res.code === 200) rankList.value = res.data.list || []
} catch (e) {
ElMessage.error(e.message || '排行加载失败')
} finally {
rankLoading.value = false
}
}
async function loadMove(page) {
if (page) movePage.page = page
moveLoading.value = true
try {
const params = { page: movePage.page, pageSize: movePage.pageSize, ...rangeParams() }
if (moveFilter.warehouse_id) params.warehouse_id = moveFilter.warehouse_id
if (moveFilter.keyword) params.keyword = moveFilter.keyword
const res = await getStockMoveReport(params)
if (res.code === 200) {
moveList.value = res.data.list || []
moveSummary.value = res.data.summary || {}
movePage.total = Number(res.data.total) || 0
}
} catch (e) {
ElMessage.error(e.message || '收发存加载失败')
} finally {
moveLoading.value = false
}
}
async function loadProfit() {
profitLoading.value = true
try {
const res = await getProfitReport(rangeParams())
if (res.code === 200) {
profitList.value = res.data.list || []
profitSummary.value = res.data.summary || {}
}
} catch (e) {
ElMessage.error(e.message || '毛利加载失败')
} finally {
profitLoading.value = false
}
}
async function loadRfm() {
rfmLoading.value = true
try {
const res = await getRfmReport({})
if (res.code === 200) {
rfmList.value = res.data.list || []
rfmSegments.value = res.data.segment_counts || {}
rfmMedian.value = res.data.median || {}
}
} catch (e) {
ElMessage.error(e.message || '客户价值加载失败')
} finally {
rfmLoading.value = false
}
}
const loaders = {
dashboard: loadDashboard,
purchase: () => loadSummary('purchase'),
sales: () => loadSummary('sales'),
rank: loadRank,
stockmove: () => loadMove(1),
profit: loadProfit,
rfm: loadRfm
}
function reloadCurrent() {
loaded[tab.value] = false
runTab(tab.value)
}
function runTab(name) {
if (loaded[name]) return
loaded[name] = true
loaders[name]()
}
function onTabChange(name) {
runTab(name)
if (name === 'dashboard') nextTick(renderTrend)
}
function onResize() {
trendChart.value?.resize()
}
onMounted(async () => {
try {
const res = await getWarehouseList({ pageSize: 100 })
if (res.code === 200) warehouses.value = res.data.list || []
} catch (e) {
warehouses.value = []
}
runTab(tab.value)
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
trendChart.value?.dispose()
trendChart.value = null
})
</script>
<style lang="less" scoped>
.erp-report {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.range-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
}
.tab-body {
min-height: 240px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
.kpi-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.kpi-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
b {
font-size: 21px;
line-height: 1.2;
}
.kpi-sub {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.panel-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.panel {
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.panel-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.chart {
height: 260px;
}
}
.warn-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
.warn-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: var(--el-color-primary);
}
b {
font-size: 18px;
}
span {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.todo-line {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
b {
color: var(--el-text-color-primary);
}
}
.dim-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.sum-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.mini-summary {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
b {
color: var(--el-text-color-primary);
}
}
.seg-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.rate-cell {
display: flex;
align-items: center;
gap: 10px;
:deep(.el-progress) {
flex: 1;
}
span {
width: 66px;
text-align: right;
font-size: 12px;
}
}
.neg-tip {
margin-top: 12px;
padding: 10px 12px;
font-size: 13px;
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
border-radius: 6px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.in {
color: var(--el-color-success);
}
.out {
color: var(--el-color-danger);
}
.warn {
color: var(--el-color-warning);
}
.danger {
color: var(--el-color-danger);
}
.muted {
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['sales_order']" title="销售订单" subtitle="接单不占库存,出库时按行带出剩余可发货量" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['sales_out']" title="销售出库单" subtitle="审核生效即扣减库存、按移动加权成本结转毛利并生成应收账款" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['sales_return']" title="销售退货单" subtitle="红字冲减库存与应收,退货数量按原出库单剩余可退量校验" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['other_in']" title="其他入库单" subtitle="盘盈、赠品、期初建账等非采购入库,可按成本价登记" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['other_out']" title="其他出库单" subtitle="领用、报损、样品等非销售出库,按移动加权成本结转减少库存" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
+484
View File
@@ -0,0 +1,484 @@
<template>
<div class="stock-page">
<div class="page-header">
<div>
<h2>库存查询</h2>
<p>结存由出入库流水驱动,可按商品汇总或展开到仓库批次,并可追溯到每一笔流水</p>
</div>
</div>
<el-tabs v-model="tab" class="tabs">
<el-tab-pane label="库存结存" name="stock">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item>
<el-input v-model="stockFilters.keyword" clearable placeholder="搜索商品/编码/规格/批次" :prefix-icon="Search" style="width: 230px" @keyup.enter="loadStock" />
</el-form-item>
<el-form-item label="仓库">
<el-select v-model="stockFilters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="loadStock">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="stockFilters.safety_state" clearable placeholder="全部" style="width: 120px" @change="loadStock">
<el-option label="低于安全库存" value="low" />
<el-option label="超储" value="over" />
<el-option label="正常" value="normal" />
</el-select>
</el-form-item>
<el-form-item label="视角">
<el-radio-group v-model="stockGroupBy" @change="loadStock">
<el-radio-button value="product">按商品</el-radio-button>
<el-radio-button value="batch">按仓库批次</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadStock">查询</el-button>
<el-button :icon="Refresh" @click="resetStock">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="stockLoading" class="table-container">
<div class="sum-bar">
<span>结存金额合计:<b>{{ money(stockSum) }}</b></span>
<span class="text-muted">按移动加权成本计价</span>
</div>
<el-table :data="stockRows" stripe border size="small">
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="category_name" label="分类" width="110" show-overflow-tooltip />
<el-table-column v-if="stockGroupBy === 'batch'" prop="warehouse_name" label="仓库" width="120" show-overflow-tooltip />
<el-table-column v-if="stockGroupBy === 'batch'" label="批次" width="120">
<template #default="{ row }">{{ row.batch_no || '-' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="数量" width="100" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column label="加权成本" width="110" align="right">
<template #default="{ row }">{{ money(row.cost_price) }}</template>
</el-table-column>
<el-table-column label="库存金额" width="120" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
<el-table-column label="安全/最高" width="120" align="right">
<template #default="{ row }">
<span class="text-muted">{{ qty(row.safety_qty) }} / {{ qty(row.max_qty) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="stateTag(row.safety_state)" size="small">{{ stateText(row.safety_state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="traceLedger(row)">流水</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="stockPage.page"
v-model:page-size="stockPage.pageSize"
:total="stockPage.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="loadStock"
@current-change="loadStock"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="出入库流水" name="ledger">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="仓库">
<el-select v-model="ledgerFilters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="loadLedger">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item label="单据类型">
<el-select v-model="ledgerFilters.doc_type" clearable placeholder="全部" style="width: 140px" @change="loadLedger">
<el-option v-for="t in docTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="ledgerFilters.doc_no" clearable placeholder="搜索单据编号" style="width: 180px" @keyup.enter="loadLedger" />
</el-form-item>
<el-form-item>
<el-input v-model="ledgerProductText" clearable placeholder="商品ID" style="width: 110px" @keyup.enter="applyLedgerProduct" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="ledgerDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" style="width: 240px" @change="loadLedger" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadLedger">查询</el-button>
<el-button :icon="Refresh" @click="resetLedger">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="ledgerLoading" class="table-container">
<el-table :data="ledgerRows" stripe border size="small">
<el-table-column label="发生时间" width="160" align="center">
<template #default="{ row }">{{ dateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="doc_no" label="单据编号" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" width="116" align="center">
<template #default="{ row }">{{ docTypeLabel(row.doc_type) }}</template>
</el-table-column>
<el-table-column prop="product_code" label="编码" width="110" />
<el-table-column prop="product_name" label="商品" min-width="150" show-overflow-tooltip />
<el-table-column prop="warehouse_name" label="仓库" width="110" show-overflow-tooltip />
<el-table-column label="批次" width="100">
<template #default="{ row }">{{ row.batch_no || '-' }}</template>
</el-table-column>
<el-table-column label="入/出" width="96" align="right">
<template #default="{ row }">
<span :class="row.direction === 1 ? 'in' : 'out'">
{{ row.direction === 1 ? '+' : '-' }}{{ qty(Math.abs(row.change_qty)) }}
</span>
</template>
</el-table-column>
<el-table-column label="结存" width="96" align="right">
<template #default="{ row }">{{ qty(row.after_qty) }}</template>
</el-table-column>
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">{{ money(row.cost_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="ledgerPage.page"
v-model:page-size="ledgerPage.pageSize"
:total="ledgerPage.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="loadLedger"
@current-change="loadLedger"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="批次与效期" name="batch">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="仓库">
<el-select v-model="batchFilters.warehouse_id" clearable placeholder="全部" style="width: 140px" @change="loadBatch">
<el-option v-for="w in warehouses" :key="w.id" :label="w.warehouse_name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="batchFilters.keyword" clearable placeholder="搜索商品/批次" style="width: 200px" @keyup.enter="loadBatch" />
</el-form-item>
<el-form-item label="效期">
<el-select v-model="batchFilters.expire_state" clearable placeholder="全部" style="width: 130px" @change="loadBatch">
<el-option label="已过期" value="expired" />
<el-option label="临近到期" value="expiring" />
<el-option label="正常" value="normal" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadBatch">查询</el-button>
<el-button :icon="Refresh" @click="resetBatch">重置</el-button>
</el-form-item>
</el-form>
</div>
<div v-loading="batchLoading" class="table-container">
<el-table :data="batchRows" stripe border size="small">
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="warehouse_name" label="仓库" width="120" show-overflow-tooltip />
<el-table-column prop="batch_no" label="批次" width="130" />
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="数量" width="100" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column label="成本" width="100" align="right">
<template #default="{ row }">{{ money(row.cost_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
<el-table-column label="到期日" width="110" align="center">
<template #default="{ row }">{{ row.expire_date ? dateOnly(row.expire_date) : '-' }}</template>
</el-table-column>
<el-table-column label="剩余" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.expired" type="danger" size="small">已过期</el-tag>
<el-tag v-else-if="row.expire_in_days !== null && row.expire_in_days <= 30" type="warning" size="small">{{ row.expire_in_days }} 天</el-tag>
<span v-else-if="row.expire_in_days !== null" class="text-muted">{{ row.expire_in_days }} 天</span>
<span v-else class="text-muted">未登记</span>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="batchPage.page"
v-model:page-size="batchPage.pageSize"
:total="batchPage.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="loadBatch"
@current-change="loadBatch"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getBatchList, getStockLedger, getStockList } from '@/api/erpStock'
import { getWarehouseList } from '@/api/erpWarehouse'
import { DOC_TYPE_ORDER, dateOnly, docTypeLabel, money, qty } from '../document/dict'
const tab = ref('stock')
const warehouses = ref([])
const docTypeOptions = DOC_TYPE_ORDER.map((t) => ({ value: t, label: docTypeLabel(t) }))
const stockGroupBy = ref('product')
const stockRows = ref([])
const stockSum = ref(0)
const stockLoading = ref(false)
const stockFilters = reactive({ keyword: '', warehouse_id: '', safety_state: '' })
const stockPage = reactive({ page: 1, pageSize: 20, total: 0 })
const ledgerRows = ref([])
const ledgerLoading = ref(false)
const ledgerFilters = reactive({ warehouse_id: '', doc_type: '', doc_no: '', product_id: '' })
const ledgerProductText = ref('')
const ledgerDates = ref([])
const ledgerPage = reactive({ page: 1, pageSize: 20, total: 0 })
const batchRows = ref([])
const batchLoading = ref(false)
const batchFilters = reactive({ warehouse_id: '', keyword: '', expire_state: '' })
const batchPage = reactive({ page: 1, pageSize: 20, total: 0 })
const stateMap = { low: { text: '偏低', tag: 'danger' }, over: { text: '超储', tag: 'warning' }, normal: { text: '正常', tag: 'success' } }
const stateText = (s) => stateMap[s]?.text || '正常'
const stateTag = (s) => stateMap[s]?.tag || 'success'
function dateTime(v) {
if (!v) return '-'
const d = new Date(v)
if (isNaN(d.getTime())) return String(v).replace('T', ' ').slice(0, 19)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
async function loadStock() {
stockLoading.value = true
try {
const params = {
page: stockPage.page,
pageSize: stockPage.pageSize,
group_by: stockGroupBy.value
}
if (stockFilters.keyword) params.keyword = stockFilters.keyword
if (stockFilters.warehouse_id) params.warehouse_id = stockFilters.warehouse_id
if (stockFilters.safety_state) params.safety_state = stockFilters.safety_state
const res = await getStockList(params)
if (res.code === 200) {
stockRows.value = res.data.list || []
stockPage.total = Number(res.data.total) || 0
stockSum.value = Number(res.data.sum_amount) || 0
} else {
ElMessage.error(res.msg || '库存加载失败')
}
} catch (e) {
ElMessage.error(e.message || '库存加载失败')
} finally {
stockLoading.value = false
}
}
function resetStock() {
stockFilters.keyword = ''
stockFilters.warehouse_id = ''
stockFilters.safety_state = ''
stockPage.page = 1
loadStock()
}
/** 从结存行跳到流水页签,只保留同一商品+仓库的记录 */
function traceLedger(row) {
ledgerFilters.product_id = row.product_id
ledgerProductText.value = String(row.product_id)
ledgerFilters.warehouse_id = stockGroupBy.value === 'batch' ? row.warehouse_id : ''
ledgerFilters.doc_no = ''
ledgerDates.value = []
ledgerPage.page = 1
tab.value = 'ledger'
loadLedger()
}
function applyLedgerProduct() {
const n = Number(ledgerProductText.value)
ledgerFilters.product_id = n > 0 ? n : ''
loadLedger()
}
async function loadLedger() {
ledgerLoading.value = true
try {
const params = { page: ledgerPage.page, pageSize: ledgerPage.pageSize }
if (ledgerFilters.warehouse_id) params.warehouse_id = ledgerFilters.warehouse_id
if (ledgerFilters.doc_type) params.doc_type = ledgerFilters.doc_type
if (ledgerFilters.product_id) params.product_id = ledgerFilters.product_id
if (ledgerFilters.doc_no) params.doc_no = ledgerFilters.doc_no
if (ledgerDates.value && ledgerDates.value.length === 2) {
params.date_from = ledgerDates.value[0]
params.date_to = ledgerDates.value[1]
}
const res = await getStockLedger(params)
if (res.code === 200) {
ledgerRows.value = res.data.list || []
ledgerPage.total = Number(res.data.total) || 0
} else {
ElMessage.error(res.msg || '流水加载失败')
}
} catch (e) {
ElMessage.error(e.message || '流水加载失败')
} finally {
ledgerLoading.value = false
}
}
function resetLedger() {
ledgerFilters.warehouse_id = ''
ledgerFilters.doc_type = ''
ledgerFilters.doc_no = ''
ledgerFilters.product_id = ''
ledgerProductText.value = ''
ledgerDates.value = []
ledgerPage.page = 1
loadLedger()
}
async function loadBatch() {
batchLoading.value = true
try {
const params = { page: batchPage.page, pageSize: batchPage.pageSize }
if (batchFilters.warehouse_id) params.warehouse_id = batchFilters.warehouse_id
if (batchFilters.keyword) params.keyword = batchFilters.keyword
if (batchFilters.expire_state) params.expire_state = batchFilters.expire_state
const res = await getBatchList(params)
if (res.code === 200) {
batchRows.value = res.data.list || []
batchPage.total = Number(res.data.total) || 0
} else {
ElMessage.error(res.msg || '批次加载失败')
}
} catch (e) {
ElMessage.error(e.message || '批次加载失败')
} finally {
batchLoading.value = false
}
}
function resetBatch() {
batchFilters.warehouse_id = ''
batchFilters.keyword = ''
batchFilters.expire_state = ''
batchPage.page = 1
loadBatch()
}
watch(tab, (v) => {
if (v === 'ledger' && !ledgerRows.value.length) loadLedger()
if (v === 'batch' && !batchRows.value.length) loadBatch()
})
onMounted(async () => {
try {
const res = await getWarehouseList({ pageSize: 100 })
if (res.code === 200) warehouses.value = res.data.list || []
} catch (e) {
warehouses.value = []
}
loadStock()
})
</script>
<style lang="less" scoped>
.stock-page {
padding: 20px;
}
.page-header {
margin-bottom: 16px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.sum-bar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
font-size: 13px;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.in {
color: var(--el-color-success);
font-weight: 600;
}
.out {
color: var(--el-color-danger);
font-weight: 600;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['stock_take']" title="库存盘点" subtitle="选择仓库载入账面数,只改实盘数;生效时差额自动生成盘盈盘亏流水" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<DocListPage :types="['transfer']" title="库存调拨" subtitle="两仓之间按加权成本平移库存,不产生损益与往来账" />
</template>
<script setup>
import DocListPage from '../document/DocListPage.vue'
</script>
@@ -0,0 +1,368 @@
<template>
<div class="warning-page">
<div class="page-header">
<div>
<h2>库存预警与补货</h2>
<p>安全库存、积压、缺货与批次效期集中提示,补货建议可一键转采购订单</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadAll">刷新</el-button>
</div>
</div>
<div class="kpi-row">
<div v-for="k in kpis" :key="k.key" class="kpi" :class="{ active: tab === k.key }" @click="tab = k.key">
<span class="kpi-label">{{ k.label }}</span>
<b :class="k.cls">{{ counts[k.key] || 0 }}</b>
</div>
</div>
<el-tabs v-model="tab" class="tabs">
<el-tab-pane label="低于安全库存" name="low">
<el-table v-loading="warnLoading" :data="warn.low" stripe border size="small">
<template #empty>
<el-empty v-if="!warnLoading" description="没有低于安全库存的商品" />
</template>
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="category_name" label="分类" width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="当前库存" width="110" align="right">
<template #default="{ row }">
<span class="danger">{{ qty(row.quantity) }}</span>
</template>
</el-table-column>
<el-table-column label="安全库存" width="110" align="right">
<template #default="{ row }">{{ qty(row.safety_qty) }}</template>
</el-table-column>
<el-table-column label="采购在途" width="110" align="right">
<template #default="{ row }">
<span :class="{ 'text-muted': !row.in_transit }">{{ qty(row.in_transit) }}</span>
</template>
</el-table-column>
<el-table-column label="建议补货" width="110" align="right">
<template #default="{ row }">
<span class="primary">{{ qty(row.suggest_qty) }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="缺货" name="out">
<el-table v-loading="warnLoading" :data="warn.out" stripe border size="small">
<template #empty>
<el-empty v-if="!warnLoading" description="暂无零库存商品" />
</template>
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="安全库存" width="110" align="right">
<template #default="{ row }">{{ qty(row.safety_qty) }}</template>
</el-table-column>
<el-table-column label="采购在途" width="110" align="right">
<template #default="{ row }">{{ qty(row.in_transit) }}</template>
</el-table-column>
<el-table-column label="建议补货" width="110" align="right">
<template #default="{ row }">
<span class="primary">{{ qty(row.suggest_qty) }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="库存积压" name="over">
<el-table v-loading="warnLoading" :data="warn.over" stripe border size="small">
<template #empty>
<el-empty v-if="!warnLoading" description="没有超过最高库存的商品" />
</template>
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="120" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="当前库存" width="110" align="right">
<template #default="{ row }">
<span class="warning">{{ qty(row.quantity) }}</span>
</template>
</el-table-column>
<el-table-column label="最高库存" width="110" align="right">
<template #default="{ row }">{{ qty(row.max_qty) }}</template>
</el-table-column>
<el-table-column label="建议处理" width="120" align="right">
<template #default="{ row }">促销或退供 {{ qty(row.suggest_qty) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="效期临近" name="expiring">
<el-table v-loading="warnLoading" :data="warn.expiring" stripe border size="small">
<template #empty>
<el-empty v-if="!warnLoading" description="30 天内没有到期或过期的批次" />
</template>
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="160" show-overflow-tooltip />
<el-table-column prop="warehouse_name" label="仓库" width="120" show-overflow-tooltip />
<el-table-column prop="batch_no" label="批次" width="130" />
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="库存" width="100" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column label="到期日" width="110" align="center">
<template #default="{ row }">{{ row.expire_date || '-' }}</template>
</el-table-column>
<el-table-column label="剩余" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.expire_in_days < 0 ? 'danger' : 'warning'" size="small">
{{ row.expire_in_days < 0 ? `过期 ${-row.expire_in_days} 天` : `${row.expire_in_days} 天` }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="提示" min-width="150">
<template #default="{ row }">
<span class="text-muted">{{ row.type_label }},建议先出或报损</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="补货建议" name="replenish">
<div class="toolbar">
<el-button type="primary" :icon="ShoppingCart" :disabled="!replenish.length" @click="toPurchaseOrder">
生成采购订单
</el-button>
<span class="text-muted">
建议合计 {{ money(replenishAmount) }},勾选行仅对补货建议生效(目标量取「月销 + 安全库存」与最高库存的较大值,已扣除在途)
</span>
</div>
<el-table
v-loading="repLoading"
:data="replenish"
stripe
border
size="small"
@selection-change="onRepSelect"
>
<el-table-column type="selection" width="46" />
<el-table-column prop="product_code" label="编码" width="120" />
<el-table-column prop="product_name" label="商品" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="64" align="center" />
<el-table-column label="现有" width="90" align="right">
<template #default="{ row }">{{ qty(row.quantity) }}</template>
</el-table-column>
<el-table-column label="安全" width="90" align="right">
<template #default="{ row }">{{ qty(row.safety_qty) }}</template>
</el-table-column>
<el-table-column label="在途" width="90" align="right">
<template #default="{ row }">
<span :class="{ 'text-muted': !row.in_transit }">{{ qty(row.in_transit) }}</span>
</template>
</el-table-column>
<el-table-column label="30天销量" width="100" align="right">
<template #default="{ row }">{{ qty(row.sold_30) }}</template>
</el-table-column>
<el-table-column label="建议数量" width="100" align="right">
<template #default="{ row }">
<span class="primary">{{ qty(row.suggest_qty) }}</span>
</template>
</el-table-column>
<el-table-column label="采购单价" width="100" align="right">
<template #default="{ row }">{{ money(row.unit_price) }}</template>
</el-table-column>
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }">{{ money(row.amount) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<DocEditor
v-model:visible="editorVisible"
doc-type="purchase_order"
:preset-items="presetItems"
@success="loadAll"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, ShoppingCart } from '@element-plus/icons-vue'
import DocEditor from '../document/DocEditor.vue'
import { getReplenishList, getStockWarning } from '@/api/erpStock'
import { money, qty } from '../document/dict'
const tab = ref('low')
const warn = ref({ low: [], over: [], out: [], expiring: [] })
const counts = ref({})
const warnLoading = ref(false)
const replenish = ref([])
const repLoading = ref(false)
const repSelected = ref([])
const editorVisible = ref(false)
const presetItems = ref([])
const kpis = [
{ key: 'low', label: '低于安全库存', cls: 'danger' },
{ key: 'out', label: '缺货', cls: 'danger' },
{ key: 'over', label: '库存积压', cls: 'warning' },
{ key: 'expiring', label: '效期临近', cls: 'warning' },
{ key: 'replenish', label: '待补货', cls: 'primary' }
]
const replenishAmount = computed(() =>
(repSelected.value.length ? repSelected.value : replenish.value).reduce((s, r) => s + (Number(r.amount) || 0), 0)
)
async function loadWarning() {
warnLoading.value = true
try {
const res = await getStockWarning({})
if (res.code === 200) {
warn.value = {
low: res.data.low || [],
over: res.data.over || [],
out: res.data.out || [],
expiring: res.data.expiring || []
}
counts.value = { ...(res.data.counts || {}), replenish: counts.value.replenish || 0 }
} else {
ElMessage.error(res.msg || '预警加载失败')
}
} catch (e) {
ElMessage.error(e.message || '预警加载失败')
} finally {
warnLoading.value = false
}
}
async function loadReplenish() {
repLoading.value = true
try {
const res = await getReplenishList({})
if (res.code === 200) {
replenish.value = res.data.list || []
counts.value = { ...counts.value, replenish: Number(res.data.total) || 0 }
} else {
ElMessage.error(res.msg || '补货建议加载失败')
}
} catch (e) {
ElMessage.error(e.message || '补货建议加载失败')
} finally {
repLoading.value = false
}
}
function loadAll() {
loadWarning()
loadReplenish()
}
function onRepSelect(rows) {
repSelected.value = rows
}
function toPurchaseOrder() {
const rows = repSelected.value.length ? repSelected.value : replenish.value
if (!rows.length) {
ElMessage.warning('暂无可转的补货建议')
return
}
presetItems.value = rows.map((r) => ({
product_id: r.product_id,
product_name: r.product_name,
spec: r.spec,
unit: r.unit,
quantity: r.suggest_qty,
unit_price: r.unit_price
}))
editorVisible.value = true
}
onMounted(loadAll)
</script>
<style lang="less" scoped>
.warning-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.kpi-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.kpi {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-bg-color);
cursor: pointer;
&.active {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7) inset;
}
.kpi-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
b {
font-size: 20px;
}
}
.danger {
color: var(--el-color-danger);
}
.warning {
color: var(--el-color-warning);
}
.primary {
color: var(--el-color-primary);
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
</style>
@@ -121,6 +121,22 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="账期(天)">
<el-input-number
v-model="form.payment_days"
:min="0"
:precision="0"
:step="10"
controls-position="right"
placeholder="0 表示现结"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="credit-tip">账期决定应付单的到期日与逾期起算。</div>
</el-col>
<el-col :span="24">
<el-form-item label="经营范围" prop="business_scope">
<el-input
@@ -283,6 +299,7 @@ const defaultForm = () => ({
address: '',
status: '1',
is_draft: 0,
payment_days: 0,
remark: '',
invoice_title: '',
tax_number: '',
@@ -426,6 +443,12 @@ function handleSaveDraft() {
padding: 0 20px;
}
.credit-tip {
margin: -8px 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
display: flex;
justify-content: space-between;
+489
View File
@@ -0,0 +1,489 @@
<template>
<div class="erp-system">
<div class="page-header">
<div>
<h2>系统参数</h2>
<p>进销存的取价口径、库存开关、单号规则与单据审批流绑定</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadAll">刷新</el-button>
<el-button type="primary" :icon="Check" :loading="saving" @click="doSave">保存参数</el-button>
</div>
</div>
<el-row :gutter="16">
<el-col :span="12" :xs="24">
<div v-loading="loading" class="panel">
<div class="panel-title">交易口径</div>
<div class="field">
<div class="field-label">
销售单据默认售价
<el-tooltip content="决定单据编辑器取价用零售价、批发价还是会员价;最低价始终作为折扣下限校验" placement="top">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<el-radio-group v-model="form.sales_price_type">
<el-radio-button value="sale">零售价</el-radio-button>
<el-radio-button value="wholesale">批发价</el-radio-button>
<el-radio-button value="member">会员价</el-radio-button>
</el-radio-group>
</div>
<div class="field">
<div class="field-label">
允许负库存出库
<el-tooltip content="关闭时出库单据在库存不足会被直接拒绝;开启后允许先出库后补入库,结存可能为负" placement="top">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<el-switch v-model="form.negative_stock_allowed" active-text="允许" inactive-text="禁止" />
</div>
<div class="field">
<div class="field-label">自动生成单据号</div>
<el-switch v-model="form.auto_generate_doc_no" active-text="自动" inactive-text="手工" />
</div>
<div class="field">
<div class="field-label">库存预警阈值</div>
<el-input-number v-model="form.stock_warning_threshold" :min="0" :max="99999" controls-position="right" style="width: 160px" />
<span class="field-tip">商品未单独设置安全库存时按该值预警</span>
</div>
</div>
</el-col>
<el-col :span="12" :xs="24">
<div v-loading="loading" class="panel">
<div class="panel-title">单据号前缀</div>
<div class="prefix-grid">
<div v-for="p in prefixFields" :key="p.key" class="field inline">
<span class="field-label">{{ p.label }}</span>
<el-input v-model="form[p.key]" maxlength="8" :placeholder="p.hint" style="width: 120px" />
</div>
</div>
<div class="panel-note">
完整单号为「前缀 + 年月日 + 流水号」。每类单据独立前缀,留空则使用括号中的默认值;
前缀只影响单号展示,不参与业务判断。
</div>
<div class="panel-title sub">快捷入口</div>
<div class="link-row">
<el-button v-for="l in links" :key="l.to" text bg :icon="l.icon" @click="router.push(l.to)">{{ l.label }}</el-button>
</div>
</div>
</el-col>
</el-row>
<div class="panel">
<div class="panel-title">
单据审批流
<el-button size="small" text type="primary" :icon="Refresh" @click="loadFlows">重新检测</el-button>
</div>
<div class="panel-note">
提交单据时优先取该类型专属流程(biz_type = erp_单据类型),没有则回落到模块级流程(biz_type = erp);
两者都没有时无法提交,只能由有权限的账号直接审核生效。
</div>
<el-table v-loading="flowLoading" :data="flowRows" border size="small">
<el-table-column label="单据类型" min-width="140">
<template #default="{ row }">{{ docTypeLabel(row.doc_type) }}</template>
</el-table-column>
<el-table-column label="biz_type" width="190">
<template #default="{ row }"><code>erp_{{ row.doc_type }}</code></template>
</el-table-column>
<el-table-column label="匹配级别" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.biz_type === 'erp_' + row.doc_type" type="success" size="small">专属流程</el-tag>
<el-tag v-else-if="row.biz_type === 'erp'" type="warning" size="small">模块级流程</el-tag>
<el-tag v-else type="danger" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="可用流程" min-width="260">
<template #default="{ row }">
<span v-if="!row.flows.length" class="text-muted">暂无可用流程,请先到审批流配置新增</span>
<el-tag v-for="f in row.flows" :key="f.flow_id" size="small" effect="plain" class="flow-tag">
{{ f.flow_name }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="goList(row.doc_type)">查看单据</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel">
<div class="panel-title">上线检查清单</div>
<div class="check-grid">
<div v-for="c in checks" :key="c.label" class="check-item" :class="{ done: c.done }">
<el-icon class="check-icon"><component :is="c.done ? Select : Minus" /></el-icon>
<div class="check-text">
<b>{{ c.label }}</b>
<span>{{ c.tip }}</span>
</div>
<el-button text size="small" @click="router.push(c.to)">前往</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Check,
Goods,
House,
Minus,
QuestionFilled,
Refresh,
Select,
Setting,
Share
} from '@element-plus/icons-vue'
import { getNormalSettings, saveNormalSettings } from '@/api/normalSetting'
import { getDocumentFlows } from '@/api/erpDocument'
import { getProductList } from '@/api/erpProduct'
import { getWarehouseList } from '@/api/erpWarehouse'
import { getCustomerList } from '@/api/customer'
import { getSupplierList } from '@/api/supplier'
import { DOC_TYPE_ORDER, docTypeLabel } from '../document/dict'
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const flowLoading = ref(false)
const flowRows = ref([])
const counts = reactive({ product: 0, warehouse: 0, partner: 0 })
// 单据前缀:一型一前缀,与后端 docNoPrefix 的缺省表一致(留空即回落到这里)
const DOC_PREFIX_DEFAULTS = {
purchase_order: 'CGDD',
purchase_in: 'CGRK',
purchase_return: 'CGTH',
sales_order: 'XSDD',
sales_out: 'XSCK',
sales_return: 'XSTH',
other_in: 'QTRK',
other_out: 'QTCK',
transfer: 'DB',
stock_take: 'PD'
}
const form = reactive({
sales_price_type: 'sale',
negative_stock_allowed: false,
auto_generate_doc_no: true,
stock_warning_threshold: 10
})
const prefixFields = DOC_TYPE_ORDER.map((t) => ({
key: `doc_prefix_${t}`,
label: docTypeLabel(t),
hint: DOC_PREFIX_DEFAULTS[t]
}))
prefixFields.forEach((p) => {
form[p.key] = ''
})
const links = [
{ label: '基础资料设置', to: '/apps/erp/ProgramManagement', icon: Setting },
{ label: '商品档案', to: '/apps/erp/basic/product', icon: Goods },
{ label: '仓库档案', to: '/apps/erp/basic/warehouse', icon: House },
{ label: '审批流配置', to: '/apps/oa/approvalcenter/settings', icon: Share }
]
const checks = computed(() => [
{
label: '商品与分类',
tip: counts.product ? `已建档 ${counts.product} 个商品` : '还没有商品,单据无法选货',
done: !!counts.product,
to: '/apps/erp/basic/product'
},
{
label: '仓库',
tip: counts.warehouse ? `已建 ${counts.warehouse} 个仓库` : '至少需要一个仓库作为出入库目的地',
done: !!counts.warehouse,
to: '/apps/erp/basic/warehouse'
},
{
label: '客户与供应商',
tip: counts.partner ? `共 ${counts.partner} 家往来单位` : '购销单据需要往来单位,含信用额度与账期',
done: !!counts.partner,
to: '/apps/erp/customers'
},
{
label: '单据审批流',
tip: flowRows.value.some((r) => r.flows.length) ? '已可用,提交单据会走审批' : '尚未配置,单据只能直接审核',
done: flowRows.value.some((r) => r.flows.length),
to: '/apps/oa/approvalcenter/settings'
}
])
async function loadSettings() {
loading.value = true
try {
const res = await getNormalSettings()
if (res.code !== 200) {
ElMessage.error(res.msg || '参数加载失败')
return
}
const map = {}
;(res.data || []).forEach((g) => (g.items || []).forEach((it) => { map[it.config_key] = it.config_value }))
const boolOf = (k, def) => (map[k] === undefined ? def : String(map[k]) === '1')
form.sales_price_type = map.sales_price_type || 'sale'
form.negative_stock_allowed = boolOf('negative_stock_allowed', false)
form.auto_generate_doc_no = boolOf('auto_generate_doc_no', true)
form.stock_warning_threshold = Number(map.stock_warning_threshold ?? 10)
prefixFields.forEach((p) => { if (map[p.key]) form[p.key] = map[p.key] })
} catch (e) {
ElMessage.error(e.message || '参数加载失败')
} finally {
loading.value = false
}
}
async function doSave() {
const bad = prefixFields.find((p) => /[^0-9A-Za-z\u4e00-\u9fa5_-]/.test(form[p.key] || ''))
if (bad) {
ElMessage.warning(`${bad.label}前缀只能使用字母、数字、汉字、下划线或短横线`)
return
}
saving.value = true
try {
const payload = {
sales_price_type: form.sales_price_type,
negative_stock_allowed: form.negative_stock_allowed ? '1' : '0',
auto_generate_doc_no: form.auto_generate_doc_no ? '1' : '0',
stock_warning_threshold: String(form.stock_warning_threshold ?? 0)
}
prefixFields.forEach((p) => { payload[p.key] = form[p.key] || '' })
const res = await saveNormalSettings(payload)
if (res.code === 200) {
ElMessage.success('参数已保存,单据取价与库存校验立即生效')
loadCounts()
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function loadFlows() {
flowLoading.value = true
try {
flowRows.value = await Promise.all(
DOC_TYPE_ORDER.map(async (t) => {
try {
const res = await getDocumentFlows(t)
return { doc_type: t, flows: res.code === 200 ? res.data.list || [] : [], biz_type: res.data?.biz_type || '' }
} catch (e) {
return { doc_type: t, flows: [], biz_type: '' }
}
})
)
} finally {
flowLoading.value = false
}
}
async function totalOf(fn, params) {
try {
const res = await fn(params)
return res.code === 200 ? Number(res.data?.total) || 0 : 0
} catch (e) {
return 0
}
}
async function loadCounts() {
const [p, w, c, s] = await Promise.all([
totalOf(getProductList, { page: 1, pageSize: 1 }),
totalOf(getWarehouseList, { page: 1, pageSize: 1 }),
totalOf(getCustomerList, { page: 1, pageSize: 1 }),
totalOf(getSupplierList, { page: 1, pageSize: 1 })
])
counts.product = p
counts.warehouse = w
counts.partner = c + s
}
function goList(docType) {
const group = docType.startsWith('purchase') ? 'purchase' : docType.startsWith('sales') ? 'sales' : 'stock'
const file = {
purchase_order: 'order', purchase_in: 'inbound', purchase_return: 'return',
sales_order: 'order', sales_out: 'outbound', sales_return: 'return',
other_in: 'inbound', other_out: 'outbound', transfer: 'transfer', stock_take: 'taking'
}[docType]
router.push(`/apps/erp/${group}/${file}`)
}
async function loadAll() {
await Promise.all([loadSettings(), loadFlows(), loadCounts()])
}
onMounted(loadAll)
</script>
<style lang="less" scoped>
.erp-system {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.panel {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 600;
margin-bottom: 14px;
&.sub {
margin-top: 18px;
}
}
.panel-note {
font-size: 12px;
line-height: 1.7;
color: var(--el-text-color-secondary);
margin: 6px 0 12px;
}
.field {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
&.inline {
margin-bottom: 10px;
}
.field-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
min-width: 120px;
}
.field-tip {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.prefix-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 4px 16px;
}
.link-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.flow-tag {
margin: 2px 6px 2px 0;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.check-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
.check-icon {
font-size: 16px;
color: var(--el-text-color-placeholder);
}
.check-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
b {
font-size: 13px;
}
span {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
&.done {
border-color: var(--el-color-success-light-5);
background: var(--el-color-success-light-9);
.check-icon {
color: var(--el-color-success);
}
}
}
code {
font-family: var(--el-font-family-mono, monospace);
font-size: 12px;
color: var(--el-color-primary);
}
</style>
@@ -51,7 +51,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { getCenterInstanceDetail } from "@/api/approval";
import { getApprovalInstanceDetail } from "@/api/approval";
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -91,7 +91,7 @@ const loadDetail = async (id) => {
loading.value = true;
detail.value = null;
try {
const res = await getCenterInstanceDetail(id);
const res = await getApprovalInstanceDetail(id);
detail.value = res?.data?.data ?? res?.data ?? null;
} catch (error) {
ElMessage.error(error?.message || "加载审批进度失败");