更新
This commit is contained in:
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || "加载审批进度失败");
|
||||
|
||||
Reference in new issue
Block a user