This commit is contained in:
zlwu committed 2026-06-25 08:06:32 +08:00
1 parent 6b6c80f182
commit 86450f38a1
53 files changed
+20800 -5339

No files matched your search

@@ -0,0 +1,310 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item v-if="actionType === 'submit'" label="审批类型" class="form-col-span-2">
<el-radio-group v-model="form.approvalType">
<el-radio value="sequential">顺序审批</el-radio>
<el-radio value="parallel">会签审批</el-radio>
<el-radio value="or">或签审批</el-radio>
</el-radio-group>
<div class="form-label-tip form-tip-block">
顺序审批:按顺序依次审批;会签:所有人都需审批;或签:任一人审批即可
</div>
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="approverIds" class="form-col-span-2">
<template #label>
选择审批人<span class="form-label-tip">支持搜索用户姓名</span>
</template>
<el-select
v-model="form.approverIds"
multiple
filterable
remote
reserve-keyword
placeholder="请选择审批人"
:remote-method="handleSearchUser"
:loading="userLoading"
style="width: 100%"
>
<el-option
v-for="user in approverList"
:key="user.userId"
:label="`${user.nickName}(${user.userName})`"
:value="String(user.userId)"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="actionType === 'approve' || actionType === 'reject'"
label="审批意见"
prop="comment"
class="form-col-span-2"
>
<el-input
v-model="form.comment"
type="textarea"
:rows="5"
placeholder="请输入审批意见"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item v-if="actionType === 'submit'" prop="remark" class="form-col-span-2">
<template #label>
提交说明<span class="form-label-tip">可选</span>
</template>
<el-input
v-model="form.remark"
type="textarea"
:rows="5"
placeholder="请输入提交说明"
maxlength="500"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<section v-if="actionType === 'submit' && form.approverIds.length > 0" class="approval-dialog-section">
<h3 class="approval-section-title">审批流程预览</h3>
<div class="approval-flow">
<el-steps :active="0" align-center>
<el-step
v-for="(approverId, index) in form.approverIds"
:key="index"
:title="getApproverName(approverId)"
/>
</el-steps>
</div>
</section>
<template #footer>
<div class="approval-dialog-actions">
<el-button @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">
<el-icon><Check /></el-icon>{{ confirmButtonText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { freightApi } from '@/api/pl'
import { fetchUserOptions } from '@/api/admin'
interface ApproverUser {
userId: string
nickName: string
userName: string
}
const emit = defineEmits<{ success: [] }>()
const dialogVisible = ref(false)
const actionType = ref<'submit' | 'approve' | 'reject'>('submit')
const settlementId = ref('')
const approvalId = ref('')
const form = ref({
approvalType: 'sequential' as 'sequential' | 'parallel' | 'or',
approverIds: [] as string[],
comment: '',
remark: ''
})
const approverList = ref<ApproverUser[]>([])
const submitLoading = ref(false)
const userLoading = ref(false)
const formRef = ref<ElFormInstance>()
const rules = computed(() => {
const baseRules: Record<string, unknown[]> = {}
if (actionType.value === 'submit') {
baseRules.approverIds = [{ required: true, message: '请选择审批人', trigger: 'change' }]
}
if (actionType.value === 'approve' || actionType.value === 'reject') {
baseRules.comment = [{ required: true, message: '请输入审批意见', trigger: 'blur' }]
}
return baseRules
})
const dialogTitle = computed(() => {
const titleMap = {
submit: '提交审批',
approve: '审批通过',
reject: '审批驳回'
}
return titleMap[actionType.value]
})
const confirmButtonText = computed(() => {
const textMap = {
submit: '提交',
approve: '通过',
reject: '驳回'
}
return textMap[actionType.value]
})
function openSubmit(id: string) {
settlementId.value = id
approvalId.value = ''
actionType.value = 'submit'
dialogVisible.value = true
resetForm()
loadApproverList()
}
function openApprove(id: string, currentApprovalId: string) {
settlementId.value = id
approvalId.value = currentApprovalId
actionType.value = 'approve'
dialogVisible.value = true
resetForm()
}
function openReject(id: string, currentApprovalId: string) {
settlementId.value = id
approvalId.value = currentApprovalId
actionType.value = 'reject'
dialogVisible.value = true
resetForm()
}
async function loadApproverList(nickName?: string) {
userLoading.value = true
try {
const res = await fetchUserOptions()
const options = (res as { data?: unknown })?.data ?? res ?? []
let list = (Array.isArray(options) ? options : []).map((item: Record<string, unknown>) => ({
userId: String(item.value ?? item.userId ?? item.id ?? ''),
nickName: String(item.label ?? item.nickName ?? item.userName ?? ''),
userName: String(item.userNo ?? item.userName ?? '')
}))
if (nickName) {
const q = nickName.toLowerCase()
list = list.filter(
(u) => u.nickName.toLowerCase().includes(q) || u.userName.toLowerCase().includes(q)
)
}
approverList.value = list
} catch {
ElMessage.error('获取审批人列表失败')
approverList.value = []
} finally {
userLoading.value = false
}
}
function handleSearchUser(query: string) {
loadApproverList(query || undefined)
}
function getApproverName(approverId: string): string {
const approver = approverList.value.find((item) => item.userId === approverId)
return approver ? approver.nickName : ''
}
async function handleConfirm() {
await formRef.value?.validate()
submitLoading.value = true
try {
if (actionType.value === 'submit') {
await freightApi.submitApproval(settlementId.value, {
approvalType: form.value.approvalType,
approverIds: form.value.approverIds,
remark: form.value.remark || undefined
})
ElMessage.success('提交成功')
} else if (actionType.value === 'approve') {
await freightApi.processApproval(approvalId.value, {
action: 'approve',
comment: form.value.comment
})
ElMessage.success('审批通过')
} else if (actionType.value === 'reject') {
await freightApi.processApproval(approvalId.value, {
action: 'reject',
comment: form.value.comment
})
ElMessage.success('已驳回')
}
dialogVisible.value = false
emit('success')
} finally {
submitLoading.value = false
}
}
function handleClose() {
dialogVisible.value = false
resetForm()
}
function resetForm() {
form.value = {
approvalType: 'sequential',
approverIds: [],
comment: '',
remark: ''
}
formRef.value?.clearValidate()
}
defineExpose({ openSubmit, openApprove, openReject })
</script>
<style scoped lang="scss">
.form-tip-block {
display: block;
margin-top: 6px;
}
.approval-dialog-section {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--border, #d9e8e2);
}
.approval-section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
}
.approval-flow {
padding: 8px 0;
}
.approval-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,163 @@
<template>
<el-dialog
v-model="visible"
title="财务发放登记"
width="640px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
v-loading="loading"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="结算单号">
<el-input :model-value="settlement?.settlementNo" disabled />
</el-form-item>
<el-form-item label="运费合计(元)">
<el-input :model-value="formatAmount(settlement?.totalFreight)" disabled />
</el-form-item>
<el-form-item label="汇款日期" prop="remittanceDate">
<el-date-picker
v-model="form.remittanceDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择汇款日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="汇款金额(元)" prop="remittanceAmount">
<el-input-number
v-model="form.remittanceAmount"
:min="0.01"
:precision="2"
:controls="false"
placeholder="请输入汇款金额"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="汇款凭证" prop="voucherUrls" class="form-col-span-2">
<AttachmentUploadField
v-model="voucherUrlsStr"
folder="pl/freight-voucher"
hint="请上传汇款凭证,至少 1 个附件"
/>
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="500"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确认发放
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { freightApi, type FreightSettlement } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
const emit = defineEmits<{ success: [] }>()
const visible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const settlement = ref<FreightSettlement | null>(null)
const formRef = ref<ElFormInstance>()
const voucherUrlsStr = ref('')
const form = reactive({
remittanceDate: '',
remittanceAmount: undefined as number | undefined,
remark: ''
})
const rules = {
remittanceDate: [{ required: true, message: '请选择汇款日期', trigger: 'change' }],
remittanceAmount: [{ required: true, message: '请输入汇款金额', trigger: 'blur' }],
voucherUrls: [
{
validator: (_rule: unknown, _value: unknown, callback: (err?: Error) => void) => {
const urls = voucherUrlsStr.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (!urls.length) callback(new Error('请上传至少一个汇款凭证'))
else callback()
},
trigger: 'change'
}
]
}
function formatAmount(amount?: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function open(id: string) {
visible.value = true
loading.value = true
form.remittanceDate = ''
form.remittanceAmount = undefined
form.remark = ''
voucherUrlsStr.value = ''
try {
const res = await freightApi.get(id)
settlement.value = (res.data ?? res) as FreightSettlement
form.remittanceAmount = settlement.value.totalFreight
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!settlement.value) return
await formRef.value?.validate()
const voucherUrls = voucherUrlsStr.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
submitLoading.value = true
try {
await freightApi.disburse(settlement.value.id, {
remittanceDate: form.remittanceDate,
remittanceAmount: form.remittanceAmount!,
voucherUrls,
remark: form.remark || undefined
})
ElMessage.success('发放登记成功')
visible.value = false
emit('success')
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>
@@ -0,0 +1,162 @@
<template>
<el-dialog
v-model="visible"
title="编辑结算单"
width="720px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
v-loading="loading"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="结算单号">
<el-input :model-value="settlement?.settlementNo" disabled />
</el-form-item>
<el-form-item label="运费合计(元)">
<el-input :model-value="formatAmount(settlement?.totalFreight)" disabled />
</el-form-item>
<el-form-item label="发票号" prop="invoiceNo">
<el-input v-model="form.invoiceNo" placeholder="请输入发票号" clearable />
</el-form-item>
<el-form-item label="开票日期" prop="invoiceDate">
<el-date-picker
v-model="form.invoiceDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开票日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="发票金额(元)" prop="invoiceAmount">
<el-input-number
v-model="form.invoiceAmount"
:min="0"
:precision="2"
:controls="false"
placeholder="请输入发票金额"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="500"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<section class="dialog-section">
<h3 class="dialog-section-title">关联运输明细</h3>
<div class="list-body list-wrap dialog-list-body-only">
<el-table :data="settlement?.transports || []" class="list-table" stripe empty-text="暂无数据">
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column prop="transportNo" label="运输编号" min-width="160" show-overflow-tooltip />
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="freightAmount" label="运费(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.freightAmount) }}</template>
</el-table-column>
</el-table>
</div>
</section>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { freightApi, type FreightSettlement } from '@/api/pl'
import { formatMoneyDisplay } from '@/utils/money-format'
const emit = defineEmits<{ success: [] }>()
const visible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const settlement = ref<FreightSettlement | null>(null)
const formRef = ref<ElFormInstance>()
const form = reactive({
invoiceNo: '',
invoiceDate: '',
invoiceAmount: undefined as number | undefined,
remark: ''
})
const rules = {}
function formatAmount(amount?: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function open(id: string) {
visible.value = true
loading.value = true
try {
const res = await freightApi.get(id)
settlement.value = (res.data ?? res) as FreightSettlement
form.invoiceNo = settlement.value.invoiceNo || ''
form.invoiceDate = settlement.value.invoiceDate || ''
form.invoiceAmount = settlement.value.invoiceAmount
form.remark = settlement.value.remark || ''
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!settlement.value) return
submitLoading.value = true
try {
await freightApi.update(settlement.value.id, {
invoiceNo: form.invoiceNo || undefined,
invoiceDate: form.invoiceDate || undefined,
invoiceAmount: form.invoiceAmount,
remark: form.remark || undefined
})
ElMessage.success('保存成功')
visible.value = false
emit('success')
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.dialog-section {
margin-top: 16px;
}
.dialog-section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -0,0 +1,230 @@
<template>
<el-dialog
v-model="visible"
:title="settlement?.settlementNo || '结算单详情'"
width="calc(100vw - 100px)"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout pl-freight-view-dialog"
modal-class="order-module-dialog-mask"
>
<div v-loading="loading" class="view-dialog-body">
<div v-if="settlement" class="view-hero">
<div class="view-hero-main">
<h3 class="view-hero-title">{{ settlement.settlementNo }}</h3>
<el-tag :type="getSettlementStatusTagType(settlement.settlementStatus)" size="small">
{{ getSettlementStatusLabel(settlement.settlementStatus) }}
</el-tag>
</div>
<div class="view-hero-summary">
<span class="view-hero-label">运费合计</span>
<span class="view-hero-amount">{{ formatAmount(settlement.totalFreight) }}</span>
</div>
</div>
<section v-if="settlement" class="view-section">
<h4 class="view-section-title">发票信息</h4>
<div class="view-kv-grid">
<div class="view-kv-item">
<span class="view-kv-label">发票号</span>
<span class="view-kv-value">{{ settlement.invoiceNo || '—' }}</span>
</div>
<div class="view-kv-item">
<span class="view-kv-label">开票日期</span>
<span class="view-kv-value">{{ settlement.invoiceDate || '—' }}</span>
</div>
<div class="view-kv-item">
<span class="view-kv-label">发票金额</span>
<span class="view-kv-value">{{
settlement.invoiceAmount ? formatAmount(settlement.invoiceAmount) : '—'
}}</span>
</div>
<div class="view-kv-item">
<span class="view-kv-label">创建时间</span>
<span class="view-kv-value">{{ formatDateTime(settlement.createTime) }}</span>
</div>
<div class="view-kv-item view-kv-item--full">
<span class="view-kv-label">备注</span>
<span class="view-kv-value">{{ settlement.remark || '—' }}</span>
</div>
</div>
</section>
<section v-if="settlement" class="view-section">
<h4 class="view-section-title">关联运输明细</h4>
<div class="list-body list-wrap dialog-list-body-only">
<el-table :data="settlement.transports || []" class="list-table" stripe empty-text="暂无数据">
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column prop="transportNo" label="运输编号" min-width="160" show-overflow-tooltip />
<el-table-column prop="licensePlate" label="车牌号" min-width="120" align="center" />
<el-table-column prop="driverName" label="司机" min-width="100" align="center" />
<el-table-column prop="freightAmount" label="运费(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.freightAmount) }}</template>
</el-table-column>
</el-table>
</div>
</section>
<section v-if="settlement?.approvalFlow?.length" class="view-section">
<h4 class="view-section-title">审批流程</h4>
<ApprovalFlowView :nodes="approvalNodes" :loading="false" />
</section>
<section
v-if="settlement?.settlementStatus === 'confirmed' && settlement.vouchers?.length"
class="view-section"
>
<h4 class="view-section-title">汇款凭证</h4>
<div class="list-body list-wrap dialog-list-body-only">
<el-table :data="settlement.vouchers" class="list-table" stripe>
<el-table-column prop="remittanceDate" label="汇款日期" min-width="120" align="center" />
<el-table-column prop="remittanceAmount" label="汇款金额" min-width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.remittanceAmount) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="createTime" label="登记时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { freightApi, type FreightSettlement } from '@/api/pl'
import ApprovalFlowView from '@/components/bx/base/ApprovalFlowView.vue'
import {
getSettlementStatusLabel,
getSettlementStatusTagType,
mapApprovalFlowToNodes
} from '@/views/pl/freight/freight-status'
import { formatMoneyDisplay } from '@/utils/money-format'
import { formatDateTime } from '@/utils/format'
const visible = ref(false)
const loading = ref(false)
const settlement = ref<FreightSettlement | null>(null)
const approvalNodes = computed(() =>
mapApprovalFlowToNodes(settlement.value?.approvalFlow || [])
)
function formatAmount(amount?: number) {
if (!amount) return '¥0.00'
return formatMoneyDisplay(amount)
}
async function open(id: string) {
visible.value = true
loading.value = true
try {
const res = await freightApi.get(id)
settlement.value = (res.data ?? res) as FreightSettlement
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.view-dialog-body {
min-height: 120px;
}
.view-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
background: var(--primary-bg, #ecfdf5);
border: 1px solid #a7f3d0;
}
.view-hero-main {
display: flex;
align-items: center;
gap: 10px;
}
.view-hero-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.view-hero-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.view-hero-label {
font-size: 12px;
color: var(--text-muted, #64748b);
}
.view-hero-amount {
font-size: 20px;
font-weight: 700;
color: var(--primary-dark, #059669);
}
.view-section {
margin-bottom: 20px;
}
.view-section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 24px;
}
.view-kv-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.view-kv-item--full {
grid-column: 1 / -1;
}
.view-kv-label {
font-size: 12px;
color: var(--text-muted, #64748b);
}
.view-kv-value {
font-size: 14px;
color: var(--text, #1e293b);
word-break: break-all;
}
@media (max-width: 640px) {
.view-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,400 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-plan-archive-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@closed="handleClosed"
@opened="handleOpened"
>
<div class="pl-arrange-layout">
<section class="pl-arrange-form-panel">
<div v-loading="loading" class="pl-arrange-form-scroll">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">产品实际数量</h3>
<PlPlanFormProductTable
:products="productRows"
show-actual-qty
editable-actual-qty-only
/>
</section>
<section class="pl-plan-form-section">
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</el-form-item>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number
v-model="formData.distanceKm"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
style="width: 100%"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
style="width: 100%"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
<el-form-item label="是否开票">
<el-switch v-model="formData.hasInvoice" :active-value="1" :inactive-value="0" />
</el-form-item>
</template>
</div>
</section>
</div>
</section>
<section class="pl-arrange-view-panel">
<h3 class="pl-arrange-view-title">计划单查看</h3>
<PlPlanViewTabs v-if="planId" ref="tabsRef" :plan-id="planId" embedded />
</section>
</div>
<template #footer>
<div class="dialog-footer pl-arrange-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>{{ submitLabel }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import {
planApi,
transportApi,
type Plan,
type PlanArchiveBody,
type PlanProduct,
type Transport
} from '@/api/pl'
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { pickBxData } from '@/utils/http/bx'
const props = defineProps<{
visible: boolean
planId: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const innerVisible = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const loading = ref(false)
const submitLoading = ref(false)
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
const plan = ref<Partial<Plan>>({})
const productRows = ref<PlanFormProductRow[]>([])
const formData = reactive({
hasFreight: 0,
distanceKm: null as number | null,
freightType: 'by_weight',
hasInvoice: 0
})
const hasFreightRef = computed(() => formData.hasFreight)
const freightTypeRef = computed(() => formData.freightType)
const freightCalc = usePlFreightCalc({
products: productRows,
hasFreight: hasFreightRef,
freightType: freightTypeRef
})
const freightUnitLabel = computed(() => resolveFreightUnitLabel(productRows.value))
const isAdjustMode = computed(() => plan.value.archiveStatus === 'archived')
const dialogTitle = computed(() => {
const no = plan.value.planNo || ''
return isAdjustMode.value
? no
? `调整归档内容 · ${no}`
: '调整归档内容'
: no
? `运输计划归档 · ${no}`
: '运输计划归档'
})
const submitLabel = computed(() => (isAdjustMode.value ? '确认保存' : '确认归档'))
function mapPlanProducts(products: PlanProduct[]): PlanFormProductRow[] {
return products.map((item, index) => ({
id: item.id,
productId: String(item.productId || index),
productNo: item.productNo || String(item.productId || index),
productName: item.productName || '',
model: item.model,
categoryName: item.categoryName,
unit: item.unit || '吨',
quantity: Number(item.quantity ?? item.inboundEstimateQty ?? item.outboundEstimateQty ?? 0),
actualQty: Number(
item.actualQty ?? item.quantity ?? item.inboundEstimateQty ?? item.outboundEstimateQty ?? 0
),
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
taxPrice: item.taxPrice != null ? Number(item.taxPrice) : undefined,
taxAmount: item.taxAmount != null ? Number(item.taxAmount) : undefined,
taxSubtotal: item.taxSubtotal != null ? Number(item.taxSubtotal) : undefined,
remark: item.remark,
isExplosive: Number(item.isExplosive ?? 0),
isToxic: Number(item.isToxic ?? 0)
}))
}
function resetState() {
plan.value = {}
productRows.value = []
Object.assign(formData, {
hasFreight: 0,
distanceKm: null,
freightType: 'by_weight',
hasInvoice: 0
})
freightCalc.resetFreightCalc()
}
async function applyFreightPrefill(data: Plan) {
formData.hasFreight = data.hasFreight ?? 0
freightCalc.resetFreightCalc()
if (data.freightAmount != null && data.hasFreight === 1) {
freightCalc.freightAmount.value = Number(data.freightAmount)
freightCalc.freightAmountTouched.value = true
}
if (!data.transportId) return
try {
const transportRes = await transportApi.get(data.transportId)
const transport = pickBxData<Transport>(transportRes)
formData.distanceKm = transport.distanceKm != null ? Number(transport.distanceKm) : null
formData.freightType = transport.freightType || 'by_weight'
formData.hasInvoice = transport.hasInvoice ?? 0
if (transport.transportQty != null) {
freightCalc.transportQty.value = Number(transport.transportQty)
freightCalc.transportQtyTouched.value = true
}
if (transport.freightPrice != null) {
freightCalc.freightPrice.value = Number(transport.freightPrice)
}
if (transport.freightAmount != null) {
freightCalc.freightAmount.value = Number(transport.freightAmount)
freightCalc.freightAmountTouched.value = true
}
} catch {
// 回退计划主表冗余字段
}
}
async function handleOpened() {
if (!props.planId) return
loading.value = true
resetState()
try {
const res = await planApi.get(props.planId)
const data = pickBxData<Plan>(res)
if (!data?.id) {
throw new Error('运输计划数据无效')
}
plan.value = data
productRows.value = mapPlanProducts(Array.isArray(data.products) ? data.products : [])
await applyFreightPrefill(data)
} catch (error) {
innerVisible.value = false
const message = error instanceof Error ? error.message : '加载运输计划失败'
ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
} finally {
loading.value = false
}
}
function handleClosed() {
resetState()
}
async function handleSubmit() {
if (!productRows.value.length) {
ElMessage.warning('暂无产品明细')
return
}
if (productRows.value.some(row => !row.id)) {
ElMessage.error('产品明细缺少行 ID,无法提交')
return
}
if (productRows.value.some(row => row.actualQty == null || Number(row.actualQty) < 0)) {
ElMessage.warning('请填写有效的实际数量')
return
}
if (formData.hasFreight === 1 && !plan.value.transportId) {
ElMessage.warning('计划有运输费用但未安排运输,请先安排运输')
return
}
submitLoading.value = true
try {
const body: PlanArchiveBody = {
products: productRows.value.map(row => ({
id: row.id!,
actualQty: Number(row.actualQty ?? 0)
})),
freight: {
hasFreight: formData.hasFreight,
...(formData.hasFreight === 1
? {
distanceKm: formData.distanceKm ?? undefined,
freightType: formData.freightType,
freightPrice:
formData.freightType === 'by_weight'
? freightCalc.freightPrice.value ?? undefined
: undefined,
freightAmount:
freightCalc.calculatedFreight.value || freightCalc.freightAmount.value || undefined,
transportQty:
formData.freightType === 'by_weight'
? freightCalc.transportQty.value ?? undefined
: undefined,
hasInvoice: formData.hasInvoice
}
: {})
}
}
await planApi.archive(props.planId, body)
ElMessage.success(isAdjustMode.value ? '保存成功' : '归档成功')
tabsRef.value?.reload()
emit('success')
innerVisible.value = false
} catch (e: unknown) {
const message = e instanceof Error ? e.message : isAdjustMode.value ? '保存失败' : '归档失败'
ElMessage.error(message)
} finally {
submitLoading.value = false
}
}
</script>
<style scoped lang="scss">
.pl-arrange-layout {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 480px;
}
.pl-arrange-form-panel {
flex: 0 0 auto;
max-height: 52vh;
overflow: hidden;
}
.pl-arrange-form-scroll {
max-height: 52vh;
overflow-y: auto;
padding-right: 4px;
}
.pl-arrange-view-panel {
flex: 1 1 auto;
min-height: 280px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pl-arrange-view-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.pl-plan-subtitle {
margin: 16px 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pl-plan-subtitle:first-of-type {
margin-top: 0;
}
.pl-arrange-freight-muted {
margin-bottom: 12px;
font-size: 13px;
color: #64748b;
}
.pl-arrange-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
</style>
<style lang="scss">
.el-dialog.pl-plan-archive-dialog.dialog-form-layout {
width: min(1680px, calc(100vw - 80px)) !important;
max-height: calc(100vh - 48px);
}
</style>
@@ -27,7 +27,7 @@
<el-table-column label="计划数量" min-width="110" align="right">
<template #default="{ row }">
<el-input-number
v-if="editableQty"
v-if="editableQty && !editableActualQtyOnly"
v-model="row.quantity"
:min="0"
:precision="3"
@@ -40,7 +40,7 @@
<el-table-column v-if="showActualQty" label="实际数量" min-width="110" align="right">
<template #default="{ row }">
<el-input-number
v-if="editableQty"
v-if="(editableQty && showActualQty) || editableActualQtyOnly"
v-model="row.actualQty"
:min="0"
:precision="3"
@@ -98,6 +98,8 @@ defineProps<{
editableQty?: boolean
/** 新增/编辑弹窗默认不展示实际数量 */
showActualQty?: boolean
/** 仅实际数量可编辑,计划数量只读(归档弹窗) */
editableActualQtyOnly?: boolean
}>()
const emit = defineEmits<{