Files
2026-10-19 01:13:17 +08:00

1304 lines
34 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>