Files
klp-oa/klp-ui/src/views/aps/index.vue
2026-03-08 16:02:44 +08:00

1581 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="aps-page">
<div class="aps-calendar-flat">
<aps-factory-calendar-board :embedded="true" :height="360" :external-query="calendarQuery" />
</div>
<!-- 顶部计划操作区 -->
<el-card class="aps-header" shadow="never">
<div class="aps-header-main">
<div class="aps-header-left">
<div class="aps-header-sub">
一键完成产品直排或库存订单排产支持按产线进行调度
</div>
<div class="aps-header-current" v-if="form.orderCode || form.orderId">
当前库存订单{{ form.orderCode || form.orderId }}
</div>
</div>
<div class="aps-header-actions">
<el-button size="small" type="primary" plain icon="el-icon-date" @click="$router.push('/aps/factory-calendar')">工厂总日历</el-button>
</div>
</div>
<div class="aps-header-filter">
<div class="dispatch-panel">
<div class="dispatch-left">
<div class="dispatch-title">快速排产</div>
<div class="dispatch-source-row">
<el-radio-group v-model="quick.sourceType" size="small">
<el-radio-button label="product">产品直排</el-radio-button>
<el-radio-button label="wmsOrder">库存订单排产</el-radio-button>
</el-radio-group>
</div>
<div class="dispatch-form-row" v-if="quick.sourceType === 'product'">
<el-input :value="quick.productName || quick.productId" placeholder="请选择产品" readonly style="width: 220px" />
<el-button size="mini" @click="openProductSelector('quick')">选择产品</el-button>
<el-input-number v-model="quick.productQty" :min="1" :step="1" style="width: 130px" />
<el-button size="mini" type="primary" plain @click="addQuickProductItem">加入清单</el-button>
</div>
<div class="dispatch-form-row" v-else>
<el-input :value="quick.orderCode || quick.orderId" placeholder="请在右侧库存订单池点击选择" readonly style="width: 260px" />
<el-tag v-if="quick.orderId" type="success" size="mini">已选库存订单</el-tag>
</div>
<div class="dispatch-form-row">
<el-select
v-model="quick.lineId"
placeholder="选择目标产线(必选)"
clearable
filterable
:loading="lineLoading"
style="width: 220px"
@visible-change="onQuickLineVisible"
@change="onLineSelected"
>
<el-option v-for="line in lineOptions" :key="line.lineId" :label="line.lineName || line.lineCode || ('编号' + line.lineId)" :value="line.lineId" />
</el-select>
<el-date-picker
v-model="quick.range"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator=""
value-format="yyyy-MM-dd HH:mm:ss"
:default-time="['00:00:00', '23:59:59']"
style="width: 360px"
/>
<el-button type="primary" icon="el-icon-magic-stick" :loading="loading.oneKey" @click="handleQuickSchedule">一键排产并查看</el-button>
</div>
</div>
<div class="dispatch-right">
<template v-if="quick.sourceType === 'wmsOrder'">
<div class="pending-order-head">
<span class="pending-order-title">库存订单池未完成</span>
<el-input v-model="pendingOrderKeyword" size="mini" clearable placeholder="订单号/客户" style="width: 180px" @keyup.enter.native="loadPendingOrders" />
<el-button size="mini" @click="loadPendingOrders">刷新</el-button>
</div>
<div class="pending-order-list" v-loading="pendingOrdersLoading">
<el-empty v-if="!pendingOrderList.length" :image-size="40" description="暂无未完成库存订单" />
<el-tag
v-for="item in pendingOrderList"
:key="item.orderId"
size="small"
:type="String(quick.orderId) === String(item.orderId) ? 'primary' : 'info'"
effect="plain"
class="pending-order-tag"
@click="pickPendingOrder(item)"
>
{{ item.orderCode || item.orderId }}
</el-tag>
</div>
</template>
<template v-else>
<div class="pending-order-head">
<span class="pending-order-title">已选产品清单</span>
</div>
<div class="product-pool-list product-pool-inline">
<el-empty v-if="!quick.productItems.length" :image-size="40" description="暂无已选产品" />
<div class="product-card product-card-inline" v-for="(p, idx) in quick.productItems" :key="p.productId + '_picked_' + idx">
<div class="p-inline-main">
<span class="p-title">{{ p.productName || p.productId }}</span>
<span class="p-sub">规格{{ p.specification || '-' }}</span>
<span class="p-sub">材质{{ p.material || '-' }}</span>
</div>
<div class="p-inline-op">
<el-input-number v-model="p.quantity" :min="1" :step="1" size="mini" style="width: 94px" />
<el-button type="text" size="mini" :disabled="idx===0" @click="moveQuickProductItem(idx,-1)"></el-button>
<el-button type="text" size="mini" :disabled="idx===quick.productItems.length-1" @click="moveQuickProductItem(idx,1)"></el-button>
<el-button type="text" size="mini" @click="removeQuickProductItem(idx)">移除</el-button>
</div>
</div>
</div>
</template>
</div>
</div>
<el-form :inline="true" size="small" :model="form">
<el-form-item label="时间范围">
<el-date-picker
v-model="form.range"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator=""
value-format="yyyy-MM-dd HH:mm:ss"
:default-time="['00:00:00', '23:59:59']"
style="width: 380px"
@change="handleQueryGantt"
/>
</el-form-item>
<el-form-item>
<el-button icon="el-icon-refresh" @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</div>
<template v-if="ganttList && ganttList.length">
<div class="result-toolbar">
<el-button size="small" type="primary" icon="el-icon-download" @click="goSheet">下载排产表</el-button>
</div>
<div class="result-head">排产甘特图</div>
<div class="gantt-wrap" v-loading="loading.gantt">
<div class="gantt-axis">
<span>{{ ganttChartStartLabel }}</span>
<span>{{ ganttChartEndLabel }}</span>
</div>
<div class="gantt-line" v-for="line in ganttLineRows" :key="line.lineId || line.lineName">
<div class="gantt-line-name">{{ line.lineName }}</div>
<div class="gantt-track">
<div
class="gantt-bar"
v-for="task in line.tasks"
:key="task.operationId || (task.startTime + '_' + task.endTime + '_' + task.productName)"
:style="ganttBarStyle(task)"
:title="ganttTaskLabel(task) + ' ' + (task.startTime || '') + ' ~ ' + (task.endTime || '')"
@click="openReschedule(task)"
>
{{ ganttTaskLabel(task) }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="aps-empty">
<el-empty>
<template slot="description">
<p>当前条件下暂无排产记录</p>
<p>如需为加急订单排产可先执行自动排产或在生成结果后通过重排进行手动调整</p>
</template>
<el-button
v-if="form.orderId"
type="primary"
size="mini"
:loading="loading.oneKey"
@click="handleOneKeySchedule"
>
为当前订单生成排产
</el-button>
</el-empty>
</div>
</template>
</el-card>
<!-- 创建计划弹窗 -->
<el-dialog
title="创建排产计划"
:visible.sync="createDialog.visible"
width="520px"
>
<el-form
ref="createFormRef"
:model="createDialog.form"
:rules="createRules"
label-width="90px"
size="small"
>
<el-form-item label="排产来源">
<el-radio-group v-model="createDialog.form.sourceType">
<el-radio label="product">产品直排</el-radio>
<el-radio label="wmsOrder">库存订单排产</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="createDialog.form.sourceType === 'product'" label="产品" prop="productId">
<el-input :value="createDialog.form.productName || createDialog.form.productId" placeholder="请选择产品" readonly style="width: calc(100% - 88px); margin-right: 8px" />
<el-button @click="openProductSelector('create')">选择</el-button>
</el-form-item>
<el-form-item v-if="createDialog.form.sourceType === 'product'" label="排产数量" prop="productQty">
<el-input-number v-model="createDialog.form.productQty" :min="1" :step="1" style="width: 100%" />
</el-form-item>
<el-form-item v-if="createDialog.form.sourceType === 'wmsOrder'" label="库存订单" prop="orderId">
<el-input :value="createDialog.form.orderCode || createDialog.form.orderId" placeholder="请选择库存订单" readonly style="width: calc(100% - 88px); margin-right: 8px" />
<el-button @click="openOrderSelector('create')">选择</el-button>
</el-form-item>
<el-form-item label="目标产线" prop="lineId">
<el-select v-model="createDialog.form.lineId" placeholder="请选择产线" clearable filterable style="width: 100%">
<el-option v-for="line in lineOptions" :key="line.lineId" :label="line.lineName || line.lineCode || ('产线' + line.lineId)" :value="line.lineId" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="createDialog.form.priority" placeholder="默认:中">
<el-option :value="0" label="低" />
<el-option :value="1" label="中" />
<el-option :value="2" label="高" />
<el-option :value="3" label="VIP" />
</el-select>
</el-form-item>
<el-form-item label="计划时间">
<el-date-picker
v-model="createDialog.form.range"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator=""
value-format="yyyy-MM-dd HH:mm:ss"
:default-time="['00:00:00', '23:59:59']"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="createDialog.form.remark"
type="textarea"
:rows="3"
placeholder="可选"
/>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="createDialog.visible = false"> </el-button>
<el-button size="small" type="primary" @click="submitCreatePlan"> </el-button>
</span>
</el-dialog>
<el-dialog title="选择库存订单" :visible.sync="orderSelector.visible" width="980px" append-to-body>
<el-form :inline="true" size="small">
<el-form-item label="订单关键字">
<el-input v-model="orderSelector.keyword" placeholder="输入库存订单号/客户名称" @keyup.enter.native="fetchOrderOptions" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" @click="fetchOrderOptions">搜索</el-button>
</el-form-item>
</el-form>
<el-table v-loading="orderSelector.loading" :data="orderSelector.list" size="mini" border height="360">
<el-table-column prop="orderCode" label="库存订单号" min-width="150" />
<el-table-column prop="customerName" label="客户" min-width="140" />
<el-table-column prop="orderStatus" label="状态" width="90">
<template slot-scope="scope">
<el-tag v-if="Number(scope.row.orderStatus) === 0" size="mini" type="info">新建</el-tag>
<el-tag v-else-if="Number(scope.row.orderStatus) === 1" size="mini" type="warning">生产中</el-tag>
<el-tag v-else-if="Number(scope.row.orderStatus) === 2" size="mini" type="success">已完成</el-tag>
<el-tag v-else size="mini">{{ scope.row.orderStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="selectOrder(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="orderSelector.total > 0" :total="orderSelector.total" :page.sync="orderSelector.pageNum" :limit.sync="orderSelector.pageSize" @pagination="fetchOrderOptions" />
</el-dialog>
<el-dialog title="选择产品" :visible.sync="productSelector.visible" width="980px" append-to-body>
<el-form :inline="true" size="small">
<el-form-item label="产品关键字">
<el-input v-model="productSelector.keyword" placeholder="输入产品名称/编号" @keyup.enter.native="fetchProductOptions" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" @click="fetchProductOptions">搜索</el-button>
</el-form-item>
</el-form>
<div class="product-dialog-toolbar">
<el-button size="mini" type="primary" plain :disabled="!selectedProductRows.length" @click="batchAddSelectedProducts">批量加入清单</el-button>
<span class="dialog-tip">已选 {{ selectedProductRows.length }} </span>
</div>
<el-table v-loading="productSelector.loading" :data="productSelector.list" size="mini" border @selection-change="onProductSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="productCode" label="产品编号" min-width="120" />
<el-table-column prop="productName" label="产品名称" min-width="150" />
<el-table-column prop="material" label="材质" min-width="100" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.material || '-' }}</template>
</el-table-column>
<el-table-column prop="specification" label="规格" min-width="120" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70">
<template slot-scope="scope">{{ scope.row.unit || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="selectProduct(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="productSelector.total > 0" :total="productSelector.total" :page.sync="productSelector.pageNum" :limit.sync="productSelector.pageSize" @pagination="fetchProductOptions" />
</el-dialog>
<!-- 重排弹窗 -->
<el-dialog
title="单工序重排"
:visible.sync="rescheduleDialog.visible"
width="520px"
>
<div class="dialog-tip">
手动排产步骤1选择目标产线2选择目标时间范围3填写调整原因并确认
</div>
<el-form
ref="rescheduleFormRef"
:model="rescheduleDialog.form"
:rules="rescheduleRules"
label-width="110px"
size="small"
>
<el-form-item label="当前产线">
<span>{{ rescheduleDialog.meta.lineName }}</span>
</el-form-item>
<el-form-item label="目标产线" prop="targetLineId">
<el-select
v-model="rescheduleDialog.form.targetLineId"
placeholder="选择产线"
filterable
@change="onLineSelected"
>
<el-option
v-for="line in lineOptions"
:key="line.lineId"
:label="line.lineName || ('产线' + line.lineId)"
:value="line.lineId"
/>
</el-select>
</el-form-item>
<el-form-item label="目标时间" prop="range">
<el-date-picker
v-model="rescheduleDialog.form.range"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator=""
value-format="yyyy-MM-dd HH:mm:ss"
:default-time="['00:00:00', '23:59:59']"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="重排原因" prop="reason">
<el-input
v-model="rescheduleDialog.form.reason"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="rescheduleDialog.visible = false"> </el-button>
<el-button size="small" type="primary" @click="submitReschedule"> </el-button>
</span>
</el-dialog>
<!-- 锁定/解锁弹窗 -->
<el-dialog
title="锁定时间窗 / 工序"
:visible.sync="lockDialog.visible"
width="520px"
>
<el-form
ref="lockFormRef"
:model="lockDialog.form"
:rules="lockRules"
label-width="110px"
size="small"
>
<el-form-item label="锁定类型" prop="lockType">
<el-radio-group v-model="lockDialog.form.lockType">
<el-radio :label="3">工序</el-radio>
<el-radio :label="2">产线时间窗</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="计划编号">
<span>{{ form.planId || '-' }}</span>
</el-form-item>
<el-form-item label="产线">
<span>{{ lockDialog.meta.lineName }}</span>
</el-form-item>
<el-form-item
v-if="lockDialog.form.lockType === 2"
label="锁定时间"
prop="range"
>
<el-date-picker
v-model="lockDialog.form.range"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
range-separator=""
value-format="yyyy-MM-dd HH:mm:ss"
:default-time="['00:00:00', '23:59:59']"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="锁定原因" prop="lockReason">
<el-input
v-model="lockDialog.form.lockReason"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="lockDialog.visible = false"> </el-button>
<el-button size="small" type="primary" @click="submitLock"> </el-button>
</span>
</el-dialog>
<!-- 解锁确认弹窗 -->
<el-dialog
title="解锁提示"
:visible.sync="unlockDialog.visible"
width="420px"
>
<div>确定要解除该工序/时间窗的锁定吗</div>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="unlockDialog.visible = false"> </el-button>
<el-button size="small" type="primary" @click="submitUnlock"> </el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { convertFromProducts, createPlan, autoSchedule, publishPlan, fetchGantt, rescheduleOperation, createLock, releaseLock } from '@/api/aps/aps'
import { listProductionLine } from '@/api/wms/productionLine'
import { listOrder as listWmsOrder } from '@/api/wms/order'
import { listProduct } from '@/api/wms/product'
import { parseTime } from '@/utils/klp'
import ApsFactoryCalendarBoard from './calendar/index.vue'
export default {
name: 'ApsPlanGantt',
components: {
ApsFactoryCalendarBoard
},
data() {
return {
loading: {
gantt: false,
creating: false,
auto: false,
publish: false,
oneKey: false
},
form: {
planId: '',
orderId: '',
orderCode: '',
lineId: null,
range: []
},
lineOptions: [],
lineLoading: false,
orderSelectorTarget: 'filter',
orderSelector: {
visible: false,
loading: false,
keyword: '',
pageNum: 1,
pageSize: 20,
total: 0,
list: []
},
lineHasMore: false,
linePageNum: 1,
lineKeyword: '',
pendingOrdersLoading: false,
pendingOrderKeyword: '',
pendingOrderList: [],
recentStorageKey: 'aps_plan_recent',
ganttList: [],
tableHeight: 520,
quick: {
sourceType: 'product',
orderId: '',
orderCode: '',
productId: null,
productName: '',
productSpec: '',
productMaterial: '',
productQty: 1,
productItems: [],
lineId: null,
range: []
},
createDialog: {
visible: false,
form: {
sourceType: 'product',
orderId: '',
orderCode: '',
productId: null,
productName: '',
productQty: 1,
priority: 1,
range: [],
remark: ''
}
},
productSelector: {
target: 'create',
visible: false,
loading: false,
keyword: '',
pageNum: 1,
pageSize:20,
total: 0,
list: []
},
selectedProductRows: [],
rescheduleDialog: {
visible: false,
meta: {
operationId: null,
lineName: ''
},
form: {
targetLineId: null,
range: [],
reason: ''
}
},
lockDialog: {
visible: false,
meta: {
operationId: null,
lineId: null,
lineName: ''
},
form: {
lockType: 3,
range: [],
lockReason: ''
}
},
unlockDialog: {
visible: false,
lockId: null,
operationId: null
},
adjustMode: false,
adjustSnapshot: [],
createRules: {
orderId: [{ required: true, message: '库存订单不能为空', trigger: 'blur' }],
productId: [{ required: true, message: '产品不能为空', trigger: 'change' }],
productQty: [{ required: true, message: '数量不能为空', trigger: 'blur' }],
lineId: [{ required: true, message: '请选择产线', trigger: 'change' }]
},
rescheduleRules: {
targetLineId: [{ required: true, message: '请选择目标产线', trigger: 'change' }],
range: [{ type: 'array', required: true, message: '请选择目标时间范围', trigger: 'change' }],
reason: [{ required: true, message: '请填写重排原因', trigger: 'blur' }]
},
lockRules: {
lockType: [{ required: true, message: '请选择锁定类型', trigger: 'change' }],
lockReason: [{ required: true, message: '请填写锁定原因', trigger: 'blur' }]
}
}
},
computed: {
calendarQuery() {
const range = (this.form.range && this.form.range.length === 2)
? [this.form.range[0].slice(0, 10), this.form.range[1].slice(0, 10)]
: []
return {
range,
lineId: this.form.lineId || undefined,
orderId: this.form.orderId || undefined,
keyword: this.form.orderCode || ''
}
},
quickReady() {
if (!this.quick.lineId) return false
if (this.quick.sourceType === 'product') {
return (this.quick.productItems || []).length > 0
}
return !!this.quick.orderId
},
ganttTimes() {
const times = (this.ganttList || [])
.flatMap(i => [i.startTime, i.endTime])
.filter(Boolean)
.map(t => new Date(t).getTime())
.filter(v => !Number.isNaN(v))
if (!times.length) return { start: null, end: null }
return { start: Math.min(...times), end: Math.max(...times) }
},
ganttChartStartLabel() {
if (!this.ganttTimes.start) return '-'
return this.formatDateTime(new Date(this.ganttTimes.start))
},
ganttChartEndLabel() {
if (!this.ganttTimes.end) return '-'
return this.formatDateTime(new Date(this.ganttTimes.end))
},
ganttLineRows() {
const map = {}
;(this.ganttList || []).forEach(item => {
const key = String(item.lineId || item.lineName || '未知产线')
if (!map[key]) {
map[key] = {
lineId: item.lineId,
lineName: item.lineName || ('产线' + (item.lineId || '-')),
tasks: []
}
}
map[key].tasks.push(item)
})
return Object.values(map).map(i => {
i.tasks.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
return i
})
}
},
created() {
this.tableHeight = document.documentElement.clientHeight - 260
this.initDefaultRange()
this.loadLines()
this.loadPendingOrders()
this.fetchProductOptions()
this.applyRouteQuery()
this.$nextTick(() => {
if (!this.loading.oneKey) {
this.handleQueryGantt()
}
})
},
methods: {
applyRouteQuery() {
const q = (this.$route && this.$route.query) || {}
if (q.orderId) {
this.form.orderId = q.orderId
if (q.orderCode) {
this.form.orderCode = q.orderCode
}
this.openCreatePlan()
this.$nextTick(() => {
if (this.createDialog && this.createDialog.form) {
this.createDialog.form.orderId = String(q.orderId)
this.createDialog.form.orderCode = q.orderCode || ''
}
if (q.autoOneKey === '1') {
this.handleOneKeySchedule()
}
})
}
},
openOrderSelector(target = 'filter') {
this.orderSelectorTarget = target
this.orderSelector.visible = true
this.orderSelector.pageNum = 1
this.fetchOrderOptions()
},
openProductSelector(target = 'create') {
this.productSelector.target = target
this.productSelector.visible = true
this.productSelector.pageNum = 1
this.fetchProductOptions()
},
fetchOrderOptions() {
this.orderSelector.loading = true
const params = {
pageNum: this.orderSelector.pageNum,
pageSize: this.orderSelector.pageSize,
orderCode: this.orderSelector.keyword || undefined
}
listWmsOrder(params).then(res => {
this.orderSelector.list = (res.rows || []).filter(item => {
const status = Number(item.orderStatus)
return status !== 2 && status !== 3
})
this.orderSelector.total = res.total || 0
}).finally(() => {
this.orderSelector.loading = false
})
},
loadPendingOrders() {
this.pendingOrdersLoading = true
listWmsOrder({
pageNum: 1,
pageSize: 200,
orderCode: this.pendingOrderKeyword || undefined
}).then(res => {
const rows = res.rows || []
this.pendingOrderList = rows.filter(item => {
const status = Number(item.orderStatus)
return status !== 2 && status !== 3
})
}).finally(() => {
this.pendingOrdersLoading = false
})
},
pickPendingOrder(item) {
const orderId = item.orderId || item.id
this.quick.orderId = String(orderId)
this.quick.orderCode = item.orderCode || ''
this.quick.sourceType = 'wmsOrder'
this.$message.success('已选择库存订单:' + (item.orderCode || item.orderId))
},
fetchProductOptions() {
this.productSelector.loading = true
listProduct({
pageNum: this.productSelector.pageNum,
pageSize: this.productSelector.pageSize,
productName: this.productSelector.keyword || undefined,
productCode: this.productSelector.keyword || undefined
}).then(res => {
this.productSelector.list = res.rows || []
this.productSelector.total = res.total || 0
}).finally(() => {
this.productSelector.loading = false
})
},
selectOrder(row) {
const orderId = row.orderId || row.id
if (!orderId) {
this.$message.warning('订单数据缺少 orderId')
return
}
const orderCode = row.orderCode || ''
if (this.orderSelectorTarget === 'create') {
this.createDialog.form.orderId = String(orderId)
this.createDialog.form.orderCode = orderCode
} else {
this.quick.orderId = String(orderId)
this.quick.orderCode = orderCode
}
this.orderSelector.visible = false
},
selectProduct(row) {
if (this.productSelector.target === 'quick') {
this.quick.productId = row.productId
this.quick.productName = row.productName || row.productCode || ''
this.quick.productSpec = row.specification || ''
this.quick.productMaterial = row.material || ''
} else {
this.createDialog.form.productId = row.productId
this.createDialog.form.productName = row.productName || row.productCode || ''
}
this.productSelector.visible = false
},
onProductSelectionChange(rows) {
this.selectedProductRows = rows || []
},
batchAddSelectedProducts() {
if (this.productSelector.target !== 'quick') {
this.$message.warning('当前模式不支持批量加入')
return
}
if (!this.selectedProductRows.length) {
this.$message.warning('请先勾选产品')
return
}
const qty = Number(this.quick.productQty || 1)
this.selectedProductRows.forEach(row => {
const existed = (this.quick.productItems || []).find(i => String(i.productId) === String(row.productId))
if (existed) {
existed.quantity = Number(existed.quantity || 0) + qty
} else {
this.quick.productItems.push({
productId: row.productId,
productName: row.productName || row.productCode || String(row.productId),
specification: row.specification || '',
material: row.material || '',
quantity: qty
})
}
})
this.$message.success('已批量加入 ' + this.selectedProductRows.length + ' 个产品')
this.productSelector.visible = false
this.selectedProductRows = []
},
addQuickProductItem() {
if (!this.quick.productId) {
this.$message.warning('请先选择产品')
return
}
const qty = Number(this.quick.productQty || 0)
if (qty <= 0) {
this.$message.warning('数量必须大于 0')
return
}
const existed = (this.quick.productItems || []).find(i => String(i.productId) === String(this.quick.productId))
if (existed) {
existed.quantity = Number(existed.quantity || 0) + qty
} else {
this.quick.productItems.push({
productId: this.quick.productId,
productName: this.quick.productName || String(this.quick.productId),
specification: this.quick.productSpec || '',
material: this.quick.productMaterial || '',
quantity: qty
})
}
this.quick.productQty = 1
},
removeQuickProductItem(index) {
this.quick.productItems.splice(index, 1)
},
moveQuickProductItem(index, step) {
const target = index + step
if (target < 0 || target >= this.quick.productItems.length) return
const arr = this.quick.productItems
const tmp = arr[index]
this.$set(arr, index, arr[target])
this.$set(arr, target, tmp)
},
goSheet() {
const q = {
templateKey: 'cold_rolling',
planId: this.form.planId || undefined,
orderId: this.form.orderId || undefined,
lineId: this.form.lineId || undefined
}
if (this.form.range && this.form.range.length === 2) {
q.queryStart = this.form.range[0]
q.queryEnd = this.form.range[1]
}
this.$router.push({ path: '/aps/sheet', query: q }).catch(() => {})
},
initDefaultRange() {
const now = new Date()
const y = now.getFullYear()
const m = now.getMonth()
const d = now.getDate()
const start = new Date(y, m, d, 0, 0, 0)
const end = new Date(y, m, d + 7, 23, 59, 59)
this.form.range = [parseTime(start), parseTime(end)]
this.quick.range = [parseTime(start), parseTime(end)]
},
formatDateTime(d) {
if (!d) return '-'
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const hh = `${d.getHours()}`.padStart(2, '0')
const mm = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
},
ganttTaskLabel(task) {
const p = task.productName || task.productCode || '产品'
const m = task.material || task.productMaterial || '-'
const s = task.specification || task.productSpec || '-'
return `${p}-${m}-${s}`
},
ganttBarStyle(task) {
const start = new Date(task.startTime).getTime()
const end = new Date(task.endTime).getTime()
const min = this.ganttTimes.start
const max = this.ganttTimes.end
if (!min || !max || Number.isNaN(start) || Number.isNaN(end) || max <= min) {
return { left: '0%', width: '2%' }
}
const left = ((start - min) / (max - min)) * 100
const width = Math.max(((end - start) / (max - min)) * 100, 1.2)
return { left: `${left}%`, width: `${width}%` }
},
loadLines(keyword) {
this.lineLoading = true
listProductionLine({ pageNum: 1, pageSize: 500, lineName: keyword || undefined }).then(res => {
const rows = (res.rows || res.data || []).map(r => ({
lineId: r.lineId,
lineName: r.lineName || r.lineCode
}))
this.lineOptions = this.sortByRecent(rows, 'lineId')
this.applyDefaultSelections()
}).catch(() => {}).finally(() => {
this.lineLoading = false
})
},
searchLines(keyword) {
this.loadLines(keyword)
},
loadMoreLines() {},
onLineSelectVisible() {},
getRecentSelections() {
try {
const raw = localStorage.getItem(this.recentStorageKey)
if (!raw) {
return { lineIdList: [], defaultLineId: null }
}
const parsed = JSON.parse(raw)
return {
lineIdList: parsed.lineIdList || [],
defaultLineId: parsed.defaultLineId || null
}
} catch (e) {
return { lineIdList: [], defaultLineId: null }
}
},
saveRecentSelections(state) {
localStorage.setItem(this.recentStorageKey, JSON.stringify(state))
},
rememberSelection(lineId) {
if (!lineId) return
const state = this.getRecentSelections()
state.lineIdList = [lineId, ...(state.lineIdList || []).filter(id => id !== lineId)].slice(0, 8)
state.defaultLineId = lineId
this.saveRecentSelections(state)
},
sortByRecent(options, idKey) {
const state = this.getRecentSelections()
const recent = state.lineIdList || []
const recentSet = new Set(recent)
const unique = []
const seen = new Set()
;(options || []).forEach(item => {
const key = item && item[idKey]
if (!seen.has(key)) {
seen.add(key)
unique.push(item)
}
})
const recentPart = []
recent.forEach(id => {
const matched = unique.find(item => item && item[idKey] === id)
if (matched) {
recentPart.push(matched)
}
})
const normalPart = unique.filter(item => !recentSet.has(item && item[idKey]))
return recentPart.concat(normalPart)
},
applyDefaultSelections() {
const state = this.getRecentSelections()
if (!this.quick.lineId && state.defaultLineId) {
this.quick.lineId = state.defaultLineId
}
},
onQuickLineVisible(visible) {
if (visible && (!this.lineOptions || !this.lineOptions.length)) {
this.loadLines()
}
},
onLineSelected(value) {
this.rememberSelection(value)
this.quick.lineId = value
this.form.lineId = value
},
resetFilter() {
this.form.planId = ''
this.form.orderId = ''
this.form.orderCode = ''
this.form.lineId = null
this.initDefaultRange()
this.applyDefaultSelections()
this.ganttList = []
},
handleQueryGantt() {
if (!this.form.range || this.form.range.length !== 2) {
this.$message.warning('请先选择时间范围')
return
}
if (!this.form.orderId && !this.form.planId && !this.form.lineId) {
// 保守起见,允许仅按时间与产线/订单过滤,此处不强制订单编号
}
const params = {
queryStart: this.form.range[0],
queryEnd: this.form.range[1],
lineId: this.form.lineId || undefined,
planId: this.form.planId || undefined,
orderId: this.form.orderId || undefined
}
this.loading.gantt = true
fetchGantt(params)
.then(res => {
this.ganttList = (res.data || []).map((r, idx) => ({
...r,
_editStartTime: r.startTime,
_editEndTime: r.endTime,
_editSequenceNo: r.sequenceNo || idx + 1
}))
})
.finally(() => {
this.loading.gantt = false
})
},
openCreatePlan() {
this.createDialog.visible = true
this.$nextTick(() => {
this.$refs.createFormRef && this.$refs.createFormRef.resetFields()
this.createDialog.form.sourceType = 'product'
this.createDialog.form.priority = 1
this.createDialog.form.productQty = 1
this.createDialog.form.lineId = this.form.lineId || null
this.createDialog.form.range = []
})
},
submitCreatePlan() {
this.$refs.createFormRef.validate(valid => {
if (!valid) return
const sourceType = this.createDialog.form.sourceType || 'product'
this.loading.creating = true
const convertReq = sourceType === 'product'
? convertFromProducts({
items: [{
productId: this.createDialog.form.productId,
quantity: Number(this.createDialog.form.productQty || 0),
productName: this.createDialog.form.productName
}],
remark: this.createDialog.form.remark
})
: Promise.resolve({ data: { wmsOrderId: String(this.createDialog.form.orderId) } })
convertReq
.then(conv => {
const wmsOrderId = conv && conv.data && conv.data.wmsOrderId
if (!wmsOrderId) throw new Error('未获取到库存订单ID')
this.form.orderId = String(wmsOrderId)
this.form.orderCode = this.createDialog.form.orderCode || this.form.orderCode
this.form.lineId = this.createDialog.form.lineId || this.form.lineId
const payload = {
orderId: String(wmsOrderId),
lineId: this.createDialog.form.lineId,
priority: this.createDialog.form.priority,
startDate: this.createDialog.form.range && this.createDialog.form.range[0],
endDate: this.createDialog.form.range && this.createDialog.form.range[1],
remark: this.createDialog.form.remark
}
return createPlan(payload)
})
.then(res => {
this.form.planId = res.data
this.$message.success('创建计划成功planId=' + res.data)
this.createDialog.visible = false
})
.finally(() => {
this.loading.creating = false
})
})
},
handleQuickSchedule() {
if (!this.quick.lineId) {
this.$message.warning('请选择要排产的目标产线')
return
}
this.createDialog.form.sourceType = this.quick.sourceType
this.createDialog.form.productId = this.quick.productId
this.createDialog.form.productName = this.quick.productName
this.createDialog.form.productQty = this.quick.productQty
this.createDialog.form.orderId = this.quick.orderId
this.createDialog.form.orderCode = this.quick.orderCode
this.createDialog.form.lineId = this.quick.lineId
this.form.lineId = this.quick.lineId
this.createDialog.form.range = this.quick.range
this.handleOneKeySchedule()
},
handleOneKeySchedule() {
const sourceType = (this.createDialog.form && this.createDialog.form.sourceType) || 'product'
const orderId = this.createDialog.form && this.createDialog.form.orderId
const hasProductItems = (this.quick.productItems || []).some(i => i && i.productId && Number(i.quantity || 0) > 0)
if (sourceType === 'product' && !hasProductItems) {
this.$message.warning('请先添加产品到排产清单')
return
}
if (sourceType === 'wmsOrder' && !orderId) {
this.$message.warning('请先选择库存订单')
return
}
this.loading.oneKey = true
const ensurePlan = () => {
if (this.form.planId) return Promise.resolve(this.form.planId)
const convertReq = sourceType === 'product'
? (() => {
const items = (this.quick.productItems || []).map(i => ({
productId: i.productId,
quantity: Number(i.quantity || 0),
productName: i.productName
})).filter(i => i.productId && i.quantity > 0)
if (!items.length) {
throw new Error('请先添加至少一个产品到排产清单')
}
return convertFromProducts({ items, remark: this.createDialog.form.remark })
})()
: Promise.resolve({ data: { wmsOrderId: String(orderId) } })
return convertReq.then(conv => {
const wmsOrderId = conv && conv.data && conv.data.wmsOrderId
this.form.orderId = String(wmsOrderId)
this.form.orderCode = this.createDialog.form.orderCode || this.form.orderCode
const payload = {
orderId: String(wmsOrderId),
lineId: this.createDialog.form.lineId,
priority: this.createDialog.form.priority || 1,
startDate: this.createDialog.form.range && this.createDialog.form.range[0],
endDate: this.createDialog.form.range && this.createDialog.form.range[1],
remark: this.createDialog.form.remark
}
return createPlan(payload)
}).then(res => {
this.form.planId = res.data
return res.data
})
}
ensurePlan()
.then(planId => autoSchedule({ planId: String(planId), clearOld: true, remark: 'ONE_KEY_SCHEDULE' }))
.then(() => {
this.$message.success('一键排产完成')
if (this.quick.range && this.quick.range.length === 2) this.form.range = this.quick.range
if (!this.form.range || this.form.range.length !== 2) this.initDefaultRange()
this.handleQueryGantt()
})
.finally(() => {
this.loading.oneKey = false
})
},
handleAutoSchedule() {
if (!this.form.planId) {
this.$message.warning('当前暂无排产计划,请先通过“创建计划”或“一键排产并查看”生成计划')
return
}
this.$confirm('将对计划 ID=' + this.form.planId + ' 执行自动排程,是否继续?', '提示', {
type: 'warning'
}).then(() => {
this.loading.auto = true
autoSchedule({
planId: String(this.form.planId),
clearOld: true,
remark: 'FE_TRIGGER'
}).then(() => {
this.$message.success('自动排程成功')
this.handleQueryGantt()
}).finally(() => {
this.loading.auto = false
})
}).catch(() => {})
},
handlePublish() {
if (!this.form.planId) {
this.$message.warning('当前暂无排产计划,请先完成排产')
return
}
this.$confirm('发布计划后将进入执行状态,是否继续?', '提示', {
type: 'warning'
}).then(() => {
this.loading.publish = true
publishPlan(String(this.form.planId))
.then(() => {
this.$message.success('发布成功')
})
.finally(() => {
this.loading.publish = false
})
}).catch(() => {})
},
enterAdjustMode() {
if (!this.ganttList.length) return
this.adjustMode = true
this.adjustSnapshot = JSON.parse(JSON.stringify(this.ganttList))
},
cancelAdjustMode() {
this.adjustMode = false
this.ganttList = JSON.parse(JSON.stringify(this.adjustSnapshot || []))
},
moveGanttRow(index, step) {
const target = index + step
if (target < 0 || target >= this.ganttList.length) return
const arr = this.ganttList
const tmp = arr[index]
this.$set(arr, index, arr[target])
this.$set(arr, target, tmp)
arr.forEach((r, i) => { r._editSequenceNo = i + 1 })
},
saveAdjustments() {
if (!this.adjustMode) return
const changed = (this.ganttList || []).filter((r, idx) => {
const old = (this.adjustSnapshot || [])[idx]
if (!old) return true
return String(r._editStartTime || '') !== String(old._editStartTime || '') ||
String(r._editEndTime || '') !== String(old._editEndTime || '') ||
Number(r._editSequenceNo || 0) !== Number(old._editSequenceNo || 0)
})
if (!changed.length) {
this.$message.info('没有检测到调整')
this.adjustMode = false
return
}
const jobs = changed.map(r => {
const payload = {
operationId: r.operationId,
targetLineId: r.lineId,
targetStartTime: r._editStartTime,
targetEndTime: r._editEndTime,
reason: 'MANUAL_ADJUST_SAVE'
}
return rescheduleOperation(payload)
})
Promise.all(jobs).then(() => {
this.$message.success('调整已保存')
this.adjustMode = false
this.handleQueryGantt()
})
},
openReschedule(row) {
this.rescheduleDialog.visible = true
this.rescheduleDialog.meta.operationId = row.operationId
this.rescheduleDialog.meta.lineName = row.lineName || ('产线' + row.lineId)
this.$nextTick(() => {
this.$refs.rescheduleFormRef && this.$refs.rescheduleFormRef.resetFields()
const state = this.getRecentSelections()
if (state.defaultLineId) {
this.rescheduleDialog.form.targetLineId = state.defaultLineId
}
})
},
submitReschedule() {
this.$refs.rescheduleFormRef.validate(valid => {
if (!valid) return
const [start, end] = this.rescheduleDialog.form.range || []
const payload = {
operationId: this.rescheduleDialog.meta.operationId,
targetLineId: this.rescheduleDialog.form.targetLineId,
targetStartTime: start,
targetEndTime: end,
reason: this.rescheduleDialog.form.reason
}
rescheduleOperation(payload).then(() => {
this.$message.success('重排成功')
this.rescheduleDialog.visible = false
this.handleQueryGantt()
})
})
},
openLock(row) {
this.lockDialog.visible = true
this.lockDialog.meta.operationId = row.operationId
this.lockDialog.meta.lineId = row.lineId
this.lockDialog.meta.lineName = row.lineName || ('产线' + row.lineId)
this.$nextTick(() => {
this.$refs.lockFormRef && this.$refs.lockFormRef.resetFields()
this.lockDialog.form.lockType = 3
})
},
submitLock() {
this.$refs.lockFormRef.validate(valid => {
if (!valid) return
const [start, end] = this.lockDialog.form.range || []
const payload = {
lockType: this.lockDialog.form.lockType,
planId: this.form.planId ? String(this.form.planId) : null,
lineId: this.lockDialog.meta.lineId,
operationId: this.lockDialog.form.lockType === 3 ? this.lockDialog.meta.operationId : null,
lockStartTime: this.lockDialog.form.lockType === 2 ? start : null,
lockEndTime: this.lockDialog.form.lockType === 2 ? end : null,
lockReason: this.lockDialog.form.lockReason
}
createLock(payload).then(() => {
this.$message.success('锁定成功')
this.lockDialog.visible = false
this.handleQueryGantt()
})
})
},
openUnlock(row) {
this.unlockDialog.lockId = row.lockId || null
this.unlockDialog.operationId = row.operationId || null
this.unlockDialog.visible = true
},
submitUnlock() {
if (!this.unlockDialog.lockId) {
this.$message.warning('当前记录未返回 lockId暂无法直接解锁请到锁管理页面处理')
this.unlockDialog.visible = false
return
}
releaseLock(this.unlockDialog.lockId).then(() => {
this.$message.success('解锁成功')
this.unlockDialog.visible = false
this.handleQueryGantt()
})
}
}
}
</script>
<style lang="scss" scoped>
.aps-page {
padding: 10px;
background: #f7f9fc;
}
.aps-calendar-flat {
margin-bottom: 10px;
}
::v-deep .aps-header.el-card,
::v-deep .aps-body.el-card {
border: none;
box-shadow: none;
}
.aps-header,
.aps-body,
.aps-calendar-flat {
border: 1px solid #eef2f7;
border-radius: 8px;
background: #fff;
}
.aps-header {
margin-bottom: 10px;
.aps-header-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.aps-header-sub {
margin-top: 4px;
font-size: 13px;
color: #606266;
}
.aps-header-current {
margin-top: 6px;
font-size: 13px;
color: #409EFF;
}
.aps-header-actions > .el-button + .el-button {
margin-left: 8px;
}
}
.aps-header-filter {
margin-top: 4px;
}
.dispatch-panel {
margin-bottom: 10px;
padding: 12px;
border: 1px solid #edf1f7;
border-radius: 10px;
background: #ffffff;
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
}
.dispatch-panel-single {
grid-template-columns: 1fr;
}
.dispatch-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.dispatch-source-row,
.dispatch-form-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.dispatch-right {
border-left: 1px solid #f0f2f6;
padding-left: 10px;
}
.pending-order-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.pending-order-title {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.pending-order-list {
min-height: 58px;
max-height: 126px;
overflow: auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-right: 4px;
}
.pending-order-list::-webkit-scrollbar,
.product-pool-list::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.pending-order-list::-webkit-scrollbar-thumb,
.product-pool-list::-webkit-scrollbar-thumb {
background: #d9e2ef;
border-radius: 4px;
}
.pending-order-list::-webkit-scrollbar-track,
.product-pool-list::-webkit-scrollbar-track {
background: transparent;
}
.pending-order-tag {
cursor: pointer;
border-radius: 12px;
}
.product-pool-list {
max-height: 190px;
overflow: auto;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.product-pool-inline {
max-height: none;
overflow: visible;
}
.product-card {
border: 1px solid #edf1f7;
border-radius: 8px;
padding: 8px;
cursor: pointer;
background: #fff;
}
.product-card-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.p-inline-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.p-inline-op {
display: flex;
align-items: center;
gap: 6px;
}
.product-card:hover {
border-color: #bcd2f7;
background: #f7fbff;
}
.product-dialog-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.p-title {
font-size: 13px;
color: #303133;
margin-bottom: 4px;
}
.p-sub {
font-size: 12px;
color: #909399;
}
.aps-body {
.el-table {
font-size: 12px;
}
}
.result-toolbar {
margin: 0 0 8px;
display: flex;
justify-content: flex-end;
}
.result-head {
font-size: 12px;
color: #606266;
margin: 0 0 8px;
}
.gantt-wrap {
border: 1px solid #eef2f7;
border-radius: 8px;
padding: 8px;
background: #fff;
}
.gantt-axis {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.gantt-line {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.gantt-line-name {
font-size: 12px;
color: #303133;
font-weight: 600;
}
.gantt-track {
position: relative;
height: 28px;
border: 1px solid #f0f2f6;
border-radius: 4px;
background: repeating-linear-gradient(to right, #fafbfd 0, #fafbfd 24px, #fff 24px, #fff 48px);
}
.gantt-bar {
position: absolute;
top: 3px;
height: 22px;
border-radius: 4px;
background: #409eff;
color: #fff;
font-size: 12px;
line-height: 22px;
padding: 0 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.aps-empty {
padding: 40px 0;
}
.dialog-tip {
font-size: 12px;
color: #909399;
margin: 0 0 8px;
}
</style>