-
-
{label}
-
{toolText[status]}
+
);
}
-function statusText(s: Station['status']): string {
- switch (s) {
- case 'running':
- return '运行';
- case 'idle':
- return '空闲';
- case 'alarm':
- return '报警';
- default:
- return '离线';
- }
+function Legend() {
+ const items: Array<[string, string]> = [
+ ['#00d68f', '运行中'],
+ ['#36a3f7', '待料'],
+ ['#ffd666', '异常'],
+ ['#4b5b73', '离线'],
+ ['#fb923c', 'AGV 上料'],
+ ['#60a5fa', 'AGV 下料'],
+ ];
+ return (
+
+ {items.map(([color, label]) => (
+
+
+ {label}
+
+ ))}
+
+ );
}
-function Legend({ color, label }: { color: string; label: string }) {
+function Footnote() {
return (
-
-
- {label}
-
+
+ 环形产线自动漫游 · 红=上料库房 / 黄=下料库房 · 右上/右下=办公区
+
);
}
\ No newline at end of file
diff --git a/bj_power_dashboard/src/pages/ProductionBoard.tsx b/bj_power_dashboard/src/pages/ProductionBoard.tsx
index 8bbc69b..4fdbaa9 100644
--- a/bj_power_dashboard/src/pages/ProductionBoard.tsx
+++ b/bj_power_dashboard/src/pages/ProductionBoard.tsx
@@ -1,157 +1,253 @@
-import { Card, Progress, Tag, Typography } from '@douyinfe/semi-ui';
import type { EChartsOption } from 'echarts';
-import type { DashboardData } from '../types';
+import type { DashboardData, Station } from '../types';
import { EChart } from '../components/EChart';
-const gridLabel = {
- color: '#8fa3bf',
- fontSize: 13,
+const C = {
+ cyan: '#22d3ee',
+ green: '#34d399',
+ blue: '#60a5fa',
+ amber: '#fbbf24',
+ panel: '#0d1a33',
+ border: 'rgba(56,189,248,0.22)',
+ text: '#e2e8f0',
+ sub: '#94a3b8',
+ dim: '#64748b',
};
-export function ProductionBoard({ data }: { data: DashboardData }) {
- const { production, progress, trends } = data;
- const st = production.stationSummary;
- const prog =
- progress.totalQty > 0 ? (progress.completedQty / progress.totalQty) * 100 : 0;
+const statusColor: Record
= {
+ running: C.green,
+ idle: C.blue,
+ alarm: '#f87171',
+ offline: '#475569',
+};
- const ringOption: EChartsOption = {
- tooltip: {
- formatter: '{b}: {c}({d}%)',
- },
- series: [
- {
- type: 'pie',
- radius: ['62%', '82%'],
- silent: true,
- label: { show: true, position: 'center', formatter: '{value}%'.replace('{value}', String(production.qualifiedRate)), fontSize: 30, fontWeight: 'bold', color: '#00d68f' },
- data: [
- { value: production.qualityOk, name: '合格', itemStyle: { color: '#00d68f' } },
- { value: production.qualityNg, name: '不合格', itemStyle: { color: '#ff5252' } },
- ],
- },
- ],
- };
+const gridLabel = { color: '#8fa3bf', fontSize: 13 };
+
+export function ProductionBoard({ data }: { data: DashboardData }) {
+ const { production, progress, trends, equipment } = data;
const trendOption: EChartsOption = {
- grid: { left: 40, right: 16, top: 28, bottom: 24 },
+ grid: { left: 48, right: 20, top: 28, bottom: 28 },
tooltip: { trigger: 'axis' },
- legend: { textStyle: gridLabel, top: 0, data: ['当日产量', '合格率'] },
xAxis: {
type: 'category',
data: trends.production.map((t) => t.time),
axisLine: { lineStyle: { color: '#335' } },
axisLabel: gridLabel,
},
- yAxis: [
- {
- type: 'value',
- name: '产量',
- splitLine: { lineStyle: { color: '#1f2a3f' } },
- axisLabel: gridLabel,
- },
- {
- type: 'value',
- name: '合格率',
- min: 80,
- max: 100,
- splitLine: { show: false },
- axisLabel: gridLabel,
- },
- ],
+ yAxis: {
+ type: 'value',
+ name: '产量',
+ splitLine: { lineStyle: { color: '#1f2a3f' } },
+ axisLabel: gridLabel,
+ },
series: [
{
- name: '当日产量',
+ name: '日产量',
type: 'line',
smooth: true,
data: trends.production.map((t) => t.output),
- itemStyle: { color: '#36a3f7' },
- areaStyle: { color: 'rgba(54,163,247,0.2)' },
- },
- {
- name: '合格率',
- type: 'line',
- yAxisIndex: 1,
- smooth: true,
- data: trends.production.map((t) => t.qualifiedRate),
- itemStyle: { color: '#00d68f' },
+ itemStyle: { color: C.cyan },
+ areaStyle: { color: 'rgba(34,211,238,0.18)' },
},
],
};
return (
-
-
+
+
+
+
+
+
+
+
+
+
+
- |
- |
- |
- |
-
-
+
+ {progress.contractNo || progress.orderNo}
-
-
-
- {production.outputToday}
+
+ {progress.productName}
+
+
+ {progress.owner || '—'}
+
+
+
+ {progress.planStart} ~ {progress.planEnd}
+ = 0 ? C.green : C.amber }}>
+ 剩余 {Math.max(progress.remainDays, 0)} 天
+
- / {production.targetToday}
-
-
日计划达成率
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
{progress.orderNo}
-
- {progress.status}
-
+
+
+ {equipment.map((s) => (
+
+
+ {s.name}
+
+ {s.status === 'running'
+ ? '作业中'
+ : s.status === 'idle'
+ ? '待料'
+ : s.status === 'alarm'
+ ? '异常'
+ : '离线'}
+
+
+
+ {s.currentOperator || '—'}
+
+ {s.currentSn &&
{s.currentSn}
}
+
+ ))}
- {progress.productName}
-
-
- 完成 {progress.completedQty} / {progress.totalQty}
-
+
+ 运行中 {production.stationSummary.running} · 待料 {production.stationSummary.idle} · 离线{' '}
+ {production.stationSummary.offline}
-
-
- 当前工序:{progress.currentProcess}
-
-
-
-
-
-
+
);
}
-function Cell({ value, label, color }: { value: number; label: string; color: string }) {
+function Field({ label }: { label: string }) {
+ return {label};
+}
+
+function Kpi({ value, label, sub, color }: { value: number; label: string; sub: string; color: string }) {
return (
-
-
{value}
-
{label}
+
+
{label}
+
{value}
+
{sub}
);
-}
\ No newline at end of file
+}
+
+function Panel({
+ title,
+ children,
+ style,
+ accent,
+}: {
+ title: string;
+ children: React.ReactNode;
+ style?: React.CSSProperties;
+ accent?: boolean;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function Bar({ percent }: { percent: number }) {
+ const p = Math.max(0, Math.min(100, percent));
+ return (
+
+ );
+}
+
+function Chip({ label, value, color }: { label: string; value: string; color: string }) {
+ return (
+
+ );
+}
diff --git a/bj_power_dashboard/src/services/dashboardService.ts b/bj_power_dashboard/src/services/dashboardService.ts
index d3efc6b..8269691 100644
--- a/bj_power_dashboard/src/services/dashboardService.ts
+++ b/bj_power_dashboard/src/services/dashboardService.ts
@@ -1,31 +1,34 @@
import { config, dashboardEndpoints } from '../config';
-import { getMockData } from '../mock';
-import type { DashboardData, Station } from '../types';
+import { getDemoData } from '../mock';
+import type {
+ DashboardData,
+ ProductionOverview,
+ ProductionProgress,
+ WarehouseOverview,
+} from '../types';
+
+export type DataMode = 'sse' | 'polling' | 'demo' | 'init';
type ChangeListener = (data: DashboardData | null) => void;
-type ModeListener = (mode: 'sse' | 'polling') => void;
+type ModeListener = (mode: DataMode) => void;
type ErrorListener = (message: string) => void;
-/** MES 各接口返回片段 */
-interface EndpointOverview {
- production?: DashboardData['production'];
- warehouse?: DashboardData['warehouse'];
-}
-interface EndpointEquipment {
- stations?: Station[];
-}
-interface EndpointAlarms {
- items?: DashboardData['alarms'];
-}
-
/**
- * 数据源:优先尝试 SSE(经 fetch 实现,可携带 X-API-TOKEN 请求头);
- * SSE 建立或读取失败时降级为 10s HTTP 轮询。
+ * 看板数据源。
+ *
+ * 双通道设计:
+ * 1. HTTP 快照轮询(兜底保真)——每 pollIntervalMs 拉一次 /dashboard/snapshot,
+ * 保证真实数据一到位、或演示期间业务起量后能自动切回,不依赖事件推送。
+ * 2. SSE 长连接(实时增强)——业务事件发生时即时刷新,可用时顶部标“实时推送”。
+ *
+ * 关于演示数据:看板是纯展示系统,MES 业务库在投产前为空,直连会让大屏显示全 0,
+ * 比展示演示数据更糟。因此真实数据为空(无工单且无产量)时自动降级演示数据并在界面标注,
+ * 演示期间轮询不停止,真实数据一到位即自动切回。
*/
export class DashboardDataService {
private baseUrl = config.mesBaseUrl;
private token = config.internalToken;
- private useMock = config.useMock;
+ private forceDemo = config.forceDemo;
private controller: AbortController | null = null;
private timer: ReturnType
| null = null;
@@ -34,7 +37,7 @@ export class DashboardDataService {
private modeListeners = new Set();
private errorListeners = new Set();
- mode: 'sse' | 'polling' | 'init' = 'init';
+ mode: DataMode = 'init';
private running = false;
onChange(cb: ChangeListener): () => void {
@@ -53,7 +56,8 @@ export class DashboardDataService {
private emit(data: DashboardData | null) {
this.changeListeners.forEach((l) => l(data));
}
- private setMode(m: 'sse' | 'polling') {
+ private setMode(m: DataMode) {
+ if (this.mode === m) return;
this.mode = m;
this.modeListeners.forEach((l) => l(m));
}
@@ -61,44 +65,85 @@ export class DashboardDataService {
this.errorListeners.forEach((l) => l(msg));
}
- /** 启动数据流(幂等,可安全重复调用) */
+ /** 启动数据流(幂等) */
start() {
if (this.running) return;
this.running = true;
- if (this.useMock) {
- // 演示模式:直接给出数据
- this.setMode('polling');
- this.emit(getMockData());
+ if (this.forceDemo) {
+ this.useDemo('已启用强制演示模式');
return;
}
+ void this.pollOnce();
+ this.startPolling();
void this.openSse();
}
- /** 停止数据流并清理资源 */
stop() {
this.running = false;
this.closeStream();
- this.clearTimer();
+ this.stopPolling();
this.changeListeners.clear();
this.modeListeners.clear();
this.errorListeners.clear();
}
- /** 手动重试:恢复默认时先重新建立 SSE,失败则轮询 */
+ /** 手动重试:重走一次完整数据流 */
retry() {
if (!this.running) this.running = true;
- if (this.useMock) {
- this.emit(getMockData());
+ if (this.forceDemo) {
+ this.useDemo('已启用强制演示模式');
return;
}
- this.clearTimer();
+ this.closeStream();
+ this.stopPolling();
+ void this.pollOnce();
+ this.startPolling();
void this.openSse();
}
- // ---------- SSE ----------
+ /** 降级到演示数据(轮询不停止,真实数据到位后自动切回) */
+ private useDemo(reason: string) {
+ this.setMode('demo');
+ this.emit(getDemoData());
+ this.emitError(reason);
+ }
+
+ // ---------- HTTP 快照轮询(兜底保真) ----------
+ private startPolling() {
+ this.stopPolling();
+ this.timer = setInterval(() => void this.pollOnce(), config.pollIntervalMs);
+ }
+ private stopPolling() {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ }
+
+ private async pollOnce() {
+ if (!this.running) return;
+ try {
+ const res = await fetch(`${this.baseUrl}${dashboardEndpoints.snapshot}`, {
+ headers: { 'X-API-TOKEN': this.token },
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const raw = (await res.json()) as Partial;
+ this.accept(raw, 'polling');
+ } catch (err) {
+ if (!this.running) return;
+ // 已处于演示模式且后端仍不可达时,不再重复弹错/重发演示数据
+ if (this.mode !== 'demo') {
+ this.useDemo(
+ `MES 不可用,已切换演示数据(${err instanceof Error ? err.message : '网络异常'})`,
+ );
+ }
+ }
+ }
+
+ // ---------- SSE(实时增强) ----------
private async openSse() {
this.closeStream();
- const url = `${this.baseUrl}/api/internal/dashboard/stream`;
+ const url = `${this.baseUrl}${dashboardEndpoints.stream}`;
const controller = new AbortController();
this.controller = controller;
try {
@@ -107,7 +152,6 @@ export class DashboardDataService {
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`SSE 连接失败:HTTP ${res.status}`);
- this.setMode('sse');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -115,28 +159,32 @@ export class DashboardDataService {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
- // 按空行切分 SSE 事件块
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
- const dataLine = block
- .split('\n')
- .find((l) => l.startsWith('data:'));
+ const dataLine = block.split('\n').find((l) => l.startsWith('data:'));
if (!dataLine) continue;
const payload = dataLine.replace(/^data:\s*/, '').trim();
+ if (!payload) continue;
try {
- const parsed = JSON.parse(payload) as DashboardData;
- if (parsed) this.emit(parsed);
+ const parsed = JSON.parse(payload) as Partial;
+ // 忽略 hello / 心跳等无数据载荷(无 production 且无 updatedAt)
+ if (!parsed || typeof parsed !== 'object' || (!parsed.production && !parsed.updatedAt)) {
+ continue;
+ }
+ this.accept(parsed, 'sse');
} catch {
- // 忽略无法解析的单条事件,继续读取
+ // 单条事件解析失败不影响后续推送
}
}
}
+ if (this.running && this.mode === 'sse') this.setMode('polling');
} catch (err) {
if (!this.running) return;
if (err instanceof Error && err.name === 'AbortError') return;
- this.emitError('SSE 连接失败,已降级为 HTTP 轮询(每 10s 刷新)');
- this.fallbackToPolling();
+ // SSE 不可用时依赖轮询通道;仍在 init 则先降级演示,等待轮询恢复
+ if (this.mode === 'init') this.useDemo('实时通道不可用,已切换演示数据');
+ else if (this.mode === 'sse') this.setMode('polling');
}
}
@@ -145,84 +193,75 @@ export class DashboardDataService {
this.controller = null;
}
- // ---------- HTTP 轮询(降级) ----------
- private fallbackToPolling() {
- this.clearTimer();
- void this.pollOnce();
- this.timer = setInterval(() => void this.pollOnce(), config.pollIntervalMs);
- }
-
- private async pollOnce() {
- try {
- const headers = { 'X-API-TOKEN': this.token };
- const fetchJson = async (path: string): Promise => {
- const res = await fetch(`${this.baseUrl}${path}`, { headers });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return (await res.json()) as T;
- };
-
- const [overview, equipment, progress, alarms, trends] = await Promise.all([
- fetchJson(dashboardEndpoints.overview),
- fetchJson(dashboardEndpoints.equipment),
- fetchJson(dashboardEndpoints.progress),
- fetchJson(dashboardEndpoints.alarms),
- fetchJson(dashboardEndpoints.trends),
- ]);
-
- const assembled: DashboardData = {
- production: overview.production ?? this.emptyProduction(),
- warehouse: overview.warehouse ?? this.emptyWarehouse(),
- equipment: equipment.stations ?? [],
- progress: progress ?? this.emptyProgress(),
- alarms: alarms.items ?? [],
- trends: trends ?? { production: [] },
- };
- this.setMode('polling');
- this.emit(assembled);
- } catch (err) {
- this.emit(null);
- this.emitError(
- `数据加载失败:${err instanceof Error ? err.message : '网络异常或无 MES 服务'}`,
- );
+ /** 接收一次数据:补全字段后判定是否为空,空则演示;有真数据则按来源标记通道 */
+ private accept(raw: Partial, source: 'sse' | 'polling') {
+ const data = normalize(raw);
+ if (isEmpty(data)) {
+ if (this.mode !== 'demo') this.useDemo('MES 暂无业务数据,已切换演示数据');
+ return;
}
+ if (this.mode !== source) this.setMode(source);
+ this.emit(data);
}
+}
- private clearTimer() {
- if (this.timer) {
- clearInterval(this.timer);
- this.timer = null;
- }
- }
+/** 真实业务数据是否为空:无工单且无产量即视为尚未投产 */
+function isEmpty(d: DashboardData): boolean {
+ return !d.progress?.orderNo && (d.production?.outputToday ?? 0) === 0;
+}
- // ---------- 空值兜底 ----------
- private emptyProduction(): DashboardData['production'] {
- return {
- outputToday: 0,
- targetToday: 0,
- qualifiedRate: 0,
- qualityOk: 0,
- qualityNg: 0,
- stationSummary: { running: 0, idle: 0, offline: 0, alarm: 0 },
- };
- }
- private emptyWarehouse(): DashboardData['warehouse'] {
- return {
- totalStock: 0,
- materialTypes: 0,
- inboundToday: 0,
- outboundToday: 0,
- movements: [],
- };
- }
- private emptyProgress(): DashboardData['progress'] {
- return {
- orderNo: '-',
- productName: '-',
- totalQty: 0,
- doneQty: 0,
- completedQty: 0,
- currentProcess: '-',
- status: '-',
- };
- }
-}
\ No newline at end of file
+/**
+ * 字段补全:后端可能只返回部分字段,缺失一律补零值,
+ * 避免前端到处判空,也避免 undefined 传进图表。
+ */
+function normalize(raw: Partial): DashboardData {
+ const p: ProductionOverview = raw.production ?? ({} as ProductionOverview);
+ const w: WarehouseOverview = raw.warehouse ?? ({} as WarehouseOverview);
+ const pr: ProductionProgress = raw.progress ?? ({} as ProductionProgress);
+ return {
+ production: {
+ outputToday: p.outputToday ?? 0,
+ targetToday: p.targetToday ?? 0,
+ passCount: p.passCount ?? 0,
+ torqueCount: p.torqueCount ?? 0,
+ inLine: p.inLine ?? 0,
+ deviceOnline: p.deviceOnline ?? 0,
+ deviceTotal: p.deviceTotal ?? 0,
+ stationSummary: {
+ running: p.stationSummary?.running ?? 0,
+ idle: p.stationSummary?.idle ?? 0,
+ offline: p.stationSummary?.offline ?? 0,
+ alarm: p.stationSummary?.alarm ?? 0,
+ },
+ },
+ warehouse: {
+ totalStock: w.totalStock ?? 0,
+ materialTypes: w.materialTypes ?? 0,
+ inboundToday: w.inboundToday ?? 0,
+ outboundToday: w.outboundToday ?? 0,
+ movements: w.movements ?? [],
+ },
+ equipment: raw.equipment ?? [],
+ progress: {
+ orderNo: pr.orderNo ?? '',
+ contractNo: pr.contractNo ?? '',
+ productName: pr.productName ?? '',
+ owner: pr.owner ?? '',
+ planStart: pr.planStart ?? '',
+ planEnd: pr.planEnd ?? '',
+ remainDays: pr.remainDays ?? 0,
+ totalQty: pr.totalQty ?? 0,
+ doneQty: pr.doneQty ?? 0,
+ progress: pr.progress ?? 0,
+ processDone: pr.processDone ?? 0,
+ processTotal: pr.processTotal ?? 0,
+ currentProcess: pr.currentProcess ?? '',
+ status: pr.status ?? '',
+ traceStepCount: pr.traceStepCount ?? 0,
+ traceable: pr.traceable ?? false,
+ },
+ traces: raw.traces ?? [],
+ trends: raw.trends ?? { production: [] },
+ isDemo: false,
+ };
+}
diff --git a/bj_power_dashboard/src/types.ts b/bj_power_dashboard/src/types.ts
index 9cd291b..a3901a2 100644
--- a/bj_power_dashboard/src/types.ts
+++ b/bj_power_dashboard/src/types.ts
@@ -1,13 +1,14 @@
/**
* 看板数据结构定义(camelCase)。
- * 产线为纯装配线,设备仅「扫码枪 + 拧紧枪」,不含其他设备。
+ *
+ * 展示口径约定(甲方要求「体现好的东西」):
+ * - 不直接展示合格率 / 不良数 / 报警等负面指标,改为正面表述(一次通过件数、全程受控、设备在线)。
+ * - 展示重点是:当前订单(合同 · 负责人 · 工期 · 完成度 · 可追溯)、产能、质量保障能力。
+ * - 真实业务数据为空时由演示数据兜底,避免大屏出现 0 或空白(isDemo=true 时角落标注)。
*/
export type StationStatus = 'running' | 'idle' | 'alarm' | 'offline';
export type ToolStatus = 'online' | 'offline' | 'busy' | 'alarm';
-export type AlarmLevel = 'critical' | 'warning';
-export type AlarmType = 'equipment' | 'material';
-export type AlarmStatus = 'active' | 'resolved';
/** 扫码枪 / 拧紧枪 状态 */
export interface ToolState {
@@ -26,18 +27,25 @@ export interface Station {
scanGun: ToolState;
tighteningGun: ToolState;
currentSn?: string;
+ /** 当前作业人(责任到人) */
+ currentOperator?: string;
}
-/** 生产/产线运行概览 */
+/** 生产 / 产能总览(屏1 KPI) */
export interface ProductionOverview {
- /** 产线当日产量 */
+ /** 今日完工产量 */
outputToday: number;
- /** 当日计划产量 */
+ /** 今日目标产量 */
targetToday: number;
- /** 合格率(0-100) */
- qualifiedRate: number;
- qualityOk: number;
- qualityNg: number;
+ /** 一次通过件数(正面表述,替代合格率) */
+ passCount: number;
+ /** 今日拧紧作业次数(体现过程受控) */
+ torqueCount: number;
+ /** 在制工件 */
+ inLine: number;
+ /** 设备在线数 / 总数 */
+ deviceOnline: number;
+ deviceTotal: number;
stationSummary: {
running: number;
idle: number;
@@ -64,27 +72,69 @@ export interface WarehouseOverview {
movements: StockMovement[];
}
-/** 工单进度 */
+/**
+ * 当前订单(屏1 核心卡片)
+ * 展示:合同信息、负责人、工期、完成度、可追溯。
+ */
export interface ProductionProgress {
+ /** 工单号 */
orderNo: string;
+ /** 合同号(一合同 = 一工单) */
+ contractNo: string;
productName: string;
+ /** 订单负责人 */
+ owner: string;
+ /** 工期起 yyyy-MM-dd */
+ planStart: string;
+ /** 工期止 yyyy-MM-dd */
+ planEnd: string;
+ /** 距交期剩余天数(负数表示超期) */
+ remainDays: number;
+ /** 订单总量 */
totalQty: number;
- completedQty: number;
- /** 校验合格数量(合格率用) */
+ /** 已完成量 */
doneQty: number;
+ /** 完成度百分比 0-100 */
+ progress: number;
+ /** 已完成工序数 */
+ processDone: number;
+ /** 总工序数 */
+ processTotal: number;
currentProcess: string;
status: string;
+ /** 该订单已沉淀的工序追溯记录数(体现可追溯) */
+ traceStepCount: number;
+ /** 是否全程可追溯 */
+ traceable: boolean;
}
-/** 报警项 */
-export interface AlarmItem {
- id: string;
- level: AlarmLevel;
- type: AlarmType;
- target: string;
- message: string;
- time: string;
- status: AlarmStatus;
+/** 工件追溯:单道工序实绩 */
+export interface TraceStep {
+ processCode: number;
+ processName: string;
+ stationNo: number;
+ operator: string;
+ result: string;
+ startedAt: string;
+ endedAt: string;
+ torqueCount: number;
+ torqueNg: number;
+ avgStrain: number;
+}
+
+/** 工件追溯:工件级完整时间线 */
+export interface TraceItem {
+ sn: string;
+ orderNo: string;
+ productName: string;
+ status: string;
+ onlineAt: string;
+ doneAt: string;
+ durationMin: number;
+ stepCount: number;
+ okCount: number;
+ ngCount: number;
+ steps: TraceStep[];
}
/** 趋势数据 */
@@ -98,12 +148,17 @@ export interface TrendData {
production: TrendSeries[];
}
-/** 看板完整数据快照 */
+/** 看板完整数据快照(演示数据与真实接口共用同一结构) */
export interface DashboardData {
+ /** 后端快照时间(用于识别心跳/空载荷) */
+ updatedAt?: string;
production: ProductionOverview;
warehouse: WarehouseOverview;
equipment: Station[];
progress: ProductionProgress;
- alarms: AlarmItem[];
+ /** 追溯轮播数据(最近完工工件) */
+ traces: TraceItem[];
trends: TrendData;
-}
\ No newline at end of file
+ /** 是否演示数据:真实业务数据为空时自动降级,角落低调标注 */
+ isDemo: boolean;
+}
diff --git a/bj_power_dashboard/start.bat b/bj_power_dashboard/start.bat
new file mode 100644
index 0000000..bdd582d
--- /dev/null
+++ b/bj_power_dashboard/start.bat
@@ -0,0 +1,21 @@
+@echo off
+rem ============================================================
+rem 北京电力 · 产线数字看板 一键启动
+rem 依赖:MES(8888) 与 Redis 已启动;浏览器将自动打开看板
+rem 演示说明:MES 业务库为空时自动展示演示数据(真实投产数据自动替换)
+rem ============================================================
+chcp 65001 >nul
+cd /d "%~dp0"
+
+where node >nul 2>nul
+if errorlevel 1 (
+ echo [错误] 未找到 Node.js,请先安装 Node 18+ 并加入 PATH
+ pause
+ exit /b 1
+)
+
+echo 正在启动产线看板... 浏览器会自动打开 http://127.0.0.1:5173
+echo 按 Ctrl+C 可停止服务,本窗口请保持开启。
+
+call npm run dev
+pause
diff --git a/bj_power_dashboard/tsconfig.tsbuildinfo b/bj_power_dashboard/tsconfig.tsbuildinfo
index cd1cd3e..8d2fa0f 100644
--- a/bj_power_dashboard/tsconfig.tsbuildinfo
+++ b/bj_power_dashboard/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/mock.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/echart.tsx","./src/components/errorretry.tsx","./src/hooks/usedashboarddata.ts","./src/pages/alarmboard.tsx","./src/pages/linemodel.tsx","./src/pages/productionboard.tsx","./src/pages/warehouseboard.tsx","./src/services/dashboardservice.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/mock.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/echart.tsx","./src/components/errorretry.tsx","./src/hooks/usedashboarddata.ts","./src/pages/linemodel.tsx","./src/pages/productionboard.tsx","./src/pages/warehouseboard.tsx","./src/services/dashboardservice.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/bj_power_dashboard/vite.config.ts b/bj_power_dashboard/vite.config.ts
index 6b261de..2253eac 100644
--- a/bj_power_dashboard/vite.config.ts
+++ b/bj_power_dashboard/vite.config.ts
@@ -14,10 +14,13 @@ export default defineConfig({
},
server: {
port: 5173,
+ // 大屏项目:启动后自动拉起浏览器,避免每次手动输地址
+ open: true,
+ host: true,
proxy: {
- // 开发环境代理到 MES,避免跨域
+ // 开发环境代理到 MES(实际端口 8888,早期写 8000 导致连不上)
'/api': {
- target: process.env.VITE_MES_BASE_URL || 'http://127.0.0.1:8000',
+ target: process.env.VITE_MES_BASE_URL || 'http://127.0.0.1:8888',
changeOrigin: true,
},
},
diff --git a/bj_power_mes/bj_power_mes.exe.bak_120023 b/bj_power_mes/bj_power_mes.exe.bak_120023
new file mode 100644
index 0000000..9a4e164
Binary files /dev/null and b/bj_power_mes/bj_power_mes.exe.bak_120023 differ
diff --git a/bj_power_mes/ent/dailyplan.go b/bj_power_mes/ent/dailyplan.go
index 483a779..55d95b0 100644
--- a/bj_power_mes/ent/dailyplan.go
+++ b/bj_power_mes/ent/dailyplan.go
@@ -4,6 +4,7 @@ package ent
import (
"bj_power_mes/ent/dailyplan"
+ "encoding/json"
"fmt"
"strings"
"time"
@@ -25,6 +26,8 @@ type DailyPlan struct {
PlanQty int `json:"planQty,omitempty"`
// 已完成数量
CompletedQty int `json:"completedQty,omitempty"`
+ // 送达接驳台列表 DOCK01..20
+ DockCodes []string `json:"dockCodes,omitempty"`
// Status holds the value of the "status" field.
Status string `json:"status,omitempty"`
// Operator holds the value of the "operator" field.
@@ -41,6 +44,8 @@ func (*DailyPlan) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
+ case dailyplan.FieldDockCodes:
+ values[i] = new([]byte)
case dailyplan.FieldID, dailyplan.FieldPlanQty, dailyplan.FieldCompletedQty:
values[i] = new(sql.NullInt64)
case dailyplan.FieldOrderNo, dailyplan.FieldPlanDate, dailyplan.FieldStatus, dailyplan.FieldOperator:
@@ -92,6 +97,14 @@ func (_m *DailyPlan) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.CompletedQty = int(value.Int64)
}
+ case dailyplan.FieldDockCodes:
+ if value, ok := values[i].(*[]byte); !ok {
+ return fmt.Errorf("unexpected type %T for field dockCodes", values[i])
+ } else if value != nil && len(*value) > 0 {
+ if err := json.Unmarshal(*value, &_m.DockCodes); err != nil {
+ return fmt.Errorf("unmarshal field dockCodes: %w", err)
+ }
+ }
case dailyplan.FieldStatus:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field status", values[i])
@@ -165,6 +178,9 @@ func (_m *DailyPlan) String() string {
builder.WriteString("completedQty=")
builder.WriteString(fmt.Sprintf("%v", _m.CompletedQty))
builder.WriteString(", ")
+ builder.WriteString("dockCodes=")
+ builder.WriteString(fmt.Sprintf("%v", _m.DockCodes))
+ builder.WriteString(", ")
builder.WriteString("status=")
builder.WriteString(_m.Status)
builder.WriteString(", ")
diff --git a/bj_power_mes/ent/dailyplan/dailyplan.go b/bj_power_mes/ent/dailyplan/dailyplan.go
index a4f4e5d..3ec9717 100644
--- a/bj_power_mes/ent/dailyplan/dailyplan.go
+++ b/bj_power_mes/ent/dailyplan/dailyplan.go
@@ -21,6 +21,8 @@ const (
FieldPlanQty = "plan_qty"
// FieldCompletedQty holds the string denoting the completedqty field in the database.
FieldCompletedQty = "completed_qty"
+ // FieldDockCodes holds the string denoting the dockcodes field in the database.
+ FieldDockCodes = "dock_codes"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldOperator holds the string denoting the operator field in the database.
@@ -40,6 +42,7 @@ var Columns = []string{
FieldPlanDate,
FieldPlanQty,
FieldCompletedQty,
+ FieldDockCodes,
FieldStatus,
FieldOperator,
FieldCreatedAt,
diff --git a/bj_power_mes/ent/dailyplan/where.go b/bj_power_mes/ent/dailyplan/where.go
index 3c979eb..adcc327 100644
--- a/bj_power_mes/ent/dailyplan/where.go
+++ b/bj_power_mes/ent/dailyplan/where.go
@@ -304,6 +304,16 @@ func CompletedQtyLTE(v int) predicate.DailyPlan {
return predicate.DailyPlan(sql.FieldLTE(FieldCompletedQty, v))
}
+// DockCodesIsNil applies the IsNil predicate on the "dockCodes" field.
+func DockCodesIsNil() predicate.DailyPlan {
+ return predicate.DailyPlan(sql.FieldIsNull(FieldDockCodes))
+}
+
+// DockCodesNotNil applies the NotNil predicate on the "dockCodes" field.
+func DockCodesNotNil() predicate.DailyPlan {
+ return predicate.DailyPlan(sql.FieldNotNull(FieldDockCodes))
+}
+
// StatusEQ applies the EQ predicate on the "status" field.
func StatusEQ(v string) predicate.DailyPlan {
return predicate.DailyPlan(sql.FieldEQ(FieldStatus, v))
diff --git a/bj_power_mes/ent/dailyplan_create.go b/bj_power_mes/ent/dailyplan_create.go
index 57df18b..1720a61 100644
--- a/bj_power_mes/ent/dailyplan_create.go
+++ b/bj_power_mes/ent/dailyplan_create.go
@@ -60,6 +60,12 @@ func (_c *DailyPlanCreate) SetNillableCompletedQty(v *int) *DailyPlanCreate {
return _c
}
+// SetDockCodes sets the "dockCodes" field.
+func (_c *DailyPlanCreate) SetDockCodes(v []string) *DailyPlanCreate {
+ _c.mutation.SetDockCodes(v)
+ return _c
+}
+
// SetStatus sets the "status" field.
func (_c *DailyPlanCreate) SetStatus(v string) *DailyPlanCreate {
_c.mutation.SetStatus(v)
@@ -279,6 +285,10 @@ func (_c *DailyPlanCreate) createSpec() (*DailyPlan, *sqlgraph.CreateSpec) {
_spec.SetField(dailyplan.FieldCompletedQty, field.TypeInt, value)
_node.CompletedQty = value
}
+ if value, ok := _c.mutation.DockCodes(); ok {
+ _spec.SetField(dailyplan.FieldDockCodes, field.TypeJSON, value)
+ _node.DockCodes = value
+ }
if value, ok := _c.mutation.Status(); ok {
_spec.SetField(dailyplan.FieldStatus, field.TypeString, value)
_node.Status = value
diff --git a/bj_power_mes/ent/dailyplan_update.go b/bj_power_mes/ent/dailyplan_update.go
index f8df55e..1ae4b14 100644
--- a/bj_power_mes/ent/dailyplan_update.go
+++ b/bj_power_mes/ent/dailyplan_update.go
@@ -12,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/dialect/sql/sqljson"
"entgo.io/ent/schema/field"
)
@@ -99,6 +100,24 @@ func (_u *DailyPlanUpdate) AddCompletedQty(v int) *DailyPlanUpdate {
return _u
}
+// SetDockCodes sets the "dockCodes" field.
+func (_u *DailyPlanUpdate) SetDockCodes(v []string) *DailyPlanUpdate {
+ _u.mutation.SetDockCodes(v)
+ return _u
+}
+
+// AppendDockCodes appends value to the "dockCodes" field.
+func (_u *DailyPlanUpdate) AppendDockCodes(v []string) *DailyPlanUpdate {
+ _u.mutation.AppendDockCodes(v)
+ return _u
+}
+
+// ClearDockCodes clears the value of the "dockCodes" field.
+func (_u *DailyPlanUpdate) ClearDockCodes() *DailyPlanUpdate {
+ _u.mutation.ClearDockCodes()
+ return _u
+}
+
// SetStatus sets the "status" field.
func (_u *DailyPlanUpdate) SetStatus(v string) *DailyPlanUpdate {
_u.mutation.SetStatus(v)
@@ -241,6 +260,17 @@ func (_u *DailyPlanUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedCompletedQty(); ok {
_spec.AddField(dailyplan.FieldCompletedQty, field.TypeInt, value)
}
+ if value, ok := _u.mutation.DockCodes(); ok {
+ _spec.SetField(dailyplan.FieldDockCodes, field.TypeJSON, value)
+ }
+ if value, ok := _u.mutation.AppendedDockCodes(); ok {
+ _spec.AddModifier(func(u *sql.UpdateBuilder) {
+ sqljson.Append(u, dailyplan.FieldDockCodes, value)
+ })
+ }
+ if _u.mutation.DockCodesCleared() {
+ _spec.ClearField(dailyplan.FieldDockCodes, field.TypeJSON)
+ }
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(dailyplan.FieldStatus, field.TypeString, value)
}
@@ -345,6 +375,24 @@ func (_u *DailyPlanUpdateOne) AddCompletedQty(v int) *DailyPlanUpdateOne {
return _u
}
+// SetDockCodes sets the "dockCodes" field.
+func (_u *DailyPlanUpdateOne) SetDockCodes(v []string) *DailyPlanUpdateOne {
+ _u.mutation.SetDockCodes(v)
+ return _u
+}
+
+// AppendDockCodes appends value to the "dockCodes" field.
+func (_u *DailyPlanUpdateOne) AppendDockCodes(v []string) *DailyPlanUpdateOne {
+ _u.mutation.AppendDockCodes(v)
+ return _u
+}
+
+// ClearDockCodes clears the value of the "dockCodes" field.
+func (_u *DailyPlanUpdateOne) ClearDockCodes() *DailyPlanUpdateOne {
+ _u.mutation.ClearDockCodes()
+ return _u
+}
+
// SetStatus sets the "status" field.
func (_u *DailyPlanUpdateOne) SetStatus(v string) *DailyPlanUpdateOne {
_u.mutation.SetStatus(v)
@@ -517,6 +565,17 @@ func (_u *DailyPlanUpdateOne) sqlSave(ctx context.Context) (_node *DailyPlan, er
if value, ok := _u.mutation.AddedCompletedQty(); ok {
_spec.AddField(dailyplan.FieldCompletedQty, field.TypeInt, value)
}
+ if value, ok := _u.mutation.DockCodes(); ok {
+ _spec.SetField(dailyplan.FieldDockCodes, field.TypeJSON, value)
+ }
+ if value, ok := _u.mutation.AppendedDockCodes(); ok {
+ _spec.AddModifier(func(u *sql.UpdateBuilder) {
+ sqljson.Append(u, dailyplan.FieldDockCodes, value)
+ })
+ }
+ if _u.mutation.DockCodesCleared() {
+ _spec.ClearField(dailyplan.FieldDockCodes, field.TypeJSON)
+ }
if value, ok := _u.mutation.Status(); ok {
_spec.SetField(dailyplan.FieldStatus, field.TypeString, value)
}
diff --git a/bj_power_mes/ent/migrate/schema.go b/bj_power_mes/ent/migrate/schema.go
index 97825a4..4377e7e 100644
--- a/bj_power_mes/ent/migrate/schema.go
+++ b/bj_power_mes/ent/migrate/schema.go
@@ -73,6 +73,7 @@ var (
{Name: "plan_date", Type: field.TypeString, Size: 10},
{Name: "plan_qty", Type: field.TypeInt, Default: 0},
{Name: "completed_qty", Type: field.TypeInt, Default: 0},
+ {Name: "dock_codes", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "status", Type: field.TypeString, Size: 20, Default: "PENDING"},
{Name: "operator", Type: field.TypeString, Size: 64, Default: ""},
{Name: "created_at", Type: field.TypeTime},
diff --git a/bj_power_mes/ent/mutation.go b/bj_power_mes/ent/mutation.go
index 48d8ae0..6bed5b8 100644
--- a/bj_power_mes/ent/mutation.go
+++ b/bj_power_mes/ent/mutation.go
@@ -1829,6 +1829,8 @@ type DailyPlanMutation struct {
addplanQty *int
completedQty *int
addcompletedQty *int
+ dockCodes *[]string
+ appenddockCodes []string
status *string
operator *string
createdAt *time.Time
@@ -2127,6 +2129,71 @@ func (m *DailyPlanMutation) ResetCompletedQty() {
m.addcompletedQty = nil
}
+// SetDockCodes sets the "dockCodes" field.
+func (m *DailyPlanMutation) SetDockCodes(s []string) {
+ m.dockCodes = &s
+ m.appenddockCodes = nil
+}
+
+// DockCodes returns the value of the "dockCodes" field in the mutation.
+func (m *DailyPlanMutation) DockCodes() (r []string, exists bool) {
+ v := m.dockCodes
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldDockCodes returns the old "dockCodes" field's value of the DailyPlan entity.
+// If the DailyPlan object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *DailyPlanMutation) OldDockCodes(ctx context.Context) (v []string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldDockCodes is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldDockCodes requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldDockCodes: %w", err)
+ }
+ return oldValue.DockCodes, nil
+}
+
+// AppendDockCodes adds s to the "dockCodes" field.
+func (m *DailyPlanMutation) AppendDockCodes(s []string) {
+ m.appenddockCodes = append(m.appenddockCodes, s...)
+}
+
+// AppendedDockCodes returns the list of values that were appended to the "dockCodes" field in this mutation.
+func (m *DailyPlanMutation) AppendedDockCodes() ([]string, bool) {
+ if len(m.appenddockCodes) == 0 {
+ return nil, false
+ }
+ return m.appenddockCodes, true
+}
+
+// ClearDockCodes clears the value of the "dockCodes" field.
+func (m *DailyPlanMutation) ClearDockCodes() {
+ m.dockCodes = nil
+ m.appenddockCodes = nil
+ m.clearedFields[dailyplan.FieldDockCodes] = struct{}{}
+}
+
+// DockCodesCleared returns if the "dockCodes" field was cleared in this mutation.
+func (m *DailyPlanMutation) DockCodesCleared() bool {
+ _, ok := m.clearedFields[dailyplan.FieldDockCodes]
+ return ok
+}
+
+// ResetDockCodes resets all changes to the "dockCodes" field.
+func (m *DailyPlanMutation) ResetDockCodes() {
+ m.dockCodes = nil
+ m.appenddockCodes = nil
+ delete(m.clearedFields, dailyplan.FieldDockCodes)
+}
+
// SetStatus sets the "status" field.
func (m *DailyPlanMutation) SetStatus(s string) {
m.status = &s
@@ -2318,7 +2385,7 @@ func (m *DailyPlanMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *DailyPlanMutation) Fields() []string {
- fields := make([]string, 0, 8)
+ fields := make([]string, 0, 9)
if m.orderNo != nil {
fields = append(fields, dailyplan.FieldOrderNo)
}
@@ -2331,6 +2398,9 @@ func (m *DailyPlanMutation) Fields() []string {
if m.completedQty != nil {
fields = append(fields, dailyplan.FieldCompletedQty)
}
+ if m.dockCodes != nil {
+ fields = append(fields, dailyplan.FieldDockCodes)
+ }
if m.status != nil {
fields = append(fields, dailyplan.FieldStatus)
}
@@ -2359,6 +2429,8 @@ func (m *DailyPlanMutation) Field(name string) (ent.Value, bool) {
return m.PlanQty()
case dailyplan.FieldCompletedQty:
return m.CompletedQty()
+ case dailyplan.FieldDockCodes:
+ return m.DockCodes()
case dailyplan.FieldStatus:
return m.Status()
case dailyplan.FieldOperator:
@@ -2384,6 +2456,8 @@ func (m *DailyPlanMutation) OldField(ctx context.Context, name string) (ent.Valu
return m.OldPlanQty(ctx)
case dailyplan.FieldCompletedQty:
return m.OldCompletedQty(ctx)
+ case dailyplan.FieldDockCodes:
+ return m.OldDockCodes(ctx)
case dailyplan.FieldStatus:
return m.OldStatus(ctx)
case dailyplan.FieldOperator:
@@ -2429,6 +2503,13 @@ func (m *DailyPlanMutation) SetField(name string, value ent.Value) error {
}
m.SetCompletedQty(v)
return nil
+ case dailyplan.FieldDockCodes:
+ v, ok := value.([]string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetDockCodes(v)
+ return nil
case dailyplan.FieldStatus:
v, ok := value.(string)
if !ok {
@@ -2514,6 +2595,9 @@ func (m *DailyPlanMutation) AddField(name string, value ent.Value) error {
// mutation.
func (m *DailyPlanMutation) ClearedFields() []string {
var fields []string
+ if m.FieldCleared(dailyplan.FieldDockCodes) {
+ fields = append(fields, dailyplan.FieldDockCodes)
+ }
if m.FieldCleared(dailyplan.FieldUpdatedAt) {
fields = append(fields, dailyplan.FieldUpdatedAt)
}
@@ -2531,6 +2615,9 @@ func (m *DailyPlanMutation) FieldCleared(name string) bool {
// error if the field is not defined in the schema.
func (m *DailyPlanMutation) ClearField(name string) error {
switch name {
+ case dailyplan.FieldDockCodes:
+ m.ClearDockCodes()
+ return nil
case dailyplan.FieldUpdatedAt:
m.ClearUpdatedAt()
return nil
@@ -2554,6 +2641,9 @@ func (m *DailyPlanMutation) ResetField(name string) error {
case dailyplan.FieldCompletedQty:
m.ResetCompletedQty()
return nil
+ case dailyplan.FieldDockCodes:
+ m.ResetDockCodes()
+ return nil
case dailyplan.FieldStatus:
m.ResetStatus()
return nil
diff --git a/bj_power_mes/ent/runtime.go b/bj_power_mes/ent/runtime.go
index 730f97e..db6844a 100644
--- a/bj_power_mes/ent/runtime.go
+++ b/bj_power_mes/ent/runtime.go
@@ -142,23 +142,23 @@ func init() {
// dailyplan.DefaultCompletedQty holds the default value on creation for the completedQty field.
dailyplan.DefaultCompletedQty = dailyplanDescCompletedQty.Default.(int)
// dailyplanDescStatus is the schema descriptor for status field.
- dailyplanDescStatus := dailyplanFields[5].Descriptor()
+ dailyplanDescStatus := dailyplanFields[6].Descriptor()
// dailyplan.DefaultStatus holds the default value on creation for the status field.
dailyplan.DefaultStatus = dailyplanDescStatus.Default.(string)
// dailyplan.StatusValidator is a validator for the "status" field. It is called by the builders before save.
dailyplan.StatusValidator = dailyplanDescStatus.Validators[0].(func(string) error)
// dailyplanDescOperator is the schema descriptor for operator field.
- dailyplanDescOperator := dailyplanFields[6].Descriptor()
+ dailyplanDescOperator := dailyplanFields[7].Descriptor()
// dailyplan.DefaultOperator holds the default value on creation for the operator field.
dailyplan.DefaultOperator = dailyplanDescOperator.Default.(string)
// dailyplan.OperatorValidator is a validator for the "operator" field. It is called by the builders before save.
dailyplan.OperatorValidator = dailyplanDescOperator.Validators[0].(func(string) error)
// dailyplanDescCreatedAt is the schema descriptor for createdAt field.
- dailyplanDescCreatedAt := dailyplanFields[7].Descriptor()
+ dailyplanDescCreatedAt := dailyplanFields[8].Descriptor()
// dailyplan.DefaultCreatedAt holds the default value on creation for the createdAt field.
dailyplan.DefaultCreatedAt = dailyplanDescCreatedAt.Default.(func() time.Time)
// dailyplanDescUpdatedAt is the schema descriptor for updatedAt field.
- dailyplanDescUpdatedAt := dailyplanFields[8].Descriptor()
+ dailyplanDescUpdatedAt := dailyplanFields[9].Descriptor()
// dailyplan.DefaultUpdatedAt holds the default value on creation for the updatedAt field.
dailyplan.DefaultUpdatedAt = dailyplanDescUpdatedAt.Default.(func() time.Time)
// dailyplan.UpdateDefaultUpdatedAt holds the default value on update for the updatedAt field.
diff --git a/bj_power_mes/frontend/src/help.js b/bj_power_mes/frontend/src/help.js
index f053e39..7d5e488 100644
--- a/bj_power_mes/frontend/src/help.js
+++ b/bj_power_mes/frontend/src/help.js
@@ -52,6 +52,7 @@ export const helpDailyPlan = {
{ name: '工单号', source: '下拉选择。来自"工单管理"已建的工单。', purpose: '该排产对应哪个生产工单', fill: '选择工单号;也可手动输入未列出的工单号' },
{ name: '排产日期', source: '日历选择。', purpose: '计划在哪一天生产', fill: '选具体日期,如 2026-08-29' },
{ name: '计划数量', source: '手工填写整数。', purpose: '这一天计划生产多少件', fill: '整数≥1;注意:该工单所有日排产的数量合计不能超过工单总数量' },
+ { name: '送达接驳台', source: '排产时人工确认(可多选 DOCK01~20产线 / DOCK21库房)。', purpose: '该工单物料经 AGV 送到哪些接驳台(DOCK n↔工位n),备料单据此填 target_dock', fill: '默认推荐该工单上次使用的接驳台,可修改;至少选一个。AGV 按把这些料送到对应工位' },
{ name: '已完成', source: '系统自动累计。只读。', purpose: '该排产已实际完成多少件', fill: '无需填写' },
{ name: '状态', source: '系统维护。只读。', purpose: '排产执行状态(待排产/已确认)', fill: '无需填写' }
]
@@ -153,39 +154,42 @@ export const helpTrace = {
]
}
-export const helpRbac = {
- title: '角色权限管理(用户/角色/权限)',
+export const helpAccount = {
+ title: '账号管理',
overview:
- '管控谁能登录、能看哪些菜单。建议只给操作员分配其工位所需菜单权限。\n登录 token 有效期短(1800秒),超时自动失效需重新登录。',
+ '管理可登录 MES 的账号:新建、编辑、停用,并为其分配角色(角色决定菜单与按钮权限)。\n建议只给操作员分配其工位所需菜单权限。系统内置 admin 账号不可修改/删除。',
sections: [
{
- title: '用户(新建/编辑)',
+ title: '列表',
fields: [
- { name: '用户名', source: '手工填写。', purpose: '登录账号,唯一', fill: '如 operator1' },
- { name: '密码', source: '手工填写,保存时加密存储。', purpose: '登录密码', fill: '≥6位;编辑时留空=不修改' },
+ { name: '状态', source: '行内开关。', purpose: '停用后该账号无法登录', fill: '直接拨动开关;内置账号(admin)已锁定' },
+ { name: '工位终端/允许工位', source: '账号设置。', purpose: '是否允许登录工位终端及各终端允许的工位号', fill: '未开通=不能登录工位终端;允许工位留空=全部' }
+ ]
+ },
+ {
+ title: '新建 / 编辑',
+ fields: [
+ { name: '用户名', source: '手工填写。', purpose: '登录账号,唯一;创建后不可改', fill: '如 operator1' },
+ { name: '密码', source: '手工填写,保存时加密存储。', purpose: '登录密码', fill: '≥6位且不能纯数字;编辑时留空=不修改' },
{ name: '姓名', source: '手工填写。', purpose: '真实姓名,操作日志追溯用', fill: '如 张三' },
- { name: '角色', source: '下拉选择。来自"角色"页已建的角色。', purpose: '决定该用户的菜单权限', fill: '选择已建角色' },
- { name: '状态', source: '选择。', purpose: '禁用后无法登录', fill: '启用 / 禁用' }
+ { name: '角色', source: '下拉选择。来自"角色管理"页。', purpose: '决定该账号的菜单与功能权限', fill: '选择已建角色' },
+ { name: '状态', source: '下拉选择。', purpose: '停用后无法登录', fill: '启用 / 停用' }
]
- },
+ }
+ ]
+}
+
+export const helpRole = {
+ title: '角色管理',
+ overview:
+ '角色决定账号能看哪些菜单(勾选菜单)与能用哪些功能(勾选其下按钮),勾选互不影响。\n内置角色:超级管理员(全部权限)、生产操作员、质检员——不可删除;超级管理员权限不可编辑。',
+ sections: [
{
- title: '角色(新建/编辑)',
+ title: '新增 / 编辑权限',
fields: [
- { name: '名称', source: '手工填写。', purpose: '角色中文名', fill: '如 装配工、质检员' },
- { name: '编码', source: '手工填写。', purpose: '角色唯一编码(SUPER_ADMIN 为内置超级管理员,勿改)', fill: '如 OPERATOR' },
- { name: '备注', source: '手工填写。', purpose: '说明', fill: '可空' },
- { name: '权限', source: '多选。来自"权限"页的权限码。', purpose: '勾选该角色可见的菜单/功能', fill: '勾选所需权限码(如 produce.workorder)' }
- ]
- },
- {
- title: '权限(新增/编辑)',
- overview: '通常由系统预置,一般无需手动新增。',
- fields: [
- { name: '编码', source: '系统预置/手工。', purpose: '权限唯一码', fill: '如 produce.workorder' },
- { name: '名称', source: '手工填写。', purpose: '权限名', fill: '如 工单管理' },
- { name: '类型', source: '手工填写。', purpose: 'MENU=菜单权限 / API=接口权限', fill: '如 MENU' },
- { name: '路径', source: '手工填写。', purpose: 'MENU 对应的前端路由路径', fill: '如 /work-order' },
- { name: '排序', source: '手工填写。', purpose: '菜单显示顺序', fill: '数字,越小越靠前' }
+ { name: '角色名称', source: '手工填写。', purpose: '角色中文名', fill: '如 库房管理员' },
+ { name: '角色编码', source: '手工填写。', purpose: '角色唯一编码,创建后不可改', fill: '如 intro-manager' },
+ { name: '权限树', source: '勾选。来自系统预置的权限菜单与按钮。', purpose: '决定该角色可见菜单与可用按钮', fill: '勾选菜单=可见;勾选按钮=可用(两者独立)' }
]
}
]
@@ -194,11 +198,17 @@ export const helpRbac = {
export const helpEventLog = {
title: '操作日志',
overview:
- '记录所有关键业务操作(建工单、物料清单维护、备料、PLC下发、拧紧、扫码报工等),按工单号、操作人、实体可追溯。\n数据由系统在各操作发生时自动写入,本页只读查询。',
- fields: [
- { name: '工单号', source: '筛选条件。数据来自日志中的 workOrderNo 字段。', purpose: '按工单号过滤该工单的所有操作', fill: '输入工单号,可空=全部' },
- { name: '操作人', source: '筛选条件。数据来自日志中的 operator 字段。', purpose: '按操作人过滤', fill: '输入姓名/账号,可空=全部' },
- { name: '类型', source: '筛选条件。数据来自日志中的事件类型字段。', purpose: '按事件类型过滤', fill: '如 EVENT_WORK_ORDER / EVENT_TORQUE,可空' }
+ '审计查看关键业务操作(工单/日排产/物料清单/备料/下发/拧紧/扫码报工/完工等),由系统自动写入,本页只读。\n支持按业务域类型、工单号、操作人、时间范围筛选,分页查看。',
+ sections: [
+ {
+ title: '查询区',
+ fields: [
+ { name: '事件类型', source: '下拉。', purpose: '按业务域过滤(工单/日排产/报工…)', fill: '选一族或留空=全部' },
+ { name: '工单号', source: '输入。', purpose: '只看某工单的全部操作', fill: '可空=全部' },
+ { name: '操作人', source: '输入。', purpose: '只看某人的操作', fill: '可空=全部' },
+ { name: '时间范围', source: '日历选择。', purpose: '限定发生时间区间', fill: '不选=全部时间' }
+ ]
+ }
]
}
diff --git a/bj_power_mes/frontend/src/layouts/MainLayout.vue b/bj_power_mes/frontend/src/layouts/MainLayout.vue
index 7f1c9f6..31aa6f4 100644
--- a/bj_power_mes/frontend/src/layouts/MainLayout.vue
+++ b/bj_power_mes/frontend/src/layouts/MainLayout.vue
@@ -8,13 +8,19 @@
MES 产线控制
-
-
-
- {{ m.name }}
-
+
+
+
+
+ {{ g.title }}
+
+
+
+ {{ m.name }}
+
+
-
diff --git a/bj_power_wms_client/frontend/src/pages/Agv.vue b/bj_power_wms_client/frontend/src/pages/Agv.vue
new file mode 100644
index 0000000..6a1db33
--- /dev/null
+++ b/bj_power_wms_client/frontend/src/pages/Agv.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+
+
+
+ 刷新
+ ● 绿色 = 空闲(无托盘) / 橙色 = 有托盘;AGV 到达前需确认目标口空闲
+
+
+
+
{{ d.dockCode }}
+
{{ d.name }}
+
{{ d.hasPallet ? '有托盘' : '空闲' }}
+
托盘号 {{ d.palletRef }}
+
+
+
+
+
+
+
+
+
+
+ 刷新状态
+ 查询
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+
+
+
+ {{ m.materialName || m.materialCode }} ×{{ m.qty }}
+
+
+ -
+
+
+
+ {{ row.dispatchedAt ? fmtTime(row.dispatchedAt) : '-' }}
+
+
+ {{ row.arrivedAt ? fmtTime(row.arrivedAt) : '-' }}
+
+
+
+
+
+ { taskPage.current = p; loadTasks() }"
+ @size-change="(s) => { taskPage.size = s; taskPage.current = 1; loadTasks() }" />
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 勾选待发料行 → 行内可改目标接驳台 → 下发
+
+
+
+
+
+ 批量设 DOCK01
+ 批量设 DOCK02
+ 下发选中
+ 将生成:{{ groupPreview.map((g) => g.dock + '×' + g.n).join(',') }}
+
+
+
+
+
+
+
+
+
+
+
+ changeDock(row, v)">
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+ { mrPage.current = p; loadMrs() }"
+ @size-change="(s) => { mrPage.size = s; mrPage.current = 1; loadMrs() }" />
+
+
+
+
+
+
diff --git a/bj_power_wms_client/frontend/src/pages/Dashboard.vue b/bj_power_wms_client/frontend/src/pages/Dashboard.vue
index abcab11..b9a675a 100644
--- a/bj_power_wms_client/frontend/src/pages/Dashboard.vue
+++ b/bj_power_wms_client/frontend/src/pages/Dashboard.vue
@@ -34,8 +34,8 @@ const entries = [
{ path: '/inventory', title: '库存查询', icon: Search },
{ path: '/inspection', title: '质量检验', icon: CircleCheck },
{ path: '/stocktake', title: '库存盘点', icon: List },
- { path: '/semi', title: '半成品/成品', icon: Box },
- { path: '/ledger', title: '备料台账', icon: Tickets },
+ { path: '/semi', title: '半成品管理', icon: Box },
+ { path: '/ledger', title: '工单备料台账', icon: Tickets },
{ path: '/zone', title: '区域维护', icon: Location },
{ path: '/material', title: '物料档案', icon: Goods },
{ path: '/display', title: '免登录大屏', icon: Monitor }
diff --git a/bj_power_wms_client/frontend/src/pages/EventLog.vue b/bj_power_wms_client/frontend/src/pages/EventLog.vue
new file mode 100644
index 0000000..92628d0
--- /dev/null
+++ b/bj_power_wms_client/frontend/src/pages/EventLog.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+
+ {{ familyName(row.eventType) }}
+ {{ row.eventType }}
+
+
+
+
+ {{ entityName(row.entityType) }}
+
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bj_power_wms_client/frontend/src/pages/Inventory.vue b/bj_power_wms_client/frontend/src/pages/Inventory.vue
index d4ce974..5633e9a 100644
--- a/bj_power_wms_client/frontend/src/pages/Inventory.vue
+++ b/bj_power_wms_client/frontend/src/pages/Inventory.vue
@@ -11,10 +11,11 @@ import { useRouter } from 'vue-router'
const router = useRouter()
-// 视图切换:库存明细(按物料聚合) / 区域汇总
-const viewTab = ref('detail')
+// 视图切换:物料汇总(一物料一行) / 库存明细(物料×区域×质量) / 区域汇总
+// 物料汇总为默认首视图:快速回答"这个物料总共有多少"
+const viewTab = ref('material')
-// 默认近3个月的起始日期(当前月份往前推3个月)
+// 默认近3个月的起始日期(当前月份往前推3个月,仅库存明细视图生效)
function defaultStart() {
const d = new Date()
d.setMonth(d.getMonth() - 3)
@@ -50,8 +51,40 @@ async function loadZones() {
}
}
-// 主列表 = 按物料聚合查询
+// 按当前视图分发加载:material→物料汇总、detail→库存明细、summary→区域汇总
async function load() {
+ if (viewTab.value === 'material') return loadMaterial()
+ if (viewTab.value === 'summary') return loadSummary()
+ return loadDetail()
+}
+
+/* ---------- 视图一:物料汇总(按物料聚合全部区域/质量,一物料一行) ---------- */
+const matLoading = ref(false)
+const matRows = ref([])
+const matTotal = ref(0)
+const matPage = reactive({ current: 1, size: 20 })
+
+async function loadMaterial() {
+ matLoading.value = true
+ try {
+ const data = await request.get('/stock/material-summary', {
+ params: {
+ materialCode: filters.materialCode.trim(),
+ materialName: filters.materialName.trim(),
+ manageMode: filters.manageMode,
+ page: matPage.current,
+ pageSize: matPage.size
+ }
+ })
+ matRows.value = data?.rows || []
+ matTotal.value = data?.total || 0
+ } finally {
+ matLoading.value = false
+ }
+}
+
+/* ---------- 视图二:库存明细(按物料×区域×质量聚合) ---------- */
+async function loadDetail() {
loading.value = true
try {
const data = await request.get('/stock/query', {
@@ -74,12 +107,31 @@ async function load() {
}
}
+/* ---------- 视图三:区域汇总(各区域 × 质量状态 × 类型) ---------- */
+const summaryLoading = ref(false)
+const summaryRows = ref([])
+async function loadSummary() {
+ summaryLoading.value = true
+ try {
+ const data = await request.get('/stock/zone-summary')
+ summaryRows.value = data?.rows || []
+ } finally {
+ summaryLoading.value = false
+ }
+}
+
function search() {
- page.current = 1
+ if (viewTab.value === 'material') matPage.current = 1
+ else if (viewTab.value === 'summary') { loadSummary(); return }
+ else page.current = 1
load()
}
-// 点击某行 → 抽屉下钻该物料的批次/SN 明细
+function handleTabChange() {
+ load()
+}
+
+// 点击物料/聚合行 → 抽屉下钻该物料的批次/SN 明细
const drawerVisible = ref(false)
const drawerLoading = ref(false)
const drawerTitle = ref('')
@@ -92,12 +144,13 @@ function typeLabel(m) {
return m === 1 ? '结构件' : m === 2 ? '精密件' : '-'
}
+// 兼容两类行:物料汇总行(无区域/质量) 与 库存明细行(带区域/质量)
function openDetails(row) {
detailCond.materialCode = row.materialCode
- detailCond.zoneCode = row.zoneCode
- detailCond.qualityStatus = row.qualityStatus
- detailCond.manageMode = row.manageMode
- drawerTitle.value = `库存明细 · ${row.materialCode} ${row.materialName}`
+ detailCond.zoneCode = row.zoneCode || ''
+ detailCond.qualityStatus = row.qualityStatus || ''
+ detailCond.manageMode = row.manageMode || ''
+ drawerTitle.value = `批次/SN 明细 · ${row.materialCode} ${row.materialName || ''}`
detailPage.current = 1
drawerVisible.value = true
loadDetails()
@@ -137,9 +190,18 @@ function gotoInbound(no) {
router.push({ path: '/inbound', query: { inboundNo: no } })
}
-// 导出:库存汇总全量(当前筛选)导出为 .xlsx
+// 导出:按当前视图导出对应粒度(物料汇总/库存明细)
async function exportCsv() {
try {
+ if (viewTab.value === 'material') {
+ await exportXlsxFetch('/api/stock/export', {
+ view: 'material',
+ materialCode: filters.materialCode.trim(),
+ materialName: filters.materialName.trim(),
+ manageMode: filters.manageMode
+ }, '物料汇总.xlsx')
+ return
+ }
await exportXlsxFetch('/api/stock/export', {
materialCode: filters.materialCode.trim(),
materialName: filters.materialName.trim(),
@@ -154,24 +216,6 @@ async function exportCsv() {
}
}
-// 区域汇总(各区域 × 质量状态 × 类型 的库存数量)
-const summaryLoading = ref(false)
-const summaryRows = ref([])
-async function loadSummary() {
- summaryLoading.value = true
- try {
- const data = await request.get('/stock/zone-summary')
- summaryRows.value = data?.rows || []
- } finally {
- summaryLoading.value = false
- }
-}
-
-function handleTabChange(name) {
- if (name === 'summary') loadSummary()
- else load()
-}
-
onMounted(() => {
loadZones()
load()
@@ -181,130 +225,202 @@ onMounted(() => {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 全部
- 结构件
- 精密件
-
-
-
-
-
-
-
-
-
-
-
- 至
-
-
-
- 查询
- 导出
-
-
-
-
-
-
-
-
-
- {{ row.materialCode }}
-
-
-
- {{ row.materialName || '-' }}
-
-
- {{ row.spec || '-' }}
-
-
-
- {{ typeLabel(row.manageMode) }}
-
-
-
- {{ row.zoneCode || '-' }}
-
-
-
- {{ row.qualityStatus || '未检' }}
-
-
-
-
-
-
- {{ row.availQty }}
-
-
-
-
-
-
-
- {{ row.lastInboundNo }}
-
- -
-
-
-
- {{ fmtTime(row.createdAt) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ row.quality }}
-
-
-
-
-
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+ 全部
+ 结构件
+ 精密件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 至
+
+
+
+
+ 查询
+
+ {{ viewTab === 'material' ? '导出物料汇总' : '导出' }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.materialCode }}
+
+
+
+ {{ row.materialName || '-' }}
+
+
+ {{ row.spec || '-' }}
+
+
+
+ {{ typeLabel(row.manageMode) }}
+
+
+
+
+
+
+ {{ row.availQty }}
+
+
+
+
+ {{ row.qtyQualified }}
+
+
+
+
+ {{ row.qtyPending }}
+
+
+
+
+ {{ row.qtyRejected }}
+
+
+
+ {{ (row.zones || []).join('、') || '-' }}
+
+
+
+
+
+
+ {{ row.lastInboundNo }}
+
+ -
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.materialCode }}
+
+
+
+ {{ row.materialName || '-' }}
+
+
+ {{ row.spec || '-' }}
+
+
+
+ {{ typeLabel(row.manageMode) }}
+
+
+
+ {{ row.zoneCode || '-' }}
+
+
+
+ {{ row.qualityStatus || '未检' }}
+
+
+
+
+
+
+ {{ row.availQty }}
+
+
+
+
+
+
+
+ {{ row.lastInboundNo }}
+
+ -
+
+
+
+ {{ fmtTime(row.createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.quality }}
+
+
+
+
+
+
+
+
@@ -315,6 +431,9 @@ onMounted(() => {
{{ typeLabel(row.manageMode) }}
+
+ {{ row.zoneCode || '未分区' }}
+
@@ -362,4 +481,4 @@ onMounted(() => {
.mb12 { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
.link { color: #409eff; cursor: pointer; }
-
\ No newline at end of file
+
diff --git a/bj_power_wms_client/frontend/src/pages/Ledger.vue b/bj_power_wms_client/frontend/src/pages/Ledger.vue
index fb474a9..633b5d0 100644
--- a/bj_power_wms_client/frontend/src/pages/Ledger.vue
+++ b/bj_power_wms_client/frontend/src/pages/Ledger.vue
@@ -48,8 +48,12 @@ function search() {
onMounted(load)
-function statusTag(s) {
- return s === '领料完结' ? 'success' : s === '进行中' ? 'primary' : 'info'
+// 展示状态由「已出库 vs 需求总量」实时派生,不再直接展示表内"进行中":
+// 领料完结(缺口=0) / 部分领料(有出库、缺口>0) / 待领料(未出过库)
+function statusView(row) {
+ if (row.status === '领料完结') return { text: '领料完结', type: 'success' }
+ if ((row.outQty || 0) > 0) return { text: '部分领料', type: 'primary' }
+ return { text: '待领料', type: 'info' }
}
@@ -71,7 +75,8 @@ function statusTag(s) {
-
+
+
@@ -99,7 +104,7 @@ function statusTag(s) {
- {{ row.status }}
+ {{ statusView(row).text }}
diff --git a/bj_power_wms_client/frontend/src/pages/Outbound.vue b/bj_power_wms_client/frontend/src/pages/Outbound.vue
index 04971d7..a665d9a 100644
--- a/bj_power_wms_client/frontend/src/pages/Outbound.vue
+++ b/bj_power_wms_client/frontend/src/pages/Outbound.vue
@@ -15,7 +15,9 @@ const operator = getRealName()
/* ===================== 备料出库(MES 工单台账) ===================== */
const queryForm = reactive({ orderNo: '' })
+// 目标工位:AGV 配送 toDock 参数,DOCK01~DOCK20 下拉选填(不选默认产线入口 DOCK01)
const targetDock = ref('')
+const dockOptions = Array.from({ length: 20 }, (_, i) => 'DOCK' + String(i + 1).padStart(2, '0'))
const loadingPrep = ref(false)
const queried = ref(false)
const rows = ref([])
@@ -231,7 +233,10 @@ onMounted(loadDicts)
-
+
+
+
查询备料台账
diff --git a/bj_power_wms_client/frontend/src/pages/RoleManage.vue b/bj_power_wms_client/frontend/src/pages/RoleManage.vue
index 1c36dde..c2703e7 100644
--- a/bj_power_wms_client/frontend/src/pages/RoleManage.vue
+++ b/bj_power_wms_client/frontend/src/pages/RoleManage.vue
@@ -181,7 +181,8 @@ function permSummary(arr) {
编辑权限
- 删除
+ 删除
diff --git a/bj_power_wms_client/frontend/src/pages/Stocktake.vue b/bj_power_wms_client/frontend/src/pages/Stocktake.vue
index 9e23bb9..2089449 100644
--- a/bj_power_wms_client/frontend/src/pages/Stocktake.vue
+++ b/bj_power_wms_client/frontend/src/pages/Stocktake.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
@@ -341,72 +308,6 @@ onMounted(() => { loadHistory() })
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 查询
-
-
-
-
-
-
-
- 汇总:各物料总库存
-
-
-
-
-
-
- {{ row.manageMode === 1 ? '结构件' : '精密件' }}
-
-
-
-
-
-
-
-
-
-
-
-
- 明细:物料分布在哪些区域、各多少
-
-
-
-
-
-
-
- {{ row.manageMode === 1 ? '结构件' : '精密件' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -437,7 +338,7 @@ onMounted(() => { loadHistory() })
-
+
\ No newline at end of file
diff --git a/bj_power_wms_client/frontend/src/pages/UserManage.vue b/bj_power_wms_client/frontend/src/pages/UserManage.vue
index a094c09..0bdc7b1 100644
--- a/bj_power_wms_client/frontend/src/pages/UserManage.vue
+++ b/bj_power_wms_client/frontend/src/pages/UserManage.vue
@@ -4,40 +4,71 @@ import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { fmtTime } from '../utils/format'
+import { can } from '../utils/perm'
-const ROLE_LABELS = { admin: '管理员', operator: '库房操作员', inspector: '质检员' }
-const roleOptions = [
- { value: 'admin', label: '管理员' },
- { value: 'operator', label: '库房操作员' },
- { value: 'inspector', label: '质检员' }
-]
+// 角色数据源:实时取自角色表(含自定义角色),code/name 与 RoleManage 同源
+const roles = ref([])
+const roleOptions = computed(() => roles.value.map((r) => ({ value: r.id, label: `${r.name}(${r.code})` })))
+const roleOfId = computed(() => {
+ const m = {}
+ for (const r of roles.value) m[r.id] = r
+ return m
+})
+async function loadRoles() {
+ try {
+ const data = await request.get('/roles/options')
+ roles.value = data?.list || []
+ } catch { /* 无权限时不阻断用户页 */ }
+}
+
+const all = ref([]) // 全量(无分页全量拉取,页面前端分页)
const list = ref([])
-const total = ref(0)
const loading = ref(false)
-const page = reactive({ current: 1, size: 10 })
+const page = reactive({ current: 1, size: 20 })
async function loadUsers() {
loading.value = true
try {
const data = await request.get('/user/list')
- list.value = data?.list || []
- total.value = list.value.length
+ all.value = data?.list || []
+ sliceList()
} finally {
loading.value = false
}
}
+function sliceList() {
+ const start = (page.current - 1) * page.size
+ list.value = all.value.slice(start, start + page.size)
+}
+function onPage() {
+ sliceList()
+}
+
+function roleName(row) {
+ const r = roleOfId.value[row.roleId]
+ if (r) return r.name
+ return row.role || '-'
+}
+function roleTagType(row) {
+ const r = roleOfId.value[row.roleId]
+ if (r && r.code === 'admin') return 'danger'
+ if (row.role === 'admin') return 'danger'
+ if (row.role === 'inspector') return 'warning'
+ return 'primary'
+}
+
/* ---------- 新增 / 编辑 弹窗 ---------- */
const dialogVisible = ref(false)
const editingId = ref(0)
const saving = ref(false)
-const form = reactive({ username: '', realName: '', role: 'operator', dept: '', password: '' })
+const form = reactive({ username: '', realName: '', roleId: 0, dept: '', password: '' })
const pwdAllNum = computed(() => /^[\d]+$/.test(form.password))
function resetForm() {
editingId.value = 0
- Object.assign(form, { username: '', realName: '', role: 'operator', dept: '', password: '' })
+ Object.assign(form, { username: '', realName: '', roleId: 0, dept: '', password: '' })
}
function openCreate() {
resetForm()
@@ -48,7 +79,7 @@ function openEdit(row) {
editingId.value = row.id
form.username = row.username
form.realName = row.realName || ''
- form.role = row.role || 'operator'
+ form.roleId = row.roleId || 0
form.dept = row.dept || ''
form.password = '' // 编辑时不填 = 不改密码
dialogVisible.value = true
@@ -58,13 +89,14 @@ async function save() {
if (!form.username.trim()) return ElMessage.warning('请输入用户名')
if (!editingId.value && !form.password) return ElMessage.warning('请输入初始密码')
if (form.password && form.password.length < 6) return ElMessage.warning('密码长度至少 6 位')
+ if (!form.roleId) return ElMessage.warning('请选择角色')
saving.value = true
try {
if (editingId.value) {
const payload = {
id: editingId.value,
realName: form.realName.trim() || undefined,
- role: form.role,
+ roleId: form.roleId,
dept: form.dept.trim() || undefined,
isActive: undefined
}
@@ -75,7 +107,7 @@ async function save() {
await request.post('/user/create', {
username: form.username.trim(),
realName: form.realName.trim() || undefined,
- role: form.role,
+ roleId: form.roleId,
dept: form.dept.trim() || undefined,
password: form.password
})
@@ -111,7 +143,10 @@ async function removeUser(row) {
} catch { /* 拦截器已提示 */ }
}
-onMounted(loadUsers)
+onMounted(() => {
+ loadRoles()
+ loadUsers()
+})
@@ -120,9 +155,9 @@ onMounted(loadUsers)
账号管理
- 仅管理员可访问;角色决定菜单与功能权限
+ 角色决定菜单与功能权限;系统内置 admin 账号不可改
-
新增账号
+
新增账号
@@ -133,11 +168,9 @@ onMounted(loadUsers)
{{ row.realName || '-' }}
-
+
-
- {{ ROLE_LABELS[row.role] || row.role }}
-
+ {{ roleName(row) }}
@@ -145,7 +178,8 @@ onMounted(loadUsers)
-
+
@@ -153,11 +187,17 @@ onMounted(loadUsers)
- 编辑
- 删除
+ 编辑
+ 删除
+
+
-
+
diff --git a/bj_power_wms_client/frontend/src/router/index.js b/bj_power_wms_client/frontend/src/router/index.js
index 384606f..8604ae5 100644
--- a/bj_power_wms_client/frontend/src/router/index.js
+++ b/bj_power_wms_client/frontend/src/router/index.js
@@ -15,13 +15,15 @@ const routes = [
{ path: 'inventory', name: 'Inventory', component: () => import('../pages/Inventory.vue'), meta: { title: '库存查询', keepAlive: true, componentName: 'Inventory' } },
{ path: 'inspection', name: 'Inspection', component: () => import('../pages/Inspection.vue'), meta: { title: '质量检验', keepAlive: true, componentName: 'Inspection' } },
{ path: 'stocktake', name: 'Stocktake', component: () => import('../pages/Stocktake.vue'), meta: { title: '库存盘点', keepAlive: true, componentName: 'Stocktake' } },
- { path: 'semi', name: 'Semi', component: () => import('../pages/Semi.vue'), meta: { title: '半成品/成品', keepAlive: true, componentName: 'Semi' } },
- { path: 'ledger', name: 'Ledger', component: () => import('../pages/Ledger.vue'), meta: { title: '备料台账', keepAlive: true, componentName: 'Ledger' } },
+ { path: 'semi', name: 'Semi', component: () => import('../pages/Semi.vue'), meta: { title: '半成品管理', keepAlive: true, componentName: 'Semi' } },
+ { path: 'ledger', name: 'Ledger', component: () => import('../pages/Ledger.vue'), meta: { title: '工单备料台账', keepAlive: true, componentName: 'Ledger' } },
+ { path: 'agv', name: 'Agv', component: () => import('../pages/Agv.vue'), meta: { title: 'AGV配送', keepAlive: true, componentName: 'Agv' } },
{ path: 'zone', name: 'Zone', component: () => import('../pages/BaseData.vue'), props: { mode: 'zone' }, meta: { title: '区域维护', keepAlive: true, componentName: 'BaseData' } },
{ path: 'material', name: 'Material', component: () => import('../pages/BaseData.vue'), props: { mode: 'material' }, meta: { title: '物料档案', keepAlive: true, componentName: 'BaseData' } },
{ path: 'change-password', name: 'ChangePassword', component: () => import('../pages/ChangePassword.vue'), meta: { title: '修改密码' } },
{ path: 'users', name: 'UserManage', component: () => import('../pages/UserManage.vue'), meta: { title: '账号管理', keepAlive: true, componentName: 'UserManage' } },
- { path: 'roles', name: 'RoleManage', component: () => import('../pages/RoleManage.vue'), meta: { title: '角色管理', keepAlive: true, componentName: 'RoleManage' } }
+ { path: 'roles', name: 'RoleManage', component: () => import('../pages/RoleManage.vue'), meta: { title: '角色管理', keepAlive: true, componentName: 'RoleManage' } },
+ { path: 'event-log', name: 'EventLog', component: () => import('../pages/EventLog.vue'), meta: { title: '操作日志', keepAlive: true, componentName: 'EventLog' } }
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }
diff --git a/bj_power_wms_client/web/static/index.html b/bj_power_wms_client/web/static/index.html
index 65bfefe..e42c87e 100644
--- a/bj_power_wms_client/web/static/index.html
+++ b/bj_power_wms_client/web/static/index.html
@@ -6,8 +6,8 @@
库房客户端
-
-
+
+
diff --git a/e2e_mes_verify.py b/e2e_mes_verify.py
new file mode 100644
index 0000000..a2e740c
--- /dev/null
+++ b/e2e_mes_verify.py
@@ -0,0 +1,407 @@
+# -*- coding: utf-8 -*-
+"""
+MES 8 项优化端到端验证(2026-09-04)
+覆盖:
+① 工单状态机(流转白名单/原因/日志/终态联动)
+② 日排产与工单状态联动
+③ BOM 需求量自动计算(不手填)
+④ 备料生成前 WMS 物料存在性校验(缺料阻断 + 降级)
+⑤ 下发 SN 归属/进线校验 + 未完成信号握手
+⑧ 流程卡打印预检 + 产品名称联查修复 + 拧紧数据渲染
+⑦ 流程多工位绑定(一流程多工位 / 一工位一启用流程 / 停用可再绑)
+依赖:本机 8888(MES) 与 8890(WMS) 已用新二进制启动。
+"""
+import json
+import os
+import subprocess
+import sys
+import time
+import urllib.request
+import urllib.error
+from datetime import date, timedelta
+
+MES = "http://127.0.0.1:8888"
+WMS = "http://127.0.0.1:8890"
+PSQL = [r"C:\Program Files\PostgreSQL\17\bin\psql.exe", "-U", "postgres", "-h", "127.0.0.1"]
+PSQL_ENV = dict(os.environ, PGPASSWORD="postgres")
+
+PASS, FAIL = [], []
+
+def report(name, ok, extra=""):
+ (PASS if ok else FAIL).append(name)
+ print(("PASS " if ok else "FAIL ") + name + ((" | " + extra) if extra else ""))
+
+def api(method, path, body=None, token=None, base=MES, raw=False, xapi=None):
+ url = base + path
+ data = None
+ headers = {"Content-Type": "application/json"}
+ if body is not None:
+ data = json.dumps(body, ensure_ascii=False).encode()
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ if xapi:
+ headers["X-API-TOKEN"] = xapi
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=15) as r:
+ if raw:
+ return r.status, r.read().decode("utf-8", "replace")
+ return r.status, json.loads(r.read().decode("utf-8", "replace"))
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode("utf-8", "replace"))
+ except Exception:
+ return e.code, {}
+ except Exception as e:
+ return 0, {"code": -1, "message": str(e)}
+
+def q(sql, db="bj_power_mes"):
+ """psql 只读/写查询,返回行文本"""
+ p = subprocess.run(PSQL + ["-d", db, "-t", "-A", "-c", sql],
+ capture_output=True, text=True, env=PSQL_ENV)
+ return p.stdout.strip()
+
+def esc(s):
+ return s.replace("'", "''")
+
+def today(offset=0):
+ return (date.today() + timedelta(days=offset)).isoformat()
+
+# 预清理(脚本可重入):清掉上次运行残留,避免工单号唯一约束冲突 / 旧SENT干扰握手
+CLEAN_SQL = """
+DELETE FROM material_request WHERE order_no LIKE 'E2E-%';
+DELETE FROM daily_plan WHERE order_no LIKE 'E2E-%';
+DELETE FROM scan_record WHERE sn LIKE 'E2E-SN-%' OR sn LIKE 'SN-DBG-%';
+DELETE FROM plc_send_log WHERE order_no LIKE 'E2E-%' OR status='SENT';
+DELETE FROM torque_record WHERE sn LIKE 'E2E-SN-%' OR sn LIKE 'SN-DBG-%' OR work_order_no LIKE 'E2E-%';
+DELETE FROM step_data WHERE sn LIKE 'E2E-SN-%' OR sn LIKE 'SN-DBG-%';
+DELETE FROM workpiece_process WHERE sn LIKE 'E2E-SN-%' OR sn LIKE 'SN-DBG-%';
+DELETE FROM association_trace WHERE finished_sn LIKE 'E2E-SN-%' OR finished_sn LIKE 'SN-DBG-%';
+DELETE FROM workpiece WHERE sn LIKE 'E2E-SN-%' OR sn LIKE 'SN-DBG-%' OR order_no LIKE 'E2E-%';
+DELETE FROM event_log WHERE work_order_no LIKE 'E2E-%';
+DELETE FROM work_order WHERE work_order_no LIKE 'E2E-%';
+DELETE FROM work_order_bom WHERE product_code LIKE 'E2E-PROD-%';
+"""
+q(CLEAN_SQL)
+
+# ---------- 登录 ----------
+st, login = api("POST", "/api/v1/login", {"username": "admin", "password": "123456"})
+token = (login.get("data") or {}).get("accessToken", "")
+report("登录 admin", st == 200 and token and login.get("code") == 0,
+ login.get("message", ""))
+if not token:
+ print(json.dumps(login, ensure_ascii=False)[:300])
+ sys.exit(1)
+
+st, pt = api("GET", "/api/v1/product-types", token=token)
+ptypes = (pt.get("data") or [])
+report("产品类型可查", pt.get("code") == 0 and len(ptypes) > 0)
+ptype_id = ptypes[0]["id"] if ptypes else 0
+
+def create_wo(no, qty=10, product_code="E2E-PROD-001", product_name="测试产品E2E", status="CREATED", seq=""):
+ body = {"workOrderNo": no, "productTypeId": ptype_id, "productCode": product_code,
+ "productName": product_name, "quantity": qty, "status": status, "processSeq": seq}
+ return api("POST", "/api/v1/work-orders", body, token)
+
+def wo_by_no(no):
+ st, r = api("GET", "/api/v1/work-orders?orderNo=" + no, token=token)
+ rows = r.get("data") or []
+ return (rows[0] if rows else None)
+
+def set_status(wo_id, status, reason=""):
+ return api("POST", "/api/v1/work-orders/status", {"id": wo_id, "status": status, "reason": reason}, token)
+
+def get_wo(wo_id):
+ st, r = api("GET", "/api/v1/work-orders/%d" % wo_id, token=token)
+ return r.get("data")
+
+D1 = today(1); D2 = today(2); D3 = today(3); TDY = today(0)
+
+# ================= A. 工单状态机 =================
+print("\n==== A. 工单状态机 ====")
+noA = "E2E-WO-A-" + today().replace("-", "")
+create_wo(noA, qty=10)
+wo = wo_by_no(noA)
+report("A1 创建工单(恒CREATED)", wo is not None and wo.get("status") == "CREATED",
+ "status=" + (wo or {}).get("status", "?"))
+
+# 非法跳转 CREATED→DONE 应拒绝
+code, r = set_status(wo["id"], "DONE")
+report("A2 非法跳转 CREATED→DONE 被拒", code == 200 and r.get("code") != 0, r.get("message", ""))
+# 合法 RELEASED
+code, r = set_status(wo["id"], "RELEASED")
+report("A3 CREATED→RELEASED 允许", r.get("code") == 0, r.get("message", ""))
+# RELEASED→DONE 拒绝
+code, r = set_status(wo["id"], "DONE")
+report("A4 RELEASED→DONE 被拒(需先开工)", r.get("code") != 0, r.get("message", ""))
+# RELEASED→IN_PROGRESS
+code, r = set_status(wo["id"], "IN_PROGRESS")
+report("A5 RELEASED→IN_PROGRESS 允许", r.get("code") == 0, r.get("message", ""))
+# 暂停带原因
+code, r = set_status(wo["id"], "PAUSED", "设备检修")
+report("A6 IN_PROGRESS→PAUSED(带原因) 允许", r.get("code") == 0, r.get("message", ""))
+# 暂停期间再完工应拒绝
+code, r = set_status(wo["id"], "DONE")
+report("A7 PAUSED→DONE 被拒", r.get("code") != 0, r.get("message", ""))
+# 恢复
+code, r = set_status(wo["id"], "IN_PROGRESS")
+report("A8 PAUSED→IN_PROGRESS 允许", r.get("code") == 0, r.get("message", ""))
+# 完工
+code, r = set_status(wo["id"], "DONE")
+report("A9 IN_PROGRESS→DONE 允许", r.get("code") == 0, r.get("message", ""))
+woA = get_wo(wo["id"])
+report("A10 DONE 记录 completedAt", (woA or {}).get("completedAt") is not None)
+# 终态不可再流转
+code, r = set_status(wo["id"], "CANCELLED")
+report("A11 DONE→CANCELLED 被拒", r.get("code") != 0, r.get("message", ""))
+
+# 取消带原因
+noB = "E2E-WO-B-" + today().replace("-", "")
+create_wo(noB, qty=5)
+woB = wo_by_no(noB)
+set_status(woB["id"], "CANCELLED", "计划取消")
+# 取消后不可流转
+code, r = set_status(woB["id"], "IN_PROGRESS")
+report("A12 CANCELLED 不可再开工", r.get("code") != 0, r.get("message", ""))
+
+# 状态日志 + 原因入库
+logrows = q("SELECT description || '|' || COALESCE(payload->>'reason','') FROM event_log WHERE work_order_no='%s' AND event_type='work.order.status' ORDER BY id DESC" % esc(noA))
+report("A13 状态流转写日志且带原因", "变更工单状态" in logrows and "设备检修" in logrows, logrows.replace("\n", " "))
+logB = q("SELECT payload->>'reason' FROM event_log WHERE work_order_no='%s' AND event_type='work.order.status' ORDER BY id DESC LIMIT 1" % esc(noB))
+report("A14 取消原因入库", logB == "计划取消", logB)
+
+# 编辑/删除约束
+noC1 = "E2E-WO-C1-" + today().replace("-", "")
+create_wo(noC1, qty=6)
+woC1 = wo_by_no(noC1)
+code, r = api("PUT", "/api/v1/work-orders", {"id": woC1["id"], "workOrderNo": noC1, "productTypeId": ptype_id, "quantity": 7}, token)
+report("A15 CREATED 可编辑", r.get("code") == 0, r.get("message", ""))
+set_status(woC1["id"], "RELEASED")
+code, r = api("PUT", "/api/v1/work-orders", {"id": woC1["id"], "workOrderNo": noC1, "productTypeId": ptype_id, "quantity": 8}, token)
+report("A16 已下发不可编辑", r.get("code") != 0, r.get("message", ""))
+code, r = api("DELETE", "/api/v1/work-orders/%d" % woC1["id"], token=token)
+report("A17 已下发不可删除", r.get("code") != 0, r.get("message", ""))
+woC1 = wo_by_no(noC1)
+
+# ================= B. 日排产联动 =================
+print("\n==== B. 日排产与工单状态联动 ====")
+noC = "E2E-WO-C-" + today().replace("-", "")
+create_wo(noC, qty=20)
+woC = wo_by_no(noC)
+def save_plan(order_no, d, qty, status=""):
+ return api("POST", "/api/v1/daily-plans", {"orderNo": order_no, "planDate": d, "planQty": qty, "status": status}, token)
+code, r = save_plan(noC, D1, 8)
+report("B1 保存日排产(PENDING)", r.get("code") == 0, r.get("message", ""))
+code, r = save_plan(noC, D2, 15)
+report("B2 排产合计超工单量被拒", r.get("code") != 0, r.get("message", ""))
+code, r = save_plan(noC, D2, 10)
+report("B3 合计未超可保存", r.get("code") == 0, r.get("message", ""))
+# 工单开工 → 排产置 PROCESSING
+set_status(woC["id"], "RELEASED")
+set_status(woC["id"], "IN_PROGRESS")
+p1 = q("SELECT status FROM daily_plan WHERE order_no='%s' AND plan_date='%s'" % (esc(noC), D1))
+report("B4 工单执行中→排产置PROCESSING", p1 == "PROCESSING", p1)
+# 暂停期间不可新增排产
+code, r = set_status(woC["id"], "PAUSED", "待料")
+code, r = save_plan(noC, D3, 2)
+report("B5 工单暂停→新增排产被拒", r.get("code") != 0 and "暂停" in r.get("message", ""), r.get("message", ""))
+code, r = set_status(woC["id"], "IN_PROGRESS")
+code, r = save_plan(noC, D3, 2)
+report("B6 恢复后可排产", r.get("code") == 0, r.get("message", ""))
+# 工单完工 → 未足额排产置 CANCELLED
+code, r = set_status(woC["id"], "DONE")
+plans = q("SELECT plan_date||':'||status FROM daily_plan WHERE order_no='%s' ORDER BY plan_date" % esc(noC)).replace("\n", " ")
+report("B7 工单DONE→未足额排产置CANCELLED", all(s in plans for s in [D1 + ":CANCELLED", D2 + ":CANCELLED", D3 + ":CANCELLED"]), plans)
+# 已结束工单不可排产
+code, r = save_plan(noC, D1, 1)
+report("B8 DONE 工单不可排产", r.get("code") != 0, r.get("message", ""))
+# 取消工单联动排产
+noCC = "E2E-WO-CC-" + today().replace("-", "")
+create_wo(noCC, qty=5)
+woCC = wo_by_no(noCC)
+save_plan(noCC, D1, 2)
+set_status(woCC["id"], "CANCELLED", "紧急取消")
+pcc = q("SELECT status FROM daily_plan WHERE order_no='%s'" % esc(noCC))
+report("B9 工单取消→排产置CANCELLED", pcc == "CANCELLED", pcc)
+
+# ================= C. 完工联动当日排产 completedQty =================
+print("\n==== C. 完工联动当日排产 ====")
+noF = "E2E-WO-F-" + today().replace("-", "")
+create_wo(noF, qty=5)
+woF = wo_by_no(noF)
+save_plan(noF, TDY, 2)
+def online(sn, order_no):
+ return api("POST", "/api/v1/workpiece/online", {"sn": sn, "orderNo": order_no}, token)
+def report_proc(sn, code, station):
+ return api("POST", "/api/v1/workpiece/process/report", {"sn": sn, "processCode": code, "stationNo": station, "steps": []}, token)
+def done(sn):
+ return api("POST", "/api/v1/workpiece/done", {"sn": sn, "batchItems": [], "serialItems": []}, token)
+code, r = online("E2E-SN-F1", noF)
+report("C1 工件进线", r.get("code") == 0, r.get("message", ""))
+code, r = report_proc("E2E-SN-F1", 1, 1)
+report("C2 工序报工", r.get("code") == 0, r.get("message", ""))
+code, r = done("E2E-SN-F1")
+report("C3 完工OK", r.get("code") == 0, r.get("message", ""))
+pc = q("SELECT completed_qty||':'||status FROM daily_plan WHERE order_no='%s' AND plan_date='%s'" % (esc(noF), TDY))
+report("C4 完工后排产 completedQty=1/置PROCESSING", pc == "1:PROCESSING", pc)
+code, r = online("E2E-SN-F2", noF)
+code2, r2 = done("E2E-SN-F2")
+report("C5 第二件进线+完工", r.get("code") == 0 and r2.get("code") == 0, r.get("message", "") + " / " + r2.get("message", ""))
+pc = q("SELECT completed_qty||':'||status FROM daily_plan WHERE order_no='%s' AND plan_date='%s'" % (esc(noF), TDY))
+report("C6 足额后排产置DONE", pc == "2:DONE", pc)
+
+# ================= D. BOM 算料 + WMS 校验 =================
+print("\n==== D. BOM 自动算料 + WMS 存在性校验 ====")
+noG = "E2E-WO-G-" + today().replace("-", "")
+PROD = "E2E-PROD-001"
+create_wo(noG, qty=5, product_code=PROD)
+woG = wo_by_no(noG)
+save_plan(noG, D3, 3)
+
+def save_bom(items):
+ return api("PUT", "/api/v1/bom", {"productCode": PROD, "items": items}, token)
+good_items = [
+ {"materialCode": "GJ-BAN-001", "materialName": "10mm钢板(结构件)", "spec": "", "unit": "件", "manageMode": "1", "unitQty": 2, "lossRate": 5},
+ {"materialCode": "JM-ZHOUCHENG-6020", "materialName": "精密轴承6020", "spec": "", "unit": "个", "manageMode": "2", "unitQty": 1, "lossRate": 0},
+]
+bom_with_bad = good_items + [{"materialCode": "NO-SUCH-CODE-9", "materialName": "幽灵料", "spec": "", "unit": "个", "manageMode": "2", "unitQty": 1, "lossRate": 0}]
+code, r = save_bom(bom_with_bad)
+report("D1 保存BOM(含缺失料) OK", r.get("code") == 0, r.get("message", ""))
+code, r = api("POST", "/api/v1/material-requests/generate", {"planDate": D3}, token)
+data = r.get("data") or {}
+report("D2 生成备料单:缺料阻断不生成", r.get("code") == 0 and data.get("blocked") is True and "NO-SUCH-CODE-9" in data.get("missing", []),
+ json.dumps(data, ensure_ascii=False))
+cnt = q("SELECT count(*) FROM material_request WHERE order_no='%s'" % esc(noG))
+report("D3 阻断时零写入", cnt == "0", cnt)
+# 修正 BOM(SaveBom 为 upsert 不删行,先移除缺料行)→ 生成成功,数量=3*2*1.05=6.3 与 3
+q("DELETE FROM work_order_bom WHERE product_code='%s' AND material_code='NO-SUCH-CODE-9'" % esc(PROD))
+code, r = save_bom(good_items)
+code, r = api("POST", "/api/v1/material-requests/generate", {"planDate": D3}, token)
+data = r.get("data") or {}
+report("D4 修正BOM后生成成功", r.get("code") == 0 and data.get("count") == 2, json.dumps(data, ensure_ascii=False))
+st, rows = api("GET", "/api/v1/material-requests?orderNo=" + noG, token=token)
+mrs = rows.get("data") or []
+rn = sorted(x.get("requestNo") for x in mrs)
+report("D5 生成两条且单号唯一", len(mrs) == 2 and len(set(rn)) == 2, ";".join(rn))
+qty_ok = sorted(round(x.get("reqQty"), 2) for x in mrs) == [3.0, 6.3]
+report("D6 reqQty=planQty×unitQty×(1+loss) 自动计算", qty_ok, str([(x.get("materialCode"), x.get("reqQty")) for x in mrs]))
+
+# WMS 直连接口(可选旁证)
+st, r = api("POST", "/api/internal/material/exists", {"codes": ["GJ-BAN-001", "NO-SUCH-CODE-9"]},
+ base=WMS, xapi="Hardman_2026")
+wdata = r.get("data") or {}
+report("D7 WMS内部接口 missing 直查", st == 200 and r.get("code") == 0 and wdata.get("missing") == ["NO-SUCH-CODE-9"],
+ json.dumps(wdata, ensure_ascii=False))
+
+# ================= E. 下发 SN 校验 + 握手 =================
+print("\n==== E. PLC 下发 SN 校验 ====")
+def plc_send(body):
+ return api("POST", "/api/v1/plc/send-process", body, token)
+code, r = plc_send({"orderNo": noG, "sn": "E2E-SN-NOPE", "stationNo": 1, "processCombination": "1"})
+report("E1 未进线SN被拒", r.get("code") != 0 and ("不属于" in r.get("message", "") or "未进线" in r.get("message", "")), r.get("message", ""))
+code, r = online("E2E-SN-001", noG)
+report("E2 进线E2E-SN-001", r.get("code") == 0, r.get("message", ""))
+code, r = plc_send({"orderNo": noG, "sn": "E2E-SN-001", "stationNo": 1, "processCombination": "1"})
+report("E3 合法SN下发成功", r.get("code") == 0, r.get("message", ""))
+# 立即再发第二条 → 上一条未收到完成信号
+code, r = plc_send({"orderNo": noG, "sn": "E2E-SN-001", "stationNo": 1, "processCombination": "1"})
+report("E4 未收到完成信号被拒(握手)", r.get("code") != 0 and "完成信号" in r.get("message", ""), r.get("message", ""))
+time.sleep(7)
+code, r = plc_send({"orderNo": noG, "sn": "E2E-SN-001", "stationNo": 1, "processCombination": "1"})
+report("E5 完成信号后再次下发成功", r.get("code") == 0, r.get("message", ""))
+# DONE 工单不可下发:SN 属于该工单才先命中 DONE 校验
+noH = "E2E-WO-H-" + today().replace("-", "")
+create_wo(noH, qty=3)
+woH = wo_by_no(noH)
+set_status(woH["id"], "RELEASED"); set_status(woH["id"], "IN_PROGRESS"); set_status(woH["id"], "DONE")
+online("E2E-SN-H1", noH)
+code, r = plc_send({"orderNo": noH, "sn": "E2E-SN-H1", "stationNo": 1, "processCombination": "1"})
+report("E6 DONE工单禁止下发", r.get("code") != 0 and "已完成" in r.get("message", ""), r.get("message", ""))
+
+# ================= F. 流程多工位绑定 =================
+print("\n==== F. 流程多工位绑定 ====")
+def flows(station_no=None):
+ u = "/api/v1/process-flows" + (("?stationNo=" + str(station_no)) if station_no else "")
+ st, r = api("GET", u, token=token)
+ return r.get("data") or []
+def save_flow(body):
+ return api("POST", "/api/v1/process-flows", body, token)
+def flow_status(fid, status):
+ return api("POST", "/api/v1/process-flows/status", {"id": fid, "status": status}, token)
+def stations():
+ st, r = api("GET", "/api/v1/stations", token=token)
+ return r.get("data") or []
+STEP11 = [{"seq": 1, "name": "装配完成", "collectType": "NONE", "isTorque": False, "remark": "", "criteria": []}]
+base_st = {x["stationNo"]: (x.get("flowId") or 0) for x in stations()}
+base_flows = {x["id"]: x for x in flows()}
+report("F1 列表含 stations 绑定字段", all(("stations" in x) for x in base_flows.values()),
+ str([(x["id"], x.get("stations")) for x in list(base_flows.values())[:3]]))
+
+# 负向:启用流程占用冲突
+code, r = save_flow({"name": "E2E-冲突流程", "stationNo": 5, "stations": [5], "steps": []})
+report("F2 工位已被启用流程占用被拒", r.get("code") != 0 and "已被启用流程" in r.get("message", ""), r.get("message", ""))
+# 停用 11/12 → 新建临时流程绑 12 → 扩到 11+12
+flow_status(11, "INACTIVE"); flow_status(12, "INACTIVE")
+code, r = save_flow({"name": "E2E-流程-双工位", "stationNo": 12, "stations": [12], "steps": STEP11})
+fid = None
+if r.get("code") == 0:
+ for f in flows():
+ if f["name"] == "E2E-流程-双工位":
+ fid = f["id"]
+report("F3 停用后可新建流程绑定工位12", r.get("code") == 0 and fid, r.get("message", ""))
+code, r = save_flow({"id": fid, "name": "E2E-流程-双工位", "stationNo": 12, "stations": [11, 12], "steps": STEP11})
+report("F4 一流程绑多工位(11+12)", r.get("code") == 0, r.get("message", ""))
+st_map = {x["stationNo"]: (x.get("flowId") or 0) for x in stations()}
+report("F5 工位11/12均指向临时流程", st_map.get(11) == fid and st_map.get(12) == fid, str(st_map))
+fl11 = flows(11)
+report("F6 按工位号过滤命中临时流程", any(x.get("id") == fid for x in fl11), str([x.get("id") for x in fl11]))
+# 恢复
+code, r = api("DELETE", "/api/v1/process-flows/%d" % fid, token=token)
+report("F7 删除流程自动解绑工位", r.get("code") == 0, r.get("message", ""))
+st_map = {x["stationNo"]: (x.get("flowId") or 0) for x in stations()}
+report("F8 删除后工位解绑", st_map.get(11) == 0 and st_map.get(12) == 0, str(st_map))
+flow_status(11, "ACTIVE"); flow_status(12, "ACTIVE")
+code, r = save_flow({"id": 11, "name": base_flows[11]["name"], "stationNo": 11, "stations": [11], "steps": STEP11})
+code2, r2 = save_flow({"id": 12, "name": base_flows[12]["name"], "stationNo": 12, "stations": [12], "steps": STEP11})
+report("F9 恢复工位11/12原绑定", r.get("code") == 0 and r2.get("code") == 0, r.get("message", "") + " / " + r2.get("message", ""))
+st_map = {x["stationNo"]: (x.get("flowId") or 0) for x in stations()}
+final_ok = st_map.get(11) == 11 and st_map.get(12) == 12 and base_st.get(11) == st_map.get(11) and base_st.get(12) == st_map.get(12)
+report("F10 恢复后绑定与基线一致", final_ok, str({11: st_map.get(11), 12: st_map.get(12)}))
+fl11_now = {x["id"]: x for x in flows()}
+ok11 = len(fl11_now.get(11, {}).get("steps", [])) == len(base_flows.get(11, {}).get("steps", []))
+ok12 = len(fl11_now.get(12, {}).get("steps", [])) == len(base_flows.get(12, {}).get("steps", []))
+report("F11 恢复后11/12号流程步骤数一致", ok11 and ok12,
+ "11:" + str(len(fl11_now.get(11, {}).get("steps", []))) + "/" + str(len(base_flows.get(11, {}).get("steps", []))) +
+ " 12:" + str(len(fl11_now.get(12, {}).get("steps", []))) + "/" + str(len(base_flows.get(12, {}).get("steps", []))))
+
+# ================= G. 流程卡预检与渲染 =================
+print("\n==== G. 流程卡打印预检 ====")
+st, r = api("GET", "/api/v1/process-card?sn=E2E-SN-001&check=1", token=token)
+miss = (r.get("data") or {}).get("missing", []) if r.get("code") == 0 else None
+report("G1 无任何工序记录时预检报缺失", miss is not None and any("工序" in m for m in miss), str(miss))
+# 补 1、7 号工序 + 拧紧数据
+report_proc("E2E-SN-001", 1, 1)
+report_proc("E2E-SN-001", 7, 7)
+code, r = api("POST", "/api/v1/torque/report", {"sn": "E2E-SN-001", "workOrder": noG, "stationNo": "7",
+ "screwNo": "S-001", "torque": 50.0, "angle": 90.0, "result": "OK"}, token)
+report("G2 拧紧上报OK", r.get("code") == 0, r.get("message", ""))
+st, r = api("GET", "/api/v1/process-card?sn=E2E-SN-001&check=1", token=token)
+miss = (r.get("data") or {}).get("missing", [])
+report("G3 补齐后预检缺失为空", r.get("code") == 0 and miss == [], str(miss))
+st, html = api("GET", "/api/v1/process-card?sn=E2E-SN-001", token=token, raw=True)
+report("G4 打印HTML含产品名/工序/拧紧数据",
+ st == 200 and "测试产品E2E" in html and "装配七" in html and "拧紧数据" in html and "S-001" in html,
+ ("len=%d" % len(html)) if html else "")
+
+# ================= 清理 =================
+print("\n==== 清理测试数据 ====")
+q(CLEAN_SQL)
+left = q("SELECT count(*) FROM work_order WHERE work_order_no LIKE 'E2E-%'")
+report("清理完成", left == "0", "剩余=" + left)
+
+print("\n========== 汇总 ==========")
+print("PASS %d / FAIL %d" % (len(PASS), len(FAIL)))
+if FAIL:
+ print("FAILED: " + "; ".join(FAIL))
+ sys.exit(1)
+print("全部通过")
diff --git a/e2e_union_verify.py b/e2e_union_verify.py
new file mode 100644
index 0000000..c248f61
--- /dev/null
+++ b/e2e_union_verify.py
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+"""双系统(管理界面统一/去英文/WMS操作日志)端到端验证。
+覆盖:WMS 角色 options+自定义角色分配+操作日志写读+权限负向;MES 权限码拆分迁移+角色树配接口+账号CRUD+内置保护+日志时间筛选。
+"""
+import json
+import os
+import subprocess
+import urllib.request
+
+PSQL = r"C:/Program Files/PostgreSQL/17/bin/psql.exe"
+WMS = "http://127.0.0.1:8890"
+MES = "http://127.0.0.1:8888"
+PASS = 0
+FAIL = 0
+
+
+def psql(db, sql):
+ env = dict(os.environ)
+ env["PGPASSWORD"] = "postgres"
+ r = subprocess.run([PSQL, "-U", "postgres", "-h", "127.0.0.1", "-d", db, "-t", "-c", sql],
+ env=env, capture_output=True)
+ out = r.stdout.decode("utf-8", "replace").strip()
+ err = r.stderr.decode("utf-8", "replace").strip()
+ if r.returncode != 0:
+ print("PSQL-ERR(%s) rc=%d | %s | %s" % (db, r.returncode, sql[:80], err))
+ return out
+
+
+def api(base, method, path, body=None, token=None, headers=None):
+ data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+ h = {"Content-Type": "application/json"}
+ if token:
+ h["Authorization"] = "Bearer " + token
+ if headers:
+ h.update(headers)
+ req = urllib.request.Request(base + path, data=data, headers=h, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=15) as r:
+ raw = r.read().decode("utf-8", "replace")
+ try:
+ return r.status, json.loads(raw)
+ except Exception:
+ return r.status, {"code": 0, "data": raw}
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode("utf-8", "replace"))
+ except Exception:
+ return e.code, {}
+ except Exception as e:
+ return 0, {"code": -1, "message": str(e)}
+
+
+def report(name, ok, extra=""):
+ global PASS, FAIL
+ if ok:
+ PASS += 1
+ print("PASS | " + name + (" | " + str(extra) if extra else ""))
+ else:
+ FAIL += 1
+ print("FAIL | " + name + (" | " + str(extra) if extra else ""))
+
+
+# ========== 预清理 ==========
+psql("bj_power_wms", "DELETE FROM users WHERE username IN ('union-admin'); DELETE FROM roles WHERE code='union-role';")
+psql("bj_power_mes", "DELETE FROM \"user\" WHERE username='union-e2e'; DELETE FROM role WHERE code='union-e2e-role';")
+
+# ========== WMS ==========
+print("\n==== WMS ====")
+st, r = api(WMS, "POST", "/api/auth/login", {"username": "admin", "password": "123456"})
+tok = (r.get("data") or {}).get("token", "")
+report("W1 admin登录", st == 200 and bool(tok))
+st, r = api(WMS, "POST", "/api/rbac/seed", {}, tok)
+report("W2 rbac/seed", st == 200 and r.get("code") == 0, r.get("message", ""))
+
+st, r = api(WMS, "GET", "/api/roles/options", token=tok)
+codes = [x.get("code") for x in (r.get("data") or {}).get("list", [])]
+report("W3 角色options(user:manage可读)", st == 200 and "admin" in codes, codes[:6])
+
+st, r = api(WMS, "GET", "/api/permissions", token=tok)
+pcodes = [x.get("code") for x in (r.get("data") or {}).get("list", [])]
+report("W4 权限含 eventlog:manage", "eventlog:manage" in pcodes)
+
+# 创建自定义角色并绑定用户(修复“自定义角色无法分配”断点)
+st, r = api(WMS, "POST", "/api/roles", {"name": "联调角色", "code": "union-role", "remark": "e2e",
+ "permissionCodes": ["dashboard:view", "inventory:view"]}, tok)
+roleId = 0
+rl = (r.get("data") or {})
+roleId = rl.get("id") or 0
+report("W5 新增自定义角色", st == 200 and roleId > 0, r.get("message", ""))
+st, r = api(WMS, "POST", "/api/user/create", {"username": "union-admin", "password": "union1234",
+ "realName": "联调", "roleId": roleId, "dept": "测试"}, tok)
+report("W6 新用户绑定自定义角色roleId", st == 200 and (r.get("data") or {}).get("roleId") == roleId,
+ r.get("message", ""))
+# 后端应回填 role 字符串为自定义角色 code(非默认 operator)
+urow = psql("bj_power_wms", "SELECT role FROM users WHERE username='union-admin'")
+report("W7 role字符串与roleId一致(自定义code)", urow == "union-role", urow)
+
+# 操作日志链路:user.create / role.create 已落库
+cnt = psql("bj_power_wms", "SELECT count(*) FROM event_log WHERE event_type LIKE 'user.%' AND operator='admin'")
+report("W8 日志已写(user.* by admin)", int(cnt or 0) >= 1, cnt)
+st, r = api(WMS, "GET", "/api/event/logs?page=1&pageSize=5&eventType=user.&operator=admin", token=tok)
+d = r.get("data") or {}
+report("W9 日志查询接口(按类型/操作人)", st == 200 and r.get("code") == 0 and d.get("total", 0) >= 1,
+ json.dumps(d, ensure_ascii=False)[:120])
+
+# 时间范围过滤(当天)
+import time as _t
+t0 = int(_t.time()) - 86400
+t1 = int(_t.time()) + 60
+st, r = api(WMS, "GET", "/api/event/logs?from=%d&to=%d" % (t0, t1), token=tok)
+report("W10 日志时间范围过滤", st == 200 and (r.get("data") or {}).get("total", 0) >= 1)
+
+# 负向:operator(store1) 无 eventlog:manage / user:manage
+st, r = api(WMS, "POST", "/api/auth/login", {"username": "store1", "password": "123456"})
+tok2 = (r.get("data") or {}).get("token", "")
+st, r = api(WMS, "GET", "/api/event/logs", token=tok2)
+report("W11 operator 无日志权限(403)", st == 403, st)
+st, r = api(WMS, "POST", "/api/roles/options", token=tok2) # GET 不匹配 -> 直接看 GET
+st, r = api(WMS, "GET", "/api/roles/options", token=tok2)
+report("W12 operator 无角色options(user:manage)", st == 403, st)
+
+# 编辑自定义角色(role.update 日志)→ 再删除(先删用户)
+st, r = api(WMS, "POST", "/api/roles/update", {"id": roleId, "name": "联调角色2",
+ "remark": "e2e-upd", "permissionCodes": ["dashboard:view"]}, tok)
+report("W13 编辑角色权限", st == 200, r.get("message", ""))
+st, r = api(WMS, "POST", "/api/roles/update", {"id": roleId, "name": "X", "permissionCodes": []}, tok)
+cnt2 = psql("bj_power_wms", "SELECT count(*) FROM event_log WHERE event_type='role.update' AND operator='admin'")
+report("W14 role.update 已写日志", int(cnt2 or 0) >= 1, cnt2)
+st, r = api(WMS, "POST", "/api/user/delete", {"id": 0}, tok) # 参数错误对照
+report("W15 删除参数校验", st == 400 or st == 500 or r.get("code") != 0)
+
+# 清理 WMS union 数据
+uid = psql("bj_power_wms", "SELECT id FROM users WHERE username='union-admin'")
+if uid:
+ api(WMS, "POST", "/api/user/delete", {"id": int(uid)}, tok)
+api(WMS, "POST", "/api/roles/delete", {"id": roleId}, tok)
+report("W16 清理完成", psql("bj_power_wms", "SELECT count(*) FROM roles WHERE code='union-role'") == "0")
+
+# ========== MES ==========
+print("\n==== MES ====")
+st, r = api(MES, "POST", "/api/v1/login", {"username": "admin", "password": "123456"})
+mtok = (r.get("data") or {}).get("accessToken", "")
+report("M1 admin登录", st == 200 and bool(mtok))
+st, r = api(MES, "GET", "/api/v1/userinfo", token=mtok)
+d = r.get("data") or {}
+menuPaths = [m.get("path") for m in d.get("menus", [])]
+report("M2 userinfo菜单含 /account /role", "/account" in menuPaths and "/role" in menuPaths, menuPaths)
+report("M3 无旧 /rbac 菜单", "/rbac" not in menuPaths)
+report("M4 roleCode=SUPER_ADMIN", d.get("roleCode") == "SUPER_ADMIN", d.get("roleCode"))
+
+# 角色 CRUD + 权限码拆分生效(后端按钮码校验走 sys.role:*)
+st, r = api(MES, "POST", "/api/v1/roles", {"name": "联调角色E2E", "code": "union-e2e-role",
+ "remark": "e2e", "permissionCodes": ["produce.workorder", "produce.trace"]}, mtok)
+report("M5 新增角色(写接口正常)", st == 200, r.get("message", ""))
+rid = psql("bj_power_mes", "SELECT id FROM role WHERE code='union-e2e-role'")
+rid = int(rid) if rid else 0
+report("M6 角色已落库", rid > 0, rid)
+
+# 编码不可改(MES 业务错误为 HTTP200 + body message,按文案断言)
+st, r = api(MES, "PUT", "/api/v1/roles", {"id": rid, "name": "改名", "code": "changed-code",
+ "remark": "", "permissionCodes": ["produce.workorder"]}, mtok)
+msg7 = (r.get("message") or "") + (r.get("msg") or "")
+report("M7 角色编码不可修改被拒", "不可修改" in msg7 and "编码" in msg7, msg7)
+st, r = api(MES, "PUT", "/api/v1/roles", {"id": rid, "name": "联调角色E2E改名", "code": "union-e2e-role",
+ "remark": "e2e-upd", "permissionCodes": ["produce.workorder"]}, mtok)
+report("M8 编辑角色名称/权限", st == 200, r.get("message", ""))
+
+# 账号 CRUD 分配新角色
+st, r = api(MES, "POST", "/api/v1/users", {"username": "union-e2e", "password": "union1234",
+ "name": "联调E2E", "roleId": rid, "status": "ENABLED",
+ "canLoginWorkstation": False, "stations": []}, mtok)
+report("M9 新增账号绑自定义角色", st == 200, r.get("message", ""))
+uid2 = psql("bj_power_mes", "SELECT id FROM \"user\" WHERE username='union-e2e'")
+report("M10 账号已落库", bool(uid2))
+st, r = api(MES, "PUT", "/api/v1/users", {"id": int(uid2), "username": "union-e2e", "name": "联调E2E",
+ "roleId": rid, "status": "DISABLED",
+ "canLoginWorkstation": False, "stations": []}, mtok)
+report("M11 停用账号", st == 200, r.get("message", ""))
+st, r = api(MES, "PUT", "/api/v1/users", {"id": int(uid2), "username": "union-e2e", "name": "联调E2E",
+ "roleId": rid, "status": "ENABLED",
+ "canLoginWorkstation": False, "stations": []}, mtok)
+report("M12 重新启用", st == 200)
+st, r = api(MES, "DELETE", "/api/v1/users/%s" % uid2, token=mtok)
+report("M13 删除账号", st == 200, r.get("message", ""))
+
+# 内置角色删除保护
+superId = psql("bj_power_mes", "SELECT id FROM role WHERE code='SUPER_ADMIN'")
+st, r = api(MES, "DELETE", "/api/v1/roles/%s" % superId, token=mtok)
+msg14 = (r.get("message") or "") + (r.get("msg") or "")
+report("M14 SUPER_ADMIN不可删", "内置角色" in msg14 and "不允许删除" in msg14, msg14)
+operId = psql("bj_power_mes", "SELECT id FROM role WHERE code='OPERATOR'")
+st, r = api(MES, "DELETE", "/api/v1/roles/%s" % operId, token=mtok)
+msg15 = (r.get("message") or "") + (r.get("msg") or "")
+report("M15 OPERATOR不可删(新保护)", "内置角色" in msg15 and "不允许删除" in msg15, msg15)
+
+# 事件日志时间范围 from/to
+import time as _tt
+now_ms = int(_tt.time() * 1000)
+st, r = api(MES, "GET", "/api/v1/event-logs?page=1&pageSize=5&from=%d&to=%d" % (now_ms - 7 * 86400000, now_ms), token=mtok)
+d = r.get("data") or {}
+report("M16 日志时间范围过滤", st == 200 and r.get("code") == 0 and d.get("total", 0) >= 0,
+ json.dumps(d, ensure_ascii=False)[:120])
+# 前缀筛选
+st, r = api(MES, "GET", "/api/v1/event-logs?eventType=work.order.&pageSize=3", token=mtok)
+report("M17 日志事件族前缀筛选", st == 200 and r.get("code") == 0, r.get("message", ""))
+
+# 清理 MES union-e2e-role
+psql("bj_power_mes", "DELETE FROM role WHERE code='union-e2e-role';")
+
+print("\n================ RESULT: PASS=%d FAIL=%d ================" % (PASS, FAIL))
diff --git a/需求规格与开发规划.md b/需求规格与开发规划.md
deleted file mode 100644
index 2cfccf8..0000000
--- a/需求规格与开发规划.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# 北京电力智能产线系统 — 需求规格与开发规划
-
-> 本文档综合《项目说明.md》《头脑风暴.md》及甲方(孙工/韩总)通话录音纪要整理,作为 5 个软件项目的开发总纲。逻辑闭环,尽量不遗漏文档/录音中提到的需求点。
-
-***
-
-## 一、项目背景
-
-为北京电力设备总厂建设一条自动化智能产线(传送带流水线,12 道工序)。软件侧规划:
-
-- 精确对接西门子 S7-1214 PLC:下发工序组合(如 `135` → 只做 1、3、5 序;`234` → 做 2、3、4 序)。
-- 对接海康 AGV 调度系统(RCS-2000):指挥 2 台 AGV 在 21 个接驳台之间搬运物料。
-- 库房记账与追溯(WMS):管到区域 + 数量 / 序列号,无智能立库、无传感器货架坐标。
-- 产线控制 (MES) + 库房 (WMS) + 看板 (Dashboard) + 工位终端 + 库房客户端。
-
-**结论性判断**:无需独立 MIS 系统。甲方 5 个系统,我方开发 3 类后台(MES/WMS/工位终端)与 2 类前端(看板/库房客户端),AGV 调度系统与拧紧工具系统为外部既有系统(仅对接)。
-
-**业务可追溯**:入库、出库、加工、页面操作、触发agv ,等等,都需要记录清楚,在工件、工单、审核、等场景标记清楚。所以,登录权限必须完善、菜单权限,token失效时间要短。
-
-***
-
-## 二、业务全貌与核心流程
-
-### 2.1 生产模式
-
-- **工单驱动、按日批量备料**:一个合同 = 一个工单(如 100 台设备),按日排产(今天 20 台)。开工前用 AGV 将当日所需全部物料(芯片=器件、螺丝=结构件、屏幕等)一次性配送到各工位(新建工单时,可以选择都是哪个工位送料,分别是什么料。仓库管理后台可以把多个料打包成一个概念,即套件。工单支持混合选择【工件(独立物料)】与【套件(虚拟物料组合)】),软件层面中途没有停线补料逻辑(返修/报废才走补料申请,主管审批)。
-- **出库强约束**:累计出库 ≤ 工单 BOM 需求量,超发需补料流程。
-
-### 2.2 两类物料差异化管理
-
-| 类型 | 例 | 管理粒度 | 入库方式 | 出库方式 |
-| --- | -------- | --------- | ---------------------------------------------------------- | :--- |
-| 精密件 | 芯片/器件/成品 | 序列号 SN,逐件 | 扫码逐件录入,单件追溯,支持连续扫码。(先选择模版,再扫描,否则大量其他信息需要填入)。可人工修改工件,再保存提交。 | 同入库 |
-| 结构件 | 机加工件/螺丝 | 图号+批次 | 按批次/图号录入,支持 Excel 批量导入,分多批到货 | 同入库 |
-
-- 物料总状态:原材料 / 半成品(记录已完成工序)/ 成品。
-- 检验三状态:未检 → 合格 / 不合格,需**批量翻转**;分来料检、过程检、成品检;部分检验项需录实测值。
-- 为保证通用性,两种物料,都用同样的表结构,结构件不写 sn即可,也可以手动选择物料类型。
-- 物料,必须有 code唯一码(物料模版里面有,新建物料时必须选择物料模版,也是物料种类,比例螺丝、剪刀,等分类),真实名称、简称、描述、等等,包含不限于上面字段。
-
-### 2.3 完整业务流程(闭环)
-
-```
-1. 库房入库 WMS 记账(精密件逐扫 SN / 结构件 Excel 批次)。为保证一致性,不做特殊区分,都可以批量、或者扫码、或者手动输入sn。功能越全越好,仅仅类型区分,和sn码有或无。
-2. 创建工单 MES:工单 + BOM + 日排产
-3. 锁库存 MES 调 WMS:按 BOM 校验并锁定库存(批次/SN)
-4. 生成备料单 系统按日排产自动算料 → 备料单 。
-5. AGV 配送 MES 调海康 RCS:库房接驳台 DOCK21 → 产线 DOCK01~20
-6. 到货上料 主线扫码枪识别托盘 → 分流决策 → 送往目标支线
-7. 支线装配 12 工位,拧紧枪采集数据,工位终端实时显示
-8. 工序报工 支线扫码枪扫码报工 → MES 更新工单进度
-9. 完工 全部工序完成 → 半成品/成品回库 WMS
-10. 追溯/打印 流程卡:工序操作人/检验人/检验结果汇总 PDF
-```
-
-补充
-
-```
-# 整体业务方案 补充
-1、日排产:可编排当日以及未来多天的生产计划,作为仓库的前瞻参考信息;日排产仅做计划预告,**不直接触发出库、不下发领料指令**。
-2、MES向下游WMS下发生产工单,工单为仓库出库唯一挂靠载体;工单携带产品、BOM、物料总需求量。
-3、工单只管控物料总的需求数量,**不管理出库节奏,不做拉动式叫料控制**;每次出库何时出、出多少、分几趟AGV送货,决策权交由仓库/WMS自主决定。
-4、仓库配送拥有两种自由选择:可早上一次性将工单所需物料全部出库配送完毕;也可拆分为多批次,分次出库、分批AGV送货。
-5、WMS实时维护工单物料台账:物料总需求数量、累计已出库数量、剩余待出库数量;系统强制校验,累计出库数量不可超过工单总需求量;当累计出库等于总需求,工单领料完结,禁止再出库。
-6、后期如需升级为MES拉动叫料补料模式,现有工单‑出库架构可无缝叠加,无需推翻改造。
-```
-
-***
-
-## 三、五个项目职责、技术栈与页面
-
-项目构成以《项目说明.md》为准,共 5 个,均可单独编译、单独部署。
-
-### 项目 A:bj\_power\_dashboard(看板,H5 前端)
-
-- 技术栈:Vue/React + ECharts + SSE。只展示,不读写业务。数据来源于项目 B(MES),经 Redis 缓存。
-- 部署:厂房大门口大显示器(联想 ECI-521 主机 + 100 吋屏)。
-- 页面:
- 1. 生产数据看板(产线运行状态、产量、进度、合格率)
- 2. 仓储数据看板(库存总量、物料种类、出入库动态)
- 3. 物流设备看板(AGV 位置/状态/电量/任务执行)
- 4. 异常报警(设备故障、物料短缺、AGV 异常)
- 5. 三维产线模型(可选,展示各工位/AGV 状态,数据须准确)【录音:面子+里子】
-
-### 项目 B:bj\_power\_mes(MES 产线控制,Go + Vue)
-
-- 技术栈:Go 后端(go-zero 或 gin)+ Vue 前端;**前端需自适应 平板/iPad/手机/台式机**。
-- 部署:库房/业务服务器(联想 ECI-521),`PostgreSQL`(MES 独立库)。
-- 职责:工单/排产/BOM/备料、调 WMS 锁库扣库、调海康 RCS 下发 AGV、写 PLC 工序码+读完成信号、收拧紧数据、工序扫码报工、半成品流转、追溯、看板缓存接口。
-- 页面:
- 1. 登录/个人中心(自适应)
- 2. 工单管理(创建/查询/排产)/ BOM / 备料单
- 3. 工位监控(12 工位状态实时)
- 4. PLC / AGV 状态页
- 5. 半成品流转
- 6. 工序扫码报工记录 / 进度
- 7. 拧紧数据查询 / 追溯报表 / 流程卡生成打印
- 8. 看板数据接口(Redis 缓存,供项目 A)
-
-### 项目 C:bj\_power\_wms(WMS 仓库,纯 Go,无前端)
-
-- 技术栈:Go 后端。**不包含前端代码**。
-- 部署:库房/业务服务器,`PostgreSQL`(WMS 独立库)。
-- 职责:物料/批次/SN、出入库、库存锁定/扣减、检验、半成品/成品、包装追溯;对外提供 API。
-- 无页面(前端由项目 E 提供)。
-
-### 项目 D:bj\_power\_workstation(12 个工位终端,Go + Vue)
-
-- 技术栈:**Go + Vue Web 应用**(独立项目,因为有扫码枪和拧紧枪)。运行于 12 台触控一体机(浏览器访问)。
-- 数据来源:项目 B(MES) API。本地 SQLite 缓存待上报的拧紧数据,**点"完成"才上报 MES**,失败保留重试(断点续传);不存储历史数据,历史数据来自 MES。
-- 页面/功能:
- 1. 登录(账号自动记录操作人)
- 2. 当前工单 + 当前工序导航(完成一步点"下一步")
- 3. 工艺文件 PDF 预览(预缓存,工位配置"工序文件"上传)
- 4. 拧紧结果实时显示(扭矩/角度/OK·NG,进度如 8/10 颗,NG 红色闪烁)
- 5. 扫码报工(主线/支线)
- 6. 完成上报 / 返修重置(记录历史操作日志)/ 暂存退回库房(半成品)
-
-### 项目 E:bj\_power\_wms\_client(库房客户端,Go + Vue)
-
-- 技术栈:**Go + Vue Web 应用**(独立项目,因为有扫码枪)。
-- 部署:库房 2 台台式机(各一显示屏,分出入库),浏览器输入 WMS(项目 C) 地址。
-- 页面/功能:
- 1. 登录
- 2. 入库:精密件逐件扫码(连续扫码、实时已扫数量)、结构件 Excel 批量导入 / 手动单条,多批到货追加
- 3. 出库:按备料单/工单领料、扫码复核(校验物料+批次)、先进先出、异常出库实时报警、自动扣减库存
- 4. 库存查询、盘点(动态/静态/抽盘、扫码快速盘点)
- 5. 质量检验:未检/合格/不合格管理、批量翻转、实测值录入
- 6. 半成品/成品入库、包装绑定(箱号 ↔ SN)
-
-***
-
-## 四、系统交互与 API 契约(跨项目)
-
-### 4.1 拓扑
-
-```
-工位终端D ──HTTP──► MES(B) API ──┬──► [MES库 PostgreSQL]
-PAD ───浏览器──► MES(B) 前端 ├──► Redis(看板缓存)
-看板A ───SSE/HTTP──► MES 读缓存 │
-库房客户端E ──HTTP──► WMS(C) API ─┴──► [WMS库 PostgreSQL]
- WMS(C) ◄══API══► MES(B) (独立库,联动)
-外部:海康RCS(AGV)、西门子PLC、丹尼科尔拧紧工具
-```
-
-### 4.2 关键 API(B ↔ C,独立数据库通过 API 互通)
-
-| 方向 | 接口 | 说明 |
-| ---- | ------------------------------------- | -------------------------------- |
-| C 提供 | `POST /api/stock/lock` | 工单锁定库存(批次/SN),带 `order_no` |
-| C 提供 | `POST /api/stock/deduct` | 出库扣减,`order_no`+qty,WMS 校验累计≤BOM |
-| C 提供 | `GET /api/stock/query` | 库存查询(MES 查料) |
-| C 提供 | `POST /api/semi/inbound` / `outbound` | 半成品入库/出库,携带"已完成工序" |
-| B 提供 | `GET /api/order/query` | 工单/工序进度(WMS 取工单) |
-
-### 4.3 对接外部
-
-- **海康 RCS**:HTTP,HMAC-SHA256 签名 + `X-LR-REQUEST-ID` 防重放。封装独立 `HikRcsClient`:`SubmitTask(fromDock,toDock,carrier)`、`QueryTask`。DOCK01\~20 产线,DOCK21 库房;路径规划 RCS 自理。
-- **西门子 PLC (S7-1214)**:用整型寄存器写工序码(如 MW100=135),读完成信号位(M100.0);**未收到完成信号不下发下一条**。
-- **拧紧工具(丹尼科尔)**:实时采集扭矩/角度/时间/结果/操作人/工单号,异常报警;数据对接 MES 质量追溯。
-
-### 4.4 看板缓存方案(已采纳)
-
-- 看板读 Redis,不直查 MES 业务库。TTL=60s;业务变更主动刷新/删 key,未刷新也会过期回源 DB 重查。
-- **防击穿**:同一 key 同一时刻仅允许一个查询打到 DB(SETNX 加锁),其他等待后重试读缓存。
-- 看板局部刷新用 SSE 推送;工位/库房客户端走普通 HTTP(强一致性)。
-
-### 4.5 拧紧数据两段式(已采纳)
-
-- 工位终端 D:实时显示 + SQLite 缓存每把数据 → 点"完成"才上报 MES → 成功后标记同步;失败保留本地重试。历史数据只存 MES。
-
-### 4.6 半成品流转(已采纳)
-
-- 工位"暂存/退回库房" → D 上报 MES 记录已完成工序 → MES 调 WMS 半成品入库 → 重上线 WMS 出库 → MES 从下一工序继续。
-
-***
-
-## 五、数据模型(核心表,逻辑闭环)
-
-### 5.1 WMS(C) 库
-
-| 表 | 关键字段 | 说明 |
-| ------------------------------------ | -------------------------------------------------------------------------------------------------- | -------------- |
-| `material` | code,name,spec,unit,manage\_mode(1批次/2序列号),is\_batch\_managed,is\_serial\_managed | 物料档案 |
-| `zone` | zone\_code,zone\_name | 待检/合格/不合格/退货区 |
-| `inventory` | manage\_mode(1结构件批次/2精密件SN),material\_code,batch\_no,sn\_code,quantity,locked\_qty,quality\_status,zone\_code,status(在库/锁定/出库/报废) | 统一库存表(批次与SN合一) |
-| `inventory_lock` | batch\_id,order\_no,locked\_qty,status,expired\_at | 批量锁定、防超卖 |
-| `inbound_order` / `inbound_detail` | 类型、批次/SN、数量、操作人、台账 | 入库(含半成品/成品) |
-| `outbound_order` / `outbound_detail` | order\_no,qty,批次/SN、复核人 | 出库,关联工单,BOM 校验 |
-| `inspection_record` | target(批次/SN),status(未检/合格/不合格),inspector,time,result\_value | 来料/过程/成品检 |
-| `semi_finished` | sn,completed\_process,quantity,zone | 半成品库存 |
-| `package_box` | box\_no,sn\_list | 包装箱 ↔ SN 关联 |
-
-### 5.2 MES(B) 库
-
-| 表 | 关键字段 | 说明 |
-| ------------------------------------- | ---------------------------------------------------------------- | ------- |
-| `work_order` | order\_no,product\_code,total\_qty,daily\_qty,plan\_start,status | 工单 |
-| `work_order_bom` | product\_code,material\_code,unit\_qty,loss\_rate | BOM |
-| `daily_plan` | order\_no,plan\_date,qty | 日排产 |
-| `material_request` | order\_no,material\_code,qty,status,target\_dock | 备料单 |
-| `dock_station` | dock\_code(DOCK01\~21),dock\_type | 21 接驳台 |
-| `plan_process` / `work_order_process` | order\_no,process\_code(135),status | 工序 / 流转 |
-| `torque_result` | sn,strain,angle,result,operator,work\_order,time | 拧紧数据 |
-| `scan_record` | station,sn,order\_no,type,operator,time | 扫码报工 |
-| `association_trace` | sn → 工序/检验/物料批次 关联链 | 追溯 |
-| RBAC | user/role/permission/dept | 登录与权限 |
-
-> 字段以开发时逐表 DDL 为准,此表为设计基线。
-
-***
-
-## 六、设备/硬件部署对应
-
-| 设备 | 数量 | 运行 | 数据来源 |
-| ------------- | ----------- | ------------ | ---------- |
-| 库房台式机 | 2 | 项目 E(出入库) | WMS(C) |
-| 看板大屏 + 主机 | 1 | 项目 A | MES 缓存 |
-| 工位触控一体机 | 12 | 项目 D | MES(B) |
-| PAD(iData P1) | 3 | 浏览器访问 B/C 前端 | MES/WMS |
-| WMS 显示器(55 吋) | 1 | 展示库存总览 | WMS |
-| 扫码枪 | 主线2+支线12+库房 | / | 上报 MES/WMS |
-| AGV | 2 | 海康 RCS(对接) | / |
-| PLC | 1 | 西门子 | MES 写工序 |
-| 拧紧工具 | / | 丹尼科尔(对接) | MES 收数据 |
-
-***
-
-## 七、分阶段开发路线(先怎么开始,再怎么开始)
-
-**阶段 0 — 工程地基**
-
-- 建 5 个工程骨架(Go 后端 + Vue 前端),改 module/包名,各自 `go build` / `pnpm build` 可运行;统一配置、日志、鉴权骨架。
-
-**阶段 1 — C WMS 核心 + E 库房客户端**
-
-- WMS 建表(物料/批次/SN/锁定/检验)→ 入库(扫码+Excel)→ 出库(锁定→扣减→BOM 校验)→ 检验批量翻转 → 追溯查询。
-- E 库房客户端界面对接,最快出可演示成果。
-
-**阶段 2 — B MES 工单/排产/备料 + 联动**
-
-- MES 建表 → 工单+BOM+日排产 → 备料单生成 → 调 WMS 查库/锁库/扣库;前端自适应页面(web/pad)。
-
-**阶段 3 — 外部对接**
-
-- 海康 RCS 客户端(签名+下发+查状态)→ AGV 闭环;PLC 工序码下发+完成信号轮询;丹尼科尔拧紧工具对接。
-
-**阶段 4 — D 工位终端 + E 深化**
-
-- D:工序导航/工艺PDF/拧紧实时/SQLite缓存/完成上报/扫码报工/暂存;E:出库复核、盘点、批量翻转、包装绑定。
-
-**阶段 5 — A 看板 + SSE + Redis**
-
-- 看板页 + MES dashboard 缓存接口(Redis1 分钟+防击穿)+ SSE 局部刷新 + 3D 模型(可选)。
-
-**阶段 6 — 闭环与验收**
-
-- 全链路联调(创建工单→备料→AGV→装配→拧紧→报工→完工入库)、流程卡打印、半成品流转、容错/断点续传、数据备份(保留≥1 年)。
-
-***
-
-## 八、验收相关指标(软件需支撑,不越硬件职责)
-
-- 扫码识别率一次成功率 ≥92%(记录扫码成功/失败,为验收提供数据证据)。
-- 看板刷新延迟 ≤4s(99%),WMS↔MES 同步 ≤2s,AGV 任务成功率 ≥99%(软件侧记录与重试)。
-- 数据保留 ≥1 年,每日自动备份。
-- 知识产权归甲方,支持二次开发(模块解耦、独立升级)。
-
-***
-
-## 九、待甲方确认清单(遗留)
-
-1. 结构件批次号规则:甲方定义 or 系统自动生成? 答:自动生产,按日期 + 自增数量
-2. 拧紧工具对接数据字段与协议(丹尼科尔是否提供 API)。 答:暂放
-3. AGV 位置/电量实时推送 or 轮询。 答:见文档:AGV海康.md
-4. PAD 网络与离线需求。 答:什么需求,离线不可工作,因为要登录授权
-5. 看板 3D 模型精度需求。 答:暂放
-
-> 已采纳的甲方明确结论(来自对话/录音):
-> ① 库房 2 台台式机 ✅ ② 看板走 Redis、1 分钟过期、防击穿 ✅
-> ③ WMS 与 MES 独立库、API 互通 ✅ ④ 拧紧数据本地缓存、点完成上报 MES ✅ ⑤ 半成品流转记录已完成工序 ✅
-
diff --git a/项目现状说明.md b/项目现状说明.md
deleted file mode 100644
index fa9332c..0000000
--- a/项目现状说明.md
+++ /dev/null
@@ -1,261 +0,0 @@
-# 北京电力 WMS 项目 · 现状说明(供重新规划)
-
-> 生成时间:2026-09-03
-> 用途:作为「重新规划」的输入材料,供其他 AI 直接阅读并制定方案
-> 核实方式:全部内容来自实际代码读取(非记忆/推测),关键结论已标注代码位置
-
----
-
-## 一、系统全景
-
-根仓库 `D:\hardman\bj_power` 下共有 **5 个子系统**:
-
-| 子系统 | 端口 | 技术 | 职责 | 备注 |
-|---|---|---|---|---|
-| `bj_power_wms` | **8890** | Go + go-zero + ent | **WMS 后端**(全部业务逻辑 + PostgreSQL) | 核心系统 |
-| `bj_power_wms_client` | **8891** | Go(仅 config/proxy) | **库房客户端**:静态托管 + `/api` 反向代理 | **无业务逻辑**,转发到 8890 |
-| `bj_power_mes` | — | Go + ent + frontend | MES 系统(工单、BOM、台账来源) | 独立服务 |
-| `bj_power_workstation` | — | Go + frontend + SQLite | 工位终端 | 独立 DB |
-| `bj_power_dashboard` | — | Vue3 + TS + Vite | 大屏展示(独立构建产物 dist) | 免登录 |
-
-**关键架构事实(已核实)**:
-- `bj_power_wms_client`(8891)**不是**纯前端壳,它是一个 Go 服务,但 `internal/` 下**只有 `config` 和 `proxy` 两个包**,无 handler、无 ent。
-- 其配置 `etc/bj_power_wms_client.yaml` 明确:`WmsAddr: http://127.0.0.1:8890`,请求转发时注入 `X-API-TOKEN: Hardman_2026`。
-- 因此:**前端页面(8891)看到的 `/api/*` 实际全部由 8890 的 WMS 后端处理**。
-- 前端源码在 `bj_power_wms_client/frontend/`,构建产物输出到 `bj_power_wms_client/web/static`。
-- `bj_power_wms`(8890)**不含 `go:embed`**,不内嵌前端,前端由 8891 单独提供。
-
-> 结论:用户口中的「wms 和 wms 客户端」= 8890(业务后端)+ 8891(客户端壳)。**所有功能/权限改造实际上都落在 8890。**
-
----
-
-## 二、技术栈
-
-**后端(bj_power_wms)**
-- Go + `go-zero`(rest 路由)+ `ent` ORM
-- PostgreSQL(`bj_power_wms`,账号 `postgres/postgres`)
-- JWT 鉴权,Header:`Authorization: Bearer {token}`
-- 统一响应包裹 `{code, message, data}`,**业务数据一律在 `data` 层**
-- ent 代码生成:`go run -tags entgenerate ./tools`(build tag `entgenerate`,入口 `tools/generate.go`)
-
-**前端(bj_power_wms_client/frontend)**
-- Vue3(`