From 9cf422ef0d4ba20e13977e9349da7dbc1113c348 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Sun, 21 Jun 2026 23:42:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(plan):=20=E8=AE=A1=E5=88=92=E8=AF=A6?= =?UTF-8?q?=E7=BB=86=E9=9D=A2=E6=9D=BF=20+=20=E6=9D=A5=E6=96=99=E9=87=8D?= =?UTF-8?q?=E9=87=8F/=E5=A4=96=E5=BE=84/=E5=88=86=E5=8D=B7=E9=87=8D?= =?UTF-8?q?=E9=87=8F=20+=20=E5=9C=A8=E7=BA=BF/=E7=94=9F=E4=BA=A7=E4=B8=AD?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9C=BA=20+=20=E5=85=A5=E5=8F=A3=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=20+=20=E4=B8=8A=E6=AC=A1=E6=A8=A1=E6=9D=BF=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: plan 增加 incoming_weight/incoming_od/split_weights(JSON) 字段及迁移 - backend: GET /plan/last-template 返回最近一条计划的工艺字段用于新增回填(多端共享) - backend: PATCH /plan/{id}/start 设为 producing,强制单卷在产(其他 producing 回退 online) - backend: 生成实绩时按卷号自动把对应计划状态置为 produced - frontend: 新增计划默认状态 online;新增时调用 last-template 自动回填 - frontend: Plan 表格行点击展开 计划详细 面板(按截图布局) - frontend: Plan 行操作增加「移动」(ready/online → producing) - frontend: 物料跟踪页加 在线计划队列 + 入口移动按钮,显示当前生产中卷 - frontend: 计划弹窗新增 轧制模式/来料重量/来料外径/1-6#分卷重量 --- backend/app/api/plan.py | 43 ++++++++- backend/app/api/production.py | 22 +++++ backend/app/database.py | 3 + backend/app/models/plan.py | 5 +- backend/app/schemas/plan.py | 33 ++++++- frontend/src/api/index.js | 2 + frontend/src/views/Material.vue | 75 +++++++++++++++ frontend/src/views/Plan.vue | 161 +++++++++++++++++++++++++++++--- 8 files changed, 326 insertions(+), 18 deletions(-) diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py index 9d824bd..58814c1 100644 --- a/backend/app/api/plan.py +++ b/backend/app/api/plan.py @@ -6,7 +6,7 @@ from datetime import datetime from app.database import get_db from app.models.plan import ProductionPlan -from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut +from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut, PlanTemplate from app.schemas.common import Response, PageResponse from app.services.auth_service import get_current_user @@ -22,6 +22,16 @@ def _parse_dt(s): return None +TEMPLATE_FIELDS = ( + "steel_grade", "incoming_thickness", "product_thickness", + "deviation_upper", "deviation_lower", + "incoming_width", "product_width", + "packaging_req", "trim_req", "rolling_mode", + "coil_diameter", "split_count", "next_process", + "incoming_weight", "incoming_od", "split_weights", +) + + @router.get("/", response_model=Response[PageResponse[PlanOut]]) async def list_plans( page: int = 1, @@ -48,6 +58,16 @@ async def list_plans( return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items)) +@router.get("/last-template", response_model=Response[PlanTemplate]) +async def get_last_template(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)): + """最近一条计划的工艺字段(不含计划号/卷号/时间),用于新增时回填。""" + result = await db.execute(select(ProductionPlan).order_by(desc(ProductionPlan.created_at)).limit(1)) + p = result.scalar_one_or_none() + if not p: + return Response.ok(PlanTemplate()) + return Response.ok(PlanTemplate(**{k: getattr(p, k, None) for k in TEMPLATE_FIELDS})) + + @router.post("/", response_model=Response[PlanOut]) async def create_plan( body: PlanCreate, @@ -98,3 +118,24 @@ async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Dep plan.status = "online" await db.flush() return Response.ok(PlanOut.model_validate(plan)) + + +@router.patch("/{plan_id}/start", response_model=Response[PlanOut]) +async def start_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)): + """移动到入口,开始生产:本条→producing,其它 producing→online(单卷在产)。""" + result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="计划不存在") + # 其它正在生产的全部回退为在线(强制单卷在产) + others = await db.execute( + select(ProductionPlan).where( + ProductionPlan.status == "producing", + ProductionPlan.id != plan_id, + ) + ) + for o in others.scalars(): + o.status = "online" + plan.status = "producing" + await db.flush() + return Response.ok(PlanOut.model_validate(plan)) diff --git a/backend/app/api/production.py b/backend/app/api/production.py index 59acd06..33499e3 100644 --- a/backend/app/api/production.py +++ b/backend/app/api/production.py @@ -6,10 +6,28 @@ from datetime import datetime from app.database import get_db from app.models.production import ProductionRecord +from app.models.plan import ProductionPlan from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut from app.schemas.common import Response, PageResponse from app.services.auth_service import get_current_user + +async def _mark_plan_produced(db: AsyncSession, record: ProductionRecord): + """根据实绩记录的卷号自动把对应计划标记为 produced。""" + candidates = [] + for v in (getattr(record, "hot_coil_no", None), record.coil_no, getattr(record, "sub_coil_no", None)): + if v and v not in candidates: + candidates.append(v) + if not candidates: + return + q = select(ProductionPlan).where( + (ProductionPlan.cold_coil_no.in_(candidates)) | (ProductionPlan.hot_coil_no.in_(candidates)) + ) + res = await db.execute(q) + for plan in res.scalars(): + if plan.status != "produced": + plan.status = "produced" + router = APIRouter() @@ -60,6 +78,8 @@ async def create_record( record = ProductionRecord(**body.model_dump()) db.add(record) await db.flush() + await _mark_plan_produced(db, record) + await db.flush() return Response.ok(ProductionRecordOut.model_validate(record)) @@ -86,4 +106,6 @@ async def update_record( for k, v in body.model_dump(exclude_none=True).items(): setattr(record, k, v) await db.flush() + await _mark_plan_produced(db, record) + await db.flush() return Response.ok(ProductionRecordOut.model_validate(record)) diff --git a/backend/app/database.py b/backend/app/database.py index e214b75..f6b0488 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -56,6 +56,9 @@ async def _run_migrations(conn): "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_count INTEGER DEFAULT 1", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS coil_diameter DOUBLE PRECISION", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS next_process VARCHAR(30)", + "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_weight DOUBLE PRECISION", + "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_od DOUBLE PRECISION", + "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB", # 状态列改为 VARCHAR 以适配新值 "ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text", # production_records 新字段 diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index 82ef45b..dc3332a 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func +from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func, JSON from app.database import Base @@ -31,6 +31,9 @@ class ProductionPlan(Base): coil_diameter = Column(Float, comment="卷径 mm") split_count = Column(Integer, default=1, comment="分卷数") next_process = Column(String(30), comment="下工序") + incoming_weight = Column(Float, comment="来料重量 t") + incoming_od = Column(Float, comment="来料外径 mm") + split_weights = Column(JSON, comment="分卷重量 [t,...]") # 兼容历史字段 shift = Column(String(10), comment="班次") diff --git a/backend/app/schemas/plan.py b/backend/app/schemas/plan.py index 8eebe47..0c53922 100644 --- a/backend/app/schemas/plan.py +++ b/backend/app/schemas/plan.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from datetime import datetime @@ -21,7 +21,10 @@ class PlanCreate(BaseModel): coil_diameter: Optional[float] = None split_count: Optional[int] = 1 next_process: Optional[str] = None - status: Optional[str] = "ready" + incoming_weight: Optional[float] = None + incoming_od: Optional[float] = None + split_weights: Optional[List[Optional[float]]] = None + status: Optional[str] = "online" remark: Optional[str] = None @@ -42,6 +45,9 @@ class PlanUpdate(BaseModel): coil_diameter: Optional[float] = None split_count: Optional[int] = None next_process: Optional[str] = None + incoming_weight: Optional[float] = None + incoming_od: Optional[float] = None + split_weights: Optional[List[Optional[float]]] = None status: Optional[str] = None remark: Optional[str] = None @@ -66,9 +72,32 @@ class PlanOut(BaseModel): coil_diameter: Optional[float] = None split_count: Optional[int] = 1 next_process: Optional[str] = None + incoming_weight: Optional[float] = None + incoming_od: Optional[float] = None + split_weights: Optional[List[Optional[float]]] = None remark: Optional[str] = None created_by: Optional[str] = None created_at: datetime class Config: from_attributes = True + + +class PlanTemplate(BaseModel): + """新增计划时回填的"上次录入"模板(不含 plan_no/卷号/时间)""" + steel_grade: Optional[str] = None + incoming_thickness: Optional[float] = None + product_thickness: Optional[float] = None + deviation_upper: Optional[float] = None + deviation_lower: Optional[float] = None + incoming_width: Optional[float] = None + product_width: Optional[float] = None + packaging_req: Optional[str] = None + trim_req: Optional[str] = None + rolling_mode: Optional[str] = None + coil_diameter: Optional[float] = None + split_count: Optional[int] = 1 + next_process: Optional[str] = None + incoming_weight: Optional[float] = None + incoming_od: Optional[float] = None + split_weights: Optional[List[Optional[float]]] = None diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index cd9eeef..96951f3 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -20,6 +20,8 @@ export const getPlans = params => request.get('/plan/', { params }) export const createPlan = data => request.post('/plan/', data) export const updatePlan = (id, data) => request.put(`/plan/${id}`, data) export const confirmPlan = id => request.patch(`/plan/${id}/confirm`) +export const startProducing = id => request.patch(`/plan/${id}/start`) +export const getLastPlanTemplate = () => request.get('/plan/last-template') // 停机管理 export const getDowntimeCategories = () => request.get('/downtime/categories') diff --git a/frontend/src/views/Material.vue b/frontend/src/views/Material.vue index ac53c69..13e4fbb 100644 --- a/frontend/src/views/Material.vue +++ b/frontend/src/views/Material.vue @@ -32,6 +32,41 @@ + +
+
+ 在线计划(入口队列) + 在线 {{ onlinePlans.length }} / 生产中 {{ producingPlan ? 1 : 0 }} + 点击「移动」把队列卷推到入口并开始生产 +
+
+
+ 生产中 + 冷卷号{{ producingPlan.cold_coil_no || producingPlan.plan_no }} + 钢种{{ producingPlan.steel_grade || '—' }} + 规格{{ fmt(producingPlan.product_thickness) }}×{{ fmt(producingPlan.product_width, 0) }} + 分卷{{ producingPlan.split_count || 1 }} +
+ + + + + + + + + + + + + +
冷卷号钢种厚度宽度分卷下达时间操作
{{ p.cold_coil_no || p.plan_no }}{{ p.steel_grade || '—' }}{{ fmt(p.product_thickness) }}{{ fmt(p.product_width, 0) }}{{ p.split_count || 1 }}{{ fmtTime(p.plan_date) }} + +
+
暂无在线计划
+
+
+
推拉酸洗线 - 物料跟踪总图
@@ -288,6 +323,7 @@ @@ -593,6 +662,12 @@ export default { .track-scroll { max-height: 640px; overflow-y: auto; } +.producing-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px 10px; font-size: 12px; border-bottom: 1px dashed $border; margin-bottom: 6px; + .kv-label { color: $text-muted; font-size: 11px; margin-left: 6px; } + .kv-value { color: $sms-highlight; font-weight: 600; } +} +.btn-sm { padding: 2px 10px; font-size: 11px; } + .hd-cnt { font-size: 11px; color: #6b7c8d; margin-left: 8px; font-weight: 400; } .sec-body { padding: 10px 14px; background: #161d24; } diff --git a/frontend/src/views/Plan.vue b/frontend/src/views/Plan.vue index b27e7cb..0350ea4 100644 --- a/frontend/src/views/Plan.vue +++ b/frontend/src/views/Plan.vue @@ -57,7 +57,9 @@ - + {{ idx + 1 }} {{ row.cold_coil_no || row.plan_no || '—' }} {{ row.hot_coil_no || '—' }} @@ -74,9 +76,10 @@ {{ row.next_process != null ? row.next_process : '—' }} {{ fmtTime(row.plan_date) }} {{ statusLabel(row.status) }} - + 编辑 - 上线 + 移动 @@ -87,8 +90,44 @@
+ +
+
计划详细 — {{ selectedRow.cold_coil_no || selectedRow.plan_no }}
+
+
钢卷号{{ selectedRow.cold_coil_no || '—' }}
+
来料厚度{{ fmtNum(selectedRow.incoming_thickness) }} [mm]
+
来料宽度{{ fmtNum(selectedRow.incoming_width, 0) }} [mm]
+
1#分卷{{ fmtNum(splitW(0), 3) }} [t]
+ +
钢种{{ selectedRow.steel_grade || '—' }}
+
产品厚度{{ fmtNum(selectedRow.product_thickness) }} [mm]
+
产品宽度{{ fmtNum(selectedRow.product_width, 0) }} [mm]
+
2#分卷{{ fmtNum(splitW(1), 3) }} [t]
+ +
轧制模式{{ selectedRow.rolling_mode || '—' }}
+
偏差上限{{ fmtNum(selectedRow.deviation_upper, 3) }} [mm]
+
分卷数{{ selectedRow.split_count != null ? selectedRow.split_count : 1 }}
+
3#分卷{{ fmtNum(splitW(2), 3) }} [t]
+ +
状态{{ statusLabel(selectedRow.status) }}
+
偏差下限{{ fmtNum(selectedRow.deviation_lower, 3) }} [mm]
+
下工序{{ selectedRow.next_process || '—' }}
+
4#分卷{{ fmtNum(splitW(3), 3) }} [t]
+ +
下达时间{{ fmtTime(selectedRow.plan_date) }}
+
来料重量{{ fmtNum(selectedRow.incoming_weight, 2) }} [t]
+
来料外径{{ fmtNum(selectedRow.incoming_od, 0) }} [mm]
+
5#分卷{{ fmtNum(splitW(4), 3) }} [t]
+ +
+
+
+
6#分卷{{ fmtNum(splitW(5), 3) }} [t]
+
+
+