工序计划拆分预览/提交,完善工艺路线选择

This commit is contained in:
tzy 2026-08-07 14:18:18 +08:00
parent 8f9bf2bdf2
commit f22754edbf
11 changed files with 2113 additions and 425 deletions

View File

@ -22,6 +22,14 @@ export interface BasicProcessPlan {
departmentName?: string departmentName?: string
sectionId?: number | null
workShopId?: number | null
sectionName?: string
workShopName?: string
operDescription?: string operDescription?: string
createBy?: number createBy?: number

View File

@ -31,15 +31,51 @@ export interface DeviceRealtimeVO {
displayName: string displayName: string
synEquipId: number synEquipId: number
bound: boolean bound: boolean
/** connected机台是否在线 */
connected?: number | null connected?: number | null
/** cnc_status控制器状态 */
cncStatus?: number | null cncStatus?: number | null
cncStatusText?: string cncStatusText?: string
/** part_count目前工件数 */
partCount?: number | null partCount?: number | null
/** part_count_required需求工件数 */
partCountRequired?: number | null
/** main_program各轴群的当前加工号码 */
mainProgram?: string | null mainProgram?: string | null
/** feedrate_act轴群的实际进给速率 */
feedrateAct?: number | null
/** feedrate_override轴群的实际进给倍率 */
feedrateOverride?: number | null
/** speed_act轴群的加工主轴转速 */
speedAct?: number | null
/** speed_cmd轴群的加工主轴转速命令 */
speedCmd?: number | null
/** load轴群的加工主轴负载率 */
load?: number | null
/** speed_override轴群的加工主轴速度百分比 */
speedOverride?: number | null
/** tool_id各轴群的加工主轴刀号 */
toolId?: string | null
/** torque_load轴向负载率 */
torqueLoad?: number | null
/** span_cutting_cycle单件加工时间 */
spanCuttingCycle?: number | null
/** span_cutting_acc累计加工时间 */
spanCuttingAcc?: number | null
/** span_power_on_acc累计开机时间 */
spanPowerOnAcc?: number | null
/** span_cutting_install安装累计加工时间 */
spanCuttingInstall?: number | null
/** span_power_on开机时间 */
spanPowerOn?: number | null
/** alarm_current警报相关 */
alarmCount?: number alarmCount?: number
alarmMessages?: string[] alarmMessages?: string[]
dataTimestamp?: number dataTimestamp?: number
sectionId?: number | null
sectionName?: string sectionName?: string
workshopId?: number | null
workshopName?: string
} }

View File

