This commit is contained in:
zlwu committed 2026-08-26 11:10:40 +08:00
1 parent dd1acdc787
commit 4222eaa593
47 files changed
+1054350 -9663

No files matched your search

@@ -125,6 +125,7 @@
hide-nav
:tab="viewTab"
show-archive-io-fields
show-actual-qty-disclaimer
feedback-editable
:arrange-driver-url="driverUrl"
:arrange-site-records="siteRecords"
@@ -211,7 +212,7 @@
const productRows = ref<PlanFormProductRow[]>([])
const formData = reactive({
hasFreight: 0,
hasFreight: 1,
distanceKm: null as number | null,
freightType: 'by_weight'
})
@@ -413,7 +414,7 @@
plan.value = {}
productRows.value = []
Object.assign(formData, {
hasFreight: 0,
hasFreight: 1,
distanceKm: null,
freightType: 'by_weight'
})
@@ -422,7 +423,7 @@
}
async function applyFreightPrefill(data: Plan) {
formData.hasFreight = data.hasFreight ?? 0
formData.hasFreight = isAdjustMode.value ? (data.hasFreight ?? 0) : 1
freightCalc.resetFreightCalc()
if (data.freightAmount != null && data.hasFreight === 1) {
@@ -25,6 +25,7 @@
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled="!isEdit"
style="width: 100%"
/>
</el-form-item>
@@ -589,7 +590,11 @@ function resolveCurrentUserName() {
}
function todayYmd(): string {
return new Date().toISOString().split('T')[0]
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isEdit = computed(() => !!props.planId)
@@ -24,10 +24,12 @@
displayFeedbackTime || '—'
}}</span>
</div>
<p v-if="!hasDriverFeedback" class="pl-plan-form-empty-hint pl-feedback-ticket-empty"
<p
v-if="!hasDriverFeedback && !driverImagesEditable"
class="pl-plan-form-empty-hint pl-feedback-ticket-empty"
>暂无司机回传</p
>
<template v-else>
<template v-if="hasDriverFeedback">
<div
v-for="row in driverTicketQtyRows"
:key="row.key"
@@ -40,6 +42,9 @@
<div>
<div class="pl-feedback-ticket-lab">实际数量({{ feedbackQtyUnit }})</div>
<div class="pl-feedback-ticket-val">{{ row.actualQty }}</div>
<div v-if="showActualQtyDisclaimer" class="pl-feedback-ticket-val-tip"
>(仅供参考,实际以附件中数量为准)</div
>
</div>
<div>
<div class="pl-feedback-ticket-lab">品牌数量</div>
@@ -55,18 +60,23 @@
<span class="pl-feedback-ticket-text">{{ feedbackRemark || '—' }}</span>
</div>
</div>
<div class="pl-feedback-ticket-foot">
<span class="pl-feedback-ticket-lab">附件</span>
<AttachmentUploadField
v-if="feedbackImageCsv"
class="pl-feedback-ticket-attach"
:model-value="feedbackImageCsv"
compact
disabled
/>
<span v-else class="pl-plan-form-empty-hint">—</span>
</div>
</template>
<div class="pl-feedback-ticket-foot">
<span class="pl-feedback-ticket-lab">附件</span>
<AttachmentUploadField
v-if="driverImagesEditable || feedbackImageCsv"
class="pl-feedback-ticket-attach"
:class="{ 'pl-feedback-ticket-attach--editable': driverImagesEditable }"
:model-value="feedbackImageCsv"
:disabled="!driverImagesEditable"
compact
folder="pl/transport/feedback"
accept="image/*,video/*,.pdf"
:limit="15"
@update:model-value="handleDriverImagesChange"
/>
<span v-else class="pl-plan-form-empty-hint">—</span>
</div>
</div>
</div>
<div v-if="feedbackEditable" class="pl-plan-feedback-card-footer">
@@ -106,6 +116,9 @@
<div>
<div class="pl-feedback-ticket-lab">实际数量({{ feedbackQtyUnit }})</div>
<div class="pl-feedback-ticket-val">{{ row.actualQty }}</div>
<div v-if="showActualQtyDisclaimer" class="pl-feedback-ticket-val-tip"
>(仅供参考,实际以附件中数量为准)</div
>
</div>
<div>
<div class="pl-feedback-ticket-lab">品牌数量</div>
@@ -148,11 +161,16 @@
<div class="pl-feedback-ticket-foot">
<span class="pl-feedback-ticket-lab">附件</span>
<AttachmentUploadField
v-if="ioOrderImageCsv"
v-if="ioOrderImagesEditable || ioOrderImageCsv"
class="pl-feedback-ticket-attach"
:class="{ 'pl-feedback-ticket-attach--editable': ioOrderImagesEditable }"
:model-value="ioOrderImageCsv"
:disabled="!ioOrderImagesEditable"
compact
disabled
folder="pl/transport/site-feedback"
accept="image/*,video/*,.pdf"
:limit="15"
@update:model-value="handleIoOrderImagesChange"
/>
<span v-else class="pl-plan-form-empty-hint">—</span>
</div>
@@ -386,8 +404,12 @@
feedbackEditable?: boolean
/** 仅运输计划归档:展示入库/出库单价与金额 */
showArchiveIoFields?: boolean
/** 司机/出库单回传「实际数量」后展示仅供参考说明 */
showActualQtyDisclaimer?: boolean
loading?: boolean
canEditDriverFeedback?: boolean
driverImagesEditable?: boolean
ioOrderImagesEditable?: boolean
hasDriverFeedback?: boolean
arrangeDriverUrl?: string
driverFeedbackTableRows?: DriverFeedbackTableRow[]
@@ -407,8 +429,11 @@
{
feedbackEditable: false,
showArchiveIoFields: false,
showActualQtyDisclaimer: false,
loading: false,
canEditDriverFeedback: false,
driverImagesEditable: false,
ioOrderImagesEditable: false,
hasDriverFeedback: false,
arrangeDriverUrl: '',
driverFeedbackTableRows: () => [],
@@ -532,8 +557,20 @@
'copy-site-url': [url?: string]
'edit-site-feedback': [row: SiteFeedbackTableRow]
'copy-site-summary': [row: SiteFeedbackTableRow]
'save-driver-images': [csv: string]
'save-io-order-images': [csv: string]
}>()
function handleDriverImagesChange(csv: string) {
if (!props.driverImagesEditable) return
emit('save-driver-images', csv)
}
function handleIoOrderImagesChange(csv: string) {
if (!props.ioOrderImagesEditable) return
emit('save-io-order-images', csv)
}
const ioSectionTitle = computed(() => {
if (!props.ioOrder) return '现场回传信息'
return props.ioOrder.kind === 'inbound' ? '入库单回传' : '出库单回传'
@@ -824,6 +861,22 @@
}
}
/* 可编辑:沿用紧凑上传卡片尺寸,与只读缩略图同排展示 */
.pl-feedback-ticket-attach--editable {
:deep(.attachment-item) {
width: 88px;
}
:deep(.attachment-preview) {
height: 66px;
}
:deep(.attachment-preview__icon) {
width: 36px;
min-height: 36px;
}
}
.pl-feedback-ticket-band {
display: flex;
align-items: center;
@@ -875,6 +928,14 @@
margin-bottom: 4px;
}
.pl-feedback-ticket-val-tip {
margin-top: 4px;
font-size: 11px;
line-height: 1.4;
font-weight: 400;
color: var(--mint-text-muted, #94a3b8);
}
.pl-feedback-ticket-val {
font-size: 22px;
font-weight: 700;
@@ -392,6 +392,7 @@
import {
inboundOrderApi,
outboundOrderApi,
planApi,
type PlIoOrder,
type TransportSiteLink
} from '@/api/pl'
@@ -440,6 +441,8 @@
tab?: string
/** 仅运输计划归档:展示入库/出库单价与金额 */
showArchiveIoFields?: boolean
/** 司机/出库单回传「实际数量」后展示仅供参考说明 */
showActualQtyDisclaimer?: boolean
/** 计划概况产品表:展示入库/出库数量(不含单价与金额) */
showInboundOutboundQty?: boolean
}>(),
@@ -453,6 +456,7 @@
hideNav: false,
tab: undefined,
showArchiveIoFields: false,
showActualQtyDisclaimer: false,
showInboundOutboundQty: false
}
)
@@ -580,6 +584,8 @@
const ioOrderEditVisible = ref(false)
const ioOrderSaving = ref(false)
const driverImagesSaving = ref(false)
const ioOrderImagesSaving = ref(false)
const ioOrderEditForm = ref<PlIoOrder | null>(null)
const ioOrderEditImagesCsv = ref('')
@@ -1092,6 +1098,60 @@
reload()
}
async function saveDriverImagesInline(csv: string) {
if (!props.planId || driverImagesSaving.value) return
const nextList = csv
? csv
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: []
if (nextList.join(',') === feedbackImageList.value.join(',')) return
driverImagesSaving.value = true
try {
await planApi.updateDriverFeedback(props.planId, {
feedbackImages: nextList
})
ElMessage.success('司机回传附件已保存')
reload()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
} finally {
driverImagesSaving.value = false
}
}
async function saveIoOrderImagesInline(csv: string) {
const order = ioOrder.value
if (!order || ioOrderImagesSaving.value || order.status === 'voided') return
const nextList = csv
? csv
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: []
const currentList = Array.isArray(order.siteFeedbackImages)
? order.siteFeedbackImages.filter(Boolean)
: []
if (nextList.join(',') === currentList.join(',')) return
const api = order.kind === 'inbound' ? inboundOrderApi : outboundOrderApi
const label = order.kind === 'inbound' ? '入库单' : '出库单'
ioOrderImagesSaving.value = true
try {
await api.patch(order.id, {
siteFeedbackImages: nextList
})
ElMessage.success(`${label}回传附件已保存`)
reload()
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '保存失败')
} finally {
ioOrderImagesSaving.value = false
}
}
const hasArrangeSiteLinks = computed(
() => props.showArrangeLinks && (props.arrangeSiteRecords?.length ?? 0) > 0
)
@@ -1149,8 +1209,13 @@
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
showArchiveIoFields: props.showArchiveIoFields,
showActualQtyDisclaimer: props.showActualQtyDisclaimer,
loading: loading.value,
canEditDriverFeedback: canEditDriverFeedback.value,
driverImagesEditable: props.feedbackEditable && canEditDriverFeedback.value,
ioOrderImagesEditable: Boolean(
props.feedbackEditable && ioOrder.value && ioOrder.value.status !== 'voided'
),
hasDriverFeedback: hasDriverFeedback.value,
arrangeDriverUrl: props.arrangeDriverUrl,
driverFeedbackTableRows: driverFeedbackTableRows.value,
@@ -1172,6 +1237,12 @@
'edit-driver': openDriverFeedbackDialog,
'copy-driver-url': copyArrangeDriverUrl,
'edit-io-order': openIoOrderEditDialog,
'save-driver-images': (csv: string) => {
void saveDriverImagesInline(csv)
},
'save-io-order-images': (csv: string) => {
void saveIoOrderImagesInline(csv)
},
'copy-site-url': (url?: string) => {
void copyArrangeSiteUrl(url)
},
@@ -26,15 +26,13 @@ const LOGO_LEFT = 50
const TITLE_STYLE = `${FONT_TITLE}font-weight:700 !important;letter-spacing:6px;line-height:1.35;`
const TITLE_SUB_STYLE = `${TITLE_STYLE}margin-top:2px;letter-spacing:10px;`
const TABLE_ROW_PADDING = '4px'
/** 信息表列宽:收货单位(4) / 联系方式(1,略加宽) / 日期(2) */
/** 信息表列宽:左栏 3/5(收货单位、收货联系方式)/ 右栏 2/5(单据编号、日期) */
const INFO_COLGROUP = `<colgroup>
<col style="width:12.75%;" />
<col style="width:12.75%;" />
<col style="width:12.75%;" />
<col style="width:12.75%;" />
<col style="width:22.5%;" />
<col style="width:13.25%;" />
<col style="width:13.25%;" />
<col style="width:20%;" />
<col style="width:20%;" />
<col style="width:20%;" />
<col style="width:20%;" />
<col style="width:20%;" />
</colgroup>`
/**
* 物料表列宽:单据编号 + 加宽编号/名称,收窄规格/单位;前 5 列合计供条款区使用。
@@ -628,22 +626,30 @@ function buildDeliverySlipInfoTableHtml(): string {
return `<table class="delivery-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<td colspan="3" style="${INFO_CELL_LEFT}">
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">收货单位:</span>
<span class="delivery-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receivingUnit}}&nbsp;</span>
</div>
</td>
<td colspan="1" style="${INFO_CELL_LEFT}">
<td colspan="2" style="${INFO_CELL_RIGHT}">
${buildDeliverySlipTypeCheckboxesHtml()}
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">联系方式:</span>
<span class="delivery-slip-info-label" style="${INFO_LABEL}">单据编号:</span>
<span class="delivery-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planNo}}&nbsp;</span>
</div>
</td>
</tr>
<tr>
<td colspan="3" style="${INFO_CELL_LEFT}">
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">收货联系方式:</span>
<span class="delivery-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">{{receiveContact}}&nbsp;</span>
</div>
</td>
<td colspan="2" style="${INFO_CELL_RIGHT}">
${buildDeliverySlipTypeCheckboxesHtml()}
<div class="delivery-slip-info-row" style="${INFO_ROW}">
<span class="delivery-slip-info-label" style="${INFO_LABEL}">日&nbsp;期:</span>
<span class="delivery-slip-info-label" style="${INFO_LABEL}">日&nbsp;&nbsp;&nbsp;期:</span>
<span class="delivery-slip-info-value-center" style="${INFO_VALUE_INLINE_CENTER}">{{planDateChinese}}&nbsp;</span>
</div>
</td>
@@ -1302,7 +1308,7 @@ function normalizeDeliverySlipGapBeforeProduct(html: string): string {
return html.replace(
new RegExp(
`(<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:<\\/span>[\\s\\S]*?<\\/tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr[\\s\\S]*?<th[^>]*>${productHeader}|<tr[\\s\\S]*?<th[^>]*>${productHeader})`
`(<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)*期:<\\/span>[\\s\\S]*?<\\/tr>\\s*)(<!--\\s*中:产品明细表[\\s\\S]*?<tr[\\s\\S]*?<th[^>]*>${productHeader}|<tr[\\s\\S]*?<th[^>]*>${productHeader})`
),
`$1${gapRow}$2`
)
@@ -1419,34 +1425,39 @@ function normalizeDeliverySlipSignRow(html: string): string {
return normalizeDeliverySlipSignLabelMarkup(result)
}
/** 信息区:收货单位 / 联系方式 / 日期 同一行(兼容旧多行布局) */
/** 信息区:收货单位+单据编号 / 收货联系方式+日期 两行 3/5+2/5(兼容旧单行布局) */
function normalizeDeliverySlipInfoLayout(html: string): string {
if (!html.includes('delivery-slip-info-table')) return html
let result = html.replace(/收货联系方式:/g, '联系方式:')
let result = html.replace(/(收货)?联系方式:/g, '收货联系方式:')
const infoMatch = result.match(/<table class="delivery-slip-info-table"[^>]*>[\s\S]*?<\/table>/i)
if (!infoMatch) return result
const infoHtml = infoMatch[0]
const rowCount = (infoHtml.match(/<tr[\s>]/g) || []).length
const hasPlanNoInInfo = infoHtml.includes('单据编号:')
const layoutOk =
rowCount === 1 &&
rowCount === 2 &&
infoHtml.includes('收货单位:') &&
infoHtml.includes('联系方式:') &&
/日(?:&nbsp;)?期:/.test(infoHtml) &&
!hasPlanNoInInfo &&
/<td[^>]*colspan="4"[^>]*>[\s\S]*?收货单位:/.test(infoHtml) &&
/<td[^>]*colspan="1"[^>]*>[\s\S]*?联系方式:/.test(infoHtml) &&
/<td[^>]*colspan="2"[^>]*>[\s\S]*?日(?:&nbsp;)?期:/.test(infoHtml) &&
!/<td[^>]*colspan="[2-5]"[^>]*>[\s\S]*?联系方式:/.test(infoHtml)
infoHtml.includes('单据编号:') &&
infoHtml.includes('收货联系方式:') &&
/日(?:&nbsp;)*期:/.test(infoHtml) &&
/<td[^>]*colspan="3"[^>]*>[\s\S]*?收货单位:/.test(infoHtml) &&
/<td[^>]*colspan="2"[^>]*>[\s\S]*?单据编号:/.test(infoHtml) &&
/<td[^>]*colspan="3"[^>]*>[\s\S]*?收货联系方式:/.test(infoHtml) &&
/<td[^>]*colspan="2"[^>]*>[\s\S]*?日(?:&nbsp;)*期:/.test(infoHtml)
if (layoutOk) {
return result.replace(
let next = result.replace(
/(<table class="delivery-slip-info-table"[^>]*>\s*)(?:<colgroup>[\s\S]*?<\/colgroup>\s*)?/i,
`$1${INFO_COLGROUP}`
)
// 单据编号值居中(兼容先前左对齐模板)
next = next.replace(
/(<span class="delivery-slip-info-label"[^>]*>单据编号:<\/span>\s*<span class=")delivery-slip-info-value-(?:left|center)(" style=")[^"]*("[^>]*>)/,
`$1delivery-slip-info-value-center$2${INFO_VALUE_INLINE_CENTER}$3`
)
return next
}
return result.replace(infoMatch[0], buildDeliverySlipInfoTableHtml())
@@ -1621,7 +1632,7 @@ function normalizeDeliverySlipDocLayout(html: string): string {
.replace(/top:4px;/g, `top:${DELIVERY_SLIP_SEAL_TOP}px;`)
.replace(/top:-96px !important;/g, `top:${DELIVERY_SLIP_SEAL_TOP}px !important;`)
.replace(/top:-96px;/g, `top:${DELIVERY_SLIP_SEAL_TOP}px;`)
.replace(/>日期:</g, '>日&nbsp;期:<')
.replace(/>日(?:&nbsp;)*期:</g, '>日&nbsp;&nbsp;&nbsp;期:<')
.replace(/border-bottom:2px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border-bottom:1px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border:2px solid #000 !important;/g, `border:${PRODUCT_GRID_BORDER} !important;`)
@@ -1708,38 +1719,38 @@ function normalizeDeliverySlipTypeCheckRow(html: string): string {
if (!result.includes('delivery-slip-type-check-row')) {
const checkRowHtml = buildDeliverySlipTypeCheckboxesHtml()
const withClass = result.replace(
/(<td colspan="3"[^>]*style="[^"]*"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
// 优先插在单据编号上方(当前布局:右栏 2/5)
const withPlanNo = result.replace(
/(<td colspan="2"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>单据编号:)/,
`$1${checkRowHtml}\n $2`
)
if (withClass !== result) {
result = withClass
if (withPlanNo !== result) {
result = withPlanNo
} else {
result = result.replace(
/(<td colspan="3"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
}
// 兼容旧模板:日期在 colspan=2 / colspan=1 单元格时仍插多选
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="2"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
}
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="1"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)?期:)/,
`$1${checkRowHtml}\n $2`
)
}
// 兼容更旧模板:单据编号位置仍插多选,随后由 infoLayout 迁移
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="3"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>单据编号:)/,
`$1${checkRowHtml}\n $2`
)
}
// 兼容旧模板:日期单元格仍插多选,随后由 infoLayout 迁移
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="2"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)*期:)/,
`$1${checkRowHtml}\n $2`
)
}
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="3"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)*期:)/,
`$1${checkRowHtml}\n $2`
)
}
if (!result.includes('delivery-slip-type-check-row')) {
result = result.replace(
/(<td colspan="1"[^>]*>\s*)(<div class="delivery-slip-info-row"[^>]*>\s*<span class="delivery-slip-info-label"[^>]*>日(?:&nbsp;)*期:)/,
`$1${checkRowHtml}\n $2`
)
}
}
return refreshDeliverySlipTypeCheckRowStyle(result)
@@ -103,6 +103,9 @@
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button class="btn-ghost" :disabled="single" @click="handleCopy()">
<el-icon><DocumentCopy /></el-icon>合同复制
</el-button>
<el-button class="btn-ghost" :disabled="!canSubmit" @click="handleSubmitBatch">
<el-icon><Document /></el-icon>提交审批
</el-button>
@@ -516,13 +519,14 @@
v-if="isManageMode"
v-model:visible="formDialogVisible"
:edit-id="formDialogEditId"
:copy-source="formDialogCopySource"
@success="handleFormSuccess"
/>
</div>
</template>
<script setup lang="ts" name="ContractListPage">
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed, onMounted, watch } from 'vue';
import {
listContract,
getContract as getContractDetail,
@@ -536,6 +540,7 @@ import {
Clock,
Close,
Document,
DocumentCopy,
Download,
Plus,
RefreshLeft,
@@ -606,6 +611,7 @@ const resolveCurrentUserId = (): string => {
// 合同表单对话框状态
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogCopySource = ref<ContractVO | null>(null)
// 列配置
const listColumnDefs = [
@@ -1087,15 +1093,50 @@ const handleExpandChange = async (row: ContractVO, expandedRows: ContractVO[]) =
}
};
const resetFormDialogState = () => {
formDialogEditId.value = null
formDialogCopySource.value = null
}
/** 新增按钮操作 */
const handleAdd = () => {
formDialogEditId.value = null
resetFormDialogState()
formDialogVisible.value = true
};
}
/** 解析当前唯一选中的合同行(优先列表数据,保证含 products) */
const resolveSingleSelectedContract = (row?: ContractVO): ContractVO | undefined => {
if (row?.id != null && String(row.id) !== '') {
return contractList.value.find((item) => String(item.id) === String(row.id)) || row
}
if (ids.value.length !== 1) return undefined
const selectedId = ids.value[0]
return (
contractList.value.find((item) => String(item.id) === String(selectedId))
|| selectedRows.value.find((item) => String(item.id) === String(selectedId))
)
}
/** 合同复制:选中一条后打开表单,仅带入产品明细 */
const handleCopy = (row?: ContractVO) => {
if (!row && ids.value.length !== 1) {
$modal.msgWarning('请选择一条数据进行合同复制')
return
}
const targetRow = resolveSingleSelectedContract(row)
if (!targetRow?.id) {
$modal.msgWarning('未找到选中的数据')
return
}
formDialogEditId.value = null
formDialogCopySource.value = targetRow
formDialogVisible.value = true
}
/** 表单对话框保存/提交成功回调 */
const handleFormSuccess = () => {
formDialogVisible.value = false
resetFormDialogState()
getList()
}
@@ -1300,7 +1341,13 @@ onMounted(() => {
} else {
handleViewChange()
}
});
})
watch(formDialogVisible, (visible) => {
if (!visible) {
resetFormDialogState()
}
})
</script>
<style scoped lang="scss">
@@ -24,6 +24,13 @@
>{{ dictStore.getLabel('crm_contract_status', detailContract?.status) }}</el-tag>
</div>
<div class="detail-actions">
<el-button
v-if="manageActions"
class="btn-ghost"
@click="handleDetailCopy()"
>
<el-icon><DocumentCopy /></el-icon>合同复制
</el-button>
<el-button
v-if="manageActions && isContractEditableStatus(detailContract?.status)"
class="btn-ghost"
@@ -844,6 +851,7 @@
<ContractFormDialog
v-model:visible="formDialogVisible"
:edit-id="formDialogEditId"
:copy-source="formDialogCopySource"
@success="handleFormSuccess"
/>
<CustomerDetailDialog v-if="customerDialogActive" ref="customerDetailRef" />
@@ -853,10 +861,14 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick } from 'vue';
import { ref, reactive, computed, onMounted, defineAsyncComponent, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from '../contractType'
import { displayFrameworkMoney, displayFrameworkQuantity } from '../frameworkProductPricing'
import {
displayFrameworkMoney,
displayFrameworkQuantity,
formatFrameworkQuantityForPrint
} from '../frameworkProductPricing'
import {
CircleCheck,
CircleClose,
@@ -865,6 +877,7 @@ import {
Close,
Delete,
Document,
DocumentCopy,
DocumentChecked,
Download,
Edit,
@@ -984,6 +997,18 @@ const currentContractId = ref<string | number>();
const formDialogVisible = ref(false);
const formDialogEditId = ref<string | null>(null);
const formDialogCopySource = ref<ContractVO | null>(null);
const resetFormDialogState = () => {
formDialogEditId.value = null
formDialogCopySource.value = null
}
watch(formDialogVisible, (open) => {
if (!open) {
resetFormDialogState()
}
})
const approvalDialogRef = ref();
const executeDialogRef = ref();
@@ -1050,6 +1075,7 @@ function handleActionSuccess() {
function handleFormSuccess() {
formDialogVisible.value = false;
resetFormDialogState();
loadDetailData();
emit('success');
}
@@ -1258,6 +1284,18 @@ const handleDetailDrawerClose = () => {
detailAttachmentFileList.value = [];
};
/** 详情抽屉 - 合同复制 */
const handleDetailCopy = () => {
if (!detailContract.value?.id) {
$modal.msgWarning('未找到合同数据')
return
}
visible.value = false
formDialogEditId.value = null
formDialogCopySource.value = detailContract.value
formDialogVisible.value = true
}
/** 详情抽屉 - 编辑 */
const handleDetailEdit = async () => {
if (!detailContract.value?.id) return
@@ -1274,7 +1312,8 @@ const handleDetailEdit = async () => {
}
}
visible.value = false
formDialogEditId.value = detailContract.value.id
resetFormDialogState()
formDialogEditId.value = String(detailContract.value.id)
formDialogVisible.value = true
}
@@ -1874,7 +1913,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${getUnitLabel(product.unit)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${product.quantity || 0}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
${pricingCells}
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.packageSpec || ''}</td>
@@ -2252,6 +2291,8 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
return unitLabel || unitValue || '';
};
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
// 构建产品明细表格行(使用 0.3px 边框)
const productRows =
contractData.products
@@ -2268,7 +2309,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${getUnitLabel(product.unit)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${product.quantity || 0}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(product.price)}</td>
<td style="padding: 4px 3px; border: 0.3px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(beforeTax)}</td>
@@ -824,7 +824,8 @@ import {
updateContract,
type ContractForm,
type ContractProductForm,
type ContractProductPriceTierForm
type ContractProductPriceTierForm,
type ContractVO
} from '@/api/crm/contract/index'
import { isFrameworkContractType, isPurchaseLikeContractType } from '../contractType'
import {
@@ -936,6 +937,8 @@ type ExtendedContractProductForm = ContractProductForm & {
const props = defineProps<{
visible: boolean
editId: string | null
/** 复制来源合同(列表选中行,含 products) */
copySource?: ContractVO | null
}>()
const emit = defineEmits<{
@@ -953,7 +956,9 @@ const userStore = useUserStore()
// 表单标题
const formTitle = computed(() => {
return form.value.id ? '编辑合同' : '新增合同'
if (props.editId) return '编辑合同'
if (props.copySource) return '复制合同'
return '新增合同'
})
// 合同期限日期范围
@@ -1077,7 +1082,7 @@ const submitLoading = ref(false)
watch(
() => props.visible,
(visible) => {
if (visible && props.editId) {
if (visible && (props.editId || props.copySource)) {
formLoading.value = true
} else if (!visible) {
formLoading.value = false
@@ -1346,7 +1351,8 @@ interface QuoteVO {
const handleOpened = async () => {
resetPartyExpand()
const isEdit = !!props.editId
if (isEdit) {
const isCopy = !!props.copySource
if (isEdit || isCopy) {
formLoading.value = true
}
try {
@@ -1354,23 +1360,15 @@ const handleOpened = async () => {
if (props.editId) {
await loadContractDetail(props.editId)
} else if (props.copySource) {
await loadContractForCopy(props.copySource)
} else {
resetForm()
form.value.signDate = new Date().toISOString().split('T')[0]
handleSignDateChange(form.value.signDate)
applyDefaultOwnerUser()
// 设置默认供方主体为"SX"
const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
if (defaultSupplier) {
form.value.supplierId = defaultSupplier.id
await handleSupplierChange(defaultSupplier.id)
}
await initNewContractForm()
}
} catch (error: any) {
console.error('合同表单初始化失败:', error)
$modal.msgError(error?.message || '加载合同数据失败')
if (isEdit) {
if (isEdit || isCopy) {
dialogVisible.value = false
}
} finally {
@@ -1378,6 +1376,20 @@ const handleOpened = async () => {
}
}
/** 新增合同表单默认值 */
const initNewContractForm = async () => {
resetForm()
form.value.signDate = new Date().toISOString().split('T')[0]
handleSignDateChange(form.value.signDate)
applyDefaultOwnerUser()
const defaultSupplier = supplierList.value.find((supplier) => supplier.supplierCode === 'SX')
if (defaultSupplier) {
form.value.supplierId = defaultSupplier.id
await handleSupplierChange(defaultSupplier.id)
}
}
/** 重置表单 */
const resetForm = () => {
form.value = {
@@ -1446,6 +1458,104 @@ const resetForm = () => {
selectedProducts.value = []
}
/** 将源合同产品明细映射为表单行(复制时清除主键及数量/单价/备注) */
const mapContractProductsFromSource = (products: any[], forCopy = false) =>
products.map((product: any) => {
const mapped: ExtendedContractProductForm = {
...product,
id: forCopy ? undefined : product.id,
contractId: forCopy ? undefined : product.contractId,
productModel: product.model || product.productModel || '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
brandMode: product.brandMode || undefined,
brandId: product.brandId || undefined,
brandName: product.brandName || undefined,
brandOptions: [],
priceTiers: (product.priceTiers || []).map((t: any) => {
let ids: string[] = []
if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
ids = t.letterOssIds.map((id: any) => String(id))
} else if (t.letterOssId != null && t.letterOssId !== '') {
ids = [String(t.letterOssId)]
}
return {
...t,
id: forCopy ? undefined : t.id,
contractId: forCopy ? undefined : t.contractId,
contractProductId: forCopy ? undefined : t.contractProductId,
letterOssIds: ids,
...(forCopy ? { price: undefined } : {})
}
}),
taxRate: product.taxRate || 6,
taxAmount: forCopy ? 0 : (product.taxAmount ?? 0),
packageSpec: product.packageSpec || '',
deliveryMethod: product.deliveryMethod || ''
}
if (forCopy) {
mapped.quantity = 0
mapped.price = 0
mapped.unitPrice = 0
mapped.subtotal = 0
mapped.remark = ''
}
return mapped
})
/** 写入产品明细并刷新金额 */
const applyContractProductsToForm = async (products: ExtendedContractProductForm[], contractType: string) => {
form.value.products = products
await enrichContractProductBrandMeta(form.value.products)
const isFw = isFrameworkContractType(contractType)
form.value.products.forEach((product: any) => {
normalizeFrameworkProductPricing(product, isFw)
if (!isFw || !isFrameworkPriceMarket(product)) {
calculateProductAmount(product)
}
})
if (isFw) {
form.value.amount = 0
} else {
calculateTotalAmount()
}
}
/** 复制合同:仅带入产品明细,客户等信息由用户重新填写 */
const loadContractForCopy = async (source: ContractVO) => {
await initNewContractForm()
const contractType = source.contractType || 'sale'
form.value.contractType = contractType
let products = Array.isArray(source.products) ? source.products : []
// 列表 includeProducts 不含价阶;框架合同尝试拉详情补全,失败则回退列表明细
if (isFrameworkContractType(contractType)) {
try {
const res = await getContract(source.id)
if (res.data?.products?.length) {
products = res.data.products
form.value.contractType = res.data.contractType || contractType
}
} catch (error) {
console.warn('框架合同复制:加载详情价阶失败,使用列表产品明细', error)
}
}
if (!products.length) {
const res = await getContract(source.id)
products = res.data?.products || []
form.value.contractType = res.data?.contractType || contractType
}
if (products.length) {
const mapped = mapContractProductsFromSource(products, true)
await applyContractProductsToForm(mapped, form.value.contractType)
}
}
/** 加载合同详情 */
const loadContractDetail = async (id: number | string) => {
const res = await getContract(id)
@@ -1492,47 +1602,8 @@ const loadContractDetail = async (id: number | string) => {
}
if (contractData.products && Array.isArray(contractData.products)) {
form.value.products = contractData.products.map((product: any) => ({
...product,
productModel: product.model || '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
brandMode: product.brandMode || undefined,
brandId: product.brandId || undefined,
brandName: product.brandName || undefined,
brandOptions: [],
priceTiers: (product.priceTiers || []).map((t: any) => {
let ids: string[] = []
if (Array.isArray(t.letterOssIds) && t.letterOssIds.length) {
ids = t.letterOssIds.map((id: any) => String(id))
} else if (t.letterOssId != null && t.letterOssId !== '') {
ids = [String(t.letterOssId)]
}
return {
...t,
letterOssIds: ids
}
}),
taxRate: product.taxRate || 6,
taxAmount: product.taxAmount ?? 0,
packageSpec: product.packageSpec || '',
deliveryMethod: product.deliveryMethod || ''
}))
await enrichContractProductBrandMeta(form.value.products)
const isFw = isFrameworkContractType(contractData.contractType)
form.value.products.forEach((product: any) => {
normalizeFrameworkProductPricing(product, isFw)
if (!isFw || !isFrameworkPriceMarket(product)) {
calculateProductAmount(product)
}
})
if (isFw) {
form.value.amount = 0
} else {
calculateTotalAmount()
}
const products = mapContractProductsFromSource(contractData.products)
await applyContractProductsToForm(products, contractData.contractType)
}
if (form.value.startDate && form.value.endDate) {
+9 -4
View File
@@ -1098,7 +1098,11 @@ import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
import { getOpportunity } from '@/api/crm/opportunity';
import { getQuote } from '@/api/crm/quote';
import { isFrameworkContractType, isContractEditableStatus, getContractReeditConfirmMessage } from './contractType';
import { displayFrameworkMoney, displayFrameworkQuantity } from './frameworkProductPricing';
import {
displayFrameworkMoney,
displayFrameworkQuantity,
formatFrameworkQuantityForPrint
} from './frameworkProductPricing';
import {
parseSettlementClauseDrafts,
resolveSettlementClauseContent
@@ -1594,6 +1598,8 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
return dictStore.getLabel('crm_product_unit', unitValue) || unitValue || '';
};
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
// 构建产品明细表格行
const productRows =
contractData.products
@@ -1610,7 +1616,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${getUnitLabel(product.unit)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${product.quantity || 0}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(product.price)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(beforeTax)}</td>
@@ -1699,7 +1705,6 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
const totalWithExtrasChinese = numberToChinese(totalWithExtras);
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
const totalWithExtrasText = isFrameworkContract
? '随行就市'
: `${totalWithExtrasChinese}(¥${formatNumber(totalWithExtras)})`;
@@ -1962,7 +1967,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.productName || ''}</td>
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${product.model || product.productModel || ''}</td>
<td style="padding: 4px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${formatBrandForPrint(product.brandMode, product.brandName)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${product.quantity || 0}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatFrameworkQuantityForPrint(product, isFrameworkContract)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxRateText}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: center; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(product.price)}</td>
<td style="padding: 4px 3px; border: 1px solid #000; text-align: right; font-size: 12px; overflow: hidden; word-break: break-all;">${formatNumber(beforeTax)}</td>
@@ -18,6 +18,8 @@ export type FrameworkPriceMode =
(typeof FRAMEWORK_PRICE_MODE)[keyof typeof FRAMEWORK_PRICE_MODE]
export const FRAMEWORK_QUANTITY_LABEL = '按客户需求'
/** 打印/PDF 富文本中「按客户需求」对应的用量文案 */
export const FRAMEWORK_QUANTITY_PRINT_LABEL = '按实际到货数量'
export const FRAMEWORK_PRICE_LABEL = '随行就市'
export type FrameworkPricingProduct = {
@@ -160,6 +162,20 @@ export function displayFrameworkQuantity(product: FrameworkPricingProduct, isFra
return formatFrameworkQuantityDisplay(normalizeFrameworkProductPricing({ ...product }, true))
}
/** 打印/PDF 富文本用量列:按客户需求时显示「按实际到货数量」,否则与原先一致输出数量 */
export function formatFrameworkQuantityForPrint(
product: FrameworkPricingProduct,
isFramework: boolean
): string {
if (isFramework) {
const normalized = normalizeFrameworkProductPricing({ ...product }, true)
if (isFrameworkQuantityCustomerDemand(normalized)) {
return FRAMEWORK_QUANTITY_PRINT_LABEL
}
}
return String(product.quantity || 0)
}
export function displayFrameworkMoney(
product: FrameworkPricingProduct,
field: 'price' | 'subtotal' | 'taxAmount',
@@ -320,7 +320,8 @@ import { ElMessage } from 'element-plus'
import {
fetchCustomerDetail,
fetchCreateCustomer,
fetchUpdateCustomer
fetchUpdateCustomer,
fetchCheckDuplicate
} from '@/api/crm/customer'
import type { CustomerForm as CustomerFormType } from '@/api/crm/customerTypes'
import { addInvoiceInfo } from '@/api/crm/invoiceInfo'
@@ -377,8 +378,40 @@ function unwrapApiData<T>(res: T | { data?: T }): T {
}
// 表单校验规则
const validateCustomerNameUnique = (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
const name = (value || '').trim()
if (!name) {
callback()
return
}
fetchCheckDuplicate({
customerName: name,
excludeId: form.value.id != null ? String(form.value.id) : undefined
})
.then((res) => {
const data = unwrapApiData(res as Record<string, unknown> | { data?: Record<string, unknown> })
if (data?.isDuplicate && Array.isArray(data.duplicates) && data.duplicates.length) {
const dup = data.duplicates[0] as Record<string, unknown>
const customerNo = (dup.customerNo || dup.customer_no || '') as string
callback(
new Error(customerNo ? `客户名称已存在(编号:${customerNo})` : '客户名称已存在')
)
return
}
callback()
})
.catch(() => callback())
}
const rules = ref<Record<string, FormItemRule[]>>({
customerName: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }],
customerName: [
{ required: true, message: '客户名称不能为空', trigger: 'blur' },
{ validator: validateCustomerNameUnique, trigger: 'blur' }
],
shortName: [{ required: true, message: '客户简称不能为空', trigger: 'blur' }],
customerType: [{ required: true, message: '客户类型不能为空', trigger: 'change' }],
source: [{ required: true, message: '客户来源不能为空', trigger: 'change' }],
@@ -592,6 +625,7 @@ function buildSubmitPayload() {
delete payload.businessLicenseImg
// 显式传空串:后端按「重新生成」处理;有值则手改入库
payload.namePinyinAbbr = (form.value.namePinyinAbbr || '').trim()
payload.customerName = (form.value.customerName || '').trim()
return payload
}
+3 -3
View File
@@ -151,7 +151,7 @@
<el-table-column
v-if="isColumnVisible('id')"
prop="id"
label="ID"
label="出入库单ID"
min-width="100"
align="center"
header-align="center"
@@ -569,7 +569,7 @@
const loading = ref(false)
const listColumnDefs = [
{ prop: 'id', label: 'ID', defaultVisible: false },
{ prop: 'id', label: '出入库单ID' },
{ prop: 'planNo', label: '计划号' },
{ prop: 'date', label: '日期' },
{ prop: 'partnerCompany', label: '往来单位' },
@@ -595,7 +595,7 @@
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('db-inventory-list-columns', listColumnDefs)
} = usePersistedListColumns('db-inventory-list-columns-v2', listColumnDefs)
const inventoryExportColumns = computed(() => {
const cols = [...visibleColumns.value]
+21 -2
View File
@@ -126,6 +126,19 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="60" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('inboundOutboundId')"
prop="inboundOutboundId"
label="出入库单ID"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.inboundOutboundId || '—' }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('inventoryDate')"
prop="inventoryDate"
@@ -559,6 +572,7 @@
const router = useRouter()
const listColumnDefs = [
{ prop: 'inboundOutboundId', label: '出入库单ID' },
{ prop: 'inventoryDate', label: '日期' },
{ prop: 'planNo', label: '计划单号' },
{ prop: 'vehicleNo', label: '车牌号' },
@@ -584,7 +598,7 @@
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('db-weighing-list-columns', listColumnDefs)
} = usePersistedListColumns('db-weighing-list-columns-v2', listColumnDefs)
const weighingExportColumns = computed(() => {
const cols = [...visibleColumns.value]
@@ -605,6 +619,9 @@
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('inboundOutboundId')) {
cols.push({ key: 'inboundOutboundId', interactive: true, prop: 'inboundOutboundId' })
}
if (isColumnVisible('inventoryDate')) {
cols.push({ key: 'inventoryDate', interactive: true, prop: 'inventoryDate' })
}
@@ -613,7 +630,9 @@
}
cols.push({ key: 'batchNo', interactive: true, prop: 'batchNo' })
for (const def of listColumnDefs) {
if (def.prop === 'inventoryDate' || def.prop === 'planNo') continue
if (def.prop === 'inventoryDate' || def.prop === 'planNo' || def.prop === 'inboundOutboundId') {
continue
}
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
+8 -8
View File
@@ -1478,20 +1478,20 @@
border-radius: 4px;
}
.chip.am {
color: #b45309;
background: #fffbeb;
color: #fff;
background: #d97706;
}
.chip.pm {
color: #1d4ed8;
background: #eff6ff;
color: #fff;
background: #2563eb;
}
.chip.duty {
color: #b91c1c;
background: #fef2f2;
color: #fff;
background: #dc2626;
}
.chip.other {
color: #475569;
background: #f1f5f9;
color: #fff;
background: #475569;
}
.chip.empty {
color: var(--mint-text-muted, #64748b);
+69 -19
View File
@@ -102,6 +102,27 @@
:selected-ids="selectedRows.map((r) => r.id)"
filename="计划归档.xlsx"
/>
<el-dropdown trigger="click" @command="handleBatchErpCommand">
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
:loading="erpBatchLoading"
>
<el-icon><DocumentChecked /></el-icon>
ERP录入
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="entered" :disabled="erpBatchLoading">
已录入ERP
</el-dropdown-item>
<el-dropdown-item command="not_entered" :disabled="erpBatchLoading">
未录入ERP
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<ListToolbarEndActions
:loading="loading"
@@ -229,10 +250,8 @@
<template #default="{ row }">
<div class="pl-plan-archive-erp-cell" @click.stop @mousedown.stop>
<el-checkbox
class="pl-plan-archive-erp-checkbox"
:model-value="Number(row.erpEntered) === 1"
:disabled="row.followStatus === 'cancelled' || erpUpdatingId === row.id"
@click.stop
@change="(checked) => handleErpEnteredChange(row, Boolean(checked))"
/>
</div>
</template>
@@ -431,6 +450,8 @@ import ListColumnStatsRow from '@/components/business/list-column-stats-row/inde
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
ArrowDown,
DocumentChecked,
FolderChecked,
RefreshLeft,
Search
@@ -472,7 +493,7 @@ const viewPlanId = ref('')
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
const archiveDialogVisible = ref(false)
const archivePlanId = ref('')
const erpUpdatingId = ref('')
const erpBatchLoading = ref(false)
const planList = ref<Plan[]>([])
const listColumnDefs = [
@@ -765,25 +786,49 @@ function handleToolbarArchive() {
handleArchive(selectedRows.value[0])
}
async function handleErpEnteredChange(row: Plan, entered: boolean) {
if (row.followStatus === 'cancelled') return
const previous = Number(row.erpEntered) === 1
if (previous === entered) return
async function handleBatchErpCommand(command: 'entered' | 'not_entered') {
const entered = command === 'entered'
const eligibleRows = selectedRows.value.filter((row) => row.followStatus !== 'cancelled')
if (eligibleRows.length === 0) {
ElMessage.warning('请选择未作废的计划')
return
}
erpUpdatingId.value = row.id
row.erpEntered = entered ? 1 : 0
const targets = eligibleRows.filter((row) => (Number(row.erpEntered) === 1) !== entered)
if (targets.length === 0) {
ElMessage.info(entered ? '所选计划均已录入 ERP' : '所选计划均未录入 ERP')
return
}
erpBatchLoading.value = true
try {
const res = await planApi.setErpEntered(row.id, entered)
const data = pickBxData<Plan>(res)
if (data?.id) {
row.erpEntered = data.erpEntered ?? (entered ? 1 : 0)
const results = await Promise.allSettled(
targets.map((row) => planApi.setErpEntered(row.id, entered))
)
let successCount = 0
results.forEach((result, index) => {
const row = targets[index]
if (result.status === 'fulfilled') {
const data = pickBxData<Plan>(result.value)
row.erpEntered = data?.erpEntered ?? (entered ? 1 : 0)
successCount += 1
return
}
const message =
result.reason instanceof Error ? result.reason.message : '更新 ERP 状态失败'
ElMessage.error(
`${row.planNo || row.id}:${message.includes('失败') ? message : `更新 ERP 状态失败:${message}`}`
)
})
if (successCount > 0) {
ElMessage.success(
entered ? `已将 ${successCount} 条计划标记为已录入 ERP` : `已将 ${successCount} 条计划标记为未录入 ERP`
)
await refreshViewIfOpen()
}
} catch (error) {
row.erpEntered = previous ? 1 : 0
const message = error instanceof Error ? error.message : '更新 ERP 状态失败'
ElMessage.error(message.includes('失败') ? message : `更新 ERP 状态失败:${message}`)
} finally {
erpUpdatingId.value = ''
erpBatchLoading.value = false
}
}
@@ -822,4 +867,9 @@ onMounted(() => {
align-items: center;
justify-content: center;
}
.pl-plan-archive-erp-checkbox {
pointer-events: none;
cursor: default;
}
</style>
+23 -23
View File
@@ -173,15 +173,6 @@
>
<el-icon><DocumentCopy /></el-icon>三方人车快速创建
</el-button>
<el-button
v-if="canForceFollowStatus"
class="btn-ghost"
:disabled="!selectedPlan"
@click="openForceFollowStatusDialog"
>
<el-icon><EditPen /></el-icon>强制改状态
</el-button>
<ListExportDropdown
:export-fn="planApi.export"
:get-filter-params="getPlanExportFilterParams"
@@ -571,6 +562,13 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="canForceFollowStatus"
class="btn-ghost"
@click="openForceFollowStatusDialog"
>
<el-icon><EditPen /></el-icon>强制改状态
</el-button>
<el-button
v-if="canVoidPlan(viewingPlan)"
class="btn-ghost"
@@ -614,27 +612,27 @@
>
<el-form label-position="top" class="form-grid-2">
<el-form-item label="计划编号" style="margin-bottom: 0">
<span>{{ selectedPlan?.planNo || '—' }}</span>
<span>{{ viewingPlan?.planNo || '—' }}</span>
</el-form-item>
<el-form-item label="单据类型" style="margin-bottom: 0">
<span>{{ getPlanTypeLabel(selectedPlan?.planType) }}</span>
<span>{{ getPlanTypeLabel(viewingPlan?.planType) }}</span>
</el-form-item>
<el-form-item label="供方" style="margin-bottom: 0">
<span>{{ selectedPlan?.supplierName || '—' }}</span>
<span>{{ viewingPlan?.supplierName || '—' }}</span>
</el-form-item>
<el-form-item label="需方" style="margin-bottom: 0">
<span>{{ selectedPlan?.customerName || '—' }}</span>
<span>{{ viewingPlan?.customerName || '—' }}</span>
</el-form-item>
<el-form-item label="产品信息" class="form-col-span-2" style="margin-bottom: 0">
<PlPlanListProductCell :products="selectedPlan?.products || []" />
<PlPlanListProductCell :products="viewingPlan?.products || []" />
</el-form-item>
<el-form-item label="当前跟进状态" style="margin-bottom: 0">
<el-tag
v-if="selectedPlan"
:type="getPlPlanFollowStatusTagType(selectedPlan.followStatus)"
v-if="viewingPlan"
:type="getPlPlanFollowStatusTagType(viewingPlan.followStatus)"
size="small"
>
{{ getPlPlanFollowStatusLabel(selectedPlan.followStatus) }}
{{ getPlPlanFollowStatusLabel(viewingPlan.followStatus) }}
</el-tag>
<span v-else>—</span>
</el-form-item>
@@ -1284,8 +1282,8 @@ function handleToolbarVoid() {
}
function openForceFollowStatusDialog() {
if (!selectedPlan.value) return
forceFollowStatusValue.value = selectedPlan.value.followStatus || ''
if (!viewingPlan.value) return
forceFollowStatusValue.value = viewingPlan.value.followStatus || ''
forceFollowDialogVisible.value = true
}
@@ -1294,22 +1292,24 @@ function resetForceFollowDialog() {
}
async function submitForceFollowStatus() {
if (!selectedPlan.value) return
if (!viewingPlan.value) return
const next = (forceFollowStatusValue.value || '').trim()
if (!next) {
ElMessage.warning('请选择目标跟进状态')
return
}
if (next === selectedPlan.value.followStatus) {
if (next === viewingPlan.value.followStatus) {
ElMessage.warning('目标状态与当前状态相同')
return
}
forceFollowLoading.value = true
try {
await planApi.forceFollowStatus(selectedPlan.value.id, next)
await planApi.forceFollowStatus(viewingPlan.value.id, next)
ElMessage.success('跟进状态已更新')
forceFollowDialogVisible.value = false
fetchData()
syncViewingPlan({ ...viewingPlan.value, followStatus: next })
await fetchData()
await refreshViewIfOpen()
} catch (e) {
const message = e instanceof Error ? e.message : '修改失败'
ElMessage.error(message)