Files
my-mom-system/erp-frontend-vue/src/views/Sales/Order/form.vue
panchengyong 80ce9ad8e2 fix: 修复销售订单新增页面3个Bug
- 销售部门选择改为 el-tree-select 树形组件,展示组织架构层级
- 物料选择弹窗过滤停用产品(enableFlag: 'Y')
- 物料弹窗单行选择模式改用 el-radio 单选框,选中状态更明显
- 物料编码列宽度由 120px 改为 min-width 160px,显示完整编码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:57:40 +08:00

1335 lines
42 KiB
Vue
Raw 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>
<div class="form-container">
<!-- 顶部操作栏 -->
<div class="page-header">
<div class="header-left">
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<template v-if="!isView">
<el-button type="primary" @click="handleSave" :loading="saveLoading">
<el-icon><Check /></el-icon>保存
</el-button>
<el-button v-if="isEdit && formData.orderStatus === '开立'" @click="handleWithdraw">
撤回
</el-button>
</template>
<el-button
v-if="canAudit"
type="success"
@click="handleAudit"
>
<el-icon><Finished /></el-icon>审核
</el-button>
<el-button
v-if="canUnaudit"
type="warning"
@click="handleUnaudit"
>
反审核
</el-button>
<el-dropdown v-if="isView || isEdit" trigger="click">
<el-button>
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handlePrint">打印</el-dropdown-item>
<el-dropdown-item v-if="isView" @click="handleEdit">编辑</el-dropdown-item>
<el-dropdown-item v-if="isView" @click="handleAdd">新增</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button @click="toggleCollapse">
{{ isCollapsed ? '展开' : '收起' }}
</el-button>
</div>
</div>
<el-card shadow="never" class="main-card">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
:disabled="isView"
label-width="100px"
>
<!-- 基础信息区域 -->
<el-collapse v-model="activeCollapse">
<el-collapse-item title="基础信息" name="basic">
<!-- 第一行单据信息 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="单据编码">
<el-input v-model="formData.orderCode" disabled placeholder="系统自动生成" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单据日期" prop="orderDate">
<el-date-picker
v-model="formData.orderDate"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单据状态">
<el-tag :type="getStatusType(formData.orderStatus)">
{{ getStatusLabel(formData.orderStatus) }}
</el-tag>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单据类型">
<el-input value="销售订单" disabled />
</el-form-item>
</el-col>
</el-row>
<!-- 第二行业务信息 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="业务类型">
<el-select v-model="formData.bizType" disabled style="width: 100%">
<el-option
v-for="item in bizTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="业务状态">
<el-select v-model="formData.bizStatus" style="width: 100%">
<el-option
v-for="item in bizStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="销售部门" prop="deptId">
<el-tree-select
v-model="formData.deptId"
:data="deptOptions"
:props="{ value: 'deptId', label: 'deptName', children: 'children' }"
placeholder="请选择销售部门"
clearable
check-strictly
filterable
style="width: 100%"
@change="onDeptChange"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="销售人员" prop="salesmanId">
<el-select
v-model="formData.salesmanId"
placeholder="请选择销售人员"
clearable
filterable
style="width: 100%"
@change="onSalesmanChange"
>
<el-option
v-for="item in salesmanOptions"
:key="item.userId"
:label="item.userName || item.nickName || ''"
:value="item.userId"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第三行操作人员 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="操作员">
<el-input v-model="formData.operatorName" disabled />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="审核员">
<el-input v-model="formData.auditorName" disabled />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="审核日期">
<el-input v-model="formData.auditDate" disabled />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="销售类型" prop="salesType">
<el-select v-model="formData.salesType" placeholder="请选择" style="width: 100%">
<el-option
v-for="item in salesTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第四行供货方式 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="供货方式" prop="supplyMode">
<el-select v-model="formData.supplyMode" placeholder="请选择" style="width: 100%">
<el-option
v-for="item in supplyModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第五行客户信息 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="客户名称" prop="clientId">
<el-input
v-model="formData.clientName"
placeholder="点击选择客户"
:readonly="!isView"
@click="!isView && (showCustomerDialog = true)"
>
<template #append v-if="!isView">
<el-button @click="showCustomerDialog = true">选择</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="合同号">
<el-input v-model="formData.contractNo" placeholder="请输入合同号">
<template #append v-if="!isView">
<el-button @click="handleUploadContract">上传</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="付款条件">
<el-input v-model="formData.paymentTerms" placeholder="如:款到发货" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="交付日期" prop="deliveryDate">
<el-date-picker
v-model="formData.deliveryDate"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<!-- 第六行交期/收货信息 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="交期状态" prop="deliveryStatus">
<el-radio-group v-model="formData.deliveryStatus">
<el-radio value="estimate">预计</el-radio>
<el-radio value="confirm">确认</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="收货人">
<el-input v-model="formData.receiver" placeholder="请输入收货人" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="电话">
<el-input v-model="formData.receiverPhone" placeholder="请输入电话" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="目的国家">
<el-input v-model="formData.destCountry" placeholder="请输入目的国家" />
</el-form-item>
</el-col>
</el-row>
<!-- 第七行地址/备注 -->
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="地址">
<el-input v-model="formData.receiverAddress" placeholder="请输入收货地址" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备注信息">
<el-input v-model="formData.remark" placeholder="请输入备注" />
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
<!-- 物料明细区域 -->
<div class="material-section">
<div class="section-header">
<span class="section-title">物料信息</span>
<div class="section-toolbar" v-if="!isView">
<el-button
type="primary"
size="small"
@click="handleImport"
:disabled="!formData.clientId"
>
引入
</el-button>
<el-button
type="primary"
size="small"
@click="handleBatchAddMaterial"
:disabled="!formData.clientId"
>
<el-icon><Plus /></el-icon>新增物料
</el-button>
</div>
</div>
<el-table
:data="formData.lines"
stripe
border
style="width: 100%"
max-height="400"
empty-text="暂无物料明细请点击上方按钮添加物料"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="itemCode" label="物料编码" width="130">
<template #default="{ row }">
<template v-if="!isView">
<el-input
v-model="row.itemCode"
placeholder="选择物料"
readonly
@click="selectMaterialForRow(row)"
@focus="selectMaterialForRow(row)"
>
<template #append>
<el-button size="small" @click.stop="selectMaterialForRow(row)">选择</el-button>
</template>
</el-input>
</template>
<span v-else>{{ row.itemCode }}</span>
</template>
</el-table-column>
<el-table-column prop="itemName" label="物料名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="specification" label="型号规格" width="150" show-overflow-tooltip />
<el-table-column prop="unitOfMeasure" label="主计量" width="80" />
<el-table-column prop="quantity" label="数量" width="120">
<template #default="{ row }">
<el-input-number
v-if="!isView"
v-model="row.quantity"
:min="0"
:precision="2"
:controls="false"
size="small"
placeholder="请输入"
style="width: 100%"
@change="calculateLineAmount(row)"
@blur="calculateLineAmount(row)"
/>
<span v-else>{{ formatNumber(row.quantity) }}</span>
</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" width="120">
<template #default="{ row }">
<el-input-number
v-if="!isView"
v-model="row.unitPrice"
:min="0"
:precision="2"
:controls="false"
size="small"
placeholder="请输入"
style="width: 100%"
@change="calculateLineAmount(row)"
@blur="calculateLineAmount(row)"
/>
<span v-else>{{ formatCurrency(row.unitPrice) }}</span>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="120" align="right">
<template #default="{ row }">
{{ formatCurrency(row.amount) }}
</template>
</el-table-column>
<el-table-column prop="qualityReq" label="质量要求" width="150">
<template #default="{ row }">
<el-input
v-if="!isView"
v-model="row.qualityReq"
size="small"
placeholder="质量要求"
/>
<span v-else>{{ row.qualityReq || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="150">
<template #default="{ row }">
<el-input
v-if="!isView"
v-model="row.remark"
size="small"
placeholder="备注"
/>
<span v-else>{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="!isView" label="操作" width="80" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link @click="handleDeleteLine($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 合计区域 -->
<div class="summary-row">
<span>合计金额</span>
<span class="total-amount">{{ formatCurrency(totalAmount) }}</span>
</div>
</div>
</el-form>
</el-card>
<!-- 客户选择弹窗 -->
<el-dialog v-model="showCustomerDialog" title="选择客户" width="800px" destroy-on-close>
<div class="dialog-search">
<el-input
v-model="customerSearch"
placeholder="客户名称/联系人"
clearable
style="width: 300px"
@keyup.enter="loadCustomers"
>
<template #append>
<el-button :icon="Search" @click="loadCustomers" />
</template>
</el-input>
</div>
<el-table
v-loading="customerLoading"
:data="customerList"
stripe
border
highlight-current-row
@row-click="handleSelectCustomer"
max-height="400"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="clientName" label="客户名称" min-width="180" />
<el-table-column prop="contact1" label="联系人" width="100" />
<el-table-column prop="contact1Tel" label="手机号" width="130" />
<el-table-column prop="enableFlag" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.enableFlag === 'Y' ? 'success' : 'info'">
{{ row.enableFlag === 'Y' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="primary" link @click.stop="handleSelectCustomer(row)">选择</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- 打印对话框 -->
<PrintDialog
v-model:visible="showPrintDialog"
:config="printConfig"
/>
<!-- 物料选择弹窗 -->
<el-dialog
v-model="showMaterialDialog"
:title="currentEditingRow ? '选择物料(单行)' : '选择物料(可多选)'"
width="900px"
destroy-on-close
>
<div class="dialog-search">
<el-input
v-model="materialSearch"
placeholder="物料编码/名称"
clearable
style="width: 300px"
@keyup.enter="loadMaterials"
>
<template #append>
<el-button :icon="Search" @click="loadMaterials" />
</template>
</el-input>
</div>
<el-table
ref="materialTableRef"
v-loading="materialLoading"
:data="materialList"
stripe
border
:row-key="(row: Material) => row.id"
highlight-current-row
@selection-change="handleMaterialSelectionChange"
@row-click="handleMaterialRowClick"
max-height="400"
>
<el-table-column
v-if="!currentEditingRow"
type="selection"
width="55"
:reserve-selection="true"
/>
<el-table-column
v-else
width="55"
align="center"
>
<template #default="{ row }">
<el-radio
v-model="selectedRadioId"
:value="row.id"
@change="handleRadioSelect(row)"
/>
</template>
</el-table-column>
<el-table-column prop="code" label="物料编码" min-width="160" show-overflow-tooltip />
<el-table-column prop="name" label="物料名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格型号" width="150" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="category" label="分类" width="120" />
<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>
<template #footer>
<div style="text-align: right">
<el-button @click="handleCancelMaterialDialog">取消</el-button>
<el-button
type="primary"
@click="handleConfirmMaterials"
:disabled="selectedMaterials.length === 0"
>
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search, Plus, Check, Finished, ArrowLeft, ArrowDown
} from '@element-plus/icons-vue'
import {
getSalesOrderDetail,
createSalesOrder,
updateSalesOrder,
auditSalesOrder,
unauditSalesOrder,
ORDER_STATUS_MAP,
SALES_TYPE_OPTIONS,
SUPPLY_MODE_OPTIONS,
BIZ_TYPE_OPTIONS,
BIZ_STATUS_OPTIONS,
type SalesOrder,
type SalesOrderLine
} from '@/api/salesOrder'
import PrintDialog from '@/components/print/PrintDialog.vue'
import type { PrintConfig } from '@/components/print/types'
import { getCustomerList, type Customer } from '@/api/customer'
import { getMaterialList, type Material } from '@/api/material'
import { listDept, handleTree, type Dept } from '@/api/system/dept'
import { listUser, type User } from '@/api/system/user'
const route = useRoute()
const router = useRouter()
// ============ 响应式状态 ============
/** 页面模式 */
const isAdd = computed(() => !route.params.id)
const isEdit = computed(() => !!route.params.id && route.path.includes('/form/'))
const isView = computed(() => route.path.includes('/view/'))
/** 页面标题 */
const pageTitle = computed(() => {
if (isAdd.value) return '新增销售订单'
if (isEdit.value) return '编辑销售订单'
return '销售订单详情'
})
/** 保存加载状态 */
const saveLoading = ref(false)
/** 折叠状态 */
const isCollapsed = ref(false)
const activeCollapse = ref(['basic'])
/** 表单引用 */
const formRef = ref()
/** 表单数据 */
const formData = reactive<SalesOrder>({
orderId: undefined,
orderCode: '',
orderDate: new Date().toISOString().split('T')[0],
orderStatus: '开立',
bizType: 'sales_order',
bizStatus: 'normal',
salesType: 'factory',
supplyMode: 'plan',
deliveryStatus: 'estimate',
clientId: undefined,
clientCode: '',
clientName: '',
contractNo: '',
paymentTerms: '',
deliveryDate: '',
receiver: '',
receiverPhone: '',
receiverAddress: '',
destCountry: '',
deptId: undefined,
deptName: '',
salesmanId: undefined,
salesmanName: '',
operatorId: undefined,
operatorName: '',
auditorId: undefined,
auditorName: '',
auditDate: '',
totalAmount: 0,
currency: 'CNY',
remark: '',
lines: []
})
/** 表单验证规则 */
const formRules = {
orderDate: [{ required: true, message: '请选择订单日期', trigger: 'change' }],
clientId: [{ required: true, message: '请选择客户', trigger: 'change' }],
deliveryDate: [{ required: true, message: '请选择交付日期', trigger: 'change' }],
salesType: [{ required: true, message: '请选择销售类型', trigger: 'change' }],
supplyMode: [{ required: true, message: '请选择供货方式', trigger: 'change' }],
deliveryStatus: [{ required: true, message: '请选择交期状态', trigger: 'change' }]
}
/** 选项数据 */
const salesTypeOptions = SALES_TYPE_OPTIONS
const supplyModeOptions = SUPPLY_MODE_OPTIONS
const bizTypeOptions = BIZ_TYPE_OPTIONS
const bizStatusOptions = BIZ_STATUS_OPTIONS
/** 客户选择 */
const showCustomerDialog = ref(false)
const customerSearch = ref('')
const customerList = ref<Customer[]>([])
const customerLoading = ref(false)
/** 物料选择 */
const showMaterialDialog = ref(false)
const materialSearch = ref('')
const materialList = ref<Material[]>([])
const materialLoading = ref(false)
const selectedMaterials = ref<Material[]>([])
const currentEditingRow = ref<SalesOrderLine | null>(null)
const materialTableRef = ref()
const selectedRadioId = ref<number | undefined>(undefined)
/** 销售部门、销售人员选项 */
const deptOptions = ref<Dept[]>([])
const userOptions = ref<User[]>([])
// ============ 计算属性 ============
/** 销售人员选项:有选中部门时仅显示该部门用户,否则显示全部 */
const salesmanOptions = computed(() => {
const list = userOptions.value
const deptId = formData.deptId
if (deptId) {
return list.filter(u => u.deptId === deptId)
}
return list
})
/** 合计金额 */
const totalAmount = computed(() => {
return (formData.lines || []).reduce((sum, line) => sum + (line.amount || 0), 0)
})
/** 是否可审核 */
const canAudit = computed(() => {
return (isEdit.value || isView.value) &&
formData.orderStatus === '开立' &&
(formData.lines?.length || 0) > 0
})
/** 是否可反审核 */
const canUnaudit = computed(() => {
return (isEdit.value || isView.value) && formData.orderStatus === '审核'
})
// ============ 方法 ============
/** 获取状态标签类型 */
function getStatusType(status?: string): string {
return ORDER_STATUS_MAP[status || '']?.type || ''
}
/** 获取状态标签文本 */
function getStatusLabel(status?: string): string {
return ORDER_STATUS_MAP[status || '']?.label || status || '开立'
}
/** 格式化数字 */
function formatNumber(val: number | undefined): string {
if (val === undefined || val === null) return '-'
return val.toLocaleString()
}
/** 格式化金额 */
function formatCurrency(val: number | undefined): string {
if (val === undefined || val === null) return '-'
return '¥' + val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** 切换折叠 */
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
activeCollapse.value = isCollapsed.value ? [] : ['basic']
}
/** 加载订单数据 */
async function loadOrderData() {
const id = route.params.id as string
if (!id) return
try {
const data = await getSalesOrderDetail(Number(id))
// 先处理明细行,确保 lines 数组正确赋值
const lines = data.lines || data.slaveList || []
// 使用解构赋值确保响应式更新
Object.keys(data).forEach(key => {
if (key !== 'lines' && key !== 'slaveList') {
(formData as any)[key] = (data as any)[key]
}
})
// 明确赋值 lines 数组
formData.lines = lines.map((line: SalesOrderLine) => ({
lineId: line.lineId,
orderId: line.orderId,
itemId: line.itemId,
itemCode: line.itemCode || '',
itemName: line.itemName || '',
specification: line.specification || '',
unitOfMeasure: line.unitOfMeasure || '',
quantity: line.quantity,
unitPrice: line.unitPrice || 0,
amount: line.amount || 0,
deliveredQty: line.deliveredQty,
qualityReq: line.qualityReq || '',
remark: line.remark || '',
delFlag: line.delFlag
}))
console.log('加载订单明细:', formData.lines)
} catch (error) {
console.error('加载订单失败:', error)
ElMessage.error('加载订单数据失败')
}
}
/** 在部门树中递归查找指定部门 */
function findDeptInTree(list: Dept[], deptId: number): Dept | undefined {
for (const d of list) {
if (d.deptId === deptId) return d
if (d.children?.length) {
const found = findDeptInTree(d.children, deptId)
if (found) return found
}
}
return undefined
}
/** 加载部门列表(树形结构) */
async function loadDeptList() {
try {
const res: any = await listDept()
const data = res?.data ?? res
const raw = Array.isArray(data) ? data : (data?.list ?? data?.rows ?? [])
deptOptions.value = handleTree(raw)
} catch (e) {
console.error('加载部门列表失败', e)
deptOptions.value = []
}
}
/** 加载用户列表(用于销售人员下拉) */
async function loadUserList() {
try {
const res = await listUser({ status: '0', pageSize: 500 })
userOptions.value = res?.rows ?? []
} catch (e) {
console.error('加载用户列表失败', e)
userOptions.value = []
}
}
/** 销售部门变更:同步名称,清空不在该部门下的销售人员 */
function onDeptChange(deptId: number | undefined) {
if (deptId) {
const dept = findDeptInTree(deptOptions.value, deptId)
formData.deptName = dept?.deptName ?? ''
const inDept = salesmanOptions.value.some(u => u.userId === formData.salesmanId)
if (!inDept) {
formData.salesmanId = undefined
formData.salesmanName = ''
}
} else {
formData.deptName = ''
}
}
/** 销售人员变更:同步名称 */
function onSalesmanChange(userId: number | undefined) {
if (userId) {
const user = userOptions.value.find(u => u.userId === userId)
formData.salesmanName = user?.userName ?? user?.nickName ?? ''
} else {
formData.salesmanName = ''
}
}
/** 加载客户列表 */
async function loadCustomers() {
customerLoading.value = true
try {
const res = await getCustomerList({ clientName: customerSearch.value })
customerList.value = res.list
} catch (error) {
console.error('加载客户失败:', error)
} finally {
customerLoading.value = false
}
}
/** 选择客户 */
function handleSelectCustomer(row: Customer) {
formData.clientId = row.clientId || row.id
formData.clientCode = row.clientCode || row.code
formData.clientName = row.clientName || row.name
formData.receiver = row.contact1 || row.contact
formData.receiverPhone = row.contact1Tel || row.phone
formData.receiverAddress = row.address
showCustomerDialog.value = false
}
/** 加载物料列表 */
async function loadMaterials() {
materialLoading.value = true
try {
const searchText = materialSearch.value?.trim() || ''
// 支持按编码或名称搜索;仅显示产品物料(产品物料标识=PRODUCT
const params: any = {
pageSize: 100, // 物料选择对话框不分页,一次性加载更多
itemOrProduct: 'PRODUCT',
enableFlag: 'Y' // 只显示启用状态的物料
}
if (searchText) {
// 如果搜索文本是纯数字或包含字母,可能是编码
// 否则按名称搜索
if (/^[A-Za-z0-9]+$/.test(searchText)) {
params.itemCode = searchText
} else {
params.itemName = searchText
}
}
const res = await getMaterialList(params)
materialList.value = res.list
} catch (error) {
console.error('加载物料失败:', error)
ElMessage.error('加载物料列表失败')
materialList.value = []
} finally {
materialLoading.value = false
}
}
/** 物料选择变化 */
function handleMaterialSelectionChange(rows: Material[]) {
if (currentEditingRow.value) {
// 单行选择模式:只保留最后一个选择
const lastRow = rows[rows.length - 1]
selectedMaterials.value = lastRow ? [lastRow] : []
} else {
// 批量添加模式:保留所有选择
selectedMaterials.value = [...rows]
}
}
/** 单选框选择(单行模式) */
function handleRadioSelect(row: Material) {
selectedMaterials.value = [row]
}
/** 物料行点击(单行选择模式) */
function handleMaterialRowClick(row: Material) {
if (currentEditingRow.value) {
// 单行选择模式:选中该行并同步单选框
selectedRadioId.value = row.id
selectedMaterials.value = [row]
}
}
/** 取消物料选择对话框 */
function handleCancelMaterialDialog() {
showMaterialDialog.value = false
selectedMaterials.value = []
currentEditingRow.value = null
}
/** 确认选择物料 */
function handleConfirmMaterials() {
if (selectedMaterials.value.length === 0) {
ElMessage.warning('请选择物料')
return
}
if (currentEditingRow.value) {
// 单行选择模式:更新当前行
const m = selectedMaterials.value[0]
if (m) {
currentEditingRow.value.itemId = m.id
currentEditingRow.value.itemCode = m.code
currentEditingRow.value.itemName = m.name
currentEditingRow.value.specification = m.spec || ''
currentEditingRow.value.unitOfMeasure = m.unit
// 保持数量和单价不变或默认,让用户手动输入
if (currentEditingRow.value.quantity === undefined) {
currentEditingRow.value.quantity = undefined
}
if (currentEditingRow.value.unitPrice === undefined || currentEditingRow.value.unitPrice === 0) {
currentEditingRow.value.unitPrice = 0
}
calculateLineAmount(currentEditingRow.value)
}
currentEditingRow.value = null
} else {
// 批量添加模式:为每个选中的物料创建新行
const newLines: SalesOrderLine[] = selectedMaterials.value.map(m => ({
itemId: m.id,
itemCode: m.code,
itemName: m.name,
specification: m.spec || '',
unitOfMeasure: m.unit,
quantity: undefined,
unitPrice: 0,
amount: 0,
qualityReq: '',
remark: ''
}))
// 过滤掉已存在的空行(没有选择物料的行)
const existingValidLines = (formData.lines || []).filter(line => line.itemId && line.itemCode)
formData.lines = [...existingValidLines, ...newLines]
ElMessage.success(`成功添加 ${newLines.length} 个物料`)
}
showMaterialDialog.value = false
selectedMaterials.value = []
}
/** 添加空行 */
function handleAddEmptyLine() {
const newLine: SalesOrderLine = {
itemId: undefined,
itemCode: '',
itemName: '',
specification: '',
unitOfMeasure: '',
quantity: undefined, // 默认为空,让用户手动输入
unitPrice: 0,
amount: 0,
qualityReq: '',
remark: ''
}
formData.lines = [...(formData.lines || []), newLine]
}
/** 批量新增物料(打开多选对话框) */
function handleBatchAddMaterial() {
currentEditingRow.value = null // null表示批量添加模式
selectedMaterials.value = []
materialSearch.value = ''
showMaterialDialog.value = true
}
/** 为单行选择物料 */
function selectMaterialForRow(row: SalesOrderLine) {
currentEditingRow.value = row
selectedMaterials.value = []
materialSearch.value = ''
showMaterialDialog.value = true
}
/** 计算行金额 */
function calculateLineAmount(row: SalesOrderLine) {
row.amount = (row.quantity || 0) * (row.unitPrice || 0)
}
/** 删除明细行 */
function handleDeleteLine(index: number) {
formData.lines?.splice(index, 1)
}
/** 引入(从合同引入物料) */
function handleImport() {
ElMessage.info('引入功能开发中')
}
/** 上传合同 */
function handleUploadContract() {
ElMessage.info('上传合同功能开发中')
}
/** 返回列表 */
function handleBack() {
router.push('/sales/order')
}
/** 保存 */
async function handleSave() {
try {
await formRef.value?.validate()
// 过滤空行(没有选择物料的行)
const validLines = (formData.lines || []).filter(
(line): line is SalesOrderLine => Boolean(line && line.itemId && line.itemCode)
)
if (validLines.length === 0) {
ElMessage.warning('请添加物料明细')
return
}
// 验证每行的数量
for (const [i, line] of validLines.entries()) {
if (!line.quantity || line.quantity <= 0) {
ElMessage.warning(`${i + 1} 行物料数量必须大于0`)
return
}
}
saveLoading.value = true
// 更新表单中的有效行
formData.lines = validLines
// 更新总金额
formData.totalAmount = totalAmount.value
if (isAdd.value) {
const res = await createSalesOrder(formData)
ElMessage.success('保存成功')
// 跳转到编辑页
router.replace(`/sales/order/form/${res.orderId}`)
} else {
if (formData.orderId == null) {
ElMessage.warning('订单ID缺失请刷新后重试或使用新增保存')
return
}
await updateSalesOrder(formData)
ElMessage.success('保存成功')
}
} catch (error: any) {
if (error !== false) {
console.error('保存失败:', error)
ElMessage.error('保存失败')
}
} finally {
saveLoading.value = false
}
}
/** 撤回 */
async function handleWithdraw() {
if (formData.orderId == null) {
ElMessage.warning('订单ID缺失无法撤回')
return
}
try {
await ElMessageBox.confirm('确认撤回此订单吗?', '提示', { type: 'warning' })
formData.orderStatus = '草稿'
await updateSalesOrder(formData)
ElMessage.success('撤回成功')
loadOrderData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('撤回失败')
}
}
}
/** 审核 */
async function handleAudit() {
if (formData.orderId == null) {
ElMessage.warning('请先保存订单后再审核')
return
}
try {
await ElMessageBox.confirm('确认审核此订单吗?', '提示', { type: 'info' })
await auditSalesOrder(formData.orderId)
ElMessage.success('审核成功')
loadOrderData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '审核失败')
}
}
}
/** 反审核 */
async function handleUnaudit() {
if (formData.orderId == null) {
ElMessage.warning('订单ID异常无法反审核')
return
}
try {
await ElMessageBox.confirm('确认反审核此订单吗?', '警告', { type: 'warning' })
await unauditSalesOrder(formData.orderId)
ElMessage.success('反审核成功')
loadOrderData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error?.message || '反审核失败')
}
}
}
/** 打印 */
const showPrintDialog = ref(false)
const printConfig = computed<PrintConfig>(() => ({
title: '销售订单',
qrCodeValue: formData.orderCode || undefined,
headerFields: [
{ label: '单据编码', value: formData.orderCode || '' },
{ label: '单据日期', value: formData.orderDate || '' },
{ label: '单据状态', value: getStatusLabel(formData.orderStatus) },
{ label: '业务类型', value: bizTypeOptions.find(o => o.value === formData.bizType)?.label || formData.bizType || '' },
{ label: '客户名称', value: formData.clientName || '' },
{ label: '销售人员', value: formData.salesmanName || '' },
{ label: '销售部门', value: formData.deptName || '' },
{ label: '合同号', value: formData.contractNo || '' },
{ label: '交付日期', value: formData.deliveryDate || '' },
{ label: '付款条件', value: formData.paymentTerms || '' },
{ label: '收货人', value: formData.receiver || '' },
{ label: '收货电话', value: formData.receiverPhone || '' },
{ label: '收货地址', value: formData.receiverAddress || '', span: 2 },
{ label: '备注', value: formData.remark || '', span: 2 }
],
columns: [
{ prop: '_index', label: '序号', type: 'index', width: '50px', align: 'center' },
{ prop: 'itemCode', label: '物料编码', width: '120px' },
{ prop: 'itemName', label: '物料名称', width: '150px' },
{ prop: 'specification', label: '型号规格', width: '120px' },
{ prop: 'unitOfMeasure', label: '主计量', width: '70px', align: 'center' },
{ prop: 'quantity', label: '数量', width: '80px', align: 'right' },
{ prop: 'unitPrice', label: '单价', width: '90px', align: 'right', type: 'amount' },
{ prop: 'amount', label: '金额', width: '100px', align: 'right', type: 'amount' },
{ prop: 'qualityReq', label: '质量要求', width: '100px' },
{ prop: 'remark', label: '备注' }
],
data: formData.lines || [],
summaries: [
{ label: '总金额', value: formatCurrency(totalAmount.value) }
],
footer: {
creator: formData.operatorName || formData.createBy || '',
approver: formData.auditorName || ''
}
}))
function handlePrint() {
showPrintDialog.value = true
}
/** 编辑(从查看页跳转) */
function handleEdit() {
router.push(`/sales/order/form/${formData.orderId}`)
}
/** 新增(从查看/编辑页跳转) */
function handleAdd() {
router.push('/sales/order/form')
}
// ============ 监听 ============
watch(showCustomerDialog, (val) => {
if (val) loadCustomers()
})
watch(showMaterialDialog, (val) => {
if (val) {
// 打开对话框时加载物料列表
loadMaterials()
// 清空之前的选择
selectedMaterials.value = []
selectedRadioId.value = undefined
// 清空表格选择状态
nextTick(() => {
materialTableRef.value?.clearSelection()
})
} else {
// 关闭对话框时清空状态
selectedMaterials.value = []
selectedRadioId.value = undefined
currentEditingRow.value = null
materialSearch.value = ''
}
})
// ============ 生命周期 ============
onMounted(() => {
loadDeptList()
loadUserList()
if (route.params.id) {
loadOrderData()
} else {
// 新增模式默认添加一个空行与目标ERP系统一致
handleAddEmptyLine()
}
})
</script>
<style scoped>
.form-container {
padding: 8px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff;
border-radius: 4px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 16px;
font-weight: 500;
color: #303133;
}
.header-right {
display: flex;
gap: 8px;
}
.main-card :deep(.el-card__body) {
padding: 16px;
}
.main-card :deep(.el-collapse-item__header) {
font-weight: 500;
font-size: 14px;
}
.main-card :deep(.el-collapse-item__content) {
padding-top: 16px;
}
.material-section {
margin-top: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.section-title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.section-toolbar {
display: flex;
gap: 8px;
}
.summary-row {
margin-top: 16px;
text-align: right;
font-size: 14px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 4px;
}
.total-amount {
font-size: 20px;
font-weight: bold;
color: #f56c6c;
margin-left: 8px;
}
.dialog-search {
margin-bottom: 16px;
}
</style>