@ -70,6 +70,9 @@ export interface ProcessPlanItemVO {
workOrderCode?: string workOrderCode?: string
/** 拆单来源工序计划ID */
splitFromId?: number | null
processCode?: string processCode?: string
operNumber?: number operNumber?: number
@ -150,6 +153,9 @@ export interface OrderProcessPlanVO {
orderItemId?: number orderItemId?: number
/** 工单号(列表按工单分组) */
workOrderCode?: string
mainCode?: string mainCode?: string
materialCode?: string materialCode?: string
@ -290,7 +296,7 @@ export function toOrderProcessPlanPayload(data: Record<string, unknown>): OrderP
const displayKeys = [ const displayKeys = [
'mainCode', 'materialCode', 'materialName', 'orderCode', 'routeCode', 'workshopName', 'mainCode', 'materialCode', 'materialName', 'orderCode', 'routeCode', 'workshopName', 'workOrderCode',
'actualStartTime', 'actualEndTime', 'completedQty', 'orderStatus', 'currentProcessName', 'actualStartTime', 'actualEndTime', 'completedQty', 'orderStatus', 'currentProcessName',
@ -302,7 +308,7 @@ export function toOrderProcessPlanPayload(data: Record<string, unknown>): OrderP
'transferOutQty', 'scrapQty', 'reworkQty', 'assign', 'assignByName', 'assignTime', 'transferOutQty', 'scrapQty', 'reworkQty', 'assign', 'assignByName', 'assignTime',
'processStatus', 'prevPlanId', 'nextPlanId', 'assignWorkList', 'processStatus', 'prevPlanId', 'nextPlanId', 'assignWorkList', 'splitFromId',
] ]
@ -354,9 +360,9 @@ export const orderProcessPlanApi = {
// 获取详情(路径参数为生产订单 id // 获取详情(路径参数为生产订单 id;可选 workOrderCode 隔离拆单工单
detail(orderItemId: number | string) { detail(orderItemId: number | string, workOrderCode?: string) {
return request<OrderProcessPlanVO>({ return request<OrderProcessPlanVO>({
@ -364,6 +370,8 @@ export const orderProcessPlanApi = {
method: 'get', method: 'get',
params: workOrderCode ? { workOrderCode } : undefined,
}) })
}, },
@ -493,6 +501,81 @@ export const orderProcessPlanApi = {
}) })
}, },
/** 工序工单拆单预览 */
splitPreview(planId: number | string) {
return request<ProcessPlanSplitPreview>({
url: `/biz/orderProcessPlan/split/preview/${planId}`,
method: 'get',
})
},
/** 工序工单拆单 */
split(data: ProcessPlanSplitPayload) {
return request<ProcessPlanSplitResult>({
url: '/biz/orderProcessPlan/split',
method: 'post',
data,
})
},
}
/** 拆单预览 */
export interface ProcessPlanSplitPreview {
planId?: number
workOrderCode?: string
orderItemId?: number
orderItemName?: string
/** 物料编码,用于查金蝶工艺路线 */
materialCode?: string
processCount?: number
processNames?: string[]
/** 工单计划数量 */
quantity?: number
/** 已进入生产数量(不可拆)= MAX(各工序投入) */
investedQty?: number
/** 可拆 = 计划 已进入生产数量 */
splittableQty?: number
canSplit?: boolean
reason?: string | null
}
/** 拆单请求quantities[0]=原单保留(≥不可拆),其余=新工单;新工艺二选一 */
export interface ProcessPlanSplitPayload {
planId: number
quantities: number[]
/** kingdee=金蝶工艺路线assemble=工序表组装 */
routeMode: 'kingdee' | 'assemble'
/** routeMode=kingdee金蝶工艺路线编码 FNumber */
routingNo?: string | null
/** routeMode=assemble完整工序明细推荐 */
assembleSteps?: ProcessPlanSplitStep[]
/** 兼容旧版:仅工序 id */
processIds?: number[]
}
/** 拆单组装工序行(对齐金蝶工艺路线主要字段) */
export interface ProcessPlanSplitStep {
processId?: number | null
operNumber?: number | null
processCode?: string | null
processName?: string | null
operDescription?: string | null
sectionId?: number | null
workshopId?: number | null
activityUnit?: string | null
optCtrlCodeName?: string | null
activityQty?: number | null
qualityInspection?: number | null
storageEntry?: number | null
procurement?: number | null
isOutsource?: number | null
}
/** 拆单结果 */
export interface ProcessPlanSplitResult {
source?: ProcessPlanItemVO
created?: ProcessPlanItemVO[]
} }
/** 委外可选工序项 */ /** 委外可选工序项 */

View File

@ -88,5 +88,43 @@ export const processRouteApi = {
// 下载导入模板 // 下载导入模板
downloadTemplate() { downloadTemplate() {
return request({ url: `/biz/processRoute/template`, method: 'get', responseType: 'blob' }) return request({ url: `/biz/processRoute/template`, method: 'get', responseType: 'blob' })
} },
/** 按物料编码查询金蝶工艺路线选项(含工艺路线编码) */
kingdeeSelect(materialCode: string) {
return request<ProcessRouteSelectOption[]>({
url: '/biz/processRoute/kingdee/select',
method: 'get',
params: { materialCode },
})
},
}
/** 金蝶工艺路线下拉选项 */
export interface ProcessRouteSelectOption {
routingNo?: string
materialCode?: string
materialName?: string
stepCount?: number
processNames?: string[]
/** 工序明细 */
steps?: KingdeeProcessRouteStep[]
}
/** 金蝶工艺路线工序行 */
export interface KingdeeProcessRouteStep {
routingNo?: string
materialCode?: string
materialName?: string
operNumber?: number
processCode?: string
processName?: string
operDescription?: string
workCenterName?: string
departmentName?: string
optCtrlCodeName?: string
activityUnit?: string
activityQty?: number
qualityInspection?: number
storageEntry?: number
} }

View File

