930 lines
28 KiB
Vue
930 lines
28 KiB
Vue
<template>
|
||
<n-modal
|
||
v-model:show="visible"
|
||
preset="card"
|
||
title="拆分工单"
|
||
:style="{ width: '1120px' }"
|
||
:content-style="{ paddingTop: '8px', paddingBottom: '4px' }"
|
||
:mask-closable="false"
|
||
@after-leave="resetState"
|
||
>
|
||
<n-spin :show="loading">
|
||
<div class="split-scroll">
|
||
<n-descriptions v-if="preview" :column="3" label-placement="left" size="small" class="split-desc">
|
||
<n-descriptions-item label="原工单编号">{{ preview.workOrderCode || '-' }}</n-descriptions-item>
|
||
<n-descriptions-item label="物料编码">{{ preview.materialCode || '-' }}</n-descriptions-item>
|
||
<n-descriptions-item label="物料/订单">{{ preview.orderItemName || '-' }}</n-descriptions-item>
|
||
<n-descriptions-item label="计划数量">{{ preview.quantity ?? 0 }}</n-descriptions-item>
|
||
<n-descriptions-item label="已进入生产">{{ preview.investedQty ?? 0 }}</n-descriptions-item>
|
||
<n-descriptions-item label="可拆数量">{{ preview.splittableQty ?? 0 }}</n-descriptions-item>
|
||
<n-descriptions-item label="原工序" :span="3">
|
||
{{ preview.processCount ?? 0 }} 道
|
||
<span v-if="preview.processNames?.length" class="process-names">
|
||
({{ preview.processNames.join('、') }})
|
||
</span>
|
||
</n-descriptions-item>
|
||
</n-descriptions>
|
||
|
||
<n-alert v-if="preview && !preview.canSplit" type="warning" :bordered="false" style="margin: 8px 0">
|
||
{{ preview.reason || '当前工单不允许拆单' }}
|
||
</n-alert>
|
||
|
||
<template v-if="preview?.canSplit">
|
||
<div class="split-section">
|
||
<div class="split-section-title">数量分配</div>
|
||
<div class="field-row">
|
||
<span class="field-label-inline">拆分方式</span>
|
||
<n-radio-group v-model:value="mode" name="splitMode" size="small">
|
||
<n-space>
|
||
<n-radio value="equal">等量拆分</n-radio>
|
||
<n-radio value="custom">指定数量</n-radio>
|
||
</n-space>
|
||
</n-radio-group>
|
||
</div>
|
||
|
||
<!-- 等量:按总份数均分计划数量,第1份留原单,其余新建 -->
|
||
<div v-if="mode === 'equal'" class="split-body">
|
||
<div class="field-row">
|
||
<span class="field-label-inline">拆成份数</span>
|
||
<n-input-number
|
||
v-model:value="equalCount"
|
||
:min="2"
|
||
:max="Math.max(2, maxEqualParts)"
|
||
:precision="0"
|
||
size="small"
|
||
style="width: 140px"
|
||
/>
|
||
<span class="hint-inline">含原工单,第1份留原单</span>
|
||
</div>
|
||
<div class="hint-inline" style="margin-top: 6px">{{ equalHint }}</div>
|
||
</div>
|
||
|
||
<!-- 指定:手动设原单保留 + 各新工单数量 -->
|
||
<div v-else class="split-body">
|
||
<div class="field-row">
|
||
<span class="field-label-inline">原单保留</span>
|
||
<n-input-number
|
||
v-model:value="keepQty"
|
||
:min="unsplittableQty"
|
||
:max="Math.max(unsplittableQty, (preview.quantity ?? 0) - 1)"
|
||
:precision="0"
|
||
size="small"
|
||
style="width: 140px"
|
||
@update:value="onKeepQtyUpdate"
|
||
/>
|
||
<span class="hint-inline">≥不可拆 {{ unsplittableQty }},拆出 {{ splitOutQty }}</span>
|
||
</div>
|
||
<div v-for="(row, idx) in customRows" :key="idx" class="custom-row">
|
||
<n-input-number
|
||
v-model:value="customRows[idx]"
|
||
:min="1"
|
||
:precision="0"
|
||
size="small"
|
||
placeholder="新工单数量"
|
||
style="flex: 1"
|
||
/>
|
||
<n-button text type="error" :disabled="customRows.length <= 1" @click="removeCustomRow(idx)">
|
||
删除
|
||
</n-button>
|
||
</div>
|
||
<n-button dashed block size="small" style="margin-top: 6px" @click="addCustomRow">+ 添加工单</n-button>
|
||
<div class="sum-line" :class="{ error: customSum !== splitOutQty }">
|
||
拆出合计: {{ customSum }} / {{ splitOutQty }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="split-section">
|
||
<div class="split-section-title">
|
||
新工单工艺路线
|
||
<n-radio-group
|
||
v-model:value="routeMode"
|
||
name="routeMode"
|
||
size="small"
|
||
style="margin-left: 12px; font-weight: 400"
|
||
>
|
||
<n-space>
|
||
<n-radio value="kingdee">金蝶工艺路线</n-radio>
|
||
<n-radio value="assemble">创建新工艺路线</n-radio>
|
||
</n-space>
|
||
</n-radio-group>
|
||
</div>
|
||
|
||
<div v-if="routeMode === 'kingdee'" class="split-body">
|
||
<div v-if="!preview?.materialCode" class="hint">当前订单无物料编码,无法查询金蝶工艺路线</div>
|
||
<template v-else>
|
||
<div class="kingdee-layout">
|
||
<div class="kingdee-left">
|
||
<div class="field-label">路线列表({{ routeList.length }})</div>
|
||
<n-spin :show="routeLoading">
|
||
<div v-if="!routeLoading && routeList.length === 0" class="hint">未查询到工艺路线</div>
|
||
<div v-else class="route-card-list">
|
||
<div
|
||
v-for="route in routeList"
|
||
:key="route.routingNo"
|
||
class="route-card"
|
||
:class="{ active: routingNo === route.routingNo }"
|
||
@click="routingNo = route.routingNo || null"
|
||
>
|
||
<div class="route-card-head">
|
||
<span class="route-code">{{ route.routingNo }}</span>
|
||
<span class="route-step-count">{{ route.stepCount ?? route.steps?.length ?? 0 }}道</span>
|
||
</div>
|
||
<div class="route-flow">
|
||
{{ (route.processNames || []).join(' → ') || '暂无工序' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</n-spin>
|
||
</div>
|
||
<div class="kingdee-right">
|
||
<div class="field-label">
|
||
工序明细
|
||
<span v-if="selectedRoute" class="route-detail-sub">
|
||
{{ selectedRoute.routingNo }} · {{ selectedSteps.length }} 道
|
||
</span>
|
||
</div>
|
||
<n-data-table
|
||
v-if="selectedRoute"
|
||
size="small"
|
||
:bordered="true"
|
||
:single-line="true"
|
||
:columns="kingdeeStepColumns"
|
||
:data="selectedSteps"
|
||
:scroll-x="900"
|
||
:max-height="260"
|
||
:pagination="false"
|
||
/>
|
||
<div v-else class="route-detail-empty">点击左侧路线查看工序</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div v-else class="split-body">
|
||
<div class="assemble-toolbar">
|
||
<span class="hint-inline">新增工艺路线方;说明为空时默认用工序名称</span>
|
||
<n-button size="small" type="primary" secondary @click="addAssembleRow">+ 添加工序</n-button>
|
||
</div>
|
||
<n-data-table
|
||
size="small"
|
||
:bordered="true"
|
||
:single-line="false"
|
||
:columns="assembleColumns"
|
||
:data="assembleRows"
|
||
:scroll-x="1080"
|
||
:max-height="280"
|
||
:pagination="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</n-spin>
|
||
|
||
<template #footer>
|
||
<n-space justify="end">
|
||
<n-button @click="visible = false">取消</n-button>
|
||
<n-button type="primary" :loading="submitting" :disabled="!canConfirm" @click="confirmSplit">
|
||
确认拆分
|
||
</n-button>
|
||
</n-space>
|
||
</template>
|
||
</n-modal>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* 拆单弹窗:
|
||
* - 已进入生产数量(不可拆)留原单;只拆可拆数量
|
||
* - 等量拆分:按「拆成份数」均分计划数量,第1份留原单,其余新建(如 30÷3 → 原10 + 新10 + 新10)
|
||
* - 指定数量:手动设原单保留 + 各新工单数量
|
||
* - 金蝶工艺路线 / 工序表组装(对齐工艺路线字段)
|
||
*/
|
||
import { computed, h, ref, watch } from 'vue'
|
||
import {
|
||
NAlert,
|
||
NButton,
|
||
NDataTable,
|
||
NDescriptions,
|
||
NDescriptionsItem,
|
||
NInput,
|
||
NInputNumber,
|
||
NModal,
|
||
NRadio,
|
||
NRadioGroup,
|
||
NSelect,
|
||
NSpace,
|
||
NSpin,
|
||
NSwitch,
|
||
useMessage,
|
||
type DataTableColumns,
|
||
type SelectOption,
|
||
} from 'naive-ui'
|
||
import {
|
||
orderProcessPlanApi,
|
||
type ProcessPlanSplitPreview,
|
||
type ProcessPlanSplitStep,
|
||
} from '@/api/orderProcessPlan'
|
||
import {
|
||
processRouteApi,
|
||
type KingdeeProcessRouteStep,
|
||
type ProcessRouteSelectOption,
|
||
} from '@/api/processRoute'
|
||
import { basicProcessPlanApi, type BasicProcessPlan } from '@/api/basicProcessPlan'
|
||
|
||
interface AssembleRow extends ProcessPlanSplitStep {
|
||
_key: string
|
||
}
|
||
|
||
const props = defineProps<{
|
||
show: boolean
|
||
planId: number | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:show': [value: boolean]
|
||
success: []
|
||
}>()
|
||
|
||
const message = useMessage()
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const preview = ref<ProcessPlanSplitPreview | null>(null)
|
||
|
||
const mode = ref<'equal' | 'custom'>('equal')
|
||
const keepQty = ref(0)
|
||
/** 等量拆分:总份数(含原单),至少 2 = 原单 + 1 新单 */
|
||
const equalCount = ref(2)
|
||
const customRows = ref<(number | null)[]>([null])
|
||
|
||
const routeMode = ref<'kingdee' | 'assemble'>('kingdee')
|
||
const routingNo = ref<string | null>(null)
|
||
const routeList = ref<ProcessRouteSelectOption[]>([])
|
||
const routeLoading = ref(false)
|
||
const processLoading = ref(false)
|
||
|
||
const processOptions = ref<SelectOption[]>([])
|
||
const processMap = ref<Record<number, BasicProcessPlan>>({})
|
||
const assembleRows = ref<AssembleRow[]>([])
|
||
const ctrlCodeOptions: SelectOption[] = [
|
||
{ label: '汇报+免检', value: '汇报+免检' },
|
||
{ label: '汇报+质量', value: '汇报+质量' },
|
||
{ label: '委外+质量', value: '委外+质量' },
|
||
{ label: '委外+汇报', value: '委外+汇报' },
|
||
]
|
||
const activityUnitOptions = ref<SelectOption[]>([
|
||
{ label: '小时', value: '小时' },
|
||
{ label: '分钟', value: '分钟' },
|
||
{ label: '秒', value: '秒' },
|
||
])
|
||
|
||
const visible = computed({
|
||
get: () => props.show,
|
||
set: (v: boolean) => emit('update:show', v),
|
||
})
|
||
|
||
const unsplittableQty = computed(() => preview.value?.investedQty ?? 0)
|
||
const planQty = computed(() => preview.value?.quantity ?? 0)
|
||
const splitOutQty = computed(() => Math.max(planQty.value - (keepQty.value ?? 0), 0))
|
||
|
||
function onKeepQtyUpdate(v: number | null) {
|
||
const min = unsplittableQty.value
|
||
const max = Math.max(min, (preview.value?.quantity ?? 0) - 1)
|
||
if (v == null || v < min) {
|
||
keepQty.value = min
|
||
return
|
||
}
|
||
if (v > max) keepQty.value = max
|
||
}
|
||
|
||
/**
|
||
* 等量拆分:把整单计划数量均分成 N 份(含原单)。
|
||
* quantities = [原单保留, 新单1, 新单2, ...]
|
||
*/
|
||
const equalQuantities = computed(() => {
|
||
const q = planQty.value
|
||
const n = equalCount.value
|
||
if (!q || !n || n < 2 || q < n) return [] as number[]
|
||
const base = Math.floor(q / n)
|
||
const rem = q % n
|
||
const list = Array.from({ length: n }, () => base)
|
||
// 余数加在最后一份,避免中间份变成 0
|
||
list[n - 1] = base + rem
|
||
if (list.some((v) => v <= 0)) return []
|
||
// 第1份必须盖住不可拆(已进入生产)数量
|
||
if (list[0] < unsplittableQty.value) return []
|
||
return list
|
||
})
|
||
|
||
/** 在「第1份 ≥ 不可拆」前提下,最多能拆成几份 */
|
||
const maxEqualParts = computed(() => {
|
||
const q = planQty.value
|
||
const inv = unsplittableQty.value
|
||
if (q < 2) return 2
|
||
let max = Math.min(q, Math.max(2, q - inv)) // 至少留 1 给某新单时可拆上限粗估
|
||
// 收紧:floor(q/n) >= inv(余数在末份时首份=floor)
|
||
while (max > 2 && Math.floor(q / max) < inv) {
|
||
max -= 1
|
||
}
|
||
// 还要保证至少能拆出 1 个新单:首份 < q
|
||
while (max > 2) {
|
||
const base = Math.floor(q / max)
|
||
if (base >= Math.max(1, inv) && base < q) break
|
||
max -= 1
|
||
}
|
||
return Math.max(2, max)
|
||
})
|
||
|
||
const equalHint = computed(() => {
|
||
const list = equalQuantities.value
|
||
if (!list.length) {
|
||
if (planQty.value < equalCount.value) return `计划数量不足以分成 ${equalCount.value} 份`
|
||
if (equalCount.value >= 2 && Math.floor(planQty.value / equalCount.value) < unsplittableQty.value) {
|
||
return `第1份无法覆盖不可拆数量 ${unsplittableQty.value},请减少份数`
|
||
}
|
||
return ''
|
||
}
|
||
const keep = list[0]
|
||
const news = list.slice(1)
|
||
const newCount = news.length
|
||
if (news.every((v) => v === news[0])) {
|
||
return `结果:原单保留 ${keep},新建 ${newCount} 张各 ${news[0]}(共 ${list.length} 张)`
|
||
}
|
||
return `结果:原单保留 ${keep},新建 ${newCount} 张(${news.join('、')})`
|
||
})
|
||
|
||
const customSum = computed(() =>
|
||
customRows.value.reduce((s, v) => s + (typeof v === 'number' && v > 0 ? v : 0), 0),
|
||
)
|
||
|
||
const selectedRoute = computed(() =>
|
||
routingNo.value ? routeList.value.find((r) => r.routingNo === routingNo.value) ?? null : null,
|
||
)
|
||
const selectedSteps = computed<KingdeeProcessRouteStep[]>(() => selectedRoute.value?.steps ?? [])
|
||
|
||
const kingdeeStepColumns: DataTableColumns<KingdeeProcessRouteStep> = [
|
||
{ title: '工序号', key: 'operNumber', width: 70, render: (r) => r.operNumber ?? '-' },
|
||
{ title: '工序编码', key: 'processCode', width: 110, ellipsis: { tooltip: true }, render: (r) => r.processCode || '-' },
|
||
{ title: '工序名称', key: 'processName', width: 120, ellipsis: { tooltip: true }, render: (r) => r.processName || '-' },
|
||
{ title: '工序说明', key: 'operDescription', minWidth: 140, ellipsis: { tooltip: true }, render: (r) => r.operDescription || '-' },
|
||
{ title: '工作中心', key: 'workCenterName', width: 110, ellipsis: { tooltip: true }, render: (r) => r.workCenterName || '-' },
|
||
{ title: '车间', key: 'departmentName', width: 100, ellipsis: { tooltip: true }, render: (r) => r.departmentName || '-' },
|
||
{ title: '控制码', key: 'optCtrlCodeName', width: 100, ellipsis: { tooltip: true }, render: (r) => r.optCtrlCodeName || '-' },
|
||
{
|
||
title: '标准工时',
|
||
key: 'activityQty',
|
||
width: 100,
|
||
render: (r) => (r.activityQty == null ? '-' : `${r.activityQty}${r.activityUnit ? ' ' + r.activityUnit : ''}`),
|
||
},
|
||
{ title: '质检', key: 'qualityInspection', width: 56, render: (r) => (r.qualityInspection === 1 ? '是' : '否') },
|
||
{ title: '入库', key: 'storageEntry', width: 56, render: (r) => (r.storageEntry === 1 ? '是' : '否') },
|
||
]
|
||
|
||
function createEmptyAssembleRow(operNumber = 10): AssembleRow {
|
||
return {
|
||
_key: `a-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||
processId: null,
|
||
operNumber,
|
||
processCode: null,
|
||
processName: null,
|
||
operDescription: null,
|
||
sectionId: null,
|
||
workshopId: null,
|
||
activityUnit: '小时',
|
||
optCtrlCodeName: null,
|
||
activityQty: null,
|
||
qualityInspection: 0,
|
||
storageEntry: 0,
|
||
procurement: 0,
|
||
isOutsource: 0,
|
||
}
|
||
}
|
||
|
||
function renumberAssembleRows() {
|
||
assembleRows.value.forEach((row, i) => {
|
||
row.operNumber = (i + 1) * 10
|
||
})
|
||
}
|
||
|
||
function addAssembleRow() {
|
||
assembleRows.value.push(createEmptyAssembleRow((assembleRows.value.length + 1) * 10))
|
||
}
|
||
|
||
function removeAssembleRow(key: string) {
|
||
if (assembleRows.value.length <= 1) return
|
||
assembleRows.value = assembleRows.value.filter((r) => r._key !== key)
|
||
renumberAssembleRows()
|
||
}
|
||
|
||
function onAssembleProcessChange(row: AssembleRow, processId: number | null) {
|
||
row.processId = processId
|
||
if (processId == null) {
|
||
row.processCode = null
|
||
row.processName = null
|
||
row.operDescription = null
|
||
row.sectionId = null
|
||
row.workshopId = null
|
||
row.qualityInspection = 0
|
||
row.storageEntry = 0
|
||
row.procurement = 0
|
||
row.isOutsource = 0
|
||
return
|
||
}
|
||
const basic = processMap.value[processId]
|
||
if (!basic) return
|
||
row.processCode = basic.code || null
|
||
row.processName = basic.name || null
|
||
if (!row.operDescription) {
|
||
row.operDescription = basic.operDescription || basic.name || null
|
||
}
|
||
row.sectionId = basic.sectionId ?? null
|
||
row.workshopId = basic.workShopId ?? null
|
||
row.qualityInspection = basic.qualityInspection ?? 0
|
||
row.storageEntry = basic.storageEntry ?? 0
|
||
row.procurement = basic.procurement ?? 0
|
||
row.isOutsource = basic.outsource ?? 0
|
||
}
|
||
|
||
function onCtrlCodeChange(row: AssembleRow, val: string | null) {
|
||
row.optCtrlCodeName = val
|
||
if (!val) return
|
||
// 含「质量」→ 质检;含「委外」→ 委外;「免检」→ 不质检
|
||
row.qualityInspection = val.includes('质量') ? 1 : 0
|
||
row.isOutsource = val.includes('委外') ? 1 : 0
|
||
}
|
||
|
||
const assembleColumns = computed<DataTableColumns<AssembleRow>>(() => [
|
||
{
|
||
title: '工序号',
|
||
key: 'operNumber',
|
||
width: 64,
|
||
render: (row) => row.operNumber ?? '-',
|
||
},
|
||
{
|
||
title: '工序',
|
||
key: 'processId',
|
||
width: 180,
|
||
render: (row) =>
|
||
h(NSelect, {
|
||
value: row.processId,
|
||
size: 'small',
|
||
filterable: true,
|
||
clearable: true,
|
||
options: processOptions.value,
|
||
loading: processLoading.value,
|
||
placeholder: '选择工序',
|
||
style: 'width: 100%',
|
||
onUpdateValue: (v: number | null) => onAssembleProcessChange(row, v),
|
||
}),
|
||
},
|
||
{
|
||
title: '工序说明',
|
||
key: 'operDescription',
|
||
width: 150,
|
||
render: (row) =>
|
||
h(NInput, {
|
||
value: row.operDescription ?? '',
|
||
size: 'small',
|
||
clearable: true,
|
||
placeholder: row.processName || '默认工序名称',
|
||
onUpdateValue: (v: string) => {
|
||
row.operDescription = v
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '标准工时',
|
||
key: 'activityQty',
|
||
width: 100,
|
||
render: (row) =>
|
||
h(NInputNumber, {
|
||
value: row.activityQty,
|
||
size: 'small',
|
||
min: 0,
|
||
precision: 3,
|
||
style: 'width: 90px',
|
||
placeholder: '工时',
|
||
onUpdateValue: (v: number | null) => {
|
||
row.activityQty = v
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '单位',
|
||
key: 'activityUnit',
|
||
width: 100,
|
||
render: (row) =>
|
||
h(NSelect, {
|
||
value: row.activityUnit,
|
||
size: 'small',
|
||
filterable: true,
|
||
tag: true,
|
||
options: activityUnitOptions.value,
|
||
placeholder: '单位',
|
||
style: 'width: 100%',
|
||
onUpdateValue: (v: string | null) => {
|
||
row.activityUnit = v
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '控制码',
|
||
key: 'optCtrlCodeName',
|
||
width: 140,
|
||
render: (row) =>
|
||
h(NSelect, {
|
||
value: row.optCtrlCodeName,
|
||
size: 'small',
|
||
filterable: true,
|
||
clearable: true,
|
||
options: ctrlCodeOptions,
|
||
placeholder: '选择控制码',
|
||
style: 'width: 100%',
|
||
onUpdateValue: (v: string | null) => onCtrlCodeChange(row, v),
|
||
}),
|
||
},
|
||
{
|
||
title: '质检',
|
||
key: 'qualityInspection',
|
||
width: 70,
|
||
render: (row) =>
|
||
h(NSwitch, {
|
||
value: row.qualityInspection === 1,
|
||
size: 'small',
|
||
onUpdateValue: (v: boolean) => {
|
||
row.qualityInspection = v ? 1 : 0
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '入库',
|
||
key: 'storageEntry',
|
||
width: 70,
|
||
render: (row) =>
|
||
h(NSwitch, {
|
||
value: row.storageEntry === 1,
|
||
size: 'small',
|
||
onUpdateValue: (v: boolean) => {
|
||
row.storageEntry = v ? 1 : 0
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 64,
|
||
fixed: 'right',
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
type: 'error',
|
||
size: 'small',
|
||
disabled: assembleRows.value.length <= 1,
|
||
onClick: () => removeAssembleRow(row._key),
|
||
},
|
||
{ default: () => '删除' },
|
||
),
|
||
},
|
||
])
|
||
|
||
const assembleValid = computed(() => {
|
||
if (!assembleRows.value.length) return false
|
||
return assembleRows.value.every((r) => r.processId != null || !!r.processName || !!r.processCode)
|
||
})
|
||
|
||
const canConfirm = computed(() => {
|
||
if (!preview.value?.canSplit || submitting.value) return false
|
||
|
||
if (mode.value === 'equal') {
|
||
// 等量:整单均分,列表本身已含原单保留
|
||
if (!equalQuantities.value.length) return false
|
||
} else {
|
||
if (keepQty.value == null || keepQty.value < unsplittableQty.value) return false
|
||
if (splitOutQty.value <= 0) return false
|
||
if (customRows.value.length < 1) return false
|
||
if (customRows.value.some((v) => v == null || v <= 0)) return false
|
||
if (customSum.value !== splitOutQty.value) return false
|
||
}
|
||
|
||
if (routeMode.value === 'kingdee') return !!routingNo.value
|
||
return assembleValid.value
|
||
})
|
||
|
||
watch(
|
||
() => [props.show, props.planId] as const,
|
||
async ([show, planId]) => {
|
||
if (!show || planId == null) return
|
||
await loadPreview(planId)
|
||
await Promise.all([
|
||
loadRouteOptions(preview.value?.materialCode),
|
||
loadProcessOptions(),
|
||
])
|
||
},
|
||
)
|
||
|
||
watch(keepQty, () => {
|
||
if (mode.value === 'custom') {
|
||
customRows.value = [splitOutQty.value > 0 ? splitOutQty.value : null]
|
||
}
|
||
})
|
||
|
||
async function loadPreview(planId: number) {
|
||
loading.value = true
|
||
preview.value = null
|
||
try {
|
||
preview.value = await orderProcessPlanApi.splitPreview(planId)
|
||
const unsplittable = preview.value?.investedQty ?? 0
|
||
keepQty.value = unsplittable
|
||
// 默认拆成 2 份(原单 + 1 新单);数量够时也可改成更多
|
||
equalCount.value = 2
|
||
customRows.value = [preview.value?.splittableQty ?? null]
|
||
mode.value = 'equal'
|
||
routeMode.value = 'kingdee'
|
||
routingNo.value = null
|
||
assembleRows.value = [createEmptyAssembleRow(10)]
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载拆单预览失败')
|
||
visible.value = false
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadRouteOptions(materialCode?: string | null) {
|
||
routeLoading.value = true
|
||
routeList.value = []
|
||
routingNo.value = null
|
||
try {
|
||
if (!materialCode) return
|
||
const list = (await processRouteApi.kingdeeSelect(materialCode)) as ProcessRouteSelectOption[]
|
||
routeList.value = (Array.isArray(list) ? list : []).filter((r) => !!r?.routingNo)
|
||
} catch (e: any) {
|
||
routeList.value = []
|
||
message.error(e?.message || '加载金蝶工艺路线失败')
|
||
} finally {
|
||
routeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadProcessOptions() {
|
||
processLoading.value = true
|
||
try {
|
||
const page: any = await basicProcessPlanApi.page({ page: 1, pageSize: 500 })
|
||
const rows: BasicProcessPlan[] = page?.records ?? page?.list ?? (Array.isArray(page) ? page : [])
|
||
const map: Record<number, BasicProcessPlan> = {}
|
||
processOptions.value = rows
|
||
.filter((r) => r?.id != null)
|
||
.map((r) => {
|
||
const id = Number(r.id)
|
||
map[id] = r
|
||
return {
|
||
label: `${r.code || ''} ${r.name || ''}`.trim() || String(id),
|
||
value: id,
|
||
}
|
||
})
|
||
processMap.value = map
|
||
} catch {
|
||
processOptions.value = []
|
||
processMap.value = {}
|
||
} finally {
|
||
processLoading.value = false
|
||
}
|
||
}
|
||
|
||
function addCustomRow() {
|
||
customRows.value.push(null)
|
||
}
|
||
|
||
function removeCustomRow(idx: number) {
|
||
if (customRows.value.length <= 1) return
|
||
customRows.value.splice(idx, 1)
|
||
}
|
||
|
||
function resetState() {
|
||
preview.value = null
|
||
mode.value = 'equal'
|
||
keepQty.value = 0
|
||
equalCount.value = 2
|
||
customRows.value = [null]
|
||
routeMode.value = 'kingdee'
|
||
routingNo.value = null
|
||
routeList.value = []
|
||
assembleRows.value = [createEmptyAssembleRow(10)]
|
||
}
|
||
|
||
function buildAssembleSteps(): ProcessPlanSplitStep[] {
|
||
return assembleRows.value.map((row, i) => {
|
||
const name = row.processName || processMap.value[row.processId!]?.name || null
|
||
const desc = (row.operDescription || '').trim() || name
|
||
return {
|
||
processId: row.processId,
|
||
operNumber: row.operNumber ?? (i + 1) * 10,
|
||
processCode: row.processCode,
|
||
processName: name,
|
||
operDescription: desc,
|
||
sectionId: row.sectionId,
|
||
workshopId: row.workshopId,
|
||
activityUnit: row.activityUnit,
|
||
optCtrlCodeName: row.optCtrlCodeName,
|
||
activityQty: row.activityQty,
|
||
qualityInspection: row.qualityInspection ?? 0,
|
||
storageEntry: row.storageEntry ?? 0,
|
||
procurement: row.procurement ?? 0,
|
||
isOutsource: row.isOutsource ?? 0,
|
||
}
|
||
})
|
||
}
|
||
|
||
async function confirmSplit() {
|
||
if (!props.planId || !canConfirm.value) return
|
||
// 等量:列表已含原单;指定:拼上 keepQty
|
||
const quantities =
|
||
mode.value === 'equal'
|
||
? equalQuantities.value
|
||
: [keepQty.value!, ...customRows.value.map((v) => Number(v))]
|
||
submitting.value = true
|
||
try {
|
||
await orderProcessPlanApi.split({
|
||
planId: props.planId,
|
||
quantities,
|
||
routeMode: routeMode.value,
|
||
routingNo: routeMode.value === 'kingdee' ? routingNo.value : null,
|
||
assembleSteps: routeMode.value === 'assemble' ? buildAssembleSteps() : undefined,
|
||
})
|
||
message.success('拆单成功')
|
||
visible.value = false
|
||
emit('success')
|
||
} catch (e: any) {
|
||
message.error(e?.message || '拆单失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.split-scroll {
|
||
max-height: min(72vh, 680px);
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
}
|
||
.split-desc {
|
||
margin-bottom: 4px;
|
||
}
|
||
.process-names {
|
||
color: #666;
|
||
font-size: 12px;
|
||
}
|
||
.split-section {
|
||
margin-top: 10px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #e8e8e8;
|
||
}
|
||
.split-section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 8px;
|
||
}
|
||
.field-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 6px;
|
||
}
|
||
.field-label-inline {
|
||
font-size: 13px;
|
||
color: #666;
|
||
min-width: 64px;
|
||
}
|
||
.split-body {
|
||
margin-top: 6px;
|
||
}
|
||
.field-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.hint,
|
||
.hint-inline {
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
.hint {
|
||
margin-top: 4px;
|
||
}
|
||
.kingdee-layout {
|
||
display: grid;
|
||
grid-template-columns: 280px 1fr;
|
||
gap: 12px;
|
||
min-height: 0;
|
||
}
|
||
.kingdee-left,
|
||
.kingdee-right {
|
||
min-width: 0;
|
||
}
|
||
.route-card-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
padding: 1px;
|
||
}
|
||
.route-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
background: #fafafa;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
.route-card:hover {
|
||
border-color: #93c5fd;
|
||
background: #f8fbff;
|
||
}
|
||
.route-card.active {
|
||
border-color: #2080f0;
|
||
background: #eff6ff;
|
||
}
|
||
.route-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 6px;
|
||
}
|
||
.route-code {
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
font-size: 12px;
|
||
}
|
||
.route-step-count {
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
white-space: nowrap;
|
||
}
|
||
.route-flow {
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
line-height: 1.35;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.route-detail-sub {
|
||
margin-left: 6px;
|
||
font-weight: 400;
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}
|
||
.route-detail-empty {
|
||
height: 260px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px dashed #e5e7eb;
|
||
border-radius: 6px;
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
background: #fafafa;
|
||
}
|
||
.assemble-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.custom-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.sum-line {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
text-align: right;
|
||
}
|
||
.sum-line.error {
|
||
color: #d03050;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.kingdee-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.route-card-list {
|
||
max-height: 140px;
|
||
}
|
||
}
|
||
</style>
|