1304 lines
34 KiB
Vue
1304 lines
34 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view v-if="blocked" class="state-box">
|
||
<text class="state-text">{{ blocked }}</text>
|
||
<view class="state-back" @tap="goBack">
|
||
<text>返回单据详情</text>
|
||
</view>
|
||
</view>
|
||
|
||
<block v-else>
|
||
<!-- 表头 -->
|
||
<view class="card">
|
||
<view class="card-head">
|
||
<text class="card-title">{{ typeLabel }}</text>
|
||
<text v-if="form.id" class="card-no">{{ form.docNo }}</text>
|
||
<text v-else class="card-tip">保存后生成单号</text>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">业务日期 <text class="required">*</text></text>
|
||
<picker mode="date" :value="form.bizDate" @change="onBizDateChange">
|
||
<view class="field-value">
|
||
<text>{{ form.bizDate || '选择日期' }}</text>
|
||
<FaIcon name="calendar-days" color="#94a3b8" :size="16" />
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view v-for="row in headRows" :key="row.key" class="field">
|
||
<text class="field-label">
|
||
{{ row.label }}<text v-if="row.required" class="required"> *</text>
|
||
</text>
|
||
<view class="field-value tap" @tap="openPicker(row.key)">
|
||
<text :class="{ muted: !row.text }">{{ row.text || row.placeholder }}</text>
|
||
<FaIcon name="chevron-right" color="#cbd5e1" :size="15" />
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="sourceTip" class="inline-tip">
|
||
<text>{{ sourceTip }}</text>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">备注</text>
|
||
<u-input
|
||
v-model="form.remark"
|
||
placeholder="选填"
|
||
:maxlength="200"
|
||
border="none"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 明细 -->
|
||
<view class="card">
|
||
<view class="card-head">
|
||
<text class="card-title">商品明细({{ items.length }} 行)</text>
|
||
<text class="card-total">¥{{ moneyText(totalAmount) }}</text>
|
||
</view>
|
||
|
||
<view v-if="!items.length" class="items-empty">
|
||
<text>还没有明细行</text>
|
||
</view>
|
||
<view
|
||
v-for="(item, index) in items"
|
||
:key="index"
|
||
class="item-row"
|
||
@tap="openItem(index)"
|
||
>
|
||
<view class="item-main">
|
||
<view class="item-line1">
|
||
<text class="item-name">{{ item.product_name || '未选商品' }}</text>
|
||
<text class="item-amount">¥{{ moneyText(lineAmount(item)) }}</text>
|
||
</view>
|
||
<view class="item-line2">
|
||
<text>{{ qtyText(item.quantity) }} {{ item.unit || '' }} × ¥{{ moneyText(item.unit_price) }}</text>
|
||
<text v-if="item.batch_no">批次 {{ item.batch_no }}</text>
|
||
<text v-if="isStockTake && item.book_qty">账面 {{ qtyText(item.book_qty) }} · 差异 {{ signed(qtyText(diffOf(item))) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="item-del" @tap.stop="removeItem(index)">
|
||
<FaIcon name="trash-can" color="#f56c6c" :size="17" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="item-tools">
|
||
<view class="tool" @tap="openItem(-1)">
|
||
<FaIcon name="plus" color="#2563eb" :size="15" />
|
||
<text>添加明细</text>
|
||
</view>
|
||
<view v-if="isStockTake && form.warehouseId" class="tool" @tap="loadTakeSheet">
|
||
<FaIcon name="clipboard-list" color="#2563eb" :size="15" />
|
||
<text>载入账面库存</text>
|
||
</view>
|
||
<view v-if="form.sourceDocId" class="tool" @tap="pullFromSource">
|
||
<FaIcon name="rotate" color="#2563eb" :size="15" />
|
||
<text>按剩余量带出</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 金额 -->
|
||
<view class="card">
|
||
<view class="line">
|
||
<text class="line-label">数量合计</text>
|
||
<text class="line-value">{{ qtyText(totalQty) }}</text>
|
||
</view>
|
||
<view class="line">
|
||
<text class="line-label">单据金额</text>
|
||
<text class="line-value">¥{{ moneyText(totalAmount) }}</text>
|
||
</view>
|
||
<view class="line">
|
||
<text class="line-label">优惠金额</text>
|
||
<view class="line-value input-cell">
|
||
<u-input
|
||
v-model="form.discountAmount"
|
||
type="digit"
|
||
placeholder="0.00"
|
||
border="none"
|
||
inputAlign="right"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="line highlight">
|
||
<text class="line-label">实付/实收</text>
|
||
<text class="line-value strong">¥{{ moneyText(actualAmount) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="footer-space"></view>
|
||
|
||
<view class="footer">
|
||
<u-button
|
||
type="primary"
|
||
plain
|
||
color="#2563eb"
|
||
shape="circle"
|
||
:loading="saving"
|
||
text="保存草稿"
|
||
@click="onSave(false)"
|
||
/>
|
||
<u-button
|
||
type="primary"
|
||
color="#2563eb"
|
||
shape="circle"
|
||
:loading="saving"
|
||
:text="flowable ? '保存并提交审批' : '保存并审核生效'"
|
||
@click="onSave(true)"
|
||
/>
|
||
</view>
|
||
</block>
|
||
|
||
<!-- 明细编辑抽屉 -->
|
||
<view v-if="itemVisible" class="item-mask" @tap="closeItem">
|
||
<view class="item-sheet" @tap.stop>
|
||
<view class="sheet-head">
|
||
<text class="sheet-btn" @tap="closeItem">取消</text>
|
||
<text class="sheet-title">{{ editingIndex >= 0 ? '编辑明细' : '添加明细' }}</text>
|
||
<text class="sheet-btn strong" @tap="confirmItem">确定</text>
|
||
</view>
|
||
<scroll-view class="sheet-body" scroll-y>
|
||
<view class="field">
|
||
<text class="field-label">商品 <text class="required">*</text></text>
|
||
<view class="field-value tap" @tap="openPicker('product')">
|
||
<text :class="{ muted: !draft.product_name }">{{ draft.product_name || '选择商品' }}</text>
|
||
<FaIcon name="chevron-right" color="#cbd5e1" :size="15" />
|
||
</view>
|
||
</view>
|
||
<view v-if="stockText" class="inline-tip">
|
||
<text>{{ stockText }}</text>
|
||
</view>
|
||
|
||
<view v-if="isStockTake" class="field">
|
||
<text class="field-label">账面数量</text>
|
||
<view class="field-value static">
|
||
<text>{{ qtyText(draft.book_qty) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">{{ isStockTake ? '实盘数量' : '数量' }} <text class="required">*</text></text>
|
||
<u-input v-model="draft.quantity" type="digit" placeholder="0" border="none" />
|
||
</view>
|
||
|
||
<view v-if="!isStockTake || draft.unit_price" class="field">
|
||
<text class="field-label">单价</text>
|
||
<view class="two-col">
|
||
<u-input v-model="draft.unit_price" type="digit" placeholder="0.00" border="none" />
|
||
<text class="col-amount">¥{{ moneyText(lineAmount(draft)) }}</text>
|
||
</view>
|
||
<text class="field-hint">留空按商品资料与价格体系自动取价</text>
|
||
</view>
|
||
|
||
<view v-if="showBatch" class="field">
|
||
<text class="field-label">
|
||
批次号<text v-if="batchRequired" class="required"> *</text>
|
||
</text>
|
||
<u-input v-model="draft.batch_no" placeholder="批次管理商品必填" :maxlength="64" border="none" />
|
||
</view>
|
||
|
||
<view v-if="showProductDate" class="field">
|
||
<text class="field-label">生产日期</text>
|
||
<picker mode="date" :value="draft.production_date" @change="onProductDateChange">
|
||
<view class="field-value">
|
||
<text>{{ draft.production_date || '选择日期' }}</text>
|
||
<FaIcon name="calendar-days" color="#94a3b8" :size="16" />
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view v-if="draft.shelf_life_days > 0 && draft.production_date" class="inline-tip">
|
||
<text>保质期 {{ draft.shelf_life_days }} 天,到期日由后端按保质期推算</text>
|
||
</view>
|
||
|
||
<view class="field">
|
||
<text class="field-label">行备注</text>
|
||
<u-input v-model="draft.remark" placeholder="选填" :maxlength="200" border="none" />
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<ErpPicker
|
||
:show="picker.show"
|
||
:title="picker.title"
|
||
:placeholder="picker.placeholder"
|
||
:options="picker.options"
|
||
:remote="picker.remote"
|
||
:loading="picker.loading"
|
||
:tip="picker.tip"
|
||
:model-value="picker.current"
|
||
@update:show="picker.show = false"
|
||
@search="onPickerSearch"
|
||
@select="onPickerSelect"
|
||
/>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* 单据开单 / 草稿编辑页(十类单据共用)。
|
||
*
|
||
* 表头字段按单据类型动态出现:采购类选供应商、销售类选客户、调拨多一个调入仓库、
|
||
* 盘点只需要仓库不需要客商 —— 这些规则全部取自 /api/erp/meta 的 doc_types,
|
||
* 与 Web 端 DocEditor、Go 端 erpValidateDocHead 同一份口径,避免端上自己判型判错。
|
||
*
|
||
* 明细只传可编辑字段:单价留空由后端按价格体系取价,金额由后端重算,
|
||
* 保证手机端提交的单与 Web 端算出来的一分不差。
|
||
*/
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import FaIcon from '@/components/FaIcon.vue'
|
||
import ErpPicker from '@/components/erp/ErpPicker.vue'
|
||
import {
|
||
DOC_STATUS,
|
||
approveDoc,
|
||
dayText,
|
||
docStatusLabel,
|
||
fetchDocFlows,
|
||
fetchDocTypes,
|
||
fetchDocs,
|
||
fetchErpMeta,
|
||
fetchExecutableDocs,
|
||
fetchPartners,
|
||
fetchProducts,
|
||
fetchStocks,
|
||
fetchTakeSheet,
|
||
fetchWarehouses,
|
||
getDoc,
|
||
getProduct,
|
||
moneyText,
|
||
qtyText,
|
||
saveDoc,
|
||
submitDoc
|
||
} from '@/api/erp.js'
|
||
|
||
const EDITABLE = [DOC_STATUS.DRAFT, DOC_STATUS.REJECTED]
|
||
|
||
const items = ref([])
|
||
const form = reactive({
|
||
id: 0,
|
||
docType: '',
|
||
docNo: '',
|
||
bizDate: today(),
|
||
warehouseId: 0,
|
||
warehouseName: '',
|
||
toWarehouseId: 0,
|
||
toWarehouseName: '',
|
||
partnerId: 0,
|
||
partnerName: '',
|
||
partnerType: '',
|
||
sourceDocId: 0,
|
||
sourceDocNo: '',
|
||
discountAmount: '',
|
||
remark: ''
|
||
})
|
||
|
||
const typeDict = ref([])
|
||
const meta = ref({ default_warehouse_id: 0, sales_price_type: '', negative_stock: false })
|
||
const warehouses = ref([])
|
||
/** 商品选择器当前页的原始行,选中后要拿 enable_batch / shelf_life_days / 价格字段 */
|
||
const productRows = ref([])
|
||
const blocked = ref('')
|
||
const saving = ref(false)
|
||
const flowable = ref(true)
|
||
|
||
const itemVisible = ref(false)
|
||
const editingIndex = ref(-1)
|
||
const draft = reactive({})
|
||
const stockText = ref('')
|
||
|
||
const picker = reactive({
|
||
show: false,
|
||
key: '',
|
||
title: '',
|
||
placeholder: '搜索',
|
||
remote: false,
|
||
loading: false,
|
||
tip: '',
|
||
options: [],
|
||
current: null
|
||
})
|
||
|
||
onLoad(async (query = {}) => {
|
||
form.docType = String(query.doc_type || '')
|
||
await loadDicts()
|
||
|
||
if (query.id) {
|
||
await loadDoc(Number(query.id))
|
||
} else {
|
||
if (form.docType) syncTypeFields()
|
||
applyDefaultWarehouse()
|
||
}
|
||
|
||
if (query.source_doc_id) await attachSource(Number(query.source_doc_id))
|
||
if (query.product_id && !query.id) await prefillProduct(Number(query.product_id), query.quantity)
|
||
|
||
loadFlowable()
|
||
uni.setNavigationBarTitle({ title: typeLabel.value || '开单' })
|
||
})
|
||
|
||
/** 该类型有没有配审批流:有则第二个按钮是「提交审批」,没有就是「直接审核生效」 */
|
||
async function loadFlowable() {
|
||
if (!form.docType) return
|
||
const flows = await fetchDocFlows(form.docType).catch(() => ({ list: [] }))
|
||
flowable.value = (flows.list || []).length > 0
|
||
}
|
||
|
||
async function loadDicts() {
|
||
try {
|
||
const [types, m, whs] = await Promise.all([fetchDocTypes(), fetchErpMeta(), fetchWarehouses({ onlyEnabled: true })])
|
||
typeDict.value = types
|
||
meta.value = {
|
||
default_warehouse_id: m.default_warehouse_id || 0,
|
||
sales_price_type: m.sales_price_type || '',
|
||
negative_stock: !!m.negative_stock
|
||
}
|
||
warehouses.value = whs
|
||
} catch (e) {
|
||
blocked.value = '基础资料加载失败,请下拉重试或稍后再试'
|
||
}
|
||
}
|
||
|
||
async function loadDoc(id) {
|
||
const doc = await getDoc(id)
|
||
if (!doc) {
|
||
blocked.value = '单据不存在或无权查看'
|
||
return
|
||
}
|
||
if (!EDITABLE.includes(Number(doc.status))) {
|
||
blocked.value = `${docStatusLabel(doc.status)}的单据不可编辑`
|
||
return
|
||
}
|
||
form.id = doc.id
|
||
form.docType = doc.doc_type
|
||
form.docNo = doc.doc_no
|
||
form.bizDate = dayText(doc.biz_date) || form.bizDate
|
||
form.warehouseId = doc.warehouse_id || 0
|
||
form.warehouseName = doc.warehouse_name || ''
|
||
form.toWarehouseId = doc.to_warehouse_id || 0
|
||
form.toWarehouseName = doc.to_warehouse_name || ''
|
||
form.partnerId = doc.partner_id || 0
|
||
form.partnerName = doc.partner_name || ''
|
||
form.sourceDocId = doc.source_doc_id || 0
|
||
form.sourceDocNo = doc.source_doc_no || ''
|
||
form.discountAmount = doc.discount_amount ? String(doc.discount_amount) : ''
|
||
form.remark = doc.remark || ''
|
||
items.value = (doc.items || []).map(toRow)
|
||
syncTypeFields()
|
||
}
|
||
|
||
/** 列表/详情的明细行 → 端上行模型(保留 id 与来源行引用,价格与库存档案快照单独存) */
|
||
function toRow(it) {
|
||
return {
|
||
id: it.id || 0,
|
||
product_id: it.product_id,
|
||
product_name: it.product_name,
|
||
product_code: it.product_code || '',
|
||
spec: it.spec || '',
|
||
unit: it.unit || '',
|
||
batch_no: it.batch_no || '',
|
||
quantity: String(it.quantity ?? ''),
|
||
book_qty: Number(it.book_qty || 0),
|
||
ref_item_id: it.ref_item_id || 0,
|
||
unit_price: String(it.unit_price ?? ''),
|
||
production_date: dayText(it.production_date),
|
||
remark: it.remark || '',
|
||
enable_batch: Number(it.enable_batch || 0),
|
||
shelf_life_days: Number(it.shelf_life_days || 0)
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------ 类型派生的表单形态
|
||
|
||
const typeInfo = computed(() => typeDict.value.find((t) => t.value === form.docType) || {})
|
||
const typeLabel = computed(() => typeInfo.value.label || '')
|
||
const needWarehouse = computed(() => !!typeInfo.value.affects_stock || isStockTake.value)
|
||
const isTransfer = computed(() => form.docType === 'transfer')
|
||
const isStockTake = computed(() => form.docType === 'stock_take')
|
||
const partnerRequired = computed(() => !!typeInfo.value.partner_type)
|
||
|
||
/** 需要往来单位时才出现客商行;调拨多一行调入仓库 */
|
||
const headRows = computed(() => {
|
||
const rows = []
|
||
if (needWarehouse.value) {
|
||
rows.push({
|
||
key: 'warehouse',
|
||
label: isStockTake.value ? '盘点仓库' : isTransfer.value ? '调出仓库' : '仓库',
|
||
text: form.warehouseName,
|
||
placeholder: '请选择仓库',
|
||
required: true
|
||
})
|
||
}
|
||
if (isTransfer.value) {
|
||
rows.push({
|
||
key: 'toWarehouse',
|
||
label: '调入仓库',
|
||
text: form.toWarehouseName,
|
||
placeholder: '请选择调入仓库',
|
||
required: true
|
||
})
|
||
}
|
||
if (partnerRequired.value) {
|
||
rows.push({
|
||
key: 'partner',
|
||
label: typeInfo.value.partner_type === 'supplier' ? '供应商' : '客户',
|
||
text: form.partnerName,
|
||
placeholder: '请选择往来单位',
|
||
required: true
|
||
})
|
||
}
|
||
if (sourceTypes.value.length) {
|
||
rows.push({
|
||
key: 'source',
|
||
label: '来源单据',
|
||
text: form.sourceDocNo,
|
||
placeholder: '选填,可关联订单/出库单',
|
||
required: false
|
||
})
|
||
}
|
||
return rows
|
||
})
|
||
|
||
/** 允许的来源单据类型(与 Go 端 docTypeSources 一致) */
|
||
const sourceTypes = computed(() => {
|
||
switch (form.docType) {
|
||
case 'purchase_in':
|
||
return ['purchase_order']
|
||
case 'purchase_return':
|
||
return ['purchase_order', 'purchase_in']
|
||
case 'sales_out':
|
||
return ['sales_order']
|
||
case 'sales_return':
|
||
return ['sales_order', 'sales_out']
|
||
default:
|
||
return []
|
||
}
|
||
})
|
||
|
||
const sourceTip = computed(() =>
|
||
form.sourceDocNo ? `已关联「${form.sourceDocNo}」,可点「按剩余量带出」生成明细` : ''
|
||
)
|
||
|
||
function syncTypeFields() {
|
||
form.partnerType = typeInfo.value.partner_type || ''
|
||
if (!partnerRequired.value) {
|
||
form.partnerId = 0
|
||
form.partnerName = ''
|
||
}
|
||
if (!isTransfer.value) {
|
||
form.toWarehouseId = 0
|
||
form.toWarehouseName = ''
|
||
}
|
||
if (!sourceTypes.value.length) {
|
||
form.sourceDocId = 0
|
||
form.sourceDocNo = ''
|
||
}
|
||
}
|
||
|
||
function applyDefaultWarehouse() {
|
||
const id = Number(meta.value.default_warehouse_id) || 0
|
||
const hit = warehouses.value.find((w) => Number(w.id) === id)
|
||
if (hit && !form.warehouseId) {
|
||
form.warehouseId = Number(hit.id)
|
||
form.warehouseName = hit.warehouse_name || ''
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------ 选择器
|
||
|
||
function openPicker(key) {
|
||
picker.key = key
|
||
picker.loading = false
|
||
picker.tip = ''
|
||
picker.remote = false
|
||
picker.current = null
|
||
picker.placeholder = '搜索'
|
||
if (key === 'warehouse' || key === 'toWarehouse') {
|
||
picker.title = key === 'warehouse' ? '选择仓库' : '选择调入仓库'
|
||
picker.current = key === 'warehouse' ? form.warehouseId : form.toWarehouseId
|
||
picker.options = warehouseOptions(key)
|
||
} else if (key === 'partner') {
|
||
picker.title = typeInfo.value.partner_type === 'supplier' ? '选择供应商' : '选择客户'
|
||
picker.remote = true
|
||
picker.current = form.partnerId
|
||
picker.options = []
|
||
loadPartners('')
|
||
} else if (key === 'product') {
|
||
picker.title = '选择商品'
|
||
picker.remote = true
|
||
picker.current = draft.product_id
|
||
picker.options = []
|
||
loadProducts('')
|
||
} else if (key === 'source') {
|
||
picker.title = '选择来源单据'
|
||
picker.remote = true
|
||
picker.current = form.sourceDocId
|
||
picker.options = []
|
||
loadSources('')
|
||
}
|
||
picker.show = true
|
||
}
|
||
|
||
function warehouseOptions(key) {
|
||
return warehouses.value
|
||
.filter((w) => !(key === 'warehouse' && form.toWarehouseId && Number(w.id) === form.toWarehouseId))
|
||
.filter((w) => !(key === 'toWarehouse' && Number(w.id) === form.warehouseId))
|
||
.map((w) => ({
|
||
value: w.id,
|
||
label: w.warehouse_name || `仓库#${w.id}`,
|
||
sub: w.remark || '',
|
||
extra: `${w.stock_kinds || 0} 种 / ¥${moneyText(w.stock_value || 0)}`
|
||
}))
|
||
}
|
||
|
||
async function loadPartners(keyword) {
|
||
picker.loading = true
|
||
try {
|
||
const { list, total } = await fetchPartners({
|
||
partnerType: typeInfo.value.partner_type || 'customer',
|
||
keyword,
|
||
onlyEnabled: true,
|
||
pageSize: 50
|
||
})
|
||
picker.options = list.map((p) => ({
|
||
value: p.partner_id,
|
||
label: p.partner_name,
|
||
sub: [p.contact_person, p.contact_phone].filter(Boolean).join(' · '),
|
||
extra: Number(p.open_amount) > 0 ? `未结 ¥${moneyText(p.open_amount)}` : ''
|
||
}))
|
||
picker.tip = total > list.length ? `共 ${total} 家,输入关键字继续找` : ''
|
||
} finally {
|
||
picker.loading = false
|
||
}
|
||
}
|
||
|
||
async function loadProducts(keyword) {
|
||
picker.loading = true
|
||
try {
|
||
const { list, total } = await fetchProducts({ keyword, onlyEnabled: true, pageSize: 50 })
|
||
productRows.value = list
|
||
picker.options = list.map((p) => ({
|
||
value: p.id,
|
||
label: p.product_name,
|
||
sub: [p.product_code, p.spec, p.category_name].filter(Boolean).join(' · '),
|
||
extra: `${qtyText(p.total_stock)} ${p.base_unit || ''}`
|
||
}))
|
||
picker.tip = total > list.length ? `共 ${total} 个商品,输入关键字继续找` : ''
|
||
} finally {
|
||
picker.loading = false
|
||
}
|
||
}
|
||
|
||
async function loadSources(keyword) {
|
||
picker.loading = true
|
||
try {
|
||
const { list } = await fetchDocs({
|
||
docTypes: sourceTypes.value,
|
||
status: DOC_STATUS.APPROVED,
|
||
keyword,
|
||
pageSize: 30
|
||
})
|
||
picker.options = list.map((d) => ({
|
||
value: d.id,
|
||
label: d.doc_no,
|
||
sub: `${docTypeText(d.doc_type)} · ${d.biz_date} · ${d.partner_name || ''}`,
|
||
extra: `¥${moneyText(d.actual_amount)}`
|
||
}))
|
||
} finally {
|
||
picker.loading = false
|
||
}
|
||
}
|
||
|
||
function onPickerSearch(keyword) {
|
||
if (picker.key === 'partner') loadPartners(keyword)
|
||
else if (picker.key === 'product') loadProducts(keyword)
|
||
else if (picker.key === 'source') loadSources(keyword)
|
||
}
|
||
|
||
async function onPickerSelect(opt) {
|
||
if (picker.key === 'warehouse') {
|
||
form.warehouseId = Number(opt.value)
|
||
form.warehouseName = opt.label
|
||
if (isTransfer.value && form.toWarehouseId === form.warehouseId) {
|
||
form.toWarehouseId = 0
|
||
form.toWarehouseName = ''
|
||
}
|
||
} else if (picker.key === 'toWarehouse') {
|
||
form.toWarehouseId = Number(opt.value)
|
||
form.toWarehouseName = opt.label
|
||
} else if (picker.key === 'partner') {
|
||
form.partnerId = Number(opt.value)
|
||
form.partnerName = opt.label
|
||
} else if (picker.key === 'source') {
|
||
form.sourceDocId = Number(opt.value)
|
||
form.sourceDocNo = opt.label
|
||
await pullFromSource()
|
||
} else if (picker.key === 'product') {
|
||
const raw = productRows.value.find((p) => Number(p.id) === Number(opt.value))
|
||
if (raw) fillDraftProduct(raw)
|
||
}
|
||
}
|
||
|
||
function docTypeText(type) {
|
||
return (typeDict.value.find((t) => t.value === type) || {}).label || type
|
||
}
|
||
|
||
// ------------------------------------------------------------ 明细
|
||
|
||
const showBatch = computed(() => !!draft.enable_batch || !!draft.batch_no || isStockTake.value)
|
||
const batchRequired = computed(() => Number(draft.enable_batch) === 1 && form.docType === 'purchase_in')
|
||
const showProductDate = computed(
|
||
() => Number(draft.shelf_life_days) > 0 || !!draft.enable_batch || !!draft.production_date
|
||
)
|
||
|
||
function openItem(index) {
|
||
editingIndex.value = index
|
||
const src = index >= 0 ? items.value[index] : emptyRow()
|
||
Object.keys(draft).forEach((k) => delete draft[k])
|
||
Object.assign(draft, { ...src })
|
||
stockText.value = ''
|
||
itemVisible.value = true
|
||
if (src.product_id) loadStockHint(src.product_id)
|
||
}
|
||
|
||
function closeItem() {
|
||
itemVisible.value = false
|
||
}
|
||
|
||
function emptyRow() {
|
||
return {
|
||
id: 0,
|
||
product_id: 0,
|
||
product_name: '',
|
||
spec: '',
|
||
unit: '',
|
||
batch_no: '',
|
||
quantity: '',
|
||
book_qty: 0,
|
||
ref_item_id: 0,
|
||
unit_price: '',
|
||
production_date: '',
|
||
remark: '',
|
||
enable_batch: 0,
|
||
shelf_life_days: 0
|
||
}
|
||
}
|
||
|
||
function fillDraftProduct(p) {
|
||
draft.product_id = p.id
|
||
draft.product_name = p.product_name
|
||
draft.spec = p.spec || ''
|
||
draft.unit = p.base_unit || ''
|
||
draft.enable_batch = Number(p.enable_batch || 0)
|
||
draft.shelf_life_days = Number(p.shelf_life_days || 0)
|
||
if (!draft.unit_price || draft.unit_price === '0') draft.unit_price = String(pickPrice(p) || '')
|
||
loadStockHint(p.id)
|
||
}
|
||
|
||
/** 预警/补货页带商品过来开单:直接铺好一行,用户只补供应商和数量 */
|
||
async function prefillProduct(id, quantity) {
|
||
try {
|
||
const p = await getProduct(id)
|
||
if (!p) return
|
||
items.value = [
|
||
{
|
||
...emptyRow(),
|
||
product_id: p.id,
|
||
product_name: p.product_name,
|
||
spec: p.spec || '',
|
||
unit: p.base_unit || '',
|
||
enable_batch: Number(p.enable_batch || 0),
|
||
shelf_life_days: Number(p.shelf_life_days || 0),
|
||
quantity: Number(quantity) > 0 ? String(quantity) : '',
|
||
unit_price: pickPrice(p) ? String(pickPrice(p)) : ''
|
||
}
|
||
]
|
||
} catch (e) {
|
||
// 商品拉不到就让用户手动加行,不拦开单入口
|
||
}
|
||
}
|
||
|
||
/** 与 Go 端 erpPickPrice 同规则:采购价/销售价按租户价格体系回落 */
|
||
function pickPrice(p) {
|
||
const type = form.docType
|
||
if (['purchase_order', 'purchase_in', 'purchase_return', 'other_in'].includes(type)) {
|
||
return Number(p.purchase_price || 0)
|
||
}
|
||
if (['sales_return'].includes(type)) return Number(p.sale_price || 0)
|
||
if (['sales_order', 'sales_out', 'other_out'].includes(type)) {
|
||
const sys = meta.value.sales_price_type
|
||
if (sys === 'wholesale' && Number(p.wholesale_price) > 0) return Number(p.wholesale_price)
|
||
if (sys === 'member' && Number(p.member_price) > 0) return Number(p.member_price)
|
||
return Number(p.sale_price || 0)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
async function loadStockHint(productId) {
|
||
stockText.value = ''
|
||
if (!typeInfo.value.affects_stock || !form.warehouseId || !productId) return
|
||
// 同一商品可能有多批次行,按商品聚合才是这一仓真正可发的量
|
||
const { list } = await fetchStocks({ warehouseId: form.warehouseId, productId, groupBy: 'product', pageSize: 1 })
|
||
const row = list[0]
|
||
if (!row) {
|
||
stockText.value = meta.value.negative_stock
|
||
? '该仓暂无此商品库存(租户允许负库存,可先开单后补货)'
|
||
: '该仓暂无此商品库存,出库会被拦截'
|
||
return
|
||
}
|
||
stockText.value = `当前库存 ${qtyText(row.quantity)} ${row.unit || ''} · 成本 ¥${moneyText(row.cost_price)}`
|
||
}
|
||
|
||
function confirmItem() {
|
||
const msg = checkDraft()
|
||
if (msg) {
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
return
|
||
}
|
||
const row = { ...draft }
|
||
if (editingIndex.value >= 0) items.value.splice(editingIndex.value, 1, row)
|
||
else items.value.push(row)
|
||
closeItem()
|
||
}
|
||
|
||
function checkDraft() {
|
||
if (!draft.product_id) return '请选择商品'
|
||
if (!(Number(draft.quantity) > 0)) return isStockTake.value ? '请填写实盘数量' : '数量必须大于 0'
|
||
if (batchRequired.value && !String(draft.batch_no || '').trim()) return '批次管理商品请填写批次号'
|
||
return ''
|
||
}
|
||
|
||
function removeItem(index) {
|
||
uni.showModal({
|
||
title: '删除明细',
|
||
content: `确定移除「${items.value[index].product_name}」这一行?`,
|
||
success: (res) => {
|
||
if (res.confirm) items.value.splice(index, 1)
|
||
}
|
||
})
|
||
}
|
||
|
||
function lineAmount(row) {
|
||
return (Number(row.quantity) || 0) * (Number(row.unit_price) || 0)
|
||
}
|
||
|
||
function diffOf(row) {
|
||
return (Number(row.quantity) || 0) - Number(row.book_qty || 0)
|
||
}
|
||
|
||
function signed(text) {
|
||
const num = Number(text) || 0
|
||
return (num > 0 ? '+' : '') + text
|
||
}
|
||
|
||
const totalQty = computed(() => items.value.reduce((sum, r) => sum + (Number(r.quantity) || 0), 0))
|
||
const totalAmount = computed(() => items.value.reduce((sum, r) => sum + lineAmount(r), 0))
|
||
const actualAmount = computed(() => {
|
||
const minus = Number(form.discountAmount) || 0
|
||
return Math.max(0, totalAmount.value - minus)
|
||
})
|
||
|
||
/** 盘点:按仓库账面库存铺行,盘点员只改实盘数 */
|
||
async function loadTakeSheet() {
|
||
const rows = await fetchTakeSheet(form.warehouseId)
|
||
if (!rows.length) {
|
||
uni.showToast({ title: '该仓库暂无在库商品', icon: 'none' })
|
||
return
|
||
}
|
||
items.value = rows.map((r) => ({
|
||
...emptyRow(),
|
||
product_id: r.product_id,
|
||
product_name: r.product_name,
|
||
spec: r.spec || '',
|
||
unit: r.unit || '',
|
||
batch_no: r.batch_no || '',
|
||
book_qty: Number(r.book_qty || 0),
|
||
quantity: String(r.book_qty || 0),
|
||
unit_price: r.cost_price ? String(r.cost_price) : ''
|
||
}))
|
||
uni.showToast({ title: `已载入 ${rows.length} 行`, icon: 'none' })
|
||
}
|
||
|
||
/** 订单转入库/出库:按「下单量 - 已执行量」带出,行 ID 作为 ref_item_id 供后端做超收校验 */
|
||
async function pullFromSource() {
|
||
if (!form.sourceDocId) return
|
||
const data = await fetchExecutableDocs(form.sourceDocId)
|
||
const rows = (data.items || []).filter((it) => it.remain_qty > 0)
|
||
if (!rows.length) {
|
||
uni.showToast({ title: '来源单据已全部执行完毕', icon: 'none' })
|
||
return
|
||
}
|
||
const build = () => {
|
||
items.value = rows.map((it) => ({
|
||
...emptyRow(),
|
||
product_id: it.product_id,
|
||
product_name: it.product_name,
|
||
spec: it.spec || '',
|
||
unit: it.unit || '',
|
||
batch_no: it.batch_no || '',
|
||
quantity: String(it.remain_qty),
|
||
unit_price: String(it.unit_price || ''),
|
||
ref_item_id: it.item_id
|
||
}))
|
||
if (partnerRequired.value && data.doc?.partner_id) {
|
||
form.partnerId = data.doc.partner_id
|
||
form.partnerName = data.doc.partner_name || ''
|
||
}
|
||
uni.showToast({ title: `已带出 ${rows.length} 行`, icon: 'none' })
|
||
}
|
||
if (!items.value.length) {
|
||
build()
|
||
return
|
||
}
|
||
uni.showModal({
|
||
title: '替换明细',
|
||
content: `按来源单据剩余量生成 ${rows.length} 行,会覆盖当前 ${items.value.length} 行明细`,
|
||
success: (res) => {
|
||
if (res.confirm) build()
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 从详情页「生成下游单」进入:带出来源单号、客商、仓库与可执行明细 */
|
||
async function attachSource(id) {
|
||
form.sourceDocId = Number(id)
|
||
const doc = await getDoc(id)
|
||
if (!doc) return
|
||
form.sourceDocNo = doc.doc_no
|
||
if (partnerRequired.value && doc.partner_id) {
|
||
form.partnerId = doc.partner_id
|
||
form.partnerName = doc.partner_name || ''
|
||
}
|
||
if (needWarehouse.value && doc.warehouse_id && !form.warehouseId) {
|
||
form.warehouseId = doc.warehouse_id
|
||
form.warehouseName = doc.warehouse_name || ''
|
||
}
|
||
await pullFromSource()
|
||
}
|
||
|
||
// ------------------------------------------------------------ 保存
|
||
|
||
function headCheck() {
|
||
if (!form.docType) return '缺少单据类型'
|
||
if (!form.bizDate) return '请选择业务日期'
|
||
if (needWarehouse.value && !form.warehouseId) return '请选择仓库'
|
||
if (isTransfer.value) {
|
||
if (!form.toWarehouseId) return '请选择调入仓库'
|
||
if (form.toWarehouseId === form.warehouseId) return '调入仓库不能与调出仓库相同'
|
||
}
|
||
if (partnerRequired.value && !form.partnerId) {
|
||
return '请选择' + (typeInfo.value.partner_type === 'supplier' ? '供应商' : '客户')
|
||
}
|
||
if (!items.value.length) return '请至少添加一行明细'
|
||
return ''
|
||
}
|
||
|
||
async function onSave(thenGo) {
|
||
const msg = headCheck()
|
||
if (msg) {
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
const { id, docNo } = await saveDoc({
|
||
id: form.id,
|
||
docType: form.docType,
|
||
bizDate: form.bizDate,
|
||
warehouseId: form.warehouseId,
|
||
toWarehouseId: form.toWarehouseId,
|
||
partnerId: form.partnerId,
|
||
sourceDocId: form.sourceDocId,
|
||
discountAmount: Number(form.discountAmount) || 0,
|
||
remark: form.remark,
|
||
items: items.value.map((row, i) => ({
|
||
id: row.id,
|
||
productId: row.product_id,
|
||
productName: row.product_name,
|
||
spec: row.spec,
|
||
unit: row.unit,
|
||
batchNo: row.batch_no,
|
||
quantity: Number(row.quantity) || 0,
|
||
bookQty: row.book_qty,
|
||
refItemId: row.ref_item_id,
|
||
unitPrice: Number(row.unit_price) || 0,
|
||
productionDate: row.production_date,
|
||
remark: row.remark,
|
||
sortOrder: i + 1
|
||
}))
|
||
})
|
||
form.id = id
|
||
form.docNo = docNo
|
||
|
||
if (thenGo) await advance(id)
|
||
else uni.showToast({ title: '草稿已保存', icon: 'success' })
|
||
} catch (e) {
|
||
// 具体原因由请求层 toast,这里只负责不吞掉「已保存但提交失败」这种半成功状态
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
/** 提交审批:未配置审批流时直接审核生效,与 Web 端按钮语义一致 */
|
||
async function advance(id) {
|
||
if (flowable.value) {
|
||
await submitDoc(id, 0)
|
||
uni.showToast({ title: '已提交审批', icon: 'success' })
|
||
} else {
|
||
await approveDoc(id)
|
||
uni.showToast({ title: '单据已审核生效', icon: 'success' })
|
||
}
|
||
setTimeout(() => uni.redirectTo({ url: `/pages/erp/document/detail?id=${id}` }), 600)
|
||
}
|
||
|
||
function goBack() {
|
||
if (form.id) uni.redirectTo({ url: `/pages/erp/document/detail?id=${form.id}` })
|
||
else uni.navigateBack()
|
||
}
|
||
|
||
function onBizDateChange(e) {
|
||
form.bizDate = e.detail.value
|
||
}
|
||
|
||
function onProductDateChange(e) {
|
||
draft.production_date = e.detail.value
|
||
}
|
||
|
||
function today() {
|
||
const d = new Date()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${d.getFullYear()}-${m}-${day}`
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/src/styles/page-common.scss';
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: $color-bg-page;
|
||
padding: 20rpx $page-padding-x 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card {
|
||
@include card;
|
||
margin-bottom: 24rpx;
|
||
padding: 24rpx 28rpx;
|
||
}
|
||
|
||
.card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.card-no {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.card-tip {
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.card-total {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.field {
|
||
padding: 16rpx 0;
|
||
border-bottom: 1rpx solid $color-divider;
|
||
|
||
&:last-child {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.field-label {
|
||
display: block;
|
||
margin-bottom: 10rpx;
|
||
font-size: 25rpx;
|
||
color: $color-text-secondary;
|
||
}
|
||
|
||
.required {
|
||
color: $color-danger;
|
||
}
|
||
|
||
.field-value {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 28rpx;
|
||
color: $color-text;
|
||
|
||
&.tap:active {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
&.static {
|
||
justify-content: flex-start;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.muted {
|
||
color: $color-text-muted;
|
||
}
|
||
}
|
||
|
||
.field-hint {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.inline-tip {
|
||
margin: 12rpx 0;
|
||
padding: 14rpx 18rpx;
|
||
background: $color-primary-bg;
|
||
border-radius: $radius-sm;
|
||
font-size: 23rpx;
|
||
line-height: 1.5;
|
||
color: $color-primary-dark;
|
||
}
|
||
|
||
.two-col {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.col-amount {
|
||
flex-shrink: 0;
|
||
font-size: 26rpx;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.items-empty {
|
||
padding: 50rpx 0;
|
||
text-align: center;
|
||
font-size: 26rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.item-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 20rpx 0;
|
||
border-bottom: 1rpx solid $color-divider;
|
||
|
||
&:active {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
.item-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.item-line1 {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.item-name {
|
||
flex: 1;
|
||
font-size: 27rpx;
|
||
color: $color-text;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.item-amount {
|
||
flex-shrink: 0;
|
||
font-size: 27rpx;
|
||
font-weight: 500;
|
||
color: $color-text;
|
||
}
|
||
|
||
.item-line2 {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8rpx 20rpx;
|
||
margin-top: 8rpx;
|
||
font-size: 23rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.item-del {
|
||
padding: 8rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.item-tools {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 20rpx;
|
||
padding-top: 20rpx;
|
||
}
|
||
|
||
.tool {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 12rpx 24rpx;
|
||
border-radius: $radius-full;
|
||
background: $color-primary-bg;
|
||
font-size: 24rpx;
|
||
color: $color-primary-dark;
|
||
|
||
&:active {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
.line {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14rpx 0;
|
||
font-size: 26rpx;
|
||
color: $color-text-secondary;
|
||
|
||
&.highlight {
|
||
margin: 8rpx -28rpx -24rpx;
|
||
padding: 20rpx 28rpx;
|
||
border-top: 1rpx solid $color-divider;
|
||
font-size: 28rpx;
|
||
color: $color-text;
|
||
}
|
||
}
|
||
|
||
.line-value {
|
||
color: $color-text;
|
||
|
||
&.strong {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
|
||
.input-cell {
|
||
flex: 1;
|
||
max-width: 260rpx;
|
||
}
|
||
|
||
.footer-space {
|
||
height: calc(160rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.footer {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
gap: 20rpx;
|
||
padding: 16rpx $page-padding-x calc(16rpx + env(safe-area-inset-bottom));
|
||
background: $color-card;
|
||
box-shadow: $shadow-header;
|
||
@include dialog-responsive;
|
||
}
|
||
|
||
.item-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 997;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
@include mask-responsive;
|
||
}
|
||
|
||
.item-sheet {
|
||
width: 100%;
|
||
max-height: 82vh;
|
||
background: $color-card;
|
||
border-radius: 32rpx 32rpx 0 0;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
display: flex;
|
||
flex-direction: column;
|
||
@include sheet-responsive;
|
||
}
|
||
|
||
.sheet-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 28rpx $page-padding-x 12rpx;
|
||
}
|
||
|
||
.sheet-title {
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.sheet-btn {
|
||
font-size: 26rpx;
|
||
color: $color-text-muted;
|
||
|
||
&.strong {
|
||
color: $color-primary;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.sheet-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
max-height: 64vh;
|
||
padding: 0 $page-padding-x 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.state-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 160rpx 40rpx;
|
||
}
|
||
|
||
.state-text {
|
||
font-size: 28rpx;
|
||
color: $color-text-secondary;
|
||
line-height: 1.6;
|
||
text-align: center;
|
||
}
|
||
|
||
.state-back {
|
||
margin-top: 36rpx;
|
||
padding: 16rpx 40rpx;
|
||
border-radius: $radius-full;
|
||
background: $color-card;
|
||
font-size: 26rpx;
|
||
color: $color-primary;
|
||
box-shadow: $shadow-card;
|
||
}
|
||
</style>
|