Add order editing and qualified downtime history

This commit is contained in:
masterdev
2026-06-17 02:45:05 +01:00
parent 565e5ca4cb
commit 7dd7652ea7
5 changed files with 321 additions and 24 deletions

View File

@@ -18,6 +18,7 @@ from .schemas import (
MachineConfigUpdate,
MachineEventIn,
ProductionOrderCreate,
ProductionOrderUpdate,
ScrapCreate,
SimulatorEventCreate,
SimulatorScenarioCreate,
@@ -466,24 +467,27 @@ def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=50
@app.get("/api/production-orders")
def list_production_orders(db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(ProductionOrder).order_by(ProductionOrder.created_at.desc())).scalars()
return [
{
"id": row.id,
"order_number": row.order_number,
"machine_id": row.machine_id,
"article_ref": row.article_ref,
"article_name": row.article_name,
"mold_ref": row.mold_ref,
"material_ref": row.material_ref,
"planned_qty": row.planned_qty,
"cavities": row.cavities,
"theoretical_cycle_time_sec": row.theoretical_cycle_time_sec,
"status": row.status,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
}
for row in rows
]
return [_serialize_production_order(db, row) for row in rows]
def _serialize_production_order(db: Session, row: ProductionOrder) -> dict:
machine = db.get(Machine, row.machine_id)
return {
"id": row.id,
"order_number": row.order_number,
"machine_id": row.machine_id,
"machine_code": machine.code if machine else None,
"article_ref": row.article_ref,
"article_name": row.article_name,
"mold_ref": row.mold_ref,
"material_ref": row.material_ref,
"planned_qty": row.planned_qty,
"cavities": row.cavities,
"theoretical_cycle_time_sec": row.theoretical_cycle_time_sec,
"status": row.status,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
}
@app.post("/api/production-orders")
@@ -503,6 +507,36 @@ def _find_order_or_404(db: Session, order_id: int) -> ProductionOrder:
return order
@app.patch("/api/production-orders/{order_id}")
def update_production_order(order_id: int, payload: ProductionOrderUpdate, db: Session = Depends(get_db)) -> dict:
order = _find_order_or_404(db, order_id)
updates = payload.model_dump(exclude_unset=True)
if not updates:
return _serialize_production_order(db, order)
if "order_number" in updates and updates["order_number"] != order.order_number:
existing = db.execute(select(ProductionOrder).where(ProductionOrder.order_number == updates["order_number"])).scalar_one_or_none()
if existing is not None:
raise HTTPException(status_code=400, detail="Production order number already exists")
if "machine_id" in updates and updates["machine_id"] != order.machine_id:
target_machine = db.get(Machine, updates["machine_id"])
if target_machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
if order.status == "running":
running_order = get_running_order(db, updates["machine_id"])
if running_order and running_order.id != order.id:
raise HTTPException(status_code=400, detail="Another order is already running on the target machine")
for field, value in updates.items():
setattr(order, field, value)
db.commit()
db.refresh(order)
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_updated"})
return _serialize_production_order(db, order)
@app.post("/api/production-orders/{order_id}/start")
def start_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
order = _find_order_or_404(db, order_id)
@@ -565,6 +599,14 @@ def get_unqualified_downtimes(db: Session = Depends(get_db)) -> list[dict]:
return [_serialize_downtime_for_qualification(db, row) for row in rows]
@app.get("/api/downtimes/qualified")
def get_qualified_downtimes(limit: int = Query(default=200, le=500), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
select(Downtime).where(Downtime.reason_code.is_not(None)).order_by(Downtime.start_time.desc()).limit(limit)
).scalars()
return [_serialize_downtime_for_qualification(db, row) for row in rows]
def _serialize_downtime_for_qualification(db: Session, row: Downtime) -> dict:
machine = db.get(Machine, row.machine_id)
order = db.get(ProductionOrder, row.production_order_id) if row.production_order_id else None

View File

@@ -27,6 +27,18 @@ class ProductionOrderCreate(BaseModel):
theoretical_cycle_time_sec: float = Field(gt=0)
class ProductionOrderUpdate(BaseModel):
order_number: str | None = None
machine_id: int | None = None
article_ref: str | None = None
article_name: str | None = None
mold_ref: str | None = None
material_ref: str | None = None
planned_qty: int | None = Field(default=None, gt=0)
cavities: int | None = Field(default=None, gt=0)
theoretical_cycle_time_sec: float | None = Field(default=None, gt=0)
class DowntimeQualify(BaseModel):
reason_code: str
comment: str | None = None

View File

@@ -41,10 +41,12 @@ export default function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
const [orders, setOrders] = useState<ProductionOrder[]>([]);
const [unqualifiedDowntimes, setUnqualifiedDowntimes] = useState<Downtime[]>([]);
const [qualifiedDowntimes, setQualifiedDowntimes] = useState<Downtime[]>([]);
const [scraps, setScraps] = useState<Scrap[]>([]);
const [references, setReferences] = useState<ReferenceData | null>(null);
const [dailyOee, setDailyOee] = useState<DailyOee | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState<number | null>(null);
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
const [activeModule, setActiveModule] = useState<ModuleKey>("atelier");
const [machineFilter, setMachineFilter] = useState("all");
const [machineSort, setMachineSort] = useState("code");
@@ -78,16 +80,19 @@ export default function App() {
}
async function loadCollections() {
const [loadedOrders, loadedUnqualifiedDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([
const [loadedOrders, loadedUnqualifiedDowntimes, loadedQualifiedDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([
fetchJson<ProductionOrder[]>("/api/production-orders"),
fetchJson<Downtime[]>("/api/downtimes/unqualified"),
fetchJson<Downtime[]>("/api/downtimes/qualified"),
fetchJson<Scrap[]>("/api/scraps"),
fetchJson<ReferenceData>("/api/reference-data"),
fetchJson<DailyOee>(`/api/oee/daily?date=${today}`),
]);
setOrders(loadedOrders);
setSelectedOrderId((current) => (current && loadedOrders.some((order) => order.id === current) ? current : loadedOrders[0]?.id ?? null));
setUnqualifiedDowntimes(loadedUnqualifiedDowntimes);
setQualifiedDowntimes(loadedQualifiedDowntimes);
setScraps(loadedScraps);
setReferences(loadedReferences);
setDailyOee(loadedDailyOee);
@@ -192,6 +197,7 @@ export default function App() {
const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null;
const machines = dashboard?.machines ?? [];
const selectedOrder = orders.find((order) => order.id === selectedOrderId) ?? null;
const openDowntimeCount = unqualifiedDowntimes.filter((downtime) => downtime.end_time === null).length;
const selectedMachineUnqualifiedDowntimes =
selectedMachineId === null ? [] : unqualifiedDowntimes.filter((downtime) => downtime.machine_id === selectedMachineId);
@@ -270,7 +276,7 @@ export default function App() {
try {
setActionErrors((current) => ({ ...current, order: null }));
await postJson("/api/production-orders", {
const createdOrder = await postJson<{ id: number; status: string }>("/api/production-orders", {
order_number: formData.get("order_number"),
machine_id: Number(formData.get("machine_id")),
article_ref: formData.get("article_ref"),
@@ -283,6 +289,7 @@ export default function App() {
});
form.reset();
setSelectedOrderId(createdOrder.id);
setFormErrors((current) => ({ ...current, order: {} }));
showToast("order", "Production order created.");
await loadAll();
@@ -291,6 +298,40 @@ export default function App() {
}
}
async function submitOrderUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedOrder) {
setActionErrors((current) => ({ ...current, order: "Select an OF before updating." }));
return;
}
const form = event.currentTarget;
if (!validateForm(form, "order")) {
return;
}
const formData = new FormData(form);
try {
setActionErrors((current) => ({ ...current, order: null }));
await patchJson(`/api/production-orders/${selectedOrder.id}`, {
order_number: formData.get("order_number"),
machine_id: Number(formData.get("machine_id")),
article_ref: formData.get("article_ref"),
article_name: formData.get("article_name"),
mold_ref: formData.get("mold_ref"),
material_ref: formData.get("material_ref"),
planned_qty: Number(formData.get("planned_qty")),
cavities: Number(formData.get("cavities")),
theoretical_cycle_time_sec: Number(formData.get("theoretical_cycle_time_sec")),
});
setFormErrors((current) => ({ ...current, order: {} }));
showToast("order", "Production order updated.");
await loadAll();
} catch (error) {
setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order update failed." }));
}
}
async function submitDowntimeQualification(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
@@ -442,6 +483,30 @@ export default function App() {
return downtime.end_time === null ? "Open - waiting qualification" : "Ended - waiting qualification";
}
function formatDurationSeconds(durationSec: number | null) {
if (durationSec === null) {
return "In progress";
}
if (durationSec < 60) {
return `${durationSec} s`;
}
const minutes = Math.floor(durationSec / 60);
const seconds = durationSec % 60;
if (minutes < 60) {
return seconds > 0 ? `${minutes} min ${seconds} s` : `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours} h ${remainingMinutes} min` : `${hours} h`;
}
function orderMachineLabel(order: ProductionOrder) {
if (order.machine_code) {
return order.machine_code;
}
return machines.find((machine) => machine.id === order.machine_id)?.code ?? `Machine ${order.machine_id}`;
}
function renderModule(): ReactNode {
if (activeModule === "atelier") {
return (
@@ -590,7 +655,7 @@ export default function App() {
if (activeModule === "orders") {
return (
<Panel title="Ordres de Fabrication" subtitle="Create and manage production orders">
<Panel title="Ordres de Fabrication" subtitle={selectedOrder ? `Selected ${selectedOrder.order_number}` : "Create and manage production orders"}>
<div className="module-grid module-grid--form-table">
<form
className="form-grid module-form"
@@ -639,16 +704,22 @@ export default function App() {
<th>OF</th>
<th>Machine</th>
<th>Article</th>
<th>Planned</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>{order.order_number}</td>
<td>{order.machine_id}</td>
<tr key={order.id} className={order.id === selectedOrderId ? "is-selected-row" : ""}>
<td>
<button type="button" className="link-button" onClick={() => setSelectedOrderId(order.id)}>
{order.order_number}
</button>
</td>
<td>{orderMachineLabel(order)}</td>
<td>{order.article_name}</td>
<td>{order.planned_qty}</td>
<td>{order.status}</td>
<td className="table-actions">
<button type="button" onClick={() => changeOrderStatus(order.id, "start")}>
@@ -666,6 +737,79 @@ export default function App() {
</tbody>
</table>
</div>
{selectedOrder ? (
<section className="order-detail">
<div className="order-detail__header">
<div>
<span className="meta-label">OF detail</span>
<h3>{selectedOrder.order_number}</h3>
</div>
<StatusBadge status={selectedOrder.status} />
</div>
<div className="order-detail__meta">
<div>
<span className="meta-label">Machine</span>
<strong>{orderMachineLabel(selectedOrder)}</strong>
</div>
<div>
<span className="meta-label">Article</span>
<strong>{selectedOrder.article_ref} / {selectedOrder.article_name}</strong>
</div>
<div>
<span className="meta-label">Cycle / cavities</span>
<strong>{selectedOrder.theoretical_cycle_time_sec} s / {selectedOrder.cavities}</strong>
</div>
<div>
<span className="meta-label">Dates</span>
<strong>
{selectedOrder.started_at ? new Date(selectedOrder.started_at).toLocaleString() : "Not started"}
{selectedOrder.ended_at ? ` -> ${new Date(selectedOrder.ended_at).toLocaleString()}` : ""}
</strong>
</div>
</div>
<form
className="form-grid config-form"
key={selectedOrder.id}
onSubmit={submitOrderUpdate}
onBlurCapture={(event) => handleFormBlur("order", event.currentTarget)}
onInputCapture={(event) => validateForm(event.currentTarget, "order")}
onChangeCapture={(event) => validateForm(event.currentTarget, "order")}
noValidate
>
<input name="order_number" defaultValue={selectedOrder.order_number} placeholder="OF number" required />
<select name="machine_id" required defaultValue={selectedOrder.machine_id}>
{machines.map((machine) => (
<option key={machine.id} value={machine.id}>
{machine.code}
</option>
))}
</select>
<input name="article_ref" defaultValue={selectedOrder.article_ref} placeholder="Article ref" required />
<input name="article_name" defaultValue={selectedOrder.article_name} placeholder="Article name" required />
<input name="mold_ref" defaultValue={selectedOrder.mold_ref ?? ""} placeholder="Mold ref" />
<input name="material_ref" defaultValue={selectedOrder.material_ref ?? ""} placeholder="Material ref" />
<input name="planned_qty" type="number" min="1" defaultValue={selectedOrder.planned_qty} placeholder="Planned qty" required />
<input name="cavities" type="number" min="1" defaultValue={selectedOrder.cavities} placeholder="Cavities" required />
<input
name="theoretical_cycle_time_sec"
type="number"
min="1"
step="0.1"
defaultValue={selectedOrder.theoretical_cycle_time_sec}
placeholder="Theoretical cycle"
required
/>
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
Update OF
</button>
{actionError("order")}
{toast("order")}
</form>
</section>
) : (
<div className="empty-state">Select an OF to see and update details.</div>
)}
</div>
</Panel>
);
@@ -763,6 +907,32 @@ export default function App() {
))}
</ul>
</section>
<section>
<span className="meta-label">Qualified stops history</span>
<ul className="timeline-list timeline-list--qualified">
{qualifiedDowntimes.map((item) => (
<li key={item.id} className={item.machine_id === selectedMachineId ? "is-current-machine" : ""}>
<div>
<strong>
{downtimeMachineLabel(item)} / {item.reason_code}
</strong>
<span>
{item.order_number ? `${item.order_number} / ` : ""}
{item.qualified_by ? `By ${item.qualified_by} / ` : ""}
{formatDurationSeconds(item.duration_sec)}
{item.comment ? ` / ${item.comment}` : ""}
</span>
</div>
<time>
{new Date(item.start_time).toLocaleString()}
{item.end_time ? ` -> ${new Date(item.end_time).toLocaleString()}` : ""}
</time>
</li>
))}
</ul>
{qualifiedDowntimes.length === 0 ? <div className="empty-state">No qualified downtime history yet.</div> : null}
</section>
</div>
{unqualifiedDowntimes.length === 0 ? <div className="empty-state">No downtime currently needs qualification.</div> : null}
</div>

View File

@@ -99,6 +99,7 @@ export type ProductionOrder = {
id: number;
order_number: string;
machine_id: number;
machine_code?: string | null;
article_ref: string;
article_name: string;
mold_ref: string | null;

View File

@@ -562,6 +562,22 @@ th {
background: var(--status-off-plan);
}
.status-badge--planned {
background: var(--status-off-plan);
}
.status-badge--running {
background: var(--status-production);
}
.status-badge--paused {
background: var(--status-unqualified);
}
.status-badge--closed {
background: var(--status-qualified);
}
.detail-stack {
display: grid;
gap: 14px;
@@ -765,6 +781,10 @@ th {
background: var(--bg-panel);
}
.timeline-list--qualified li::before {
background: var(--status-production);
}
.downtime-board {
display: grid;
gap: 14px;
@@ -907,6 +927,41 @@ table button {
overflow-x: auto;
}
.order-detail {
display: grid;
grid-column: 1 / -1;
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel-alt);
}
.order-detail__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.order-detail__header h3 {
margin: 4px 0 0;
font-family: "Space Grotesk", sans-serif;
font-size: var(--text-xl);
}
.order-detail__meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.order-detail__meta strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
table {
width: 100%;
border-collapse: collapse;
@@ -923,6 +978,22 @@ td {
font-size: var(--text-sm);
}
tr.is-selected-row td {
background: var(--accent-light);
}
.link-button {
min-height: 0;
border: 0;
background: transparent;
color: var(--teal-900);
padding: 0;
font-weight: 700;
text-align: left;
text-decoration: underline;
text-underline-offset: 3px;
}
.table-actions {
display: flex;
gap: 8px;
@@ -981,6 +1052,7 @@ td {
.oee-grid,
.machine-context,
.detail-columns,
.order-detail__meta,
.config-form {
grid-template-columns: 1fr;
}