From 672cd2332972adba80640c6788dc4ea2712ff865 Mon Sep 17 00:00:00 2001 From: SunYF <123@hard_man.com> Date: Fri, 18 Sep 2026 13:54:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E7=B1=BB?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 工序工位支持0号上线位,更新解析逻辑与注释 2. 物料清单模块重命名为产品物料清单并优化文案 3. WMS与工位端新增用户/角色详情抽屉组件 4. 工单模块新增自动生成工单号、工位分组选择器 5. 日排产支持批量生成与详情查看 6. 工单列表重构操作菜单与表单优化 --- _inject_detail.py | 146 ++++++++++++++++ .../frontend/src/components/DetailDrawer.vue | 55 ++++++ .../src/components/ProcessSeqPicker.vue | 57 ++++++- bj_power_mes/frontend/src/help.js | 2 +- bj_power_mes/frontend/src/pages/Bom.vue | 6 +- bj_power_mes/frontend/src/pages/DailyPlan.vue | 142 ++++++++++++++-- bj_power_mes/frontend/src/pages/WorkOrder.vue | 96 ++++++++--- bj_power_mes/internal/handler/processcard.go | 4 +- bj_power_mes/internal/logic/dailyplan.go | 159 +++++++++++++++--- bj_power_mes/internal/logic/processseq.go | 4 +- bj_power_mes/internal/logic/workorder.go | 56 +++++- bj_power_mes/public/index.html | 2 +- .../frontend/src/components/DetailDrawer.vue | 55 ++++++ .../frontend/src/pages/RoleManage.vue | 20 ++- .../frontend/src/pages/UserManage.vue | 7 +- .../frontend/src/components/DetailDrawer.vue | 55 ++++++ 审计总览_4视角.md | 121 ------------- 17 files changed, 780 insertions(+), 207 deletions(-) create mode 100644 _inject_detail.py create mode 100644 bj_power_mes/frontend/src/components/DetailDrawer.vue create mode 100644 bj_power_wms/frontend/src/components/DetailDrawer.vue create mode 100644 bj_power_workstation/frontend/src/components/DetailDrawer.vue delete mode 100644 审计总览_4视角.md diff --git a/_inject_detail.py b/_inject_detail.py new file mode 100644 index 0000000..7d5377f --- /dev/null +++ b/_inject_detail.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# 为列表页自动注入「详情」按钮 + DetailDrawer 抽屉。 +# 字段从已有 抽取(中文 label 复用,type:time 自动识别)。 +import re, sys, os + +SKIP_LABELS = {'操作', '图片', '操作人', '附件', '明细', '记录', '步骤'} + +def parse_columns(tpl): + fields = [] + seen = set() + col_re = re.compile(r']*?)(/?>)', re.S) + i = 0 + while True: + m = col_re.search(tpl, i) + if not m: + break + attrs = m.group(1) + closing = m.group(2) + if closing == '/>': + block = m.group(0) + i = m.end() + else: + k = tpl.find('', m.end()) + if k < 0: + i = m.end() + continue + block = tpl[m.start():k + len('')] + i = k + len('') + m_prop = re.search(r'prop="([^"]+)"', block) + m_label = re.search(r'label="([^"]+)"', block) + m_time = re.search(r'fmtTime\(row\.(\w+)\)', block) + m_dynamic = re.search(r':label=', block) + if not m_label or m_dynamic: + continue + label = m_label.group(1) + if label in SKIP_LABELS: + continue + if m_time: + key = m_time.group(1) + if key not in seen: + fields.append({'key': key, 'label': label, 'type': 'time'}) + seen.add(key) + continue + if m_prop: + key = m_prop.group(1) + if key not in seen: + fields.append({'key': key, 'label': label}) + seen.add(key) + return fields + +def fmt_field(f): + if f.get('type') == 'time': + return " { key: '%s', label: '%s', type: 'time' }," % (f['key'], f['label']) + return " { key: '%s', label: '%s' }," % (f['key'], f['label']) + +def inject(path): + s = open(path, encoding='utf-8').read() + if 'DetailDrawer' in s: + print(' SKIP (already has DetailDrawer):', path) + return False + if 'openDetail(' in s: + print(' SKIP (already has openDetail):', path) + return False + # script + template + m_script = re.search(r'', s, re.S) + m_tpl = re.search(r'', s, re.S) + if not m_script or not m_tpl: + print(' SKIP (no script/template):', path) + return False + script = m_script.group(1) + tpl = m_tpl.group(1) + + fields = parse_columns(tpl) + if not fields: + print(' SKIP (no columns parsed):', path) + return False + + # 1) import + imports = list(re.finditer(r"^\s*import .* from '[^']+'\s*$", script, re.M)) + if not imports: + print(' SKIP (no import lines):', path) + return False + last_import = imports[-1] + ins_import = "import DetailDrawer from '../components/DetailDrawer.vue'\n" + # 若路径是 ../../components(子目录页)则调整 + if '../components/' not in script and '../../components/' in s: + ins_import = "import DetailDrawer from '../../components/DetailDrawer.vue'\n" + script_new = script[:last_import.end()] + "\n" + ins_import + script[last_import.end():] + + # 2) openDetail + detail state before + fields_block = ",\n".join(fmt_field(f) for f in fields) + open_detail = ( + "\n// 详情抽屉\n" + "const detail = reactive({ show: false, title: '', fields: [], row: null })\n" + "function openDetail(row) {\n" + " detail.row = row\n" + " detail.title = `详情 · #${row.id}`\n" + " detail.fields = [\n" + fields_block + "\n ]\n" + " detail.show = true\n" + "}\n" + ) + script_new = script_new.rstrip() + if not script_new.endswith('}'): + script_new = script_new + "\n" + script_new = script_new + open_detail + + # 3) 详情 column right after \n" + " \n" + " \n" + ) + mt = re.search(r']*>', tpl) + if not mt: + print(' SKIP (no el-table):', path) + return False + # 插入到 之后(同一行或下一行) + insert_pos = mt.end() + tpl_new = tpl[:insert_pos] + "\n" + detail_col + tpl[insert_pos:] + + # 4) DetailDrawer before last in template + last_div = tpl_new.rfind('') + drawer = ( + "\n \n" + ) + tpl_new = tpl_new[:last_div] + drawer + tpl_new[last_div:] + + new_s = s + new_s = new_s[:m_script.start(1)] + script_new + new_s[m_script.end(1):] + # 重新定位 template(script 长度变化不影响 template 区域位置,因 template 在 script 之后) + new_s = new_s[:m_tpl.start(1)] + tpl_new + new_s[m_tpl.end(1):] + + open(path, 'w', encoding='utf-8').write(new_s) + print(' OK fields=%d : %s' % (len(fields), path)) + return True + +if __name__ == '__main__': + for p in sys.argv[1:]: + print('FILE', p) + try: + inject(p) + except Exception as e: + print(' ERROR', p, e) diff --git a/bj_power_mes/frontend/src/components/DetailDrawer.vue b/bj_power_mes/frontend/src/components/DetailDrawer.vue new file mode 100644 index 0000000..c167adb --- /dev/null +++ b/bj_power_mes/frontend/src/components/DetailDrawer.vue @@ -0,0 +1,55 @@ + + + diff --git a/bj_power_mes/frontend/src/components/ProcessSeqPicker.vue b/bj_power_mes/frontend/src/components/ProcessSeqPicker.vue index 7e71612..2541216 100644 --- a/bj_power_mes/frontend/src/components/ProcessSeqPicker.vue +++ b/bj_power_mes/frontend/src/components/ProcessSeqPicker.vue @@ -6,14 +6,29 @@ {{ summary }} - 工位{{ i }} + +
相同「工艺流程」的工位归到同一组——勾选后这些工位将共用同一套工艺步骤。
\ No newline at end of file diff --git a/bj_power_mes/frontend/src/pages/WorkOrder.vue b/bj_power_mes/frontend/src/pages/WorkOrder.vue index a9966b1..37e7a45 100644 --- a/bj_power_mes/frontend/src/pages/WorkOrder.vue +++ b/bj_power_mes/frontend/src/pages/WorkOrder.vue @@ -57,12 +57,11 @@ - + + + + - 删除 + + 更多 ▾ + +
@@ -84,23 +94,31 @@ - - - + + + - - + + + + + + - + - + + + + @@ -133,6 +151,8 @@ + @@ -159,6 +179,9 @@
共 {{ previewDates.length }} 天:{{ previewDates.join('、') || '-' }}
平均分摊到 {{ previewDates.length }} 天(余数补前几天)
每日 {{ sched.dailyQty }} 件 × {{ previewDates.length }} 天(超未开工数量将被拒)
+
+ 本次计划总量 {{ previewTotal }},工单数量 {{ sched.quantity }}(超出工单数量,保存将被拒绝) +
@@ -214,7 +237,7 @@ function seqToCodes(s) { const out = [] String(s).split(',').forEach((p) => { const n = Number(p) - if (n >= 1 && !out.includes(n)) out.push(n) + if (n >= 0 && !out.includes(n)) out.push(n) }) return out.sort((a, b) => a - b) } @@ -232,7 +255,7 @@ const query = reactive({ orderNo: '', productCode: '', productName: '', contract const list = ref([]) const productTypes = ref([]) -const dialog = reactive({ show: false, id: 0, workOrderNo: '', productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', processSeq: '', status: 'CREATED' }) +const dialog = reactive({ show: false, id: 0, workOrderNo: '', productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', processSeq: '', status: 'CREATED' }) const seq = reactive({ show: false, id: 0, orderNo: '', productCode: '', productName: '', processSeq: '' }) const stationNos = ref([]) @@ -249,15 +272,32 @@ async function load() { async function loadProducts() { productTypes.value = (await request.get('/product-types')) || [] } +function genOrderNo() { + const d = new Date() + const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}` + return `WO-${ymd}-001` +} +// Q1=A:选产品型号后自动带出产品编号/名称(只读,避免手填错位) +function onProductTypeChange(id) { + const p = productTypes.value.find((x) => x.id === id) + if (p) { + dialog.productCode = p.code || '' + dialog.productName = p.name || '' + } else { + dialog.productCode = '' + dialog.productName = '' + } +} function openCreate() { - Object.assign(dialog, { show: true, id: 0, workOrderNo: `WO${Date.now()}`, productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', processSeq: '', status: 'CREATED' }) + Object.assign(dialog, { show: true, id: 0, workOrderNo: genOrderNo(), productTypeId: 0, productCode: '', productName: '', contractNo: '', projectNo: '', productSerial: '', quantity: 1, dueDate: '', planStart: '', processSeq: '', status: 'CREATED' }) } function openEdit(row) { - Object.assign(dialog, { show: true, id: row.id, workOrderNo: row.workOrderNo, productTypeId: row.productTypeId, productCode: row.productCode, productName: row.productName, contractNo: row.contractNo || '', projectNo: row.projectNo || '', productSerial: row.productSerial || '', quantity: row.quantity, dueDate: row.dueDate ? String(row.dueDate).slice(0, 10) : '', processSeq: row.processSeq || '', status: row.status }) + Object.assign(dialog, { show: true, id: row.id, workOrderNo: row.workOrderNo, productTypeId: row.productTypeId, productCode: row.productCode, productName: row.productName, contractNo: row.contractNo || '', projectNo: row.projectNo || '', productSerial: row.productSerial || '', quantity: row.quantity, dueDate: row.dueDate ? String(row.dueDate).slice(0, 10) : '', planStart: row.planStart ? String(row.planStart).slice(0, 10) : '', processSeq: row.processSeq || '', status: row.status }) } async function save() { if (!dialog.dueDate) return ElMessage.warning('请选择完成日期(必填)') - const body = { id: dialog.id, workOrderNo: dialog.workOrderNo, productTypeId: dialog.productTypeId, productCode: dialog.productCode, productName: dialog.productName, contractNo: dialog.contractNo, projectNo: dialog.projectNo, productSerial: dialog.productSerial, quantity: dialog.quantity, dueDate: dialog.dueDate, processSeq: dialog.processSeq } + if (!dialog.processSeq || !dialog.processSeq.trim()) return ElMessage.warning('请至少选择一个工位(工位组合为必填项)') + const body = { id: dialog.id, workOrderNo: dialog.workOrderNo, productTypeId: dialog.productTypeId, productCode: dialog.productCode, productName: dialog.productName, contractNo: dialog.contractNo, projectNo: dialog.projectNo, productSerial: dialog.productSerial, quantity: dialog.quantity, dueDate: dialog.dueDate, planStart: dialog.planStart, processSeq: dialog.processSeq } if (dialog.id) await request.put('/work-orders', body) else await request.post('/work-orders', body) ElMessage.success('保存成功') @@ -315,10 +355,20 @@ async function del(row) { load() } +// 操作列「更多」菜单分发:工位组合 / 编辑 / 删除 +function moreAction(row, cmd) { + if (cmd === 'seq') openSeq(row) + else if (cmd === 'edit') openEdit(row) + else if (cmd === 'del') del(row) +} + // ---- 自动排产(引导式,Item K)---- -const sched = reactive({ show: false, orderNo: '', mode: 'AVG', dailyQty: 1, segments: [{ range: null }] }) +const sched = reactive({ show: false, orderNo: '', mode: 'AVG', dailyQty: 1, segments: [{ range: null }], planStart: '', dueDate: '', quantity: 0, finishedNum: 0 }) function openAutoSchedule(row) { - Object.assign(sched, { show: true, orderNo: row.workOrderNo, mode: 'AVG', dailyQty: 1, segments: [{ range: null }] }) + Object.assign(sched, { + show: true, orderNo: row.workOrderNo, mode: 'AVG', dailyQty: 1, segments: [{ range: null }], + planStart: row.planStart || '', dueDate: row.dueDate || '', quantity: row.quantity || 0, finishedNum: row.finishedNum || 0 + }) } function addSegment() { sched.segments.push({ range: null }) } function removeSegment(i) { sched.segments.splice(i, 1) } @@ -340,6 +390,12 @@ const previewDates = computed(() => { } return out.sort() }) +// 本次排产计划总量(仅用于前端提示);AVG 模式后端按未开工数量自动均摊,此处不预估精确值 +const previewTotal = computed(() => { + if (!previewDates.length) return 0 + if (sched.mode === 'FIXED') return sched.dailyQty * previewDates.length + return 0 +}) async function submitAutoSchedule() { const segments = sched.segments .filter((s) => s.range && s.range.length === 2 && s.range[0] && s.range[1]) diff --git a/bj_power_mes/internal/handler/processcard.go b/bj_power_mes/internal/handler/processcard.go index 219ef12..bbf8e60 100644 --- a/bj_power_mes/internal/handler/processcard.go +++ b/bj_power_mes/internal/handler/processcard.go @@ -229,7 +229,7 @@ func hasTorqueStep(ctx context.Context, client *ent.Client, sn string, wp *ent.W return n > 0 } -// parseCardSeq 解析工单/工件工序组合 "1,3,5" → 工位号列表 +// parseCardSeq 解析工单/工件工序组合 "1,3,5" → 工位号列表(含 0 号上线位) func parseCardSeq(s string) []int { out := []int{} for _, p := range strings.Split(s, ",") { @@ -238,7 +238,7 @@ func parseCardSeq(s string) []int { continue } var n int - if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 1 && n <= logic.ProcessSeqMaxStation { + if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n >= 0 && n <= logic.ProcessSeqMaxStation { out = append(out, n) } } diff --git a/bj_power_mes/internal/logic/dailyplan.go b/bj_power_mes/internal/logic/dailyplan.go index 0dd8feb..f81feb0 100644 --- a/bj_power_mes/internal/logic/dailyplan.go +++ b/bj_power_mes/internal/logic/dailyplan.go @@ -30,13 +30,16 @@ type DailyPlanReq struct { CompletedQty int `json:"completedQty"` Status string `json:"status"` DockCodes []string `json:"dockCodes"` + // 批量排产(新建时与 PlanDate 二选一):日期范围 [start,end] + 分摊模式 + PlanRange []string `json:"planRange"` // [start,end] yyyy-MM-dd,批量生成多条日排产 + Mode string `json:"mode"` // AVG(平均分摊)/FIXED(固定日产);单条保存时忽略 } // SaveDailyPlan 创建/更新日排产(同一 工单+日期 唯一) // 排产状态枚举统一为:PENDING(待执行)/PROCESSING(执行中)/DONE(已完成)/CANCELLED(已取消) func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator string) error { - if req.OrderNo == "" || req.PlanDate == "" { - return errors.New("工单号和排产日期必填") + if req.OrderNo == "" { + return errors.New("工单号必填") } // 校验:该工单所有日排产计划数量累计不得超过工单总数量(保存时合计,改小可、改大不超总量) wo, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(req.OrderNo)).First(ctx) @@ -49,6 +52,14 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator if wo.Status == "PAUSED" { return errors.New("该工单已暂停,恢复执行前不能新增排产") } + // 批量排产(新建 + 日期范围):一键生成多天日排产,参考工单「自动排产」 + if req.Id == 0 && len(req.PlanRange) == 2 && req.PlanRange[0] != "" && req.PlanRange[1] != "" { + return s.saveDailyPlanBatch(ctx, req, operator, wo) + } + // 单条排产(编辑既有行 / 新建单日) + if req.PlanDate == "" { + return errors.New("排产日期必填") + } cur, err := s.ctx.EntClient.DailyPlan.Query(). Where(dailyplan.OrderNo(req.OrderNo)).All(ctx) if err != nil { @@ -85,6 +96,10 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator return errors.New("非法的排产状态") } if err == nil && exist != nil { + // 编辑时若不传 status,保留该行原有状态(避免编辑把已启用(CONFIRMED)误回退为待启用) + if req.Status == "" { + status = exist.Status + } _, err = s.ctx.EntClient.DailyPlan.UpdateOneID(exist.ID). SetOrderNo(req.OrderNo).SetPlanDate(req.PlanDate).SetPlanQty(req.PlanQty).SetStatus(status). SetDockCodes(dockCodes(req.DockCodes)).Save(ctx) @@ -108,6 +123,90 @@ func (s *Service) SaveDailyPlan(ctx context.Context, req DailyPlanReq, operator return nil } +// saveDailyPlanBatch 批量排产(新建 + 日期范围):把 total 件按模式分摊到范围内每一天, +// 生成多条 daily_plan(status=PENDING 待人工启用)。合计受「未开工数量」约束,执行中/已完工日计划受保护不被覆盖。 +func (s *Service) saveDailyPlanBatch(ctx context.Context, req DailyPlanReq, operator string, wo *ent.WorkOrder) error { + dates, err := expandDates([]DateSegment{{Start: req.PlanRange[0], End: req.PlanRange[1]}}) + if err != nil { + return err + } + if len(dates) == 0 { + return errors.New("请选择排产日期范围") + } + remain, err := s.unscheduledQty(ctx, wo) + if err != nil { + return err + } + exist, _ := s.ctx.EntClient.DailyPlan.Query().Where(dailyplan.OrderNo(req.OrderNo)).All(ctx) + existTotal := 0 + for _, p := range exist { + existTotal += p.PlanQty + } + quota := remain - existTotal + if quota < 0 { + quota = 0 + } + days := len(dates) + var planQty []int + switch req.Mode { + case "FIXED": + if req.PlanQty <= 0 { + return errors.New("固定日产模式需填写每日产量(>0)") + } + if req.PlanQty*days > quota { + return errors.New("固定日产×天数超过未开工数量(工单总数量−已完工−在产未完工),无法排产") + } + planQty = distributeQty(req.PlanQty*days, days, "FIXED", req.PlanQty) + case "AVG", "": + if req.PlanQty <= 0 { + return errors.New("排产总数量需大于 0") + } + if req.PlanQty > quota { + return errors.New("排产总数量超过未开工数量(工单总数量−已完工−在产未完工),无法排产") + } + planQty = distributeQty(req.PlanQty, days, "AVG", 0) + default: + return errors.New("排产模式仅支持 AVG(平均分摊) / FIXED(固定日产)") + } + sum := 0 + for _, q := range planQty { + sum += q + } + if sum == 0 { + return errors.New("未开工数量为 0,无需排产(已在产/已完工)") + } + for i, ds := range dates { + q := planQty[i] + if q <= 0 { + continue + } + existOne, _ := s.ctx.EntClient.DailyPlan.Query(). + Where(dailyplan.OrderNo(req.OrderNo), dailyplan.PlanDate(ds)).First(ctx) + if existOne != nil { + // 保护执行中/已完工的排产不被覆盖(其数量已计入 existTotal,跳过不会超排) + if existOne.Status == "PROCESSING" || existOne.Status == "DONE" { + continue + } + _, err = s.ctx.EntClient.DailyPlan.UpdateOneID(existOne.ID). + SetPlanQty(q).SetStatus("PENDING").SetOperator(operator).Save(ctx) + } else { + _, err = s.ctx.EntClient.DailyPlan.Create(). + SetOrderNo(req.OrderNo).SetPlanDate(ds).SetPlanQty(q). + SetStatus("PENDING").SetOperator(operator).SetDockCodes(dockCodes(req.DockCodes)).Save(ctx) + } + if err != nil { + return err + } + } + if len(dockCodes(req.DockCodes)) > 0 { + s.rememberOrderDocks(ctx, req.OrderNo, dockCodes(req.DockCodes)) + } + s.ctx.EventLog.Write(ctx, "daily.plan.batch", req.OrderNo, operator, "daily_plan", req.OrderNo, + "日排产批量生成", map[string]any{"days": days, "total": sum, "mode": req.Mode, "quota": quota}) + s.notifyDashboard() + return nil +} + // dockCodes 规范化接驳台列表:仅保留 DOCK01..DOCK20 / DOCK21,去重、保持顺序 func dockCodes(codes []string) []string { seen := map[string]bool{} @@ -281,6 +380,38 @@ func expandDates(segments []DateSegment) ([]string, error) { return out, nil } +// distributeQty 把 total 件分摊到 days 天: +// AVG 平均分摊(余数补到前面几天,合计=total);FIXED 每日 dailyQty(末日取余数,合计=min(total, dailyQty*days))。 +// 返回每日数量切片(长度=days)。调用方需自行校验 total 不超过可排上限(quota)。 +func distributeQty(total, days int, mode string, dailyQty int) []int { + out := make([]int, days) + if days <= 0 || total <= 0 { + return out + } + switch mode { + case "FIXED": + left := total + for i := 0; i < days; i++ { + if left >= dailyQty { + out[i] = dailyQty + left -= dailyQty + } else { + out[i] = left + left = 0 + } + } + default: // AVG + base := total / days + for i := 0; i < days; i++ { + out[i] = base + } + for i := 0; i < total-base*days; i++ { + out[i]++ + } + } + return out +} + // AutoSchedule 引导式自动排产:生成多条 daily_plan(status=PENDING,未启用),供人工确认后启用。 // 排产合计(含已有排产)≤ 未开工数量;已进线未完工工件跨天继续、不被重排冲掉。 func (s *Service) AutoSchedule(ctx context.Context, req AutoScheduleReq, operator string) error { @@ -325,34 +456,16 @@ func (s *Service) AutoSchedule(ctx context.Context, req AutoScheduleReq, operato days := len(dates) var planQty []int switch req.Mode { - case "AVG": - base := quota / days - planQty = make([]int, days) - for i := range planQty { - planQty[i] = base - } - // 余数补到前面几天,保证合计=quota - for i := 0; i < quota-base*days; i++ { - planQty[i]++ - } case "FIXED": if req.DailyQty <= 0 { return errors.New("固定日产模式需填写每日产量(>0)") } - planQty = make([]int, days) - left := quota - for i := range planQty { - if left >= req.DailyQty { - planQty[i] = req.DailyQty - left -= req.DailyQty - } else { - planQty[i] = left - left = 0 - } - } if quota > req.DailyQty*days { return errors.New("固定日产×天数超过未开工数量,无法排产") } + planQty = distributeQty(quota, days, "FIXED", req.DailyQty) + case "AVG", "": + planQty = distributeQty(quota, days, "AVG", 0) } sum := 0 for _, q := range planQty { diff --git a/bj_power_mes/internal/logic/processseq.go b/bj_power_mes/internal/logic/processseq.go index 1e2f25f..22386a9 100644 --- a/bj_power_mes/internal/logic/processseq.go +++ b/bj_power_mes/internal/logic/processseq.go @@ -15,7 +15,7 @@ const ProcessSeqMaxStation = 999 // ParseProcessSeq 解析工位组合字符串为升序去重的工位码列表。 // 唯一格式:逗号分隔 "1,3,5" / "10,11,12"。传送带不回走,故组合天然按工位号升序执行。 -// 工位号取值 1..ProcessSeqMaxStation:工位主数据可扩展到 14、15…(虚拟工位仅记录,不连 PLC)。 +// 工位号取值 0..ProcessSeqMaxStation:0/13 为虚拟上线/下线位(不连 PLC、仅记录),工位主数据可扩展到 14、15…。 func ParseProcessSeq(s string) []int { s = strings.TrimSpace(s) if s == "" { @@ -29,7 +29,7 @@ func ParseProcessSeq(s string) []int { continue } n, err := strconv.Atoi(p) - if err != nil || n < 1 || n > ProcessSeqMaxStation || seen[n] { + if err != nil || n < 0 || n > ProcessSeqMaxStation || seen[n] { continue } seen[n] = true diff --git a/bj_power_mes/internal/logic/workorder.go b/bj_power_mes/internal/logic/workorder.go index e3462d8..931aa55 100644 --- a/bj_power_mes/internal/logic/workorder.go +++ b/bj_power_mes/internal/logic/workorder.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "strconv" + "strings" "time" "bj_power_mes/ent" @@ -34,15 +36,22 @@ type WorkOrderReq struct { // CreateWorkOrder 创建工单(状态恒 CREATED,后续用状态流转按钮推进) func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operator string) error { - if req.WorkOrderNo == "" { - return errors.New("工单号不能为空") - } if req.ProductTypeId == 0 { return errors.New("请选择产品型号") } if req.Quantity <= 0 { req.Quantity = 1 } + // 工单号:未传入则按规则生成 WO-日期-001;若与已有工单号重复,自动递增序号(001→002…)。 + if req.WorkOrderNo == "" { + req.WorkOrderNo = defaultWorkOrderNo() + } + finalNo, err := s.resolveUniqueWorkOrderNo(ctx, req.WorkOrderNo) + if err != nil { + return err + } + req.WorkOrderNo = finalNo + status := req.Status if status == "" { status = "CREATED" @@ -50,11 +59,8 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato if status != "CREATED" { status = "CREATED" // 创建恒为已创建;不允许直接创建到后续状态 } - // 工位组合=本工单要经过的工位,按工位号升序(传送带不回走)。 - // 完整的产线定义 = 关联工位(station.flow_id)+ 工位号 1→12 顺序,不再有独立的"工艺路线"对象。 - if req.ProcessSeq == "" { - req.ProcessSeq = FullProcessSeq - } else if err := ValidateProcessSeq(req.ProcessSeq); err != nil { + // 工位组合为本工单要经过的工位,按工位号升序(传送带不回走),必填项,禁止留空/自动默认全选。 + if err := ValidateProcessSeq(req.ProcessSeq); err != nil { return err } req.ProcessSeq = NormalizeProcessSeq(req.ProcessSeq) @@ -95,6 +101,40 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato return nil } +// defaultWorkOrderNo 工单号默认规则:WO-YYYYMMDD-001(如 WO-20260918-001)。 +func defaultWorkOrderNo() string { + return fmt.Sprintf("WO-%s-001", time.Now().Format("20060102")) +} + +// resolveUniqueWorkOrderNo 工单号唯一性保障:若 want 已存在,则按尾部序号自动递增 +// (WO-20260918-001 → 002 → 003…),最多尝试 999 次,仍冲突则报错由前端手动指定。 +// 尾部序号解析失败(无数字后缀)时,退化为在末尾追加 -001/-002…。 +func (s *Service) resolveUniqueWorkOrderNo(ctx context.Context, want string) (string, error) { + parts := strings.Split(want, "-") + prefix := want + seq := 0 + if len(parts) >= 2 { + if n, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + seq = n + prefix = strings.Join(parts[:len(parts)-1], "-") + } + } + for i := 0; i < 999; i++ { + cand := want + if i > 0 { + cand = fmt.Sprintf("%s-%03d", prefix, seq+i) + } + cnt, err := s.ctx.EntClient.WorkOrder.Query().Where(workorder.WorkOrderNo(cand)).Count(ctx) + if err != nil { + return "", err + } + if cnt == 0 { + return cand, nil + } + } + return "", errors.New("工单号自动编号失败(序号已用尽),请手动指定工单号") +} + // UpdateWorkOrder 编辑工单。仅「已创建」状态允许编辑(已下发/执行中/已暂停只能流转,已完成/已取消只读)。 func (s *Service) UpdateWorkOrder(ctx context.Context, req WorkOrderReq, operator string) error { if req.Id == 0 { diff --git a/bj_power_mes/public/index.html b/bj_power_mes/public/index.html index 045f844..7bcab18 100644 --- a/bj_power_mes/public/index.html +++ b/bj_power_mes/public/index.html @@ -6,7 +6,7 @@ MES 产线控制 - + diff --git a/bj_power_wms/frontend/src/components/DetailDrawer.vue b/bj_power_wms/frontend/src/components/DetailDrawer.vue new file mode 100644 index 0000000..c167adb --- /dev/null +++ b/bj_power_wms/frontend/src/components/DetailDrawer.vue @@ -0,0 +1,55 @@ + + + diff --git a/bj_power_wms/frontend/src/pages/RoleManage.vue b/bj_power_wms/frontend/src/pages/RoleManage.vue index 4a74b31..be2f26c 100644 --- a/bj_power_wms/frontend/src/pages/RoleManage.vue +++ b/bj_power_wms/frontend/src/pages/RoleManage.vue @@ -6,6 +6,7 @@ import request from '../utils/request' import { fmtTime } from '../utils/format' import { can } from '../utils/perm' import PageHelp from '../components/PageHelp.vue' +import DetailDrawer from '../components/DetailDrawer.vue' import { helpRole } from '../help' const loading = ref(false) @@ -153,6 +154,22 @@ function permSummary(arr) { const names = codes.map((c) => codeName.value[c] || c) return names.length > 8 ? `${names.slice(0, 8).join('、')} 等 ${names.length} 项` : names.join('、') } + +// 角色详情抽屉 +const detail = reactive({ show: false, title: '', fields: [], row: null }) +function openDetail(row) { + detail.row = row + detail.title = `角色详情 · ${row.name}` + detail.fields = [ + { key: 'id', label: 'ID' }, + { key: 'code', label: '编码' }, + { key: 'name', label: '名称' }, + { key: 'remark', label: '备注' }, + { key: 'permissionCodes', label: '权限数', format: (v) => (Array.isArray(v) ? (v.includes('*') ? '全部' : v.length + ' 项') : '-') }, + { key: 'createdAt', label: '创建时间', type: 'time' } + ] + detail.show = true +}