@ -208,6 +208,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/biz/maintenanceRecord/index.vue'), component: () => import('@/views/biz/maintenanceRecord/index.vue'),
meta: { title: '设备维修保养记录', icon: 'BuildOutline' } meta: { title: '设备维修保养记录', icon: 'BuildOutline' }
}, },
// 设备状态监控(独立页)
{
path: 'biz/device/board',
name: 'deviceRealtimeBoard',
component: () => import('@/views/biz/device/board.vue'),
meta: { title: '设备状态监控', icon: 'PulseOutline' }
},
// 开发工具 // 开发工具
{ {
path: 'tool/gen', path: 'tool/gen',

File diff suppressed because it is too large Load Diff

View File

@ -62,7 +62,6 @@
<!-- 表头 --> <!-- 表头 -->
<div class="board-table-head"> <div class="board-table-head">
<div class="col-check" /> <div class="col-check" />
<div class="col-expand" />
<div class="col-product">产品 / 工单编号</div> <div class="col-product">产品 / 工单编号</div>
<div class="col-qty">数量</div> <div class="col-qty">数量</div>
<div class="col-mode">模式</div> <div class="col-mode">模式</div>
@ -80,29 +79,13 @@
</div> </div>
<div v-for="order in tableData" :key="orderRowKey(order)" class="order-block"> <div v-for="order in tableData" :key="orderRowKey(order)" class="order-block">
<div class="order-row" :class="{ expanded: isExpanded(order), 'derived-expanded': isDerivedExpanded(order) }"> <div class="order-row" :class="{ expanded: isExpanded(order) }">
<div class="col-check"> <div class="col-check">
<n-checkbox <n-checkbox
:checked="selectedOrderIds.includes(order.orderItemId!)" :checked="selectedOrderIds.includes(orderRowKey(order))"
@update:checked="(v: boolean) => toggleSelect(order.orderItemId!, v)" @update:checked="(v: boolean) => toggleSelect(orderRowKey(order), v)"
/> />
</div> </div>
<div class="col-expand">
<n-button
quaternary
circle
size="tiny"
title="展开返工单 / 拆分工单"
@click="toggleDerivedExpand(order)"
>
<template #icon>
<n-icon>
<RemoveOutline v-if="isDerivedExpanded(order)" />
<AddOutline v-else />
</n-icon>
</template>
</n-button>
</div>
<div class="col-product"> <div class="col-product">
<div class="product-name">{{ order.materialName || '-' }}</div> <div class="product-name">{{ order.materialName || '-' }}</div>
<div class="product-code"> <div class="product-code">
@ -153,43 +136,6 @@
</div> </div>
</div> </div>
<div v-if="isDerivedExpanded(order)" class="order-derived-expand">
<div class="derived-section">
<div class="derived-section-head">
<span class="derived-section-title">返工单</span>
<n-button text type="primary" size="tiny" @click="onMoreSelect('createRework', order)">创建返工单</n-button>
</div>
<div v-if="!getDerivedOrders(order).rework.length" class="derived-empty">暂无返工单</div>
<div v-else class="derived-list">
<div
v-for="item in getDerivedOrders(order).rework"
:key="item.id"
class="derived-item"
>
<span class="derived-code">{{ item.code }}</span>
<span class="derived-meta">{{ item.quantity ?? '-' }} · {{ item.statusLabel }}</span>
</div>
</div>
</div>
<div class="derived-section">
<div class="derived-section-head">
<span class="derived-section-title">拆分工单</span>
<n-button text type="primary" size="tiny" @click="onMoreSelect('splitWorkOrder', order)">拆分工单</n-button>
</div>
<div v-if="!getDerivedOrders(order).split.length" class="derived-empty">暂无拆分工单</div>
<div v-else class="derived-list">
<div
v-for="item in getDerivedOrders(order).split"
:key="item.id"
class="derived-item"
>
<span class="derived-code">{{ item.code }}</span>
<span class="derived-meta">{{ item.quantity ?? '-' }} · {{ item.statusLabel }}</span>
</div>
</div>
</div>
</div>
<div v-if="isExpanded(order)" class="order-expand"> <div v-if="isExpanded(order)" class="order-expand">
<div v-if="!(order.processList?.length)" class="expand-empty">暂无工序明细</div> <div v-if="!(order.processList?.length)" class="expand-empty">暂无工序明细</div>
<div v-else class="card-flow-scroll"> <div v-else class="card-flow-scroll">
@ -221,8 +167,6 @@
<!--模型抽屉 --> <!--模型抽屉 -->
<PlmModelDrawer ref="plmModelDrawerRef" /> <PlmModelDrawer ref="plmModelDrawerRef" />
<div class="board-pagination"> <div class="board-pagination">
<n-pagination <n-pagination
v-model:page="pagination.page" v-model:page="pagination.page"
@ -407,6 +351,13 @@
</template> </template>
</n-modal> </n-modal>
<!-- 工序工单拆单整单全部工序一起按数量拆 -->
<SplitProcessDialog
v-model:show="splitVisible"
:plan-id="splitPlanId"
@success="onSplitSuccess"
/>
<!-- 派工 --> <!-- 派工 -->
<n-modal v-model:show="dispatchmodal" title="工序派工" preset="card" style="width: 800px" :mask-closable="false"> <n-modal v-model:show="dispatchmodal" title="工序派工" preset="card" style="width: 800px" :mask-closable="false">
<n-divider /> <n-divider />
@ -664,13 +615,13 @@ import { ref, reactive, onMounted, h, computed, type Component } from 'vue'
import { import {
NButton, NSpace, NIcon, NTag, NSpin, NModal, NForm, NFormItem, NGrid, NGi, NButton, NSpace, NIcon, NTag, NSpin, NModal, NForm, NFormItem, NGrid, NGi,
NInput, NInputNumber, NDatePicker, NCheckbox, NEmpty, NPagination, NDropdown, NDescriptions, NInput, NInputNumber, NDatePicker, NCheckbox, NEmpty, NPagination, NDropdown, NDescriptions,
NDescriptionsItem, NAlert, NUpload, NText, NCard, NRadioGroup, NRadio, useMessage, useDialog, NDescriptionsItem, NAlert, NUpload, NText, NCard, NRadioGroup, NRadio, NDivider, useMessage, useDialog,
type UploadCustomRequestOptions, type FormRules, type UploadCustomRequestOptions, type FormRules,
} from 'naive-ui' } from 'naive-ui'
import { import {
AddOutline, RemoveOutline, SearchOutline, RefreshOutline, CloudUploadOutline, AddOutline, SearchOutline, RefreshOutline, CloudUploadOutline,
DownloadOutline, ChevronDownOutline, CheckmarkCircleOutline, CheckmarkCircle, DownloadOutline, ChevronDownOutline, CheckmarkCircleOutline, CheckmarkCircle,
EllipseOutline, TimeOutline, PrintOutline, SyncOutline, PeopleOutline, EllipseOutline, TimeOutline, PrintOutline, PeopleOutline,
GitBranchOutline, CloseCircleOutline, LockClosedOutline, TrashOutline, EyeSharp, GitBranchOutline, CloseCircleOutline, LockClosedOutline, TrashOutline, EyeSharp,
MenuOutline, MenuOutline,
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
@ -688,6 +639,7 @@ import {
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import ProcessFlowStepper from './components/ProcessFlowStepper.vue' import ProcessFlowStepper from './components/ProcessFlowStepper.vue'
import ProcessCard from './components/ProcessCard.vue' import ProcessCard from './components/ProcessCard.vue'
import SplitProcessDialog from './components/SplitProcessDialog.vue'
import PlmModelDrawer from '@/components/PlmModelDrawer.vue' import PlmModelDrawer from '@/components/PlmModelDrawer.vue'
import type { PlmModelOpenContext } from '@/api/plmModel' import type { PlmModelOpenContext } from '@/api/plmModel'
import Pgitem from './pgitem.vue' import Pgitem from './pgitem.vue'
@ -706,9 +658,8 @@ const searchForm = reactive({
}) })
const tableData = ref<OrderProcessPlanVO[]>([]) const tableData = ref<OrderProcessPlanVO[]>([])
const expandedIds = ref<Set<number>>(new Set()) const expandedIds = ref<Set<string>>(new Set())
const derivedExpandedIds = ref<Set<number>>(new Set()) const selectedOrderIds = ref<string[]>([])
const selectedOrderIds = ref<number[]>([])
const loading = ref(false) const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, itemCount: 0 }) const pagination = reactive({ page: 1, pageSize: 10, itemCount: 0 })
@ -716,6 +667,10 @@ const detailVisible = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailData = ref<OrderProcessPlanVO | null>(null) const detailData = ref<OrderProcessPlanVO | null>(null)
/** 工序工单拆单 */
const splitVisible = ref(false)
const splitPlanId = ref<number | null>(null)
/** 订单级工序重排弹窗状态 */ /** 订单级工序重排弹窗状态 */
const reorderVisible = ref(false) const reorderVisible = ref(false)
const reorderLoading = ref(false) const reorderLoading = ref(false)
@ -835,7 +790,6 @@ const moreOptions = [
label: '派生操作', label: '派生操作',
key: 'group-derived', key: 'group-derived',
children: [ children: [
{ label: '创建返工单', key: 'createRework', icon: dropdownIcon(SyncOutline) },
{ label: '创建工序委外', key: 'createOutsource', icon: dropdownIcon(PeopleOutline) }, { label: '创建工序委外', key: 'createOutsource', icon: dropdownIcon(PeopleOutline) },
{ label: '拆分工单', key: 'splitWorkOrder', icon: dropdownIcon(GitBranchOutline) }, { label: '拆分工单', key: 'splitWorkOrder', icon: dropdownIcon(GitBranchOutline) },
{ label: '汇报详情', key: 'reportDetailView', icon: dropdownIcon(EyeSharp) }, { label: '汇报详情', key: 'reportDetailView', icon: dropdownIcon(EyeSharp) },
@ -860,57 +814,78 @@ const moreOptions = [
}, },
] ]
/** 列表行主键:按工单号(拆单后同订单多工单分行) */
function orderRowKey(row: OrderProcessPlanVO) { function orderRowKey(row: OrderProcessPlanVO) {
return row.orderItemId ?? `order-${row.mainCode}-${formatWorkOrderCodes(row)}` if (row.workOrderCode) return row.workOrderCode
return `oi-${row.orderItemId ?? 'x'}`
} }
/** 汇总展示订单下各道工序的主工单号 */ /** 订单级工单号展示 */
function formatWorkOrderCodes(order?: OrderProcessPlanVO | null) { function formatWorkOrderCodes(order?: OrderProcessPlanVO | null) {
const codes = (order?.processList ?? []).map((p) => p.workOrderCode).filter(Boolean) if (order?.workOrderCode) return order.workOrderCode
return codes.length ? codes.join('、') : '-' const codes = (order?.processList ?? [])
.map((p) => p.workOrderCode)
.filter((c): c is string => !!c)
const unique = [...new Set(codes)]
return unique.length ? unique.join('、') : '-'
} }
function sortedProcessList(list?: ProcessPlanItemVO[]) { function sortedProcessList(list?: ProcessPlanItemVO[]) {
return [...(list ?? [])].sort((a, b) => (a.operNumber ?? 0) - (b.operNumber ?? 0)) return [...(list ?? [])].sort((a, b) => {
const oa = a.operNumber ?? 0
const ob = b.operNumber ?? 0
if (oa !== ob) return oa - ob
return (a.planId ?? 0) - (b.planId ?? 0)
})
} }
function isExpanded(order: OrderProcessPlanVO) { function isExpanded(order: OrderProcessPlanVO) {
return order.orderItemId != null && expandedIds.value.has(order.orderItemId) return expandedIds.value.has(orderRowKey(order))
}
function isDerivedExpanded(order: OrderProcessPlanVO) {
return order.orderItemId != null && derivedExpandedIds.value.has(order.orderItemId)
} }
function toggleExpand(order: OrderProcessPlanVO) { function toggleExpand(order: OrderProcessPlanVO) {
if (order.orderItemId == null) return const key = orderRowKey(order)
const next = new Set(expandedIds.value) const next = new Set(expandedIds.value)
if (next.has(order.orderItemId)) next.delete(order.orderItemId) if (next.has(key)) next.delete(key)
else next.add(order.orderItemId) else next.add(key)
expandedIds.value = next expandedIds.value = next
} }
function toggleDerivedExpand(order: OrderProcessPlanVO) { /** 整单拆分入口:有计划数量且未全部完工即可点开 */
if (order.orderItemId == null) return function canSplitWorkOrder(order: OrderProcessPlanVO) {
const next = new Set(derivedExpandedIds.value) const qty = order.quantity ?? 0
if (next.has(order.orderItemId)) next.delete(order.orderItemId) if (qty <= 0) return false
else next.add(order.orderItemId) if (order.orderStatus === 2) return false
derivedExpandedIds.value = next return (order.processList?.length ?? 0) > 0
} }
/** 派生工单(返工 / 拆分),待后端字段接入后替换占位逻辑 */ function openSplitFromOrder(order: OrderProcessPlanVO) {
function getDerivedOrders(order: OrderProcessPlanVO) { if (!hasPermission('biz:orderProcessPlan:edit')) {
const raw = order as OrderProcessPlanVO & { message.warning('无拆单权限')
reworkOrders?: Array<{ id: string | number; code?: string; quantity?: number; statusLabel?: string }> return
splitOrders?: Array<{ id: string | number; code?: string; quantity?: number; statusLabel?: string }>
} }
return { const list = sortedProcessList(order.processList)
rework: raw.reworkOrders ?? [], const seed = list.find((p) => p.planId != null)
split: raw.splitOrders ?? [], if (!seed?.planId) {
message.warning('当前工单暂无工序,无法拆单')
return
}
if (!canSplitWorkOrder(order)) {
message.warning('该工单不可拆(无数量或已完工)')
return
}
splitPlanId.value = seed.planId
splitVisible.value = true
}
function onSplitSuccess() {
loadData()
if (detailVisible.value && detailData.value?.orderItemId) {
openDetail(detailData.value)
} }
} }
function toggleSelect(id: number, checked: boolean) { function toggleSelect(id: string, checked: boolean) {
if (checked) selectedOrderIds.value = [...selectedOrderIds.value, id] if (checked) selectedOrderIds.value = [...selectedOrderIds.value, id]
else selectedOrderIds.value = selectedOrderIds.value.filter(v => v !== id) else selectedOrderIds.value = selectedOrderIds.value.filter(v => v !== id)
} }
@ -982,7 +957,7 @@ function handlePageSizeChange(pageSize: number) {
function onBatchSelect(key: string) { function onBatchSelect(key: string) {
if (key === 'expandAll') { if (key === 'expandAll') {
expandedIds.value = new Set(tableData.value.map(o => o.orderItemId!).filter(Boolean)) expandedIds.value = new Set(tableData.value.map(o => orderRowKey(o)))
} else if (key === 'collapseAll') { } else if (key === 'collapseAll') {
expandedIds.value = new Set() expandedIds.value = new Set()
} }
@ -997,11 +972,13 @@ function onMoreSelect(key: string, order: OrderProcessPlanVO) {
openReportDetail(order) openReportDetail(order)
return; return;
} }
if (key === 'splitWorkOrder') {
openSplitFromOrder(order)
return
}
const labels: Record<string, string> = { const labels: Record<string, string> = {
print: '打印', print: '打印',
createRework: '创建返工单',
splitWorkOrder: '拆分工单',
withdraw: '撤回', withdraw: '撤回',
freeze: '冻结工单', freeze: '冻结工单',
delete: '删除', delete: '删除',
@ -1117,7 +1094,7 @@ async function openDetail(order: OrderProcessPlanVO) {
detailLoading.value = true detailLoading.value = true
detailData.value = null detailData.value = null
try { try {
detailData.value = await orderProcessPlanApi.detail(order.orderItemId) detailData.value = await orderProcessPlanApi.detail(order.orderItemId, order.workOrderCode)
} catch { } catch {
detailData.value = order detailData.value = order
} finally { } finally {
@ -1137,12 +1114,12 @@ async function openReorder(order: OrderProcessPlanVO) {
reorderOrderMeta.value = order reorderOrderMeta.value = order
reorderList.value = [] reorderList.value = []
try { try {
const detail = await orderProcessPlanApi.detail(order.orderItemId) const detail = await orderProcessPlanApi.detail(order.orderItemId, order.workOrderCode)
reorderOrderMeta.value = detail reorderOrderMeta.value = detail
// operNumber sort // operNumber
reorderList.value = sortedProcessList(detail.processList).map((p) => ({ ...p })) reorderList.value = sortedProcessList(detail.processList).map((p) => ({ ...p }))
if (!reorderList.value.length) { if (!reorderList.value.length) {
message.warning('当前单暂无工序计划') message.warning('当前单暂无工序计划')
} }
} catch { } catch {
// //
@ -1393,9 +1370,8 @@ onMounted(loadData)
.board-table-head, .board-table-head,
.order-row { .order-row {
display: grid; display: grid;
/* 勾选 | 展开 | 产品工单 | 数量 | 模式 | 齐套率 | 工序轴 | 计划时间 | 生命周期 | 操作 */ /* 勾选 | 产品工单 | 数量 | 模式 | 齐套率 | 工序轴 | 计划时间 | 生命周期 | 操作 */
grid-template-columns: grid-template-columns:
40px
40px 40px
minmax(220px, 1.6fr) minmax(220px, 1.6fr)
64px 64px
@ -1408,7 +1384,7 @@ onMounted(loadData)
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 0 16px; padding: 0 16px;
min-width: 1320px; min-width: 1280px;
} }
.board-table-head { .board-table-head {
@ -1438,16 +1414,7 @@ onMounted(loadData)
background: rgba(32, 128, 240, 0.04); background: rgba(32, 128, 240, 0.04);
} }
.order-row.derived-expanded { .col-check {
background: rgba(24, 160, 88, 0.04);
}
.order-row.expanded.derived-expanded {
background: linear-gradient(90deg, rgba(24, 160, 88, 0.04) 0%, rgba(32, 128, 240, 0.04) 100%);
}
.col-check,
.col-expand {
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
@ -1530,74 +1497,11 @@ onMounted(loadData)
} }
.order-expand { .order-expand {
padding: 12px 16px 16px 68px; padding: 12px 16px 16px 56px;
background: linear-gradient(180deg, rgba(32, 128, 240, 0.03) 0%, #fff 100%); background: linear-gradient(180deg, rgba(32, 128, 240, 0.03) 0%, #fff 100%);
border-top: 1px dashed #e8eaed; border-top: 1px dashed #e8eaed;
} }
.order-derived-expand {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 16px 12px 68px;
background: linear-gradient(180deg, rgba(24, 160, 88, 0.03) 0%, #fff 100%);
border-top: 1px dashed #e8eaed;
}
.derived-section {
min-width: 0;
padding: 10px 12px;
background: #fff;
border: 1px solid #eef0f3;
border-radius: 8px;
}
.derived-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.derived-section-title {
font-size: 13px;
font-weight: 600;
color: #1a1a1a;
}
.derived-empty {
font-size: 12px;
color: #8c8c8c;
padding: 4px 0;
}
.derived-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.derived-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: #fafbfc;
border-radius: 6px;
font-size: 12px;
}
.derived-code {
font-weight: 500;
color: #333;
}
.derived-meta {
color: #8c8c8c;
white-space: nowrap;
}
.card-flow-scroll, .card-flow-scroll,
.detail-cards { .detail-cards {
display: flex; display: flex;

View File

@ -79,6 +79,13 @@
ghost ghost
@click="emit('assign', process)" @click="emit('assign', process)"
>派工</n-button> >派工</n-button>
<n-button
v-if="canSplit"
size="tiny"
type="primary"
ghost
@click="emit('split', process)"
>拆单</n-button>
<n-button <n-button
v-if="canEdit" v-if="canEdit"
size="tiny" size="tiny"
@ -86,7 +93,6 @@
@click="emit('edit', process)" @click="emit('edit', process)"
>编辑</n-button> >编辑</n-button>
<n-button size="tiny" type="info" ghost @click="emit('viewModel', process)">模型</n-button> <n-button size="tiny" type="info" ghost @click="emit('viewModel', process)">模型</n-button>
<n-button size="tiny" quaternary disabled>返工</n-button>
<n-button <n-button
v-if="canOutsource" v-if="canOutsource"
size="tiny" size="tiny"
@ -135,6 +141,7 @@ const props = defineProps<{
isLast?: boolean isLast?: boolean
canEdit?: boolean canEdit?: boolean
canAssign?: boolean canAssign?: boolean
canSplit?: boolean
canOutsource?: boolean canOutsource?: boolean
canSubmitDetail?: boolean canSubmitDetail?: boolean
}>() }>()
@ -142,6 +149,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
edit: [process: ProcessPlanItemVO] edit: [process: ProcessPlanItemVO]
assign: [process: ProcessPlanItemVO] assign: [process: ProcessPlanItemVO]
split: [process: ProcessPlanItemVO]
viewModel: [process: ProcessPlanItemVO] viewModel: [process: ProcessPlanItemVO]
outsource: [process: ProcessPlanItemVO] outsource: [process: ProcessPlanItemVO]
submitDetail: [process: ProcessPlanItemVO] submitDetail: [process: ProcessPlanItemVO]

View File

@ -39,7 +39,12 @@ const emit = defineEmits<{
}>() }>()
const steps = computed(() => { const steps = computed(() => {
const list = [...(props.list ?? [])].sort((a, b) => (a.operNumber ?? 0) - (b.operNumber ?? 0)) const list = [...(props.list ?? [])].sort((a, b) => {
const oa = a.operNumber ?? 0
const ob = b.operNumber ?? 0
if (oa !== ob) return oa - ob
return (a.planId ?? 0) - (b.planId ?? 0)
})
return list.map((item, idx) => { return list.map((item, idx) => {
const label = item.processName || item.processCode || `工序${idx + 1}` const label = item.processName || item.processCode || `工序${idx + 1}`
const state = item.processStatus === 4 ? 'done' : item.processStatus != null && item.processStatus >= 2 ? 'active' : 'pending' const state = item.processStatus === 4 ? 'done' : item.processStatus != null && item.processStatus >= 2 ? 'active' : 'pending'

View File

@ -0,0 +1,929 @@
<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>

View File

@ -206,6 +206,13 @@
</template> </template>
</n-modal> </n-modal>
<!-- 工序工单拆单 -->
<SplitProcessDialog
v-model:show="splitVisible"
:plan-id="splitPlanId"
@success="loadData"
/>
<!--派工弹框--> <!--派工弹框-->
<n-modal <n-modal
v-model:show="dispatchmodal" v-model:show="dispatchmodal"
@ -422,6 +429,7 @@ import {
import { dictDataApi } from '@/api/org' import { dictDataApi } from '@/api/org'
import Pgitem from './pgitem.vue' import Pgitem from './pgitem.vue'
import SplitProcessDialog from './components/SplitProcessDialog.vue'
//import { userApi } from '@/api/system' //import { userApi } from '@/api/system'
@ -433,6 +441,25 @@ const hasPermission = (permission: string) => userStore.hasPermission(permission
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
const splitVisible = ref(false)
const splitPlanId = ref<number | null>(null)
/** 列表拆单入口:是否可拆以后端预览为准,这里只做粗过滤 */
function canSplitProcess(proc: ProcessPlanItemVO) {
const qty = proc.quantity ?? 0
return qty > 0
}
function openSplit(process: ProcessPlanItemVO) {
if (!process.planId) {
message.warning('工序计划无效')
return
}
// id
splitPlanId.value = process.planId
splitVisible.value = true
}
// //
const searchForm = reactive<any>({ const searchForm = reactive<any>({
id: '', id: '',
@ -450,6 +477,7 @@ const orderScrollX = 2090
const processScrollX = 2788 const processScrollX = 2788
function orderRowKey(row: OrderProcessPlanVO) { function orderRowKey(row: OrderProcessPlanVO) {
if (row.workOrderCode) return row.workOrderCode
return row.orderItemId ?? `order-${row.mainCode}-${row.orderCode}` return row.orderItemId ?? `order-${row.mainCode}-${row.orderCode}`
} }
@ -490,7 +518,7 @@ function buildProcessActionColumn(order: OrderProcessPlanVO): DataTableColumns<P
return { return {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 140, width: 200,
fixed: 'right', fixed: 'right',
align: 'center', align: 'center',
render(row) { render(row) {
@ -511,6 +539,14 @@ function buildProcessActionColumn(order: OrderProcessPlanVO): DataTableColumns<P
onClick: () => disphand(row, order), onClick: () => disphand(row, order),
}, { default: () => '派工' })) }, { default: () => '派工' }))
} }
if (hasPermission('biz:orderProcessPlan:edit') && canSplitProcess(row)) {
buttons.push(h(NButton, {
size: 'small',
type: 'info',
ghost: true,
onClick: () => openSplit(row),
}, { default: () => '拆单' }))
}
return buttons.length > 0 ? h(NSpace, { justify: 'center' }, { default: () => buttons }) : '-' return buttons.length > 0 ? h(NSpace, { justify: 'center' }, { default: () => buttons }) : '-'
}, },
} }
@ -602,8 +638,12 @@ const orderColumns: DataTableColumns<OrderProcessPlanVO> = [
align: 'center', align: 'center',
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
render: (row) => { render: (row) => {
const codes = (row.processList ?? []).map((p) => p.workOrderCode).filter(Boolean) if (row.workOrderCode) return row.workOrderCode
return codes.length ? codes.join('、') : '-' const codes = (row.processList ?? [])
.map((p) => p.workOrderCode)
.filter((c): c is string => !!c)
const unique = [...new Set(codes)]
return unique.length ? unique.join('、') : '-'
}, },
}, },
{ title: '数量', key: 'quantity', width: 72, align: 'center' }, { title: '数量', key: 'quantity', width: 72, align: 'center' },