Initial Plast Track MVP
This commit is contained in:
5
.codexignore
Normal file
5
.codexignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
./archive/*
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
**/__pycache__
|
||||||
|
backend/.pytest_cache
|
||||||
71
.gitignore
vendored
Normal file
71
.gitignore
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Secrets and local environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.gitea
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# OS and editor files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs and runtime files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# Node / frontend artifacts
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
frontend/coverage/
|
||||||
|
frontend/.vite/
|
||||||
|
frontend/*.tsbuildinfo
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Python / backend artifacts
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
backend/.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
backend/.venv/
|
||||||
|
|
||||||
|
# Local databases and generated data
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
pgdata/
|
||||||
|
postgres-data/
|
||||||
|
mosquitto/data/
|
||||||
|
mosquitto/log/
|
||||||
|
|
||||||
|
# Docker / compose local overrides
|
||||||
|
docker-compose.override.yml
|
||||||
|
compose.override.yml
|
||||||
|
|
||||||
|
# Local archives and non-production notes
|
||||||
|
archive/
|
||||||
|
archive/*
|
||||||
331
README.md
Normal file
331
README.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Plast Track MVP
|
||||||
|
|
||||||
|
Lightweight MES/IoT MVP for supervising multi-brand injection molding machines using passive signal acquisition, MQTT event ingestion, operator input, and real-time web dashboards.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
This MVP supervises production without Euromap dependency and without sending any command back to the press. It focuses on:
|
||||||
|
|
||||||
|
- machine status visualization
|
||||||
|
- automatic cycle counting
|
||||||
|
- real cycle time capture
|
||||||
|
- current production order tracking
|
||||||
|
- produced quantity and scrap quantity
|
||||||
|
- automatic downtime detection
|
||||||
|
- operator downtime qualification
|
||||||
|
- scrap declaration
|
||||||
|
- daily OEE/TRS
|
||||||
|
- workshop dashboard in real time
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Passive machine signal / external sensor
|
||||||
|
-> isolated relay / optocoupler / industrial DI module
|
||||||
|
-> IoT gateway or edge service
|
||||||
|
-> MQTT topic
|
||||||
|
-> FastAPI backend
|
||||||
|
-> PostgreSQL
|
||||||
|
-> WebSocket / REST API
|
||||||
|
-> React frontend dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
- `database`: PostgreSQL with schema and seed data
|
||||||
|
- `mqtt`: Mosquitto broker
|
||||||
|
- `backend`: FastAPI API, MQTT consumer, downtime logic, OEE computation, WebSocket notifications
|
||||||
|
- `edge-simulator`: publishes simulated machine events on MQTT
|
||||||
|
- `frontend`: React + TypeScript operator/dashboard interface
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
/backend
|
||||||
|
/frontend
|
||||||
|
/edge-simulator
|
||||||
|
/database/init.sql
|
||||||
|
/database/seed.sql
|
||||||
|
/mqtt/mosquitto.conf
|
||||||
|
/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Docker Desktop or Docker Engine with Compose
|
||||||
|
|
||||||
|
Start the full MVP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Exposed services:
|
||||||
|
|
||||||
|
- frontend: `http://localhost:5173`
|
||||||
|
- backend API: `http://localhost:8000`
|
||||||
|
- backend health: `http://localhost:8000/health`
|
||||||
|
- PostgreSQL: `localhost:5432`
|
||||||
|
- MQTT: `localhost:1883`
|
||||||
|
|
||||||
|
## Demo Data
|
||||||
|
|
||||||
|
Seeded machines:
|
||||||
|
|
||||||
|
- `INJ-01` - Haitian Mars II
|
||||||
|
- `INJ-02` - Engel e-victory
|
||||||
|
- `INJ-03` - Arburg Allrounder
|
||||||
|
|
||||||
|
Seeded production orders:
|
||||||
|
|
||||||
|
- `OF-1001` running on `INJ-01`
|
||||||
|
- `OF-1002` planned on `INJ-02`
|
||||||
|
|
||||||
|
The edge simulator continuously publishes cycles and stop events for the demo machines.
|
||||||
|
|
||||||
|
## MQTT Topics And Payloads
|
||||||
|
|
||||||
|
Topic pattern:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plasttrack/machines/{machine_code}/events
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw signal event example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "digital_input_changed",
|
||||||
|
"timestamp": "2026-06-14T10:32:15Z",
|
||||||
|
"input_name": "cycle_signal",
|
||||||
|
"input_value": true,
|
||||||
|
"cycle_time_sec": 18.7,
|
||||||
|
"source": "digital_input"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend derives valid cycles from the configured signal edge and timing rules. It still stores normalized `cycle_completed` events if upstream integrations publish them directly.
|
||||||
|
|
||||||
|
Automatic stop event example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "machine_stopped",
|
||||||
|
"timestamp": "2026-06-14T10:35:20Z",
|
||||||
|
"auto_detected": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
Machines:
|
||||||
|
|
||||||
|
- `GET /api/dashboard`
|
||||||
|
- `GET /api/machines`
|
||||||
|
- `GET /api/machines/{machine_id}`
|
||||||
|
- `GET /api/machines/{machine_id}/config`
|
||||||
|
- `PATCH /api/machines/{machine_id}/config`
|
||||||
|
- `GET /api/machines/{machine_id}/status`
|
||||||
|
- `GET /api/machines/{machine_id}/events`
|
||||||
|
- `GET /api/machines/{machine_id}/cycles`
|
||||||
|
- `GET /api/machines/{machine_id}/downtimes`
|
||||||
|
|
||||||
|
Production orders:
|
||||||
|
|
||||||
|
- `GET /api/production-orders`
|
||||||
|
- `POST /api/production-orders`
|
||||||
|
- `POST /api/production-orders/{id}/start`
|
||||||
|
- `POST /api/production-orders/{id}/pause`
|
||||||
|
- `POST /api/production-orders/{id}/close`
|
||||||
|
|
||||||
|
Downtimes:
|
||||||
|
|
||||||
|
- `GET /api/downtimes/open`
|
||||||
|
- `POST /api/downtimes/{id}/qualify`
|
||||||
|
|
||||||
|
Scrap:
|
||||||
|
|
||||||
|
- `GET /api/scraps`
|
||||||
|
- `POST /api/scraps`
|
||||||
|
|
||||||
|
OEE:
|
||||||
|
|
||||||
|
- `GET /api/oee/daily?date=2026-06-14`
|
||||||
|
- `GET /api/oee/machines/{machine_id}?from=2026-06-14T00:00:00Z&to=2026-06-14T23:59:59Z`
|
||||||
|
|
||||||
|
Reference data:
|
||||||
|
|
||||||
|
- `GET /api/reference-data`
|
||||||
|
|
||||||
|
WebSocket:
|
||||||
|
|
||||||
|
- `ws://localhost:8000/ws/dashboard`
|
||||||
|
|
||||||
|
## Manual API Checks
|
||||||
|
|
||||||
|
List machines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/machines
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a production order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/production-orders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"order_number": "OF-2001",
|
||||||
|
"machine_id": 2,
|
||||||
|
"article_ref": "ART-2001",
|
||||||
|
"article_name": "Bac Technique",
|
||||||
|
"mold_ref": "M-2001",
|
||||||
|
"material_ref": "PP-BLACK",
|
||||||
|
"planned_qty": 1200,
|
||||||
|
"cavities": 2,
|
||||||
|
"theoretical_cycle_time_sec": 21.5
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Qualify a downtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/downtimes/1/qualify \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"reason_code": "attente_matiere",
|
||||||
|
"comment": "Material not available at the machine",
|
||||||
|
"qualified_by": "operateur_1"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Declare scrap:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/scraps \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"production_order_id": 1,
|
||||||
|
"quantity": 12,
|
||||||
|
"reason_code": "bavure",
|
||||||
|
"comment": "Flash on parting line",
|
||||||
|
"operator_name": "operateur_1"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## OEE Logic
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OEE = Availability x Performance x Quality
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented rules:
|
||||||
|
|
||||||
|
- planned time: overlap of started production orders with the query window
|
||||||
|
- downtime: overlap of machine downtimes with the query window
|
||||||
|
- operating time: `planned time - downtime`
|
||||||
|
- performance: `theoretical cycle contribution / operating time`, capped at `100%`
|
||||||
|
- quality: `good quantity / total produced quantity`
|
||||||
|
|
||||||
|
## How The Simulator Works
|
||||||
|
|
||||||
|
The simulator publishes:
|
||||||
|
|
||||||
|
- `digital_input_changed` for `machine_power_on`
|
||||||
|
- `digital_input_changed` for `cycle_signal`
|
||||||
|
- `digital_input_changed` for `general_alarm`
|
||||||
|
|
||||||
|
Each machine gets a nominal cycle time, a pulse width, and a random stop probability. The backend consumes these MQTT events, detects the configured cycle edge, and updates production data in PostgreSQL.
|
||||||
|
|
||||||
|
## Connecting A Real Passive Gateway Later
|
||||||
|
|
||||||
|
To replace the simulator with a real gateway:
|
||||||
|
|
||||||
|
1. Read passive, electrically isolated machine signals only.
|
||||||
|
2. Publish normalized JSON events to the same MQTT topic pattern.
|
||||||
|
3. Keep event timestamps in UTC ISO-8601 format.
|
||||||
|
4. Map per-machine debounce, min/max cycle time, and stop detection delay in the database or future admin UI.
|
||||||
|
|
||||||
|
Expected minimum signals:
|
||||||
|
|
||||||
|
- `machine_power_on`
|
||||||
|
- `cycle_signal`
|
||||||
|
|
||||||
|
Optional signals:
|
||||||
|
|
||||||
|
- `auto_mode`
|
||||||
|
- `mold_open`
|
||||||
|
- `ejector_forward`
|
||||||
|
- `general_alarm`
|
||||||
|
- `pump_running`
|
||||||
|
|
||||||
|
## Industrial Safety
|
||||||
|
|
||||||
|
- Never wire the IoT gateway directly to PLC outputs or machine circuits.
|
||||||
|
- Use interface relays, optocouplers, or isolated industrial input modules.
|
||||||
|
- Validate every wiring decision with a qualified automation or industrial electrical technician.
|
||||||
|
- Keep acquisition passive and non-intrusive.
|
||||||
|
- Do not modify the machine PLC logic for this MVP.
|
||||||
|
- This MVP must never command or pilot the press.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Backend tests included:
|
||||||
|
|
||||||
|
- OEE calculation unit test
|
||||||
|
- MQTT event parsing test
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend build check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Limits
|
||||||
|
|
||||||
|
- No authentication or role management
|
||||||
|
- No production scheduling calendar
|
||||||
|
- No historian-grade buffering on the edge side
|
||||||
|
- No historical reporting endpoints beyond daily OEE/TRS
|
||||||
|
- No real passive gateway adapter package yet; the simulator publishes normalized MQTT payloads
|
||||||
|
- WebSocket currently pushes refresh notifications, then the UI refetches data
|
||||||
|
- The simulator models passive-signal-derived events, not actual electrical IO reads
|
||||||
|
|
||||||
|
## Current UI Modules
|
||||||
|
|
||||||
|
The frontend is organized into module tabs rather than one long dashboard page:
|
||||||
|
|
||||||
|
- `Atelier`: machine cards, status filtering, sorting, quick actions, fullscreen workshop mode
|
||||||
|
- `Machine`: selected machine detail, cycle trend, recent events, downtime history
|
||||||
|
- `TRS`: daily OEE/TRS summary and per-machine OEE
|
||||||
|
- `OF`: production order creation and status management
|
||||||
|
- `Arrets`: open downtime qualification
|
||||||
|
- `Rebuts`: scrap declaration and scrap log
|
||||||
|
- `Config`: persistent passive signal and timing configuration per machine
|
||||||
|
|
||||||
|
Operator forms include inline validation, inline API error feedback, and success toasts.
|
||||||
|
|
||||||
|
## Industrial Readiness Backlog
|
||||||
|
|
||||||
|
Next hardening steps before a real shop-floor pilot:
|
||||||
|
|
||||||
|
- define a real passive gateway adapter spec with exact input mapping and MQTT payload examples
|
||||||
|
- add edge-side buffering for MQTT/backend outage periods
|
||||||
|
- expand tests for downtime transitions, order status transitions, and signal debounce edge cases
|
||||||
|
- add historical reporting endpoints for OEE, downtime, and scrap trends
|
||||||
267
UI.md
Normal file
267
UI.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# UI Guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This file documents the current frontend UI direction for `Plast Track MVP`.
|
||||||
|
|
||||||
|
The product surface is now organized as module tabs instead of one long dashboard page. The goal is to keep the operator focused on one task area at a time while preserving fast access to workshop status, selected machine context, and actions.
|
||||||
|
|
||||||
|
## UI Direction
|
||||||
|
|
||||||
|
The current UI aims for:
|
||||||
|
|
||||||
|
- modern industrial dashboard
|
||||||
|
- simple and readable operator flows
|
||||||
|
- elegant but restrained visual language
|
||||||
|
- fast status scanning
|
||||||
|
- clear separation between monitoring modules and operator actions
|
||||||
|
|
||||||
|
Design principles:
|
||||||
|
|
||||||
|
- show machine state first
|
||||||
|
- reduce visual noise
|
||||||
|
- separate workflows into tabs
|
||||||
|
- keep passive acquisition and machine safety explicit
|
||||||
|
- avoid sending any command back to machines
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The application uses a tabbed module workspace:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hero Header
|
||||||
|
Summary Ribbon
|
||||||
|
Module Tabs
|
||||||
|
Current Module Panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Current module tabs:
|
||||||
|
|
||||||
|
- `Atelier`: machine cards, filtering, sorting, quick actions, fullscreen workshop mode
|
||||||
|
- `Machine`: selected machine detail, cycles, events, downtime history
|
||||||
|
- `TRS`: daily OEE/TRS and per-machine OEE
|
||||||
|
- `OF`: production order creation and status management
|
||||||
|
- `Arrets`: open downtime qualification and timeline
|
||||||
|
- `Rebuts`: scrap declaration and recent scrap log
|
||||||
|
- `Config`: selected machine signal and timing configuration
|
||||||
|
|
||||||
|
Desktop, tablet, and mobile all use the same module model. On smaller screens, tab buttons and module content stack naturally without horizontal page scroll.
|
||||||
|
|
||||||
|
## Main UI Blocks
|
||||||
|
|
||||||
|
### 1. Hero Header
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- establish product identity
|
||||||
|
- show realtime connection status
|
||||||
|
- provide top-level hierarchy
|
||||||
|
|
||||||
|
Content:
|
||||||
|
|
||||||
|
- product title
|
||||||
|
- realtime status
|
||||||
|
- error feedback if data loading fails
|
||||||
|
|
||||||
|
### 2. Summary Ribbon
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- provide workshop-level scanning before entering a module
|
||||||
|
|
||||||
|
Current metrics:
|
||||||
|
|
||||||
|
- global OEE
|
||||||
|
- machines in production
|
||||||
|
- open downtimes
|
||||||
|
- produced quantity vs scrap quantity
|
||||||
|
|
||||||
|
### 3. Module Tabs
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- separate supervision, production order, downtime, scrap, TRS, and configuration workflows
|
||||||
|
- avoid crowding all operator forms on one page
|
||||||
|
- keep each module readable on tablets and production displays
|
||||||
|
|
||||||
|
### 4. Atelier Module
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- show every machine at a glance
|
||||||
|
- support quick triage and routing to the right workflow
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- machine card grid
|
||||||
|
- status filter
|
||||||
|
- sort by code, OEE, or stops first
|
||||||
|
- fullscreen workshop mode
|
||||||
|
- state duration badge
|
||||||
|
- no-active-OF empty state
|
||||||
|
- quick actions to OF, Rebuts, and Arrets modules
|
||||||
|
|
||||||
|
### 5. Machine Module
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- turn the selected machine into the focal point
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
- status KPIs
|
||||||
|
- current order
|
||||||
|
- average cycle
|
||||||
|
- daily OEE
|
||||||
|
- machine context
|
||||||
|
- recent cycle SVG bars
|
||||||
|
- recent events
|
||||||
|
- downtime history
|
||||||
|
|
||||||
|
### 6. TRS Module
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- keep daily performance visible without mixing it with operator forms
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
- global OEE
|
||||||
|
- availability
|
||||||
|
- performance
|
||||||
|
- quality
|
||||||
|
- per-machine OEE list
|
||||||
|
|
||||||
|
### 7. Operator Action Modules
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- keep write workflows focused and auditable
|
||||||
|
|
||||||
|
Modules:
|
||||||
|
|
||||||
|
- `OF`: create, start, pause, and close production orders
|
||||||
|
- `Arrets`: qualify open downtimes
|
||||||
|
- `Rebuts`: declare scrap
|
||||||
|
- `Config`: update passive signal configuration
|
||||||
|
|
||||||
|
All forms use inline validation, inline API error feedback, and success toasts.
|
||||||
|
|
||||||
|
## Visual Language
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Fonts:
|
||||||
|
|
||||||
|
- `Instrument Sans` for interface text
|
||||||
|
- `Space Grotesk` for headings and numeric emphasis
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
Base direction:
|
||||||
|
|
||||||
|
- light industrial gray background
|
||||||
|
- industrial blue primary structure
|
||||||
|
- anthracite text and secondary structure
|
||||||
|
- technical orange active accent
|
||||||
|
- white panels for contrast
|
||||||
|
|
||||||
|
Final palette:
|
||||||
|
|
||||||
|
| Usage | Color | Code |
|
||||||
|
|-------|-------|------|
|
||||||
|
| Couleur principale | Bleu industriel | `#0A2F5A` |
|
||||||
|
| Couleur secondaire | Gris anthracite | `#2B2B2B` |
|
||||||
|
| Couleur accent | Orange technique | `#F28C28` |
|
||||||
|
| Fond clair | Gris tres clair | `#F5F7FA` |
|
||||||
|
| Fond / contraste | Blanc | `#FFFFFF` |
|
||||||
|
|
||||||
|
Status colors:
|
||||||
|
|
||||||
|
- `production`: industrial blue
|
||||||
|
- `arret_non_qualifie`: technical orange
|
||||||
|
- `arret_qualifie`: anthracite
|
||||||
|
- `reglage`: mixed industrial blue / orange
|
||||||
|
- `hors_planning`: neutral anthracite tint
|
||||||
|
|
||||||
|
### Surfaces
|
||||||
|
|
||||||
|
Surfaces use:
|
||||||
|
|
||||||
|
- rounded panels
|
||||||
|
- soft shadows
|
||||||
|
- token-driven borders
|
||||||
|
- high contrast status text
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
Desktop:
|
||||||
|
|
||||||
|
- centered workspace
|
||||||
|
- full tab row
|
||||||
|
- wide machine grid
|
||||||
|
- split layouts inside form/list modules
|
||||||
|
|
||||||
|
Tablet:
|
||||||
|
|
||||||
|
- tab row wraps to fewer columns
|
||||||
|
- form/list modules stack
|
||||||
|
|
||||||
|
Mobile:
|
||||||
|
|
||||||
|
- single-column flow
|
||||||
|
- summary cards stack
|
||||||
|
- tabs stack
|
||||||
|
- tables scroll horizontally only inside their wrapper
|
||||||
|
- touch targets remain at least 44px high
|
||||||
|
|
||||||
|
## Operator Experience
|
||||||
|
|
||||||
|
The UI is designed so an operator or supervisor can quickly:
|
||||||
|
|
||||||
|
1. see which machines are running or stopped
|
||||||
|
2. filter or sort machines by operational need
|
||||||
|
3. open a selected machine detail view
|
||||||
|
4. create or manage production orders
|
||||||
|
5. qualify downtime
|
||||||
|
6. declare scrap
|
||||||
|
7. review daily TRS/OEE
|
||||||
|
8. configure passive signal timing per machine
|
||||||
|
|
||||||
|
## Current Frontend Files
|
||||||
|
|
||||||
|
Core files:
|
||||||
|
|
||||||
|
- [frontend/src/App.tsx](/d:/@@@DEV/apps/plast%20track/frontend/src/App.tsx:1)
|
||||||
|
- [frontend/src/styles.css](/d:/@@@DEV/apps/plast%20track/frontend/src/styles.css:1)
|
||||||
|
- [frontend/src/components/MachineCard.tsx](/d:/@@@DEV/apps/plast%20track/frontend/src/components/MachineCard.tsx:1)
|
||||||
|
- [frontend/src/components/Panel.tsx](/d:/@@@DEV/apps/plast%20track/frontend/src/components/Panel.tsx:1)
|
||||||
|
- [frontend/src/components/CycleBarChart.tsx](/d:/@@@DEV/apps/plast%20track/frontend/src/components/CycleBarChart.tsx:1)
|
||||||
|
|
||||||
|
## Suggested Next UI Improvements
|
||||||
|
|
||||||
|
Near-term:
|
||||||
|
|
||||||
|
- improve table empty states for OF and machine event history
|
||||||
|
- add optimistic row updates for order and downtime actions
|
||||||
|
- add tablet-first operator shortcuts for common reason codes
|
||||||
|
|
||||||
|
Medium-term:
|
||||||
|
|
||||||
|
- create separate operator and supervisor views
|
||||||
|
- add richer downtime timeline with duration buckets
|
||||||
|
- add historical trend charts for OEE and scrap
|
||||||
|
- add saved workshop display presets
|
||||||
|
|
||||||
|
High-value UX additions:
|
||||||
|
|
||||||
|
- keyboard-friendly action flow for industrial tablets
|
||||||
|
- larger gloved-use mode
|
||||||
|
- alarm emphasis rules without overusing red
|
||||||
|
- quick action drawer for the selected machine
|
||||||
|
|
||||||
|
## UI Goal
|
||||||
|
|
||||||
|
The target is not a generic admin panel.
|
||||||
|
|
||||||
|
The target is a calm, production-oriented interface that helps people notice problems fast, act with minimal friction, and understand machine performance in context.
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Plast Track MVP backend package."""
|
||||||
21
backend/app/config.py
Normal file
21
backend/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "Plast Track MVP"
|
||||||
|
database_url: str = "postgresql+psycopg://plasttrack:plasttrack@database:5432/plasttrack"
|
||||||
|
mqtt_host: str = "mqtt"
|
||||||
|
mqtt_port: int = 1883
|
||||||
|
mqtt_topic: str = "plasttrack/machines/+/events"
|
||||||
|
mqtt_client_id: str = "plast-track-backend"
|
||||||
|
frontend_origin: str = "http://localhost:5173"
|
||||||
|
downtime_check_interval_sec: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
23
backend/app/database.py
Normal file
23
backend/app/database.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
466
backend/app/main.py
Normal file
466
backend/app/main.py
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .database import Base, SessionLocal, engine, get_db
|
||||||
|
from .models import Cycle, Downtime, DowntimeReason, Machine, MachineEvent, ProductionOrder, Scrap, ScrapReason
|
||||||
|
from .schemas import DowntimeQualify, MachineConfigUpdate, ProductionOrderCreate, ScrapCreate
|
||||||
|
from .services.dashboard import build_dashboard_snapshot, serialize_machine_card, serialize_machine_config
|
||||||
|
from .services.events import detect_missing_cycles, get_open_downtime, get_running_order
|
||||||
|
from .services.mqtt_listener import MqttListener
|
||||||
|
from .services.oee import compute_daily_oee, compute_machine_oee
|
||||||
|
from .services.realtime import hub
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
mqtt_listener = MqttListener(hub.notify)
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def seed_defaults(session: Session) -> None:
|
||||||
|
if session.execute(select(func.count(Machine.id))).scalar_one() == 0:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
Machine(code="INJ-01", name="INJ-01", brand="Haitian", model="Mars II"),
|
||||||
|
Machine(code="INJ-02", name="INJ-02", brand="Engel", model="e-victory"),
|
||||||
|
Machine(code="INJ-03", name="INJ-03", brand="Arburg", model="Allrounder"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if session.execute(select(func.count(DowntimeReason.code))).scalar_one() == 0:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
DowntimeReason(code="changement_moule", label="Changement moule"),
|
||||||
|
DowntimeReason(code="reglage", label="Reglage"),
|
||||||
|
DowntimeReason(code="attente_matiere", label="Attente matiere"),
|
||||||
|
DowntimeReason(code="attente_operateur", label="Attente operateur"),
|
||||||
|
DowntimeReason(code="panne_machine", label="Panne machine"),
|
||||||
|
DowntimeReason(code="panne_moule", label="Panne moule"),
|
||||||
|
DowntimeReason(code="attente_qualite", label="Attente qualite"),
|
||||||
|
DowntimeReason(code="nettoyage", label="Nettoyage"),
|
||||||
|
DowntimeReason(code="pause", label="Pause"),
|
||||||
|
DowntimeReason(code="micro_arret", label="Micro arret"),
|
||||||
|
DowntimeReason(code="autre", label="Autre"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if session.execute(select(func.count(ScrapReason.code))).scalar_one() == 0:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ScrapReason(code="bavure", label="Bavure"),
|
||||||
|
ScrapReason(code="manque_matiere", label="Manque matiere"),
|
||||||
|
ScrapReason(code="brulure", label="Brulure"),
|
||||||
|
ScrapReason(code="retassure", label="Retassure"),
|
||||||
|
ScrapReason(code="deformation", label="Deformation"),
|
||||||
|
ScrapReason(code="point_noir", label="Point noir"),
|
||||||
|
ScrapReason(code="humidite", label="Humidite"),
|
||||||
|
ScrapReason(code="couleur_non_conforme", label="Couleur non conforme"),
|
||||||
|
ScrapReason(code="collage_moule", label="Collage moule"),
|
||||||
|
ScrapReason(code="casse_piece", label="Casse piece"),
|
||||||
|
ScrapReason(code="defaut_dimensionnel", label="Defaut dimensionnel"),
|
||||||
|
ScrapReason(code="autre", label="Autre"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if session.execute(select(func.count(ProductionOrder.id))).scalar_one() == 0:
|
||||||
|
machine_1 = session.execute(select(Machine).where(Machine.code == "INJ-01")).scalar_one()
|
||||||
|
machine_2 = session.execute(select(Machine).where(Machine.code == "INJ-02")).scalar_one()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ProductionOrder(
|
||||||
|
order_number="OF-1001",
|
||||||
|
machine_id=machine_1.id,
|
||||||
|
article_ref="ART-001",
|
||||||
|
article_name="Boitier 1L",
|
||||||
|
mold_ref="M-001",
|
||||||
|
material_ref="PP-NEUTRAL",
|
||||||
|
planned_qty=4000,
|
||||||
|
cavities=2,
|
||||||
|
theoretical_cycle_time_sec=18.5,
|
||||||
|
status="running",
|
||||||
|
started_at=now,
|
||||||
|
),
|
||||||
|
ProductionOrder(
|
||||||
|
order_number="OF-1002",
|
||||||
|
machine_id=machine_2.id,
|
||||||
|
article_ref="ART-002",
|
||||||
|
article_name="Capot Technique",
|
||||||
|
mold_ref="M-002",
|
||||||
|
material_ref="ABS-BLACK",
|
||||||
|
planned_qty=2500,
|
||||||
|
cavities=1,
|
||||||
|
theoretical_cycle_time_sec=24.0,
|
||||||
|
status="running",
|
||||||
|
started_at=now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def downtime_monitor() -> None:
|
||||||
|
while True:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
changes = detect_missing_cycles(session)
|
||||||
|
for change in changes:
|
||||||
|
hub.notify({"type": "refresh", "source": "downtime-monitor", **change})
|
||||||
|
await asyncio.sleep(settings.downtime_check_interval_sec)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
with SessionLocal() as session:
|
||||||
|
seed_defaults(session)
|
||||||
|
|
||||||
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
mqtt_listener.start()
|
||||||
|
app.state.downtime_task = asyncio.create_task(downtime_monitor())
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
mqtt_listener.stop()
|
||||||
|
app.state.downtime_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def healthcheck() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/dashboard")
|
||||||
|
def get_dashboard(db: Session = Depends(get_db)) -> dict:
|
||||||
|
return build_dashboard_snapshot(db)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/reference-data")
|
||||||
|
def get_reference_data(db: Session = Depends(get_db)) -> dict:
|
||||||
|
return {
|
||||||
|
"downtime_reasons": [
|
||||||
|
{"code": row.code, "label": row.label}
|
||||||
|
for row in db.execute(select(DowntimeReason).order_by(DowntimeReason.label)).scalars().all()
|
||||||
|
],
|
||||||
|
"scrap_reasons": [
|
||||||
|
{"code": row.code, "label": row.label}
|
||||||
|
for row in db.execute(select(ScrapReason).order_by(ScrapReason.label)).scalars().all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines")
|
||||||
|
def list_machines(db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
machines = db.execute(select(Machine).order_by(Machine.code)).scalars().all()
|
||||||
|
return [serialize_machine_card(db, machine) for machine in machines]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}")
|
||||||
|
def get_machine(machine_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine = db.get(Machine, machine_id)
|
||||||
|
if machine is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
return serialize_machine_card(db, machine)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}/config")
|
||||||
|
def get_machine_config(machine_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine = db.get(Machine, machine_id)
|
||||||
|
if machine is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/machines/{machine_id}/config")
|
||||||
|
def update_machine_config(machine_id: int, payload: MachineConfigUpdate, db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine = db.get(Machine, machine_id)
|
||||||
|
if machine is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "cycle_signal_edge" in updates and updates["cycle_signal_edge"] not in {"rising", "falling"}:
|
||||||
|
raise HTTPException(status_code=400, detail="cycle_signal_edge must be rising or falling")
|
||||||
|
|
||||||
|
min_cycle = updates.get("min_cycle_time_sec", machine.min_cycle_time_sec)
|
||||||
|
max_cycle = updates.get("max_cycle_time_sec", machine.max_cycle_time_sec)
|
||||||
|
if min_cycle > max_cycle:
|
||||||
|
raise HTTPException(status_code=400, detail="min_cycle_time_sec must be less than or equal to max_cycle_time_sec")
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
|
setattr(machine, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(machine)
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "machine_config_updated"})
|
||||||
|
return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}/status")
|
||||||
|
def get_machine_status(machine_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine = db.get(Machine, machine_id)
|
||||||
|
if machine is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
running_order = get_running_order(db, machine.id)
|
||||||
|
open_downtime = get_open_downtime(db, machine.id)
|
||||||
|
return {
|
||||||
|
"machine_id": machine.id,
|
||||||
|
"code": machine.code,
|
||||||
|
"status": machine.status,
|
||||||
|
"powered_on": machine.powered_on,
|
||||||
|
"last_cycle_at": machine.last_cycle_at.isoformat() if machine.last_cycle_at else None,
|
||||||
|
"active_order_id": running_order.id if running_order else None,
|
||||||
|
"open_downtime_id": open_downtime.id if open_downtime else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}/events")
|
||||||
|
def get_machine_events(machine_id: int, limit: int = Query(default=50, le=200), db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(MachineEvent).where(MachineEvent.machine_id == machine_id).order_by(MachineEvent.timestamp.desc()).limit(limit)
|
||||||
|
).scalars()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"timestamp": row.timestamp.isoformat(),
|
||||||
|
"payload": row.payload,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}/cycles")
|
||||||
|
def get_machine_cycles(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(Cycle).where(Cycle.machine_id == machine_id).order_by(Cycle.timestamp.desc()).limit(limit)
|
||||||
|
).scalars()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"timestamp": row.timestamp.isoformat(),
|
||||||
|
"cycle_time_sec": row.cycle_time_sec,
|
||||||
|
"produced_qty": row.produced_qty,
|
||||||
|
"is_valid": row.is_valid,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/machines/{machine_id}/downtimes")
|
||||||
|
def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(Downtime).where(Downtime.machine_id == machine_id).order_by(Downtime.start_time.desc()).limit(limit)
|
||||||
|
).scalars()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"start_time": row.start_time.isoformat(),
|
||||||
|
"end_time": row.end_time.isoformat() if row.end_time else None,
|
||||||
|
"duration_sec": row.duration_sec,
|
||||||
|
"reason_code": row.reason_code,
|
||||||
|
"comment": row.comment,
|
||||||
|
"auto_detected": row.auto_detected,
|
||||||
|
"qualified_by": row.qualified_by,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/production-orders")
|
||||||
|
def create_production_order(payload: ProductionOrderCreate, db: Session = Depends(get_db)) -> dict:
|
||||||
|
order = ProductionOrder(**payload.model_dump(), status="planned")
|
||||||
|
db.add(order)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_created"})
|
||||||
|
return {"id": order.id, "status": order.status}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_order_or_404(db: Session, order_id: int) -> ProductionOrder:
|
||||||
|
order = db.get(ProductionOrder, order_id)
|
||||||
|
if order is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Production order not found")
|
||||||
|
return 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)
|
||||||
|
running_order = get_running_order(db, order.machine_id)
|
||||||
|
if running_order and running_order.id != order.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Another order is already running on this machine")
|
||||||
|
order.status = "running"
|
||||||
|
order.started_at = order.started_at or datetime.now(UTC)
|
||||||
|
machine = db.get(Machine, order.machine_id)
|
||||||
|
if machine:
|
||||||
|
machine.status = "production"
|
||||||
|
machine.status_since = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_started"})
|
||||||
|
return {"id": order.id, "status": order.status}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/production-orders/{order_id}/pause")
|
||||||
|
def pause_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
order = _find_order_or_404(db, order_id)
|
||||||
|
order.status = "paused"
|
||||||
|
machine = db.get(Machine, order.machine_id)
|
||||||
|
if machine:
|
||||||
|
machine.status = "hors_planning"
|
||||||
|
machine.status_since = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_paused"})
|
||||||
|
return {"id": order.id, "status": order.status}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/production-orders/{order_id}/close")
|
||||||
|
def close_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
|
order = _find_order_or_404(db, order_id)
|
||||||
|
order.status = "closed"
|
||||||
|
order.ended_at = datetime.now(UTC)
|
||||||
|
machine = db.get(Machine, order.machine_id)
|
||||||
|
if machine:
|
||||||
|
machine.status = "hors_planning"
|
||||||
|
machine.status_since = datetime.now(UTC)
|
||||||
|
open_downtime = get_open_downtime(db, order.machine_id)
|
||||||
|
if open_downtime is not None:
|
||||||
|
open_downtime.end_time = datetime.now(UTC)
|
||||||
|
open_downtime.duration_sec = max(int((open_downtime.end_time - open_downtime.start_time).total_seconds()), 0)
|
||||||
|
db.commit()
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_closed"})
|
||||||
|
return {"id": order.id, "status": order.status}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/downtimes/open")
|
||||||
|
def get_open_downtimes(db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
rows = db.execute(select(Downtime).where(Downtime.end_time.is_(None)).order_by(Downtime.start_time.asc())).scalars()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"machine_id": row.machine_id,
|
||||||
|
"production_order_id": row.production_order_id,
|
||||||
|
"start_time": row.start_time.isoformat(),
|
||||||
|
"reason_code": row.reason_code,
|
||||||
|
"comment": row.comment,
|
||||||
|
"auto_detected": row.auto_detected,
|
||||||
|
"qualified_by": row.qualified_by,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/downtimes/{downtime_id}/qualify")
|
||||||
|
def qualify_downtime(downtime_id: int, payload: DowntimeQualify, db: Session = Depends(get_db)) -> dict:
|
||||||
|
downtime = db.get(Downtime, downtime_id)
|
||||||
|
if downtime is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Downtime not found")
|
||||||
|
downtime.reason_code = payload.reason_code
|
||||||
|
downtime.comment = payload.comment
|
||||||
|
downtime.qualified_by = payload.qualified_by
|
||||||
|
machine = db.get(Machine, downtime.machine_id)
|
||||||
|
if machine and downtime.end_time is None:
|
||||||
|
machine.status = "reglage" if payload.reason_code in {"reglage", "changement_moule"} else "arret_qualifie"
|
||||||
|
machine.status_since = datetime.now(UTC)
|
||||||
|
db.commit()
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": downtime.machine_id, "event_type": "downtime_qualified"})
|
||||||
|
return {"id": downtime.id, "reason_code": downtime.reason_code}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scraps")
|
||||||
|
def create_scrap(payload: ScrapCreate, db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine = db.execute(select(Machine).where(Machine.code == payload.machine_id)).scalar_one_or_none()
|
||||||
|
if machine is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
scrap = Scrap(
|
||||||
|
machine_id=machine.id,
|
||||||
|
production_order_id=payload.production_order_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
reason_code=payload.reason_code,
|
||||||
|
comment=payload.comment,
|
||||||
|
operator_name=payload.operator_name,
|
||||||
|
timestamp=payload.timestamp or datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(scrap)
|
||||||
|
db.commit()
|
||||||
|
hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "scrap_created"})
|
||||||
|
return {"id": scrap.id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/scraps")
|
||||||
|
def list_scraps(db: Session = Depends(get_db)) -> list[dict]:
|
||||||
|
rows = db.execute(select(Scrap).order_by(Scrap.timestamp.desc()).limit(200)).scalars()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"machine_id": row.machine_id,
|
||||||
|
"production_order_id": row.production_order_id,
|
||||||
|
"quantity": row.quantity,
|
||||||
|
"reason_code": row.reason_code,
|
||||||
|
"comment": row.comment,
|
||||||
|
"operator_name": row.operator_name,
|
||||||
|
"timestamp": row.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/oee/daily")
|
||||||
|
def get_daily_oee(date_value: date = Query(alias="date"), db: Session = Depends(get_db)) -> dict:
|
||||||
|
machine_ids = db.execute(select(Machine.id).order_by(Machine.code)).scalars().all()
|
||||||
|
return compute_daily_oee(db, date_value, list(machine_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/oee/machines/{machine_id}")
|
||||||
|
def get_machine_oee(machine_id: int, from_value: datetime = Query(alias="from"), to_value: datetime = Query(alias="to"), db: Session = Depends(get_db)) -> dict:
|
||||||
|
if db.get(Machine, machine_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
|
return compute_machine_oee(db, machine_id, from_value, to_value)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/dashboard")
|
||||||
|
async def dashboard_websocket(websocket: WebSocket):
|
||||||
|
await hub.connect(websocket)
|
||||||
|
try:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
await websocket.send_json({"type": "snapshot", "data": build_dashboard_snapshot(session)})
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
hub.disconnect(websocket)
|
||||||
126
backend/app/models.py
Normal file
126
backend/app/models.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Machine(Base):
|
||||||
|
__tablename__ = "machines"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120))
|
||||||
|
brand: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
model: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
status: Mapped[str] = mapped_column(String(50), default="hors_planning")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
powered_on: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
cycle_signal_edge: Mapped[str] = mapped_column(String(20), default="rising")
|
||||||
|
debounce_ms: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
|
min_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
max_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
|
stop_detection_delay_sec: Mapped[int] = mapped_column(Integer, default=180)
|
||||||
|
last_cycle_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionOrder(Base):
|
||||||
|
__tablename__ = "production_orders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
order_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||||
|
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||||
|
article_ref: Mapped[str] = mapped_column(String(80))
|
||||||
|
article_name: Mapped[str] = mapped_column(String(160))
|
||||||
|
mold_ref: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
material_ref: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
planned_qty: Mapped[int] = mapped_column(Integer)
|
||||||
|
cavities: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
theoretical_cycle_time_sec: Mapped[float] = mapped_column(Float)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), default="planned")
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class MachineEvent(Base):
|
||||||
|
__tablename__ = "machine_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Cycle(Base):
|
||||||
|
__tablename__ = "cycles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||||
|
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
cycle_time_sec: Mapped[float] = mapped_column(Float)
|
||||||
|
cavities: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
produced_qty: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
is_valid: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Downtime(Base):
|
||||||
|
__tablename__ = "downtimes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||||
|
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||||
|
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
duration_sec: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
reason_code: Mapped[str | None] = mapped_column(String(80), ForeignKey("downtime_reasons.code"))
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
|
auto_detected: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
qualified_by: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Scrap(Base):
|
||||||
|
__tablename__ = "scraps"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
|
||||||
|
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer)
|
||||||
|
reason_code: Mapped[str] = mapped_column(String(80), ForeignKey("scrap_reasons.code"))
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
|
operator_name: Mapped[str] = mapped_column(String(120))
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DowntimeReason(Base):
|
||||||
|
__tablename__ = "downtime_reasons"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(160))
|
||||||
|
|
||||||
|
|
||||||
|
class ScrapReason(Base):
|
||||||
|
__tablename__ = "scrap_reasons"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(160))
|
||||||
|
|
||||||
|
|
||||||
|
class Operator(Base):
|
||||||
|
__tablename__ = "operators"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
59
backend/app/schemas.py
Normal file
59
backend/app/schemas.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MachineEventIn(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
machine_id: str
|
||||||
|
event_type: str
|
||||||
|
timestamp: datetime
|
||||||
|
cycle_time_sec: float | None = None
|
||||||
|
source: str | None = "digital_input"
|
||||||
|
input_name: str | None = None
|
||||||
|
input_value: bool | int | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionOrderCreate(BaseModel):
|
||||||
|
order_number: str
|
||||||
|
machine_id: int
|
||||||
|
article_ref: str
|
||||||
|
article_name: str
|
||||||
|
mold_ref: str | None = None
|
||||||
|
material_ref: str | None = None
|
||||||
|
planned_qty: int = Field(gt=0)
|
||||||
|
cavities: int = Field(default=1, gt=0)
|
||||||
|
theoretical_cycle_time_sec: float = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class DowntimeQualify(BaseModel):
|
||||||
|
reason_code: str
|
||||||
|
comment: str | None = None
|
||||||
|
qualified_by: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScrapCreate(BaseModel):
|
||||||
|
machine_id: str
|
||||||
|
production_order_id: int | None = None
|
||||||
|
quantity: int = Field(gt=0)
|
||||||
|
reason_code: str
|
||||||
|
comment: str | None = None
|
||||||
|
operator_name: str
|
||||||
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DailyOeeQuery(BaseModel):
|
||||||
|
date: date
|
||||||
|
|
||||||
|
|
||||||
|
class MachineConfigUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
cycle_signal_edge: str | None = None
|
||||||
|
debounce_ms: int | None = Field(default=None, ge=0)
|
||||||
|
min_cycle_time_sec: int | None = Field(default=None, ge=1)
|
||||||
|
max_cycle_time_sec: int | None = Field(default=None, ge=1)
|
||||||
|
stop_detection_delay_sec: int | None = Field(default=None, ge=1)
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Backend service modules."""
|
||||||
118
backend/app/services/dashboard.py
Normal file
118
backend/app/services/dashboard.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Cycle, Downtime, Machine, ProductionOrder, Scrap
|
||||||
|
from .events import get_open_downtime, get_running_order
|
||||||
|
from .oee import compute_machine_oee
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def _today_bounds() -> tuple[datetime, datetime]:
|
||||||
|
start = datetime.combine(datetime.now(UTC).date(), time.min, tzinfo=UTC)
|
||||||
|
return start, start + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_machine_config(machine: Machine) -> dict:
|
||||||
|
return {
|
||||||
|
"name": machine.name,
|
||||||
|
"brand": machine.brand,
|
||||||
|
"model": machine.model,
|
||||||
|
"is_active": machine.is_active,
|
||||||
|
"cycle_signal_edge": machine.cycle_signal_edge,
|
||||||
|
"debounce_ms": machine.debounce_ms,
|
||||||
|
"min_cycle_time_sec": machine.min_cycle_time_sec,
|
||||||
|
"max_cycle_time_sec": machine.max_cycle_time_sec,
|
||||||
|
"stop_detection_delay_sec": machine.stop_detection_delay_sec,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_machine_card(session: Session, machine: Machine) -> dict:
|
||||||
|
running_order = get_running_order(session, machine.id)
|
||||||
|
open_downtime = get_open_downtime(session, machine.id)
|
||||||
|
today_start, today_end = _today_bounds()
|
||||||
|
|
||||||
|
recent_cycles = session.execute(
|
||||||
|
select(Cycle.cycle_time_sec).where(Cycle.machine_id == machine.id).order_by(Cycle.timestamp.desc()).limit(20)
|
||||||
|
).scalars().all()
|
||||||
|
avg_cycle = sum(recent_cycles) / len(recent_cycles) if recent_cycles else 0
|
||||||
|
|
||||||
|
order_produced_qty = 0
|
||||||
|
order_scrap_qty = 0
|
||||||
|
progress = 0
|
||||||
|
if running_order is not None:
|
||||||
|
order_produced_qty = session.execute(
|
||||||
|
select(func.coalesce(func.sum(Cycle.produced_qty), 0)).where(
|
||||||
|
Cycle.production_order_id == running_order.id,
|
||||||
|
Cycle.is_valid.is_(True),
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
order_scrap_qty = session.execute(
|
||||||
|
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(Scrap.production_order_id == running_order.id)
|
||||||
|
).scalar_one()
|
||||||
|
progress = min(order_produced_qty / running_order.planned_qty, 1) if running_order.planned_qty else 0
|
||||||
|
|
||||||
|
daily_oee = compute_machine_oee(session, machine.id, today_start, today_end)
|
||||||
|
latest_downtime = session.execute(
|
||||||
|
select(Downtime).where(Downtime.machine_id == machine.id).order_by(Downtime.start_time.desc()).limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": machine.id,
|
||||||
|
"code": machine.code,
|
||||||
|
"name": machine.name,
|
||||||
|
"brand": machine.brand,
|
||||||
|
"model": machine.model,
|
||||||
|
"status": machine.status,
|
||||||
|
"status_since": machine.status_since.isoformat() if machine.status_since else None,
|
||||||
|
"powered_on": machine.powered_on,
|
||||||
|
"config": serialize_machine_config(machine),
|
||||||
|
"active_order": None
|
||||||
|
if running_order is None
|
||||||
|
else {
|
||||||
|
"id": running_order.id,
|
||||||
|
"order_number": running_order.order_number,
|
||||||
|
"article_ref": running_order.article_ref,
|
||||||
|
"article_name": running_order.article_name,
|
||||||
|
"mold_ref": running_order.mold_ref,
|
||||||
|
"material_ref": running_order.material_ref,
|
||||||
|
"planned_qty": running_order.planned_qty,
|
||||||
|
"cavities": running_order.cavities,
|
||||||
|
"theoretical_cycle_time_sec": running_order.theoretical_cycle_time_sec,
|
||||||
|
"status": running_order.status,
|
||||||
|
},
|
||||||
|
"cycle_real_avg_sec": round(avg_cycle, 2),
|
||||||
|
"produced_qty": int(order_produced_qty),
|
||||||
|
"scrap_qty": int(order_scrap_qty),
|
||||||
|
"order_progress": round(progress, 4),
|
||||||
|
"today_oee": daily_oee,
|
||||||
|
"open_downtime": None
|
||||||
|
if open_downtime is None
|
||||||
|
else {
|
||||||
|
"id": open_downtime.id,
|
||||||
|
"reason_code": open_downtime.reason_code,
|
||||||
|
"start_time": open_downtime.start_time.isoformat(),
|
||||||
|
"comment": open_downtime.comment,
|
||||||
|
},
|
||||||
|
"last_downtime": None
|
||||||
|
if latest_downtime is None
|
||||||
|
else {
|
||||||
|
"id": latest_downtime.id,
|
||||||
|
"reason_code": latest_downtime.reason_code,
|
||||||
|
"start_time": latest_downtime.start_time.isoformat(),
|
||||||
|
"end_time": latest_downtime.end_time.isoformat() if latest_downtime.end_time else None,
|
||||||
|
"duration_sec": latest_downtime.duration_sec,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_snapshot(session: Session) -> dict:
|
||||||
|
machines = session.execute(select(Machine).where(Machine.is_active.is_(True)).order_by(Machine.code)).scalars().all()
|
||||||
|
return {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"machines": [serialize_machine_card(session, machine) for machine in machines],
|
||||||
|
}
|
||||||
295
backend/app/services/events.py
Normal file
295
backend/app/services/events.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
|
||||||
|
from ..schemas import MachineEventIn
|
||||||
|
|
||||||
|
|
||||||
|
REGALAGE_REASON_CODES = {"reglage", "changement_moule"}
|
||||||
|
SIGNAL_EVENT_TYPE = "digital_input_changed"
|
||||||
|
POWER_INPUT_NAME = "machine_power_on"
|
||||||
|
CYCLE_INPUT_NAME = "cycle_signal"
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_utc(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_machine_event(payload: str | bytes) -> MachineEventIn:
|
||||||
|
raw = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
||||||
|
data = json.loads(raw)
|
||||||
|
return MachineEventIn.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_signal_value(value: bool | int | str | None) -> bool | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value != 0
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "on", "high"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "off", "low"}:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_machine(session: Session, machine_code: str) -> Machine:
|
||||||
|
machine = session.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
|
||||||
|
if machine is None:
|
||||||
|
machine = Machine(code=machine_code, name=machine_code, status="hors_planning")
|
||||||
|
session.add(machine)
|
||||||
|
session.flush()
|
||||||
|
return machine
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_order(session: Session, machine_id: int) -> ProductionOrder | None:
|
||||||
|
return session.execute(
|
||||||
|
select(ProductionOrder).where(
|
||||||
|
ProductionOrder.machine_id == machine_id,
|
||||||
|
ProductionOrder.status == "running",
|
||||||
|
).order_by(ProductionOrder.started_at.desc())
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_downtime(session: Session, machine_id: int) -> Downtime | None:
|
||||||
|
return session.execute(
|
||||||
|
select(Downtime).where(
|
||||||
|
Downtime.machine_id == machine_id,
|
||||||
|
Downtime.end_time.is_(None),
|
||||||
|
).order_by(Downtime.start_time.desc())
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_input_event(session: Session, machine_id: int, input_name: str) -> MachineEvent | None:
|
||||||
|
rows = session.execute(
|
||||||
|
select(MachineEvent).where(
|
||||||
|
MachineEvent.machine_id == machine_id,
|
||||||
|
MachineEvent.event_type == SIGNAL_EVENT_TYPE,
|
||||||
|
).order_by(MachineEvent.timestamp.desc()).limit(50)
|
||||||
|
).scalars()
|
||||||
|
for row in rows:
|
||||||
|
if row.payload.get("input_name") == input_name:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def derive_machine_status(machine: Machine, open_downtime: Downtime | None, running_order: ProductionOrder | None) -> str:
|
||||||
|
if not machine.powered_on or running_order is None:
|
||||||
|
return "hors_planning"
|
||||||
|
if open_downtime is None:
|
||||||
|
return "production"
|
||||||
|
if open_downtime.reason_code in REGALAGE_REASON_CODES:
|
||||||
|
return "reglage"
|
||||||
|
if open_downtime.reason_code:
|
||||||
|
return "arret_qualifie"
|
||||||
|
return "arret_non_qualifie"
|
||||||
|
|
||||||
|
|
||||||
|
def open_downtime_if_needed(session: Session, machine: Machine, event_time: datetime, auto_detected: bool = True) -> Downtime | None:
|
||||||
|
running_order = get_running_order(session, machine.id)
|
||||||
|
if running_order is None or not machine.powered_on:
|
||||||
|
machine.status = "hors_planning"
|
||||||
|
machine.status_since = event_time
|
||||||
|
return None
|
||||||
|
|
||||||
|
open_downtime = get_open_downtime(session, machine.id)
|
||||||
|
if open_downtime is not None:
|
||||||
|
return open_downtime
|
||||||
|
|
||||||
|
downtime = Downtime(
|
||||||
|
machine_id=machine.id,
|
||||||
|
production_order_id=running_order.id,
|
||||||
|
start_time=event_time,
|
||||||
|
auto_detected=auto_detected,
|
||||||
|
)
|
||||||
|
session.add(downtime)
|
||||||
|
machine.status = "arret_non_qualifie"
|
||||||
|
machine.status_since = event_time
|
||||||
|
session.flush()
|
||||||
|
return downtime
|
||||||
|
|
||||||
|
|
||||||
|
def close_open_downtime(session: Session, machine: Machine, event_time: datetime) -> Downtime | None:
|
||||||
|
downtime = get_open_downtime(session, machine.id)
|
||||||
|
if downtime is None:
|
||||||
|
return None
|
||||||
|
downtime.end_time = event_time
|
||||||
|
downtime.duration_sec = max(int((event_time - downtime.start_time).total_seconds()), 0)
|
||||||
|
machine.status_since = event_time
|
||||||
|
session.flush()
|
||||||
|
return downtime
|
||||||
|
|
||||||
|
|
||||||
|
def _is_matching_edge(previous_value: bool | None, current_value: bool | None, configured_edge: str) -> bool:
|
||||||
|
if previous_value is None or current_value is None:
|
||||||
|
return False
|
||||||
|
if configured_edge == "falling":
|
||||||
|
return previous_value is True and current_value is False
|
||||||
|
return previous_value is False and current_value is True
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_cycle(
|
||||||
|
session: Session,
|
||||||
|
machine: Machine,
|
||||||
|
running_order: ProductionOrder | None,
|
||||||
|
event_time: datetime,
|
||||||
|
cycle_time_sec: float,
|
||||||
|
) -> None:
|
||||||
|
cavities = running_order.cavities if running_order else 1
|
||||||
|
is_valid = machine.min_cycle_time_sec <= cycle_time_sec <= machine.max_cycle_time_sec
|
||||||
|
session.add(
|
||||||
|
Cycle(
|
||||||
|
machine_id=machine.id,
|
||||||
|
production_order_id=running_order.id if running_order else None,
|
||||||
|
timestamp=event_time,
|
||||||
|
cycle_time_sec=cycle_time_sec,
|
||||||
|
cavities=cavities,
|
||||||
|
produced_qty=cavities if is_valid else 0,
|
||||||
|
is_valid=is_valid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if cycle_time_sec >= machine.min_cycle_time_sec:
|
||||||
|
machine.last_cycle_at = event_time
|
||||||
|
machine.status_since = event_time
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_cycle_signal_event(
|
||||||
|
session: Session,
|
||||||
|
machine: Machine,
|
||||||
|
running_order: ProductionOrder | None,
|
||||||
|
incoming_event: MachineEventIn,
|
||||||
|
event_time: datetime,
|
||||||
|
previous_signal_event: MachineEvent | None,
|
||||||
|
) -> None:
|
||||||
|
input_value = normalize_signal_value(incoming_event.input_value)
|
||||||
|
previous_value = None
|
||||||
|
previous_signal_time = None
|
||||||
|
if previous_signal_event is not None:
|
||||||
|
previous_value = normalize_signal_value(previous_signal_event.payload.get("input_value"))
|
||||||
|
previous_signal_time = _ensure_utc(previous_signal_event.timestamp)
|
||||||
|
|
||||||
|
if input_value is None or not _is_matching_edge(previous_value, input_value, machine.cycle_signal_edge):
|
||||||
|
return
|
||||||
|
|
||||||
|
if previous_signal_time is not None:
|
||||||
|
elapsed_ms = (event_time - previous_signal_time).total_seconds() * 1000
|
||||||
|
if elapsed_ms < machine.debounce_ms:
|
||||||
|
return
|
||||||
|
|
||||||
|
close_open_downtime(session, machine, event_time)
|
||||||
|
|
||||||
|
if machine.last_cycle_at is None:
|
||||||
|
fallback_cycle_time = incoming_event.cycle_time_sec
|
||||||
|
if fallback_cycle_time and fallback_cycle_time >= machine.min_cycle_time_sec:
|
||||||
|
_persist_cycle(session, machine, running_order, event_time, float(fallback_cycle_time))
|
||||||
|
else:
|
||||||
|
machine.last_cycle_at = event_time
|
||||||
|
machine.status_since = event_time
|
||||||
|
return
|
||||||
|
|
||||||
|
cycle_time_sec = (event_time - _ensure_utc(machine.last_cycle_at)).total_seconds()
|
||||||
|
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_machine_event(session: Session, incoming_event: MachineEventIn) -> dict:
|
||||||
|
event_time = _ensure_utc(incoming_event.timestamp)
|
||||||
|
machine = _get_or_create_machine(session, incoming_event.machine_id)
|
||||||
|
previous_signal_event = None
|
||||||
|
should_refresh = False
|
||||||
|
if incoming_event.event_type == SIGNAL_EVENT_TYPE and incoming_event.input_name:
|
||||||
|
previous_signal_event = get_last_input_event(session, machine.id, incoming_event.input_name)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
MachineEvent(
|
||||||
|
machine_id=machine.id,
|
||||||
|
event_type=incoming_event.event_type,
|
||||||
|
timestamp=event_time,
|
||||||
|
payload=incoming_event.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
running_order = get_running_order(session, machine.id)
|
||||||
|
|
||||||
|
if incoming_event.event_type == "machine_power_on":
|
||||||
|
machine.powered_on = True
|
||||||
|
should_refresh = True
|
||||||
|
elif incoming_event.event_type == "machine_power_off":
|
||||||
|
machine.powered_on = False
|
||||||
|
close_open_downtime(session, machine, event_time)
|
||||||
|
should_refresh = True
|
||||||
|
elif incoming_event.event_type == "machine_stopped":
|
||||||
|
open_downtime_if_needed(session, machine, event_time, auto_detected=True)
|
||||||
|
should_refresh = True
|
||||||
|
elif incoming_event.event_type == "cycle_completed":
|
||||||
|
close_open_downtime(session, machine, event_time)
|
||||||
|
cycle_time_sec = float(incoming_event.cycle_time_sec or 0)
|
||||||
|
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
|
||||||
|
should_refresh = True
|
||||||
|
elif incoming_event.event_type == SIGNAL_EVENT_TYPE:
|
||||||
|
input_name = incoming_event.input_name
|
||||||
|
input_value = normalize_signal_value(incoming_event.input_value)
|
||||||
|
if input_name == POWER_INPUT_NAME and input_value is not None:
|
||||||
|
machine.powered_on = input_value
|
||||||
|
if not input_value:
|
||||||
|
close_open_downtime(session, machine, event_time)
|
||||||
|
should_refresh = True
|
||||||
|
elif input_name == CYCLE_INPUT_NAME:
|
||||||
|
previous_cycle_at = machine.last_cycle_at
|
||||||
|
_handle_cycle_signal_event(session, machine, running_order, incoming_event, event_time, previous_signal_event)
|
||||||
|
should_refresh = machine.last_cycle_at != previous_cycle_at
|
||||||
|
elif input_name == "general_alarm" and input_value:
|
||||||
|
should_refresh = True
|
||||||
|
|
||||||
|
machine.status = derive_machine_status(machine, get_open_downtime(session, machine.id), running_order)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"machine_code": machine.code,
|
||||||
|
"machine_id": machine.id,
|
||||||
|
"event_type": incoming_event.event_type,
|
||||||
|
"timestamp": event_time.isoformat(),
|
||||||
|
"status": machine.status,
|
||||||
|
"should_refresh": should_refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_missing_cycles(session: Session, now: datetime | None = None) -> list[dict]:
|
||||||
|
current_time = _ensure_utc(now) if now else datetime.now(UTC)
|
||||||
|
machines = session.execute(select(Machine).where(Machine.is_active.is_(True))).scalars().all()
|
||||||
|
changes: list[dict] = []
|
||||||
|
|
||||||
|
for machine in machines:
|
||||||
|
running_order = get_running_order(session, machine.id)
|
||||||
|
if running_order is None or not machine.powered_on or machine.last_cycle_at is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
delay_sec = max(machine.stop_detection_delay_sec, 1)
|
||||||
|
last_cycle_at = _ensure_utc(machine.last_cycle_at)
|
||||||
|
elapsed = (current_time - last_cycle_at).total_seconds()
|
||||||
|
if elapsed < delay_sec:
|
||||||
|
continue
|
||||||
|
|
||||||
|
downtime = get_open_downtime(session, machine.id)
|
||||||
|
if downtime is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
downtime_start = last_cycle_at + timedelta(seconds=delay_sec)
|
||||||
|
open_downtime_if_needed(session, machine, downtime_start, auto_detected=True)
|
||||||
|
machine.status = "arret_non_qualifie"
|
||||||
|
machine.status_since = current_time
|
||||||
|
changes.append({"machine_code": machine.code, "event_type": "machine_stopped", "status": machine.status})
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
session.rollback()
|
||||||
|
return changes
|
||||||
50
backend/app/services/mqtt_listener.py
Normal file
50
backend/app/services/mqtt_listener.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..database import SessionLocal
|
||||||
|
from .events import handle_machine_event, parse_machine_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MqttListener:
|
||||||
|
def __init__(self, notify_callback) -> None:
|
||||||
|
self.settings = get_settings()
|
||||||
|
self.notify_callback = notify_callback
|
||||||
|
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.settings.mqtt_client_id)
|
||||||
|
self.client.on_connect = self.on_connect
|
||||||
|
self.client.on_message = self.on_message
|
||||||
|
self.thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
try:
|
||||||
|
self.client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("MQTT disconnect failed")
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
self.client.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60)
|
||||||
|
self.client.loop_forever()
|
||||||
|
|
||||||
|
def on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||||
|
logger.info("Connected to MQTT with code %s", reason_code)
|
||||||
|
client.subscribe(self.settings.mqtt_topic)
|
||||||
|
|
||||||
|
def on_message(self, client, userdata, message) -> None:
|
||||||
|
try:
|
||||||
|
incoming_event = parse_machine_event(message.payload)
|
||||||
|
with SessionLocal() as session:
|
||||||
|
result = handle_machine_event(session, incoming_event)
|
||||||
|
if result.pop("should_refresh", False):
|
||||||
|
self.notify_callback({"type": "refresh", "source": "mqtt", **result})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to handle MQTT message on topic %s", message.topic)
|
||||||
202
backend/app/services/oee.py
Normal file
202
backend/app/services/oee.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Cycle, Downtime, ProductionOrder, Scrap
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OeeTotals:
|
||||||
|
planned_time_sec: float = 0
|
||||||
|
downtime_sec: float = 0
|
||||||
|
operating_time_sec: float = 0
|
||||||
|
total_cycles: int = 0
|
||||||
|
theoretical_cycle_time_sec: float = 0
|
||||||
|
performance_time_sec: float = 0
|
||||||
|
total_produced_qty: int = 0
|
||||||
|
good_qty: int = 0
|
||||||
|
scrap_qty: int = 0
|
||||||
|
availability: float = 0
|
||||||
|
performance: float = 0
|
||||||
|
quality: float = 0
|
||||||
|
oee: float = 0
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, float | int]:
|
||||||
|
return {
|
||||||
|
"planned_time_sec": round(self.planned_time_sec, 2),
|
||||||
|
"downtime_sec": round(self.downtime_sec, 2),
|
||||||
|
"operating_time_sec": round(self.operating_time_sec, 2),
|
||||||
|
"total_cycles": self.total_cycles,
|
||||||
|
"theoretical_cycle_time_sec": round(self.theoretical_cycle_time_sec, 2),
|
||||||
|
"total_produced_qty": self.total_produced_qty,
|
||||||
|
"good_qty": self.good_qty,
|
||||||
|
"scrap_qty": self.scrap_qty,
|
||||||
|
"availability": round(self.availability, 4),
|
||||||
|
"performance": round(self.performance, 4),
|
||||||
|
"quality": round(self.quality, 4),
|
||||||
|
"oee": round(self.oee, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_oee_metrics(
|
||||||
|
*,
|
||||||
|
planned_time_sec: float,
|
||||||
|
downtime_sec: float,
|
||||||
|
total_cycles: int,
|
||||||
|
performance_time_sec: float,
|
||||||
|
theoretical_cycle_time_sec: float,
|
||||||
|
total_produced_qty: int,
|
||||||
|
scrap_qty: int,
|
||||||
|
) -> OeeTotals:
|
||||||
|
operating_time_sec = max(planned_time_sec - downtime_sec, 0)
|
||||||
|
good_qty = max(total_produced_qty - scrap_qty, 0)
|
||||||
|
|
||||||
|
availability = operating_time_sec / planned_time_sec if planned_time_sec > 0 else 0
|
||||||
|
performance = performance_time_sec / operating_time_sec if operating_time_sec > 0 else 0
|
||||||
|
performance = min(max(performance, 0), 1)
|
||||||
|
quality = good_qty / total_produced_qty if total_produced_qty > 0 else 0
|
||||||
|
oee = availability * performance * quality
|
||||||
|
|
||||||
|
return OeeTotals(
|
||||||
|
planned_time_sec=planned_time_sec,
|
||||||
|
downtime_sec=downtime_sec,
|
||||||
|
operating_time_sec=operating_time_sec,
|
||||||
|
total_cycles=total_cycles,
|
||||||
|
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
|
||||||
|
performance_time_sec=performance_time_sec,
|
||||||
|
total_produced_qty=total_produced_qty,
|
||||||
|
good_qty=good_qty,
|
||||||
|
scrap_qty=scrap_qty,
|
||||||
|
availability=availability,
|
||||||
|
performance=performance,
|
||||||
|
quality=quality,
|
||||||
|
oee=oee,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def overlap_seconds(window_start: datetime, window_end: datetime, start: datetime, end: datetime | None) -> float:
|
||||||
|
actual_end = end or window_end
|
||||||
|
overlap_start = max(window_start, start)
|
||||||
|
overlap_end = min(window_end, actual_end)
|
||||||
|
if overlap_end <= overlap_start:
|
||||||
|
return 0
|
||||||
|
return (overlap_end - overlap_start).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_machine_oee(session: Session, machine_id: int, window_start: datetime, window_end: datetime) -> dict:
|
||||||
|
current_time = datetime.now(UTC)
|
||||||
|
effective_window_end = min(window_end, current_time)
|
||||||
|
if effective_window_end <= window_start:
|
||||||
|
effective_window_end = window_end
|
||||||
|
|
||||||
|
order_rows = session.execute(
|
||||||
|
select(ProductionOrder).where(
|
||||||
|
ProductionOrder.machine_id == machine_id,
|
||||||
|
ProductionOrder.started_at.is_not(None),
|
||||||
|
ProductionOrder.started_at < effective_window_end,
|
||||||
|
func.coalesce(ProductionOrder.ended_at, effective_window_end) > window_start,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
|
||||||
|
planned_time_sec = 0.0
|
||||||
|
for order in order_rows:
|
||||||
|
effective_order_end = min(order.ended_at, effective_window_end) if order.ended_at else effective_window_end
|
||||||
|
planned_time_sec += overlap_seconds(window_start, effective_window_end, order.started_at, effective_order_end)
|
||||||
|
|
||||||
|
cycle_rows = session.execute(
|
||||||
|
select(
|
||||||
|
Cycle.production_order_id,
|
||||||
|
func.count(Cycle.id),
|
||||||
|
func.coalesce(func.sum(Cycle.produced_qty), 0),
|
||||||
|
).where(
|
||||||
|
Cycle.machine_id == machine_id,
|
||||||
|
Cycle.timestamp >= window_start,
|
||||||
|
Cycle.timestamp <= effective_window_end,
|
||||||
|
Cycle.is_valid.is_(True),
|
||||||
|
).group_by(Cycle.production_order_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_cycles = sum(int(row[1]) for row in cycle_rows)
|
||||||
|
total_produced_qty = sum(int(row[2]) for row in cycle_rows)
|
||||||
|
|
||||||
|
performance_time_sec = 0.0
|
||||||
|
weighted_theoretical = 0.0
|
||||||
|
counted_cycles = 0
|
||||||
|
for production_order_id, cycle_count, _ in cycle_rows:
|
||||||
|
if production_order_id is None:
|
||||||
|
continue
|
||||||
|
order = session.get(ProductionOrder, production_order_id)
|
||||||
|
if order is None:
|
||||||
|
continue
|
||||||
|
performance_time_sec += order.theoretical_cycle_time_sec * int(cycle_count)
|
||||||
|
weighted_theoretical += order.theoretical_cycle_time_sec * int(cycle_count)
|
||||||
|
counted_cycles += int(cycle_count)
|
||||||
|
|
||||||
|
theoretical_cycle_time_sec = weighted_theoretical / counted_cycles if counted_cycles > 0 else 0
|
||||||
|
|
||||||
|
downtime_rows = session.execute(
|
||||||
|
select(Downtime).where(
|
||||||
|
Downtime.machine_id == machine_id,
|
||||||
|
Downtime.start_time < effective_window_end,
|
||||||
|
func.coalesce(Downtime.end_time, effective_window_end) > window_start,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
downtime_sec = sum(
|
||||||
|
overlap_seconds(window_start, effective_window_end, row.start_time, min(row.end_time, effective_window_end) if row.end_time else effective_window_end)
|
||||||
|
for row in downtime_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
scrap_qty = session.execute(
|
||||||
|
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(
|
||||||
|
Scrap.machine_id == machine_id,
|
||||||
|
Scrap.timestamp >= window_start,
|
||||||
|
Scrap.timestamp <= effective_window_end,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if planned_time_sec == 0 and (total_cycles > 0 or downtime_sec > 0):
|
||||||
|
planned_time_sec = (effective_window_end - window_start).total_seconds()
|
||||||
|
|
||||||
|
totals = calculate_oee_metrics(
|
||||||
|
planned_time_sec=planned_time_sec,
|
||||||
|
downtime_sec=downtime_sec,
|
||||||
|
total_cycles=total_cycles,
|
||||||
|
performance_time_sec=performance_time_sec,
|
||||||
|
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
|
||||||
|
total_produced_qty=total_produced_qty,
|
||||||
|
scrap_qty=int(scrap_qty),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"machine_id": machine_id,
|
||||||
|
"from": window_start.isoformat(),
|
||||||
|
"to": effective_window_end.isoformat(),
|
||||||
|
**totals.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_daily_oee(session: Session, target_date: date, machine_ids: list[int]) -> dict:
|
||||||
|
window_start = datetime.combine(target_date, time.min, tzinfo=UTC)
|
||||||
|
window_end = window_start + timedelta(days=1)
|
||||||
|
machine_metrics = [compute_machine_oee(session, machine_id, window_start, window_end) for machine_id in machine_ids]
|
||||||
|
|
||||||
|
overall = calculate_oee_metrics(
|
||||||
|
planned_time_sec=sum(item["planned_time_sec"] for item in machine_metrics),
|
||||||
|
downtime_sec=sum(item["downtime_sec"] for item in machine_metrics),
|
||||||
|
total_cycles=sum(item["total_cycles"] for item in machine_metrics),
|
||||||
|
performance_time_sec=sum(item["theoretical_cycle_time_sec"] * item["total_cycles"] for item in machine_metrics),
|
||||||
|
theoretical_cycle_time_sec=0,
|
||||||
|
total_produced_qty=sum(item["total_produced_qty"] for item in machine_metrics),
|
||||||
|
scrap_qty=sum(item["scrap_qty"] for item in machine_metrics),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": target_date.isoformat(),
|
||||||
|
"machines": machine_metrics,
|
||||||
|
"overall": overall.as_dict(),
|
||||||
|
}
|
||||||
40
backend/app/services/realtime.py
Normal file
40
backend/app/services/realtime.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeHub:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connections: set[WebSocket] = set()
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self._loop = loop
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
self._connections.add(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket) -> None:
|
||||||
|
self._connections.discard(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, payload: dict) -> None:
|
||||||
|
stale: list[WebSocket] = []
|
||||||
|
for websocket in self._connections:
|
||||||
|
try:
|
||||||
|
await websocket.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
stale.append(websocket)
|
||||||
|
for websocket in stale:
|
||||||
|
self._connections.discard(websocket)
|
||||||
|
|
||||||
|
def notify(self, payload: dict) -> None:
|
||||||
|
if self._loop is None:
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(self.broadcast(payload), self._loop)
|
||||||
|
|
||||||
|
|
||||||
|
hub = RealtimeHub()
|
||||||
2
backend/pytest.ini
Normal file
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.2
|
||||||
|
sqlalchemy==2.0.41
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
|
pydantic-settings==2.9.1
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
pytest==8.3.5
|
||||||
159
backend/tests/test_events.py
Normal file
159
backend/tests/test_events.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.main import start_production_order
|
||||||
|
from app.models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
|
||||||
|
from app.schemas import MachineEventIn
|
||||||
|
from app.services.events import _is_matching_edge, detect_missing_cycles, handle_machine_event, normalize_signal_value, parse_machine_event
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_machine_event() -> None:
|
||||||
|
payload = """
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "digital_input_changed",
|
||||||
|
"timestamp": "2026-06-14T10:32:15Z",
|
||||||
|
"input_name": "cycle_signal",
|
||||||
|
"input_value": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
event = parse_machine_event(payload)
|
||||||
|
|
||||||
|
assert event.machine_id == "INJ-01"
|
||||||
|
assert event.event_type == "digital_input_changed"
|
||||||
|
assert event.input_name == "cycle_signal"
|
||||||
|
assert event.input_value == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_signal_value() -> None:
|
||||||
|
assert normalize_signal_value(True) is True
|
||||||
|
assert normalize_signal_value(0) is False
|
||||||
|
assert normalize_signal_value("high") is True
|
||||||
|
assert normalize_signal_value("OFF") is False
|
||||||
|
assert normalize_signal_value("unknown") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_matching() -> None:
|
||||||
|
assert _is_matching_edge(False, True, "rising") is True
|
||||||
|
assert _is_matching_edge(True, False, "falling") is True
|
||||||
|
assert _is_matching_edge(False, False, "rising") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_session() -> Session:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
TestingSessionLocal = sessionmaker(bind=engine)
|
||||||
|
session = TestingSessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
Base.metadata.drop_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_running_machine(session: Session) -> Machine:
|
||||||
|
machine = Machine(
|
||||||
|
code="INJ-T1",
|
||||||
|
name="INJ-T1",
|
||||||
|
brand="Test",
|
||||||
|
model="Press",
|
||||||
|
powered_on=True,
|
||||||
|
cycle_signal_edge="rising",
|
||||||
|
debounce_ms=300,
|
||||||
|
min_cycle_time_sec=5,
|
||||||
|
max_cycle_time_sec=300,
|
||||||
|
stop_detection_delay_sec=180,
|
||||||
|
)
|
||||||
|
session.add(machine)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
ProductionOrder(
|
||||||
|
order_number="OF-T1",
|
||||||
|
machine_id=machine.id,
|
||||||
|
article_ref="ART-T1",
|
||||||
|
article_name="Test Part",
|
||||||
|
planned_qty=100,
|
||||||
|
cavities=1,
|
||||||
|
theoretical_cycle_time_sec=10,
|
||||||
|
status="running",
|
||||||
|
started_at=datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return machine
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_signal_debounce_suppresses_noisy_edge(db_session: Session) -> None:
|
||||||
|
machine = seed_running_machine(db_session)
|
||||||
|
first_edge = datetime(2026, 6, 14, 8, 0, 0, tzinfo=timezone.utc)
|
||||||
|
noisy_edge = first_edge + timedelta(milliseconds=100)
|
||||||
|
db_session.add(
|
||||||
|
MachineEvent(
|
||||||
|
machine_id=machine.id,
|
||||||
|
event_type="digital_input_changed",
|
||||||
|
timestamp=first_edge,
|
||||||
|
payload={"input_name": "cycle_signal", "input_value": False},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = handle_machine_event(
|
||||||
|
db_session,
|
||||||
|
MachineEventIn(
|
||||||
|
machine_id=machine.code,
|
||||||
|
event_type="digital_input_changed",
|
||||||
|
timestamp=noisy_edge,
|
||||||
|
input_name="cycle_signal",
|
||||||
|
input_value=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cycle_count = db_session.execute(select(Cycle)).scalars().all()
|
||||||
|
db_session.refresh(machine)
|
||||||
|
assert cycle_count == []
|
||||||
|
assert machine.last_cycle_at is None
|
||||||
|
assert result["should_refresh"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_missing_cycles_opens_unqualified_downtime(db_session: Session) -> None:
|
||||||
|
machine = seed_running_machine(db_session)
|
||||||
|
machine.last_cycle_at = datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
changes = detect_missing_cycles(db_session, now=datetime(2026, 6, 14, 8, 4, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
downtime = db_session.execute(select(Downtime).where(Downtime.machine_id == machine.id)).scalar_one()
|
||||||
|
db_session.refresh(machine)
|
||||||
|
assert changes == [{"machine_code": machine.code, "event_type": "machine_stopped", "status": "arret_non_qualifie"}]
|
||||||
|
assert downtime.reason_code is None
|
||||||
|
assert downtime.start_time.replace(tzinfo=timezone.utc) == datetime(2026, 6, 14, 8, 3, tzinfo=timezone.utc)
|
||||||
|
assert machine.status == "arret_non_qualifie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_order_rejects_second_running_order_on_machine(db_session: Session) -> None:
|
||||||
|
machine = seed_running_machine(db_session)
|
||||||
|
planned_order = ProductionOrder(
|
||||||
|
order_number="OF-T2",
|
||||||
|
machine_id=machine.id,
|
||||||
|
article_ref="ART-T2",
|
||||||
|
article_name="Second Part",
|
||||||
|
planned_qty=50,
|
||||||
|
cavities=1,
|
||||||
|
theoretical_cycle_time_sec=12,
|
||||||
|
status="planned",
|
||||||
|
)
|
||||||
|
db_session.add(planned_order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
start_production_order(planned_order.id, db_session)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert exc.value.detail == "Another order is already running on this machine"
|
||||||
38
backend/tests/test_oee.py
Normal file
38
backend/tests/test_oee.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.services.oee import calculate_oee_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_oee_metrics() -> None:
|
||||||
|
metrics = calculate_oee_metrics(
|
||||||
|
planned_time_sec=3600,
|
||||||
|
downtime_sec=600,
|
||||||
|
total_cycles=100,
|
||||||
|
performance_time_sec=2500,
|
||||||
|
theoretical_cycle_time_sec=25,
|
||||||
|
total_produced_qty=200,
|
||||||
|
scrap_qty=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert round(metrics.availability, 4) == 0.8333
|
||||||
|
assert round(metrics.performance, 4) == 0.8333
|
||||||
|
assert round(metrics.quality, 4) == 0.95
|
||||||
|
assert round(metrics.oee, 4) == 0.6597
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_window_uses_current_time_bound(monkeypatch) -> None:
|
||||||
|
from app.services import oee
|
||||||
|
|
||||||
|
fixed_now = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
class FixedDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return fixed_now if tz else fixed_now.replace(tzinfo=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(oee, "datetime", FixedDateTime)
|
||||||
|
|
||||||
|
start = datetime(2026, 6, 14, 0, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 6, 15, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert oee.overlap_seconds(start, fixed_now, start, end) == timedelta(hours=12).total_seconds()
|
||||||
106
database/init.sql
Normal file
106
database/init.sql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS machines (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
brand VARCHAR(120),
|
||||||
|
model VARCHAR(120),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'hors_planning',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
powered_on BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cycle_signal_edge VARCHAR(20) NOT NULL DEFAULT 'rising',
|
||||||
|
debounce_ms INTEGER NOT NULL DEFAULT 300,
|
||||||
|
min_cycle_time_sec INTEGER NOT NULL DEFAULT 5,
|
||||||
|
max_cycle_time_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
|
stop_detection_delay_sec INTEGER NOT NULL DEFAULT 180,
|
||||||
|
last_cycle_at TIMESTAMPTZ,
|
||||||
|
status_since TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS production_orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_number VARCHAR(80) NOT NULL UNIQUE,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id),
|
||||||
|
article_ref VARCHAR(80) NOT NULL,
|
||||||
|
article_name VARCHAR(160) NOT NULL,
|
||||||
|
mold_ref VARCHAR(80),
|
||||||
|
material_ref VARCHAR(80),
|
||||||
|
planned_qty INTEGER NOT NULL,
|
||||||
|
cavities INTEGER NOT NULL DEFAULT 1,
|
||||||
|
theoretical_cycle_time_sec DOUBLE PRECISION NOT NULL,
|
||||||
|
status VARCHAR(40) NOT NULL DEFAULT 'planned',
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS downtime_reasons (
|
||||||
|
code VARCHAR(80) PRIMARY KEY,
|
||||||
|
label VARCHAR(160) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scrap_reasons (
|
||||||
|
code VARCHAR(80) PRIMARY KEY,
|
||||||
|
label VARCHAR(160) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operators (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS machine_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id),
|
||||||
|
event_type VARCHAR(80) NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cycles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id),
|
||||||
|
production_order_id INTEGER REFERENCES production_orders(id),
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
cycle_time_sec DOUBLE PRECISION NOT NULL,
|
||||||
|
cavities INTEGER NOT NULL DEFAULT 1,
|
||||||
|
produced_qty INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_valid BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS downtimes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id),
|
||||||
|
production_order_id INTEGER REFERENCES production_orders(id),
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_sec INTEGER,
|
||||||
|
reason_code VARCHAR(80) REFERENCES downtime_reasons(code),
|
||||||
|
comment TEXT,
|
||||||
|
auto_detected BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
qualified_by VARCHAR(120),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scraps (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id),
|
||||||
|
production_order_id INTEGER REFERENCES production_orders(id),
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
reason_code VARCHAR(80) NOT NULL REFERENCES scrap_reasons(code),
|
||||||
|
comment TEXT,
|
||||||
|
operator_name VARCHAR(120) NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_machine_events_machine_time ON machine_events(machine_id, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cycles_machine_time ON cycles(machine_id, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downtimes_machine_time ON downtimes(machine_id, start_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scraps_machine_time ON scraps(machine_id, timestamp DESC);
|
||||||
101
database/seed.sql
Normal file
101
database/seed.sql
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
INSERT INTO machines (code, name, brand, model, status)
|
||||||
|
VALUES
|
||||||
|
('INJ-01', 'INJ-01', 'Haitian', 'Mars II', 'production'),
|
||||||
|
('INJ-02', 'INJ-02', 'Engel', 'e-victory', 'hors_planning'),
|
||||||
|
('INJ-03', 'INJ-03', 'Arburg', 'Allrounder', 'hors_planning')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO downtime_reasons (code, label)
|
||||||
|
VALUES
|
||||||
|
('changement_moule', 'Changement moule'),
|
||||||
|
('reglage', 'Reglage'),
|
||||||
|
('attente_matiere', 'Attente matiere'),
|
||||||
|
('attente_operateur', 'Attente operateur'),
|
||||||
|
('panne_machine', 'Panne machine'),
|
||||||
|
('panne_moule', 'Panne moule'),
|
||||||
|
('attente_qualite', 'Attente qualite'),
|
||||||
|
('nettoyage', 'Nettoyage'),
|
||||||
|
('pause', 'Pause'),
|
||||||
|
('micro_arret', 'Micro arret'),
|
||||||
|
('autre', 'Autre')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO scrap_reasons (code, label)
|
||||||
|
VALUES
|
||||||
|
('bavure', 'Bavure'),
|
||||||
|
('manque_matiere', 'Manque matiere'),
|
||||||
|
('brulure', 'Brulure'),
|
||||||
|
('retassure', 'Retassure'),
|
||||||
|
('deformation', 'Deformation'),
|
||||||
|
('point_noir', 'Point noir'),
|
||||||
|
('humidite', 'Humidite'),
|
||||||
|
('couleur_non_conforme', 'Couleur non conforme'),
|
||||||
|
('collage_moule', 'Collage moule'),
|
||||||
|
('casse_piece', 'Casse piece'),
|
||||||
|
('defaut_dimensionnel', 'Defaut dimensionnel'),
|
||||||
|
('autre', 'Autre')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO operators (name)
|
||||||
|
VALUES
|
||||||
|
('operateur_1'),
|
||||||
|
('operateur_2')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO production_orders (
|
||||||
|
order_number,
|
||||||
|
machine_id,
|
||||||
|
article_ref,
|
||||||
|
article_name,
|
||||||
|
mold_ref,
|
||||||
|
material_ref,
|
||||||
|
planned_qty,
|
||||||
|
cavities,
|
||||||
|
theoretical_cycle_time_sec,
|
||||||
|
status,
|
||||||
|
started_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'OF-1001',
|
||||||
|
m.id,
|
||||||
|
'ART-001',
|
||||||
|
'Boitier 1L',
|
||||||
|
'M-001',
|
||||||
|
'PP-NEUTRAL',
|
||||||
|
4000,
|
||||||
|
2,
|
||||||
|
18.5,
|
||||||
|
'running',
|
||||||
|
NOW()
|
||||||
|
FROM machines m
|
||||||
|
WHERE m.code = 'INJ-01'
|
||||||
|
ON CONFLICT (order_number) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO production_orders (
|
||||||
|
order_number,
|
||||||
|
machine_id,
|
||||||
|
article_ref,
|
||||||
|
article_name,
|
||||||
|
mold_ref,
|
||||||
|
material_ref,
|
||||||
|
planned_qty,
|
||||||
|
cavities,
|
||||||
|
theoretical_cycle_time_sec,
|
||||||
|
status,
|
||||||
|
started_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'OF-1002',
|
||||||
|
m.id,
|
||||||
|
'ART-002',
|
||||||
|
'Capot Technique',
|
||||||
|
'M-002',
|
||||||
|
'ABS-BLACK',
|
||||||
|
2500,
|
||||||
|
1,
|
||||||
|
24.0,
|
||||||
|
'running',
|
||||||
|
NOW()
|
||||||
|
FROM machines m
|
||||||
|
WHERE m.code = 'INJ-02'
|
||||||
|
ON CONFLICT (order_number) DO NOTHING;
|
||||||
74
docker-compose.yml
Normal file
74
docker-compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: plasttrack
|
||||||
|
POSTGRES_USER: plasttrack
|
||||||
|
POSTGRES_PASSWORD: plasttrack
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
- ./database/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U plasttrack -d plasttrack"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mosquitto_sub -h localhost -p 1883 -t '$$SYS/broker/version' -C 1 -W 3 >/dev/null 2>&1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://plasttrack:plasttrack@database:5432/plasttrack
|
||||||
|
MQTT_HOST: mqtt
|
||||||
|
MQTT_PORT: 1883
|
||||||
|
MQTT_TOPIC: plasttrack/machines/+/events
|
||||||
|
FRONTEND_ORIGIN: http://localhost:5173
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
mqtt:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
edge-simulator:
|
||||||
|
build:
|
||||||
|
context: ./edge-simulator
|
||||||
|
environment:
|
||||||
|
MQTT_HOST: mqtt
|
||||||
|
MQTT_PORT: 1883
|
||||||
|
MQTT_TOPIC_PREFIX: plasttrack/machines
|
||||||
|
SIM_MACHINE_CODES: INJ-01,INJ-02,INJ-03
|
||||||
|
depends_on:
|
||||||
|
mqtt:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: http://localhost:8000
|
||||||
|
VITE_WS_URL: ws://localhost:8000/ws/dashboard
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
13
edge-simulator/Dockerfile
Normal file
13
edge-simulator/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY simulator.py .
|
||||||
|
|
||||||
|
CMD ["python", "simulator.py"]
|
||||||
1
edge-simulator/requirements.txt
Normal file
1
edge-simulator/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
paho-mqtt==2.1.0
|
||||||
138
edge-simulator/simulator.py
Normal file
138
edge-simulator/simulator.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulatedMachine:
|
||||||
|
code: str
|
||||||
|
nominal_cycle_time_sec: float
|
||||||
|
stop_probability: float
|
||||||
|
downtime_range_sec: tuple[int, int]
|
||||||
|
pulse_width_sec: float = 0.18
|
||||||
|
downtime_until: float = 0
|
||||||
|
initialized: bool = False
|
||||||
|
cycle_signal_state: bool = False
|
||||||
|
signal_reset_at: float = 0
|
||||||
|
next_cycle_at: float = 0
|
||||||
|
next_cycle_time_sec: float = 0
|
||||||
|
alarm_state: bool = False
|
||||||
|
last_cycle_edge_at: float = 0
|
||||||
|
|
||||||
|
def schedule_next_cycle(self, now_monotonic: float) -> None:
|
||||||
|
self.next_cycle_time_sec = round(random.uniform(self.nominal_cycle_time_sec - 2.5, self.nominal_cycle_time_sec + 2.5), 1)
|
||||||
|
self.next_cycle_at = now_monotonic + max(self.next_cycle_time_sec / 5, 0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def build_event(machine_code: str, event_type: str, **payload) -> dict:
|
||||||
|
return {
|
||||||
|
"machine_id": machine_code,
|
||||||
|
"event_type": event_type,
|
||||||
|
"timestamp": utc_now(),
|
||||||
|
**payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_signal_event(machine_code: str, input_name: str, input_value: bool, **payload) -> dict:
|
||||||
|
return build_event(
|
||||||
|
machine_code,
|
||||||
|
"digital_input_changed",
|
||||||
|
input_name=input_name,
|
||||||
|
input_value=input_value,
|
||||||
|
source="digital_input",
|
||||||
|
**payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_json(client: mqtt.Client, topic: str, payload: dict) -> None:
|
||||||
|
client.publish(topic, json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_machine(client: mqtt.Client, topic: str, machine: SimulatedMachine, now_monotonic: float) -> None:
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "machine_power_on", True))
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "auto_mode", True))
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "cycle_signal", False))
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "general_alarm", False))
|
||||||
|
machine.initialized = True
|
||||||
|
machine.schedule_next_cycle(now_monotonic)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mqtt_host = os.getenv("MQTT_HOST", "localhost")
|
||||||
|
mqtt_port = int(os.getenv("MQTT_PORT", "1883"))
|
||||||
|
topic_prefix = os.getenv("MQTT_TOPIC_PREFIX", "plasttrack/machines")
|
||||||
|
machine_codes = [item.strip() for item in os.getenv("SIM_MACHINE_CODES", "INJ-01,INJ-02,INJ-03").split(",") if item.strip()]
|
||||||
|
|
||||||
|
machines = [
|
||||||
|
SimulatedMachine(code=machine_codes[0], nominal_cycle_time_sec=18.5, stop_probability=0.03, downtime_range_sec=(45, 120)),
|
||||||
|
SimulatedMachine(code=machine_codes[1] if len(machine_codes) > 1 else "INJ-02", nominal_cycle_time_sec=24.0, stop_probability=0.05, downtime_range_sec=(60, 180)),
|
||||||
|
SimulatedMachine(code=machine_codes[2] if len(machine_codes) > 2 else "INJ-03", nominal_cycle_time_sec=32.0, stop_probability=0.07, downtime_range_sec=(90, 240)),
|
||||||
|
]
|
||||||
|
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="plast-track-edge-simulator")
|
||||||
|
client.connect(mqtt_host, mqtt_port, 60)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
for machine in machines:
|
||||||
|
topic = f"{topic_prefix}/{machine.code}/events"
|
||||||
|
|
||||||
|
if not machine.initialized:
|
||||||
|
initialize_machine(client, topic, machine, now_monotonic)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if machine.cycle_signal_state and now_monotonic >= machine.signal_reset_at:
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "cycle_signal", False))
|
||||||
|
machine.cycle_signal_state = False
|
||||||
|
|
||||||
|
if now_monotonic < machine.downtime_until:
|
||||||
|
if not machine.alarm_state:
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "general_alarm", True))
|
||||||
|
machine.alarm_state = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if machine.alarm_state:
|
||||||
|
publish_json(client, topic, build_signal_event(machine.code, "general_alarm", False))
|
||||||
|
machine.alarm_state = False
|
||||||
|
machine.schedule_next_cycle(now_monotonic)
|
||||||
|
|
||||||
|
if random.random() < machine.stop_probability and now_monotonic >= machine.next_cycle_at:
|
||||||
|
machine.downtime_until = now_monotonic + random.randint(*machine.downtime_range_sec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not machine.cycle_signal_state and now_monotonic >= machine.next_cycle_at:
|
||||||
|
cycle_time_sec = machine.next_cycle_time_sec or round(machine.nominal_cycle_time_sec, 1)
|
||||||
|
publish_json(
|
||||||
|
client,
|
||||||
|
topic,
|
||||||
|
build_signal_event(
|
||||||
|
machine.code,
|
||||||
|
"cycle_signal",
|
||||||
|
True,
|
||||||
|
cycle_time_sec=cycle_time_sec,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
machine.cycle_signal_state = True
|
||||||
|
machine.signal_reset_at = now_monotonic + machine.pulse_width_sec
|
||||||
|
machine.last_cycle_edge_at = now_monotonic
|
||||||
|
machine.schedule_next_cycle(now_monotonic)
|
||||||
|
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Plast Track MVP</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1382
frontend/package-lock.json
generated
Normal file
1382
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "plast-track-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
857
frontend/src/App.tsx
Normal file
857
frontend/src/App.tsx
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
import { FormEvent, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import CycleBarChart from "./components/CycleBarChart";
|
||||||
|
import KpiBlock from "./components/KpiBlock";
|
||||||
|
import MachineCard from "./components/MachineCard";
|
||||||
|
import Panel from "./components/Panel";
|
||||||
|
import StatusBadge from "./components/StatusBadge";
|
||||||
|
import { fetchJson, patchJson, postJson, WS_URL } from "./lib/api";
|
||||||
|
import { Cycle, DailyOee, DashboardSnapshot, Downtime, MachineEvent, ProductionOrder, ReferenceData, Scrap } from "./lib/types";
|
||||||
|
|
||||||
|
type DetailState = {
|
||||||
|
events: MachineEvent[];
|
||||||
|
cycles: Cycle[];
|
||||||
|
downtimes: Downtime[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastKey = "config" | "order" | "downtime" | "scrap";
|
||||||
|
type ModuleKey = "atelier" | "machine" | "trs" | "orders" | "downtimes" | "scrap" | "config";
|
||||||
|
type FormErrors = Record<ToastKey, Record<string, string>>;
|
||||||
|
type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const initialFormErrors: FormErrors = {
|
||||||
|
config: {},
|
||||||
|
order: {},
|
||||||
|
downtime: {},
|
||||||
|
scrap: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleTabs: Array<{ key: ModuleKey; label: string; description: string }> = [
|
||||||
|
{ key: "atelier", label: "Atelier", description: "Vue machines" },
|
||||||
|
{ key: "machine", label: "Machine", description: "Cycles et evenements" },
|
||||||
|
{ key: "trs", label: "TRS", description: "Performance jour" },
|
||||||
|
{ key: "orders", label: "OF", description: "Ordres fabrication" },
|
||||||
|
{ key: "downtimes", label: "Arrets", description: "Qualification" },
|
||||||
|
{ key: "scrap", label: "Rebuts", description: "Declaration" },
|
||||||
|
{ key: "config", label: "Config", description: "Parametrage signal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
|
||||||
|
const [orders, setOrders] = useState<ProductionOrder[]>([]);
|
||||||
|
const [openDowntimes, setOpenDowntimes] = 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 [activeModule, setActiveModule] = useState<ModuleKey>("atelier");
|
||||||
|
const [machineFilter, setMachineFilter] = useState("all");
|
||||||
|
const [machineSort, setMachineSort] = useState("code");
|
||||||
|
const [isWorkshopMode, setIsWorkshopMode] = useState(false);
|
||||||
|
const [nowMs, setNowMs] = useState(Date.now());
|
||||||
|
const [detail, setDetail] = useState<DetailState>({ events: [], cycles: [], downtimes: [] });
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState("Connecting");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [formErrors, setFormErrors] = useState<FormErrors>(initialFormErrors);
|
||||||
|
const [actionErrors, setActionErrors] = useState<Record<ToastKey, string | null>>({
|
||||||
|
config: null,
|
||||||
|
order: null,
|
||||||
|
downtime: null,
|
||||||
|
scrap: null,
|
||||||
|
});
|
||||||
|
const [toasts, setToasts] = useState<Record<ToastKey, string | null>>({
|
||||||
|
config: null,
|
||||||
|
order: null,
|
||||||
|
downtime: null,
|
||||||
|
scrap: null,
|
||||||
|
});
|
||||||
|
const refreshTimer = useRef<number | null>(null);
|
||||||
|
const toastTimers = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const snapshot = await fetchJson<DashboardSnapshot>("/api/dashboard");
|
||||||
|
setDashboard(snapshot);
|
||||||
|
if (selectedMachineId === null && snapshot.machines.length > 0) {
|
||||||
|
setSelectedMachineId(snapshot.machines[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCollections() {
|
||||||
|
const [loadedOrders, loadedOpenDowntimes, loadedScraps, loadedReferences, loadedDailyOee] = await Promise.all([
|
||||||
|
fetchJson<ProductionOrder[]>("/api/production-orders"),
|
||||||
|
fetchJson<Downtime[]>("/api/downtimes/open"),
|
||||||
|
fetchJson<Scrap[]>("/api/scraps"),
|
||||||
|
fetchJson<ReferenceData>("/api/reference-data"),
|
||||||
|
fetchJson<DailyOee>(`/api/oee/daily?date=${today}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setOrders(loadedOrders);
|
||||||
|
setOpenDowntimes(loadedOpenDowntimes);
|
||||||
|
setScraps(loadedScraps);
|
||||||
|
setReferences(loadedReferences);
|
||||||
|
setDailyOee(loadedDailyOee);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(machineId: number) {
|
||||||
|
const [events, cycles, downtimes] = await Promise.all([
|
||||||
|
fetchJson<MachineEvent[]>(`/api/machines/${machineId}/events`),
|
||||||
|
fetchJson<Cycle[]>(`/api/machines/${machineId}/cycles`),
|
||||||
|
fetchJson<Downtime[]>(`/api/machines/${machineId}/downtimes`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setDetail({ events, cycles, downtimes });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
setErrorMessage(null);
|
||||||
|
await Promise.all([loadDashboard(), loadCollections()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(key: ToastKey, message: string) {
|
||||||
|
window.clearTimeout(toastTimers.current[key]);
|
||||||
|
setToasts((current) => ({ ...current, [key]: message }));
|
||||||
|
toastTimers.current[key] = window.setTimeout(() => {
|
||||||
|
setToasts((current) => ({ ...current, [key]: null }));
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll().catch((error: Error) => {
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
setConnectionStatus("Load failed");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNowMs(Date.now()), 30000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMachineId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadDetail(selectedMachineId).catch((error: Error) => setErrorMessage(error.message));
|
||||||
|
}, [selectedMachineId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
let isDisposed = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
socket = new WebSocket(WS_URL);
|
||||||
|
socket.onopen = () => {
|
||||||
|
attempt = 0;
|
||||||
|
setConnectionStatus("Live");
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectionStatus("Reconnecting");
|
||||||
|
const delayMs = Math.min(30000, 1000 * 2 ** attempt);
|
||||||
|
attempt += 1;
|
||||||
|
reconnectTimer = window.setTimeout(connect, delayMs);
|
||||||
|
};
|
||||||
|
socket.onerror = () => setConnectionStatus("Reconnecting");
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data) as { type: string; data?: DashboardSnapshot };
|
||||||
|
if (message.type === "snapshot" && message.data) {
|
||||||
|
setDashboard(message.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshTimer.current) {
|
||||||
|
window.clearTimeout(refreshTimer.current);
|
||||||
|
}
|
||||||
|
refreshTimer.current = window.setTimeout(() => {
|
||||||
|
loadAll().catch((error: Error) => setErrorMessage(error.message));
|
||||||
|
if (selectedMachineId !== null) {
|
||||||
|
loadDetail(selectedMachineId).catch((error: Error) => setErrorMessage(error.message));
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isDisposed = true;
|
||||||
|
if (refreshTimer.current) {
|
||||||
|
window.clearTimeout(refreshTimer.current);
|
||||||
|
}
|
||||||
|
if (reconnectTimer) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
}
|
||||||
|
socket?.close();
|
||||||
|
};
|
||||||
|
}, [selectedMachineId]);
|
||||||
|
|
||||||
|
const selectedMachine = dashboard?.machines.find((machine) => machine.id === selectedMachineId) ?? null;
|
||||||
|
const machines = dashboard?.machines ?? [];
|
||||||
|
const summaryMetrics = {
|
||||||
|
production: machines.filter((machine) => machine.status === "production").length,
|
||||||
|
openDowntimes: openDowntimes.length,
|
||||||
|
producedQty: machines.reduce((total, machine) => total + machine.produced_qty, 0),
|
||||||
|
scrapQty: machines.reduce((total, machine) => total + machine.scrap_qty, 0),
|
||||||
|
oee: Math.round((dailyOee?.overall.oee ?? 0) * 100),
|
||||||
|
};
|
||||||
|
const machineStatuses = Array.from(new Set(machines.map((machine) => machine.status))).sort();
|
||||||
|
const visibleMachines = [...machines]
|
||||||
|
.filter((machine) => machineFilter === "all" || machine.status === machineFilter)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (machineSort === "oee") {
|
||||||
|
return right.today_oee.oee - left.today_oee.oee;
|
||||||
|
}
|
||||||
|
if (machineSort === "stops") {
|
||||||
|
return Number(Boolean(right.open_downtime)) - Number(Boolean(left.open_downtime));
|
||||||
|
}
|
||||||
|
return left.code.localeCompare(right.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFormControls(form: HTMLFormElement) {
|
||||||
|
return Array.from(form.elements).filter((element): element is FormControl => {
|
||||||
|
return element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form: HTMLFormElement, key: ToastKey) {
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
getFormControls(form).forEach((control) => {
|
||||||
|
if (!control.name || control.disabled || control.type === "submit" || control.type === "button") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!control.checkValidity()) {
|
||||||
|
nextErrors[control.name] = control.validationMessage || "Invalid value.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormErrors((current) => ({ ...current, [key]: nextErrors }));
|
||||||
|
return Object.keys(nextErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormBlur(key: ToastKey, form: HTMLFormElement) {
|
||||||
|
validateForm(form, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldError(key: ToastKey, name: string) {
|
||||||
|
const message = formErrors[key][name];
|
||||||
|
return message ? <span className="field-error">{message}</span> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionError(key: ToastKey) {
|
||||||
|
return actionErrors[key] ? <div className="inline-error">{actionErrors[key]}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOrder(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
if (!validateForm(form, "order")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionErrors((current) => ({ ...current, order: null }));
|
||||||
|
await postJson("/api/production-orders", {
|
||||||
|
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")),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setFormErrors((current) => ({ ...current, order: {} }));
|
||||||
|
showToast("order", "Production order created.");
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order creation failed." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDowntimeQualification(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
if (!validateForm(form, "downtime")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionErrors((current) => ({ ...current, downtime: null }));
|
||||||
|
await postJson(`/api/downtimes/${formData.get("downtime_id")}/qualify`, {
|
||||||
|
reason_code: formData.get("reason_code"),
|
||||||
|
comment: formData.get("comment"),
|
||||||
|
qualified_by: formData.get("qualified_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setFormErrors((current) => ({ ...current, downtime: {} }));
|
||||||
|
showToast("downtime", "Downtime qualified.");
|
||||||
|
await loadAll();
|
||||||
|
if (selectedMachineId !== null) {
|
||||||
|
await loadDetail(selectedMachineId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setActionErrors((current) => ({ ...current, downtime: error instanceof Error ? error.message : "Downtime qualification failed." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScrap(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedMachine) {
|
||||||
|
setActionErrors((current) => ({ ...current, scrap: "Select a machine before declaring scrap." }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = event.currentTarget;
|
||||||
|
if (!validateForm(form, "scrap")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionErrors((current) => ({ ...current, scrap: null }));
|
||||||
|
await postJson("/api/scraps", {
|
||||||
|
machine_id: selectedMachine.code,
|
||||||
|
production_order_id: selectedMachine.active_order?.id ?? null,
|
||||||
|
quantity: Number(formData.get("quantity")),
|
||||||
|
reason_code: formData.get("reason_code"),
|
||||||
|
comment: formData.get("comment"),
|
||||||
|
operator_name: formData.get("operator_name"),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setFormErrors((current) => ({ ...current, scrap: {} }));
|
||||||
|
showToast("scrap", "Scrap declaration recorded.");
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
setActionErrors((current) => ({ ...current, scrap: error instanceof Error ? error.message : "Scrap declaration failed." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeOrderStatus(orderId: number, action: "start" | "pause" | "close") {
|
||||||
|
try {
|
||||||
|
setActionErrors((current) => ({ ...current, order: null }));
|
||||||
|
await postJson(`/api/production-orders/${orderId}/${action}`);
|
||||||
|
await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
setActionErrors((current) => ({ ...current, order: error instanceof Error ? error.message : "Order status change failed." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMachineConfig(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedMachine) {
|
||||||
|
setActionErrors((current) => ({ ...current, config: "Select a machine before saving configuration." }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
if (!validateForm(form, "config")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
try {
|
||||||
|
setActionErrors((current) => ({ ...current, config: null }));
|
||||||
|
await patchJson(`/api/machines/${selectedMachine.id}/config`, {
|
||||||
|
name: formData.get("name"),
|
||||||
|
brand: formData.get("brand"),
|
||||||
|
model: formData.get("model"),
|
||||||
|
is_active: formData.get("is_active") === "on",
|
||||||
|
cycle_signal_edge: formData.get("cycle_signal_edge"),
|
||||||
|
debounce_ms: Number(formData.get("debounce_ms")),
|
||||||
|
min_cycle_time_sec: Number(formData.get("min_cycle_time_sec")),
|
||||||
|
max_cycle_time_sec: Number(formData.get("max_cycle_time_sec")),
|
||||||
|
stop_detection_delay_sec: Number(formData.get("stop_detection_delay_sec")),
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormErrors((current) => ({ ...current, config: {} }));
|
||||||
|
showToast("config", "Machine configuration saved.");
|
||||||
|
await loadAll();
|
||||||
|
await loadDetail(selectedMachine.id);
|
||||||
|
} catch (error) {
|
||||||
|
setActionErrors((current) => ({ ...current, config: error instanceof Error ? error.message : "Machine configuration failed." }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMachine(machineId: number, module: ModuleKey = activeModule) {
|
||||||
|
setSelectedMachineId(machineId);
|
||||||
|
setActiveModule(module);
|
||||||
|
if (module !== "atelier") {
|
||||||
|
setIsWorkshopMode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEventLabel(item: MachineEvent) {
|
||||||
|
if (item.event_type !== "digital_input_changed") {
|
||||||
|
return item.event_type;
|
||||||
|
}
|
||||||
|
const inputName = String(item.payload.input_name ?? "input");
|
||||||
|
const inputValue = String(item.payload.input_value ?? "");
|
||||||
|
return `${inputName} -> ${inputValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(key: ToastKey) {
|
||||||
|
return toasts[key] ? <div className="inline-toast">{toasts[key]}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModule(): ReactNode {
|
||||||
|
if (activeModule === "atelier") {
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Dashboard Atelier"
|
||||||
|
subtitle="Machine status"
|
||||||
|
actions={
|
||||||
|
<div className="panel-actions">
|
||||||
|
<select value={machineFilter} onChange={(event) => setMachineFilter(event.target.value)} aria-label="Filter machines by status">
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{machineStatuses.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={machineSort} onChange={(event) => setMachineSort(event.target.value)} aria-label="Sort machines">
|
||||||
|
<option value="code">Sort by code</option>
|
||||||
|
<option value="oee">Sort by OEE</option>
|
||||||
|
<option value="stops">Stops first</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => setIsWorkshopMode((current) => !current)}>
|
||||||
|
{isWorkshopMode ? "Exit fullscreen" : "Fullscreen atelier"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="machine-grid">
|
||||||
|
{visibleMachines.map((machine) => (
|
||||||
|
<MachineCard
|
||||||
|
key={machine.id}
|
||||||
|
machine={machine}
|
||||||
|
selected={machine.id === selectedMachineId}
|
||||||
|
nowMs={nowMs}
|
||||||
|
onSelect={() => selectMachine(machine.id, "machine")}
|
||||||
|
onCreateOrder={() => selectMachine(machine.id, "orders")}
|
||||||
|
onDeclareScrap={() => selectMachine(machine.id, "scrap")}
|
||||||
|
onQualifyDowntime={() => selectMachine(machine.id, "downtimes")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{visibleMachines.length === 0 ? <div className="empty-state">No machines match the current filter.</div> : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModule === "machine") {
|
||||||
|
return (
|
||||||
|
<Panel title="Detail Machine" subtitle={selectedMachine ? selectedMachine.code : "Select a machine"}>
|
||||||
|
{selectedMachine ? (
|
||||||
|
<div className="detail-stack">
|
||||||
|
<div className="metric-row">
|
||||||
|
<KpiBlock label="Status" value={<StatusBadge status={selectedMachine.status} />} />
|
||||||
|
<KpiBlock label="Current order" value={selectedMachine.active_order?.order_number ?? "None"} />
|
||||||
|
<KpiBlock label="Avg. cycle" value={`${selectedMachine.cycle_real_avg_sec.toFixed(1)} s`} />
|
||||||
|
<KpiBlock label="Daily OEE" value={`${Math.round(selectedMachine.today_oee.oee * 100)}%`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="machine-context">
|
||||||
|
<div>
|
||||||
|
<span className="meta-label">Machine</span>
|
||||||
|
<strong>
|
||||||
|
{selectedMachine.brand} {selectedMachine.model}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="meta-label">Signal setup</span>
|
||||||
|
<strong>
|
||||||
|
{selectedMachine.config.cycle_signal_edge} edge / {selectedMachine.config.debounce_ms} ms /{" "}
|
||||||
|
{selectedMachine.config.stop_detection_delay_sec} s
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="meta-label">Good parts</span>
|
||||||
|
<strong>{selectedMachine.produced_qty - selectedMachine.scrap_qty}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="cycle-panel">
|
||||||
|
<div className="cycle-panel__header">
|
||||||
|
<h3>Recent cycles</h3>
|
||||||
|
<span>{detail.cycles.length} samples</span>
|
||||||
|
</div>
|
||||||
|
<CycleBarChart cycles={detail.cycles} theoreticalCycleTimeSec={selectedMachine.active_order?.theoretical_cycle_time_sec ?? 0} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="detail-columns">
|
||||||
|
<div>
|
||||||
|
<h3>Recent events</h3>
|
||||||
|
<ul className="list">
|
||||||
|
{detail.events.slice(0, 8).map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<strong>{renderEventLabel(item)}</strong>
|
||||||
|
<span>{new Date(item.timestamp).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Downtime history</h3>
|
||||||
|
<ul className="list">
|
||||||
|
{detail.downtimes.slice(0, 8).map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<strong>{item.reason_code ?? "Waiting qualification"}</strong>
|
||||||
|
<span>{new Date(item.start_time).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>No machine selected.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModule === "trs") {
|
||||||
|
return (
|
||||||
|
<Panel title="Vue TRS Journalier" subtitle={today}>
|
||||||
|
{dailyOee ? (
|
||||||
|
<div className="module-grid module-grid--split">
|
||||||
|
<div className="oee-grid">
|
||||||
|
<KpiBlock label="OEE" value={`${Math.round(dailyOee.overall.oee * 100)}%`} tone="dark" />
|
||||||
|
<KpiBlock label="Availability" value={`${Math.round(dailyOee.overall.availability * 100)}%`} />
|
||||||
|
<KpiBlock label="Performance" value={`${Math.round(dailyOee.overall.performance * 100)}%`} />
|
||||||
|
<KpiBlock label="Quality" value={`${Math.round(dailyOee.overall.quality * 100)}%`} />
|
||||||
|
</div>
|
||||||
|
<div className="oee-mini-list">
|
||||||
|
{machines.map((machine) => (
|
||||||
|
<button key={machine.id} type="button" className="oee-mini-list__item" onClick={() => selectMachine(machine.id, "machine")}>
|
||||||
|
<span>{machine.code}</span>
|
||||||
|
<strong>{Math.round(machine.today_oee.oee * 100)}%</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModule === "orders") {
|
||||||
|
return (
|
||||||
|
<Panel title="Ordres de Fabrication" subtitle="Create and manage production orders">
|
||||||
|
<div className="module-grid module-grid--form-table">
|
||||||
|
<form
|
||||||
|
className="form-grid module-form"
|
||||||
|
onSubmit={submitOrder}
|
||||||
|
onBlurCapture={(event) => handleFormBlur("order", event.currentTarget)}
|
||||||
|
onInputCapture={(event) => validateForm(event.currentTarget, "order")}
|
||||||
|
onChangeCapture={(event) => validateForm(event.currentTarget, "order")}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<input name="order_number" placeholder="OF number" required />
|
||||||
|
{fieldError("order", "order_number")}
|
||||||
|
<select name="machine_id" required defaultValue={selectedMachine?.id ?? ""}>
|
||||||
|
<option value="" disabled>
|
||||||
|
Machine
|
||||||
|
</option>
|
||||||
|
{machines.map((machine) => (
|
||||||
|
<option key={machine.id} value={machine.id}>
|
||||||
|
{machine.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldError("order", "machine_id")}
|
||||||
|
<input name="article_ref" placeholder="Article ref" required />
|
||||||
|
{fieldError("order", "article_ref")}
|
||||||
|
<input name="article_name" placeholder="Article name" required />
|
||||||
|
{fieldError("order", "article_name")}
|
||||||
|
<input name="mold_ref" placeholder="Mold ref" />
|
||||||
|
<input name="material_ref" placeholder="Material ref" />
|
||||||
|
<input name="planned_qty" type="number" min="1" placeholder="Planned qty" required />
|
||||||
|
{fieldError("order", "planned_qty")}
|
||||||
|
<input name="cavities" type="number" min="1" defaultValue="1" placeholder="Cavities" required />
|
||||||
|
{fieldError("order", "cavities")}
|
||||||
|
<input name="theoretical_cycle_time_sec" type="number" min="1" step="0.1" placeholder="Theoretical cycle" required />
|
||||||
|
{fieldError("order", "theoretical_cycle_time_sec")}
|
||||||
|
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.order).length > 0}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
{actionError("order")}
|
||||||
|
{toast("order")}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>OF</th>
|
||||||
|
<th>Machine</th>
|
||||||
|
<th>Article</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>
|
||||||
|
<td>{order.article_name}</td>
|
||||||
|
<td>{order.status}</td>
|
||||||
|
<td className="table-actions">
|
||||||
|
<button type="button" onClick={() => changeOrderStatus(order.id, "start")}>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => changeOrderStatus(order.id, "pause")}>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => changeOrderStatus(order.id, "close")}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModule === "downtimes") {
|
||||||
|
return (
|
||||||
|
<Panel title="Qualification Arret" subtitle={`${openDowntimes.length} open downtime(s)`}>
|
||||||
|
<div className="module-grid module-grid--form-table">
|
||||||
|
<form
|
||||||
|
className="form-grid module-form"
|
||||||
|
onSubmit={submitDowntimeQualification}
|
||||||
|
onBlurCapture={(event) => handleFormBlur("downtime", event.currentTarget)}
|
||||||
|
onInputCapture={(event) => validateForm(event.currentTarget, "downtime")}
|
||||||
|
onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<select name="downtime_id" required defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Open downtime
|
||||||
|
</option>
|
||||||
|
{openDowntimes.map((downtime) => (
|
||||||
|
<option key={downtime.id} value={downtime.id}>
|
||||||
|
#{downtime.id} machine {downtime.machine_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldError("downtime", "downtime_id")}
|
||||||
|
<select name="reason_code" required defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Reason
|
||||||
|
</option>
|
||||||
|
{references?.downtime_reasons.map((reason) => (
|
||||||
|
<option key={reason.code} value={reason.code}>
|
||||||
|
{reason.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldError("downtime", "reason_code")}
|
||||||
|
<input name="qualified_by" placeholder="Operator" required />
|
||||||
|
{fieldError("downtime", "qualified_by")}
|
||||||
|
<textarea name="comment" placeholder="Comment" rows={3} />
|
||||||
|
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.downtime).length > 0}>
|
||||||
|
Qualify
|
||||||
|
</button>
|
||||||
|
{actionError("downtime")}
|
||||||
|
{toast("downtime")}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="timeline-list">
|
||||||
|
{openDowntimes.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<div>
|
||||||
|
<strong>Machine {item.machine_id}</strong>
|
||||||
|
<span>Waiting qualification</span>
|
||||||
|
</div>
|
||||||
|
<time>{new Date(item.start_time).toLocaleString()}</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{openDowntimes.length === 0 ? <div className="empty-state">No open downtime currently needs qualification.</div> : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModule === "scrap") {
|
||||||
|
return (
|
||||||
|
<Panel title="Saisie Rebut" subtitle={selectedMachine?.code ?? "Select machine"}>
|
||||||
|
<div className="module-grid module-grid--form-table">
|
||||||
|
<form
|
||||||
|
className="form-grid module-form"
|
||||||
|
onSubmit={submitScrap}
|
||||||
|
onBlurCapture={(event) => handleFormBlur("scrap", event.currentTarget)}
|
||||||
|
onInputCapture={(event) => validateForm(event.currentTarget, "scrap")}
|
||||||
|
onChangeCapture={(event) => validateForm(event.currentTarget, "scrap")}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<input name="operator_name" placeholder="Operator" required />
|
||||||
|
{fieldError("scrap", "operator_name")}
|
||||||
|
<input name="quantity" type="number" min="1" placeholder="Quantity" required />
|
||||||
|
{fieldError("scrap", "quantity")}
|
||||||
|
<select name="reason_code" required defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Scrap reason
|
||||||
|
</option>
|
||||||
|
{references?.scrap_reasons.map((reason) => (
|
||||||
|
<option key={reason.code} value={reason.code}>
|
||||||
|
{reason.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldError("scrap", "reason_code")}
|
||||||
|
<textarea name="comment" placeholder="Comment" rows={3} />
|
||||||
|
<button type="submit" className="primary-button" disabled={!selectedMachine || Object.keys(formErrors.scrap).length > 0}>
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
{actionError("scrap")}
|
||||||
|
{toast("scrap")}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="list">
|
||||||
|
{scraps.slice(0, 12).map((scrap) => (
|
||||||
|
<li key={scrap.id}>
|
||||||
|
<strong>
|
||||||
|
{scrap.quantity} pcs / {scrap.reason_code}
|
||||||
|
</strong>
|
||||||
|
<span>{new Date(scrap.timestamp).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{scraps.length === 0 ? <div className="empty-state">No scrap has been declared yet.</div> : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title="Config Machine" subtitle={selectedMachine?.code ?? "No machine selected"}>
|
||||||
|
{selectedMachine ? (
|
||||||
|
<form
|
||||||
|
className="form-grid config-form"
|
||||||
|
onSubmit={submitMachineConfig}
|
||||||
|
onBlurCapture={(event) => handleFormBlur("config", event.currentTarget)}
|
||||||
|
onInputCapture={(event) => validateForm(event.currentTarget, "config")}
|
||||||
|
onChangeCapture={(event) => validateForm(event.currentTarget, "config")}
|
||||||
|
key={selectedMachine.id}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<input name="name" placeholder="Display name" defaultValue={selectedMachine.config.name} required />
|
||||||
|
{fieldError("config", "name")}
|
||||||
|
<input name="brand" placeholder="Brand" defaultValue={selectedMachine.config.brand ?? ""} />
|
||||||
|
<input name="model" placeholder="Model" defaultValue={selectedMachine.config.model ?? ""} />
|
||||||
|
<label className="checkbox-field">
|
||||||
|
<input name="is_active" type="checkbox" defaultChecked={selectedMachine.config.is_active} />
|
||||||
|
<span>Active on dashboard</span>
|
||||||
|
</label>
|
||||||
|
<select name="cycle_signal_edge" defaultValue={selectedMachine.config.cycle_signal_edge}>
|
||||||
|
<option value="rising">Rising edge</option>
|
||||||
|
<option value="falling">Falling edge</option>
|
||||||
|
</select>
|
||||||
|
<input name="debounce_ms" type="number" min="0" defaultValue={selectedMachine.config.debounce_ms} placeholder="Debounce ms" required />
|
||||||
|
{fieldError("config", "debounce_ms")}
|
||||||
|
<input
|
||||||
|
name="min_cycle_time_sec"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={selectedMachine.config.min_cycle_time_sec}
|
||||||
|
placeholder="Min cycle sec"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{fieldError("config", "min_cycle_time_sec")}
|
||||||
|
<input
|
||||||
|
name="max_cycle_time_sec"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={selectedMachine.config.max_cycle_time_sec}
|
||||||
|
placeholder="Max cycle sec"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{fieldError("config", "max_cycle_time_sec")}
|
||||||
|
<input
|
||||||
|
name="stop_detection_delay_sec"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={selectedMachine.config.stop_detection_delay_sec}
|
||||||
|
placeholder="Stop delay sec"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{fieldError("config", "stop_detection_delay_sec")}
|
||||||
|
<button type="submit" className="primary-button" disabled={Object.keys(formErrors.config).length > 0}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{actionError("config")}
|
||||||
|
{toast("config")}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p>No machine selected.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell ${isWorkshopMode && activeModule === "atelier" ? "app-shell--workshop" : ""}`}>
|
||||||
|
<main className="workspace">
|
||||||
|
<header className="hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Plast Track MVP</p>
|
||||||
|
<h1>Workshop supervision</h1>
|
||||||
|
</div>
|
||||||
|
<div className="hero__status">
|
||||||
|
<span className="hero__dot" aria-hidden="true" />
|
||||||
|
<strong>{connectionStatus}</strong>
|
||||||
|
{errorMessage ? <p>{errorMessage}</p> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{connectionStatus === "Reconnecting" ? <div className="connection-banner">Reconnecting...</div> : null}
|
||||||
|
|
||||||
|
<section className="summary-ribbon" aria-label="Workshop summary">
|
||||||
|
<KpiBlock label="Global OEE" value={`${summaryMetrics.oee}%`} subLabel="Daily aggregate" tone="dark" />
|
||||||
|
<KpiBlock label="Production" value={summaryMetrics.production} subLabel={`${machines.length} machines monitored`} />
|
||||||
|
<KpiBlock label="Open stops" value={summaryMetrics.openDowntimes} subLabel="Awaiting qualification" />
|
||||||
|
<KpiBlock label="Output / scrap" value={`${summaryMetrics.producedQty} / ${summaryMetrics.scrapQty}`} subLabel="Pieces today" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="module-tabs" aria-label="Modules">
|
||||||
|
{moduleTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
className={`module-tabs__button ${activeModule === tab.key ? "is-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveModule(tab.key);
|
||||||
|
if (tab.key !== "atelier") {
|
||||||
|
setIsWorkshopMode(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<small>{tab.description}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="module-view">{renderModule()}</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/CycleBarChart.tsx
Normal file
51
frontend/src/components/CycleBarChart.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Cycle } from "../lib/types";
|
||||||
|
|
||||||
|
type CycleBarChartProps = {
|
||||||
|
cycles: Cycle[];
|
||||||
|
theoreticalCycleTimeSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CycleBarChart({ cycles, theoreticalCycleTimeSec = 0 }: CycleBarChartProps) {
|
||||||
|
const visibleCycles = cycles.slice(0, 24).reverse();
|
||||||
|
const maxCycle = Math.max(theoreticalCycleTimeSec, ...visibleCycles.map((cycle) => cycle.cycle_time_sec), 1);
|
||||||
|
const chartWidth = 480;
|
||||||
|
const chartHeight = 150;
|
||||||
|
const padding = 18;
|
||||||
|
const gap = 4;
|
||||||
|
const usableHeight = chartHeight - padding * 2;
|
||||||
|
const barWidth = visibleCycles.length > 0 ? (chartWidth - padding * 2 - gap * (visibleCycles.length - 1)) / visibleCycles.length : 0;
|
||||||
|
const referenceY = chartHeight - padding - (Math.min(theoreticalCycleTimeSec, maxCycle) / maxCycle) * usableHeight;
|
||||||
|
|
||||||
|
if (visibleCycles.length === 0) {
|
||||||
|
return <div className="cycle-chart cycle-chart--empty">No cycle data yet</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="cycle-chart" viewBox={`0 0 ${chartWidth} ${chartHeight}`} role="img" aria-label="Recent cycle time trend">
|
||||||
|
{theoreticalCycleTimeSec > 0 ? (
|
||||||
|
<line className="cycle-chart__reference" x1={padding} y1={referenceY} x2={chartWidth - padding} y2={referenceY} />
|
||||||
|
) : null}
|
||||||
|
{visibleCycles.map((cycle, index) => {
|
||||||
|
const barHeight = Math.max((cycle.cycle_time_sec / maxCycle) * usableHeight, 6);
|
||||||
|
const x = padding + index * (barWidth + gap);
|
||||||
|
const y = chartHeight - padding - barHeight;
|
||||||
|
const isSlow = theoreticalCycleTimeSec > 0 && cycle.cycle_time_sec > theoreticalCycleTimeSec * 1.1;
|
||||||
|
const className = isSlow ? "cycle-chart__bar cycle-chart__bar--slow" : "cycle-chart__bar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={cycle.id}
|
||||||
|
className={className}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={Math.max(barWidth, 2)}
|
||||||
|
height={barHeight}
|
||||||
|
rx="2"
|
||||||
|
>
|
||||||
|
<title>{`${cycle.cycle_time_sec.toFixed(1)} s`}</title>
|
||||||
|
</rect>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/KpiBlock.tsx
Normal file
18
frontend/src/components/KpiBlock.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type KpiBlockProps = {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
subLabel?: string;
|
||||||
|
tone?: "default" | "dark";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KpiBlock({ label, value, subLabel, tone = "default" }: KpiBlockProps) {
|
||||||
|
return (
|
||||||
|
<article className={`kpi-block kpi-block--${tone}`}>
|
||||||
|
<span className="kpi-block__label">{label}</span>
|
||||||
|
<strong className="kpi-block__value">{value}</strong>
|
||||||
|
{subLabel ? <span className="kpi-block__sub-label">{subLabel}</span> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/components/MachineCard.tsx
Normal file
138
frontend/src/components/MachineCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { MachineCardData } from "../lib/types";
|
||||||
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
|
type MachineCardProps = {
|
||||||
|
machine: MachineCardData;
|
||||||
|
selected: boolean;
|
||||||
|
nowMs: number;
|
||||||
|
onSelect: () => void;
|
||||||
|
onCreateOrder: () => void;
|
||||||
|
onDeclareScrap: () => void;
|
||||||
|
onQualifyDowntime: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDurationSince(timestamp: string | null, nowMs: number) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "No timestamp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Math.max(nowMs - new Date(timestamp).getTime(), 0);
|
||||||
|
const elapsedMinutes = Math.floor(elapsedMs / 60000);
|
||||||
|
if (elapsedMinutes < 1) {
|
||||||
|
return "< 1 min";
|
||||||
|
}
|
||||||
|
if (elapsedMinutes < 60) {
|
||||||
|
return `${elapsedMinutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(elapsedMinutes / 60);
|
||||||
|
const minutes = elapsedMinutes % 60;
|
||||||
|
return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MachineCard({
|
||||||
|
machine,
|
||||||
|
selected,
|
||||||
|
nowMs,
|
||||||
|
onSelect,
|
||||||
|
onCreateOrder,
|
||||||
|
onDeclareScrap,
|
||||||
|
onQualifyDowntime,
|
||||||
|
}: MachineCardProps) {
|
||||||
|
const progressPercent = Math.round(machine.order_progress * 100);
|
||||||
|
const hasActiveOrder = Boolean(machine.active_order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`machine-card ${selected ? "is-selected" : ""}`}>
|
||||||
|
<button type="button" className="machine-card__select" onClick={onSelect}>
|
||||||
|
<div className="machine-card__top">
|
||||||
|
<div>
|
||||||
|
<p className="machine-card__code">{machine.code}</p>
|
||||||
|
<p className="machine-card__meta">{[machine.brand, machine.model].filter(Boolean).join(" / ")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="machine-card__status-stack">
|
||||||
|
<StatusBadge status={machine.status} />
|
||||||
|
<span className="duration-badge">{formatDurationSince(machine.status_since, nowMs)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="machine-card__hero">
|
||||||
|
<div>
|
||||||
|
<span className="machine-card__label">Current order</span>
|
||||||
|
<strong>{machine.active_order?.order_number ?? "No active order"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="machine-card__label">OEE today</span>
|
||||||
|
<strong>{Math.round(machine.today_oee.oee * 100)}%</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasActiveOrder ? (
|
||||||
|
<div className="machine-card__empty">
|
||||||
|
<strong>No active OF</strong>
|
||||||
|
<span>Create or start a production order before counting production for this press.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="machine-card__stats">
|
||||||
|
<div>
|
||||||
|
<span>Article</span>
|
||||||
|
<strong>{machine.active_order?.article_name ?? "-"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Mold</span>
|
||||||
|
<strong>{machine.active_order?.mold_ref ?? "-"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Real cycle</span>
|
||||||
|
<strong>{machine.cycle_real_avg_sec.toFixed(1)} s</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Theoretical</span>
|
||||||
|
<strong>{machine.active_order?.theoretical_cycle_time_sec ?? 0} s</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Produced</span>
|
||||||
|
<strong>{machine.produced_qty}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Scrap</span>
|
||||||
|
<strong>{machine.scrap_qty}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Last stop</span>
|
||||||
|
<strong>{machine.last_downtime?.reason_code ?? "None"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="machine-card__compact-kpi">
|
||||||
|
<span>Quality</span>
|
||||||
|
<strong>{Math.round(machine.today_oee.quality * 100)}%</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="machine-card__progress">
|
||||||
|
<div className="machine-card__progress-head">
|
||||||
|
<span>Order progress</span>
|
||||||
|
<strong>{progressPercent}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="progress-track">
|
||||||
|
<progress value={progressPercent} max={100} aria-label={`Order progress ${progressPercent}%`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="machine-card__actions" aria-label={`${machine.code} quick actions`}>
|
||||||
|
<button type="button" onClick={onCreateOrder}>
|
||||||
|
OF
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDeclareScrap} disabled={!hasActiveOrder}>
|
||||||
|
Rebut
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onQualifyDowntime} disabled={!machine.open_downtime}>
|
||||||
|
Arret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/Panel.tsx
Normal file
22
frontend/src/components/Panel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
|
||||||
|
type PanelProps = PropsWithChildren<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function Panel({ title, subtitle, actions, children }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<header className="panel__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{title}</p>
|
||||||
|
{subtitle ? <h2>{subtitle}</h2> : null}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/StatusBadge.tsx
Normal file
15
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
type StatusBadgeProps = {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
production: "Production",
|
||||||
|
arret_non_qualifie: "Arret non qualifie",
|
||||||
|
arret_qualifie: "Arret qualifie",
|
||||||
|
reglage: "Reglage",
|
||||||
|
hors_planning: "Hors planning",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||||
|
return <span className={`status-badge status-badge--${status}`}>{(labels[status] ?? status).toUpperCase()}</span>;
|
||||||
|
}
|
||||||
33
frontend/src/lib/api.ts
Normal file
33
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8000/ws/dashboard";
|
||||||
|
|
||||||
|
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(detail || `Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postJson<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
|
return fetchJson<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchJson<T>(path: string, payload: unknown): Promise<T> {
|
||||||
|
return fetchJson<T>(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
154
frontend/src/lib/types.ts
Normal file
154
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
export type OrderSummary = {
|
||||||
|
id: number;
|
||||||
|
order_number: string;
|
||||||
|
article_ref: string;
|
||||||
|
article_name: string;
|
||||||
|
mold_ref: string | null;
|
||||||
|
material_ref: string | null;
|
||||||
|
planned_qty: number;
|
||||||
|
cavities: number;
|
||||||
|
theoretical_cycle_time_sec: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MachineConfig = {
|
||||||
|
name: string;
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
cycle_signal_edge: string;
|
||||||
|
debounce_ms: number;
|
||||||
|
min_cycle_time_sec: number;
|
||||||
|
max_cycle_time_sec: number;
|
||||||
|
stop_detection_delay_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MachineCardData = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
status: string;
|
||||||
|
status_since: string | null;
|
||||||
|
powered_on: boolean;
|
||||||
|
config: MachineConfig;
|
||||||
|
active_order: OrderSummary | null;
|
||||||
|
cycle_real_avg_sec: number;
|
||||||
|
produced_qty: number;
|
||||||
|
scrap_qty: number;
|
||||||
|
order_progress: number;
|
||||||
|
today_oee: {
|
||||||
|
oee: number;
|
||||||
|
availability: number;
|
||||||
|
performance: number;
|
||||||
|
quality: number;
|
||||||
|
total_cycles: number;
|
||||||
|
};
|
||||||
|
open_downtime: {
|
||||||
|
id: number;
|
||||||
|
reason_code: string | null;
|
||||||
|
start_time: string;
|
||||||
|
comment: string | null;
|
||||||
|
} | null;
|
||||||
|
last_downtime: {
|
||||||
|
id: number;
|
||||||
|
reason_code: string | null;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string | null;
|
||||||
|
duration_sec: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardSnapshot = {
|
||||||
|
timestamp: string;
|
||||||
|
machines: MachineCardData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MachineEvent = {
|
||||||
|
id: number;
|
||||||
|
event_type: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Cycle = {
|
||||||
|
id: number;
|
||||||
|
timestamp: string;
|
||||||
|
cycle_time_sec: number;
|
||||||
|
produced_qty: number;
|
||||||
|
is_valid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Downtime = {
|
||||||
|
id: number;
|
||||||
|
machine_id?: number;
|
||||||
|
production_order_id: number | null;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string | null;
|
||||||
|
duration_sec: number | null;
|
||||||
|
reason_code: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
auto_detected: boolean;
|
||||||
|
qualified_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductionOrder = {
|
||||||
|
id: number;
|
||||||
|
order_number: string;
|
||||||
|
machine_id: number;
|
||||||
|
article_ref: string;
|
||||||
|
article_name: string;
|
||||||
|
mold_ref: string | null;
|
||||||
|
material_ref: string | null;
|
||||||
|
planned_qty: number;
|
||||||
|
cavities: number;
|
||||||
|
theoretical_cycle_time_sec: number;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
ended_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Scrap = {
|
||||||
|
id: number;
|
||||||
|
machine_id: number;
|
||||||
|
production_order_id: number | null;
|
||||||
|
quantity: number;
|
||||||
|
reason_code: string;
|
||||||
|
comment: string | null;
|
||||||
|
operator_name: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReferenceOption = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReferenceData = {
|
||||||
|
downtime_reasons: ReferenceOption[];
|
||||||
|
scrap_reasons: ReferenceOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyOee = {
|
||||||
|
date: string;
|
||||||
|
machines: Array<{
|
||||||
|
machine_id: number;
|
||||||
|
availability: number;
|
||||||
|
performance: number;
|
||||||
|
quality: number;
|
||||||
|
oee: number;
|
||||||
|
total_cycles: number;
|
||||||
|
total_produced_qty: number;
|
||||||
|
scrap_qty: number;
|
||||||
|
}>;
|
||||||
|
overall: {
|
||||||
|
availability: number;
|
||||||
|
performance: number;
|
||||||
|
quality: number;
|
||||||
|
oee: number;
|
||||||
|
total_cycles: number;
|
||||||
|
total_produced_qty: number;
|
||||||
|
scrap_qty: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
969
frontend/src/styles.css
Normal file
969
frontend/src/styles.css
Normal file
@@ -0,0 +1,969 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600&family=Space+Grotesk:wght@600&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #F5F7FA;
|
||||||
|
--bg-panel: #FFFFFF;
|
||||||
|
--bg-panel-alt: color-mix(in srgb, #F5F7FA 78%, #FFFFFF);
|
||||||
|
--border: color-mix(in srgb, #2B2B2B 14%, #FFFFFF);
|
||||||
|
--teal-900: #0A2F5A;
|
||||||
|
--teal-700: color-mix(in srgb, #0A2F5A 78%, #2B2B2B);
|
||||||
|
--teal-100: color-mix(in srgb, #0A2F5A 12%, #FFFFFF);
|
||||||
|
--accent: #F28C28;
|
||||||
|
--accent-light: color-mix(in srgb, #F28C28 14%, #FFFFFF);
|
||||||
|
--text-primary: #2B2B2B;
|
||||||
|
--text-secondary: color-mix(in srgb, #2B2B2B 72%, #FFFFFF);
|
||||||
|
--text-on-dark: #FFFFFF;
|
||||||
|
--status-production: #0A2F5A;
|
||||||
|
--status-unqualified: #F28C28;
|
||||||
|
--status-qualified: #2B2B2B;
|
||||||
|
--status-setup: color-mix(in srgb, #0A2F5A 72%, #F28C28);
|
||||||
|
--status-off-plan: color-mix(in srgb, #2B2B2B 48%, #FFFFFF);
|
||||||
|
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
--text-3xl: 2rem;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-base);
|
||||||
|
font-family: "Instrument Sans", sans-serif;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--workshop {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--workshop .hero,
|
||||||
|
.app-shell--workshop .summary-ribbon,
|
||||||
|
.app-shell--workshop .module-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--workshop .workspace {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--workshop .panel {
|
||||||
|
min-height: calc(100vh - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--workshop .machine-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
width: min(1480px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.panel,
|
||||||
|
.machine-card,
|
||||||
|
.kpi-block {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__status {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--teal-900);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__status p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--status-production);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-banner {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.meta-label,
|
||||||
|
.kpi-block__label,
|
||||||
|
.machine-card__label,
|
||||||
|
th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions select,
|
||||||
|
.panel-actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions button {
|
||||||
|
background: var(--teal-900);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ribbon {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs__button {
|
||||||
|
display: grid;
|
||||||
|
min-height: 66px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs__button span {
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs__button small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs__button:hover,
|
||||||
|
.module-tabs__button.is-active {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs__button.is-active {
|
||||||
|
box-shadow: inset 0 -3px 0 var(--accent), 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-view {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-grid--split {
|
||||||
|
grid-template-columns: 1.4fr 0.8fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-grid--form-table {
|
||||||
|
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-form,
|
||||||
|
.config-form {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form .checkbox-field,
|
||||||
|
.config-form .primary-button,
|
||||||
|
.config-form .inline-toast {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block--dark {
|
||||||
|
background: var(--teal-900);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block--dark .kpi-block__label,
|
||||||
|
.kpi-block--dark .kpi-block__sub-label {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block__label,
|
||||||
|
.kpi-block__sub-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block__value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-block__sub-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
transition: box-shadow 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card:hover,
|
||||||
|
.machine-card.is-selected {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 16px rgba(10, 47, 90, 0.12), 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__top,
|
||||||
|
.machine-card__hero,
|
||||||
|
.machine-card__progress-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__status-stack {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__code {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__hero {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__hero strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__empty strong {
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__empty span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__stats span,
|
||||||
|
.machine-card__progress span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__stats strong {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__progress {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__actions button:hover:not(:disabled) {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track progress {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track progress::-webkit-progress-bar {
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track progress::-webkit-progress-value {
|
||||||
|
background: var(--teal-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track progress::-moz-progress-bar {
|
||||||
|
background: var(--teal-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--production {
|
||||||
|
background: var(--status-production);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--arret_non_qualifie {
|
||||||
|
background: var(--status-unqualified);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--arret_qualifie {
|
||||||
|
background: var(--status-qualified);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--reglage {
|
||||||
|
background: var(--status-setup);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--hors_planning {
|
||||||
|
background: var(--status-off-plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row,
|
||||||
|
.oee-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.6fr 0.8fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-context strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-panel {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--teal-900);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-panel span {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-chart {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 150px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--teal-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-chart--empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 150px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-chart__reference {
|
||||||
|
stroke: var(--teal-100);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-chart__bar {
|
||||||
|
fill: var(--status-production);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cycle-chart__bar--slow {
|
||||||
|
fill: var(--status-unqualified);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list li {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 12px 12px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list li::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 8px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--status-unqualified);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list li::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 29px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 12px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list li:last-child::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list strong,
|
||||||
|
.timeline-list span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list span,
|
||||||
|
.timeline-list time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oee-mini-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oee-mini-list__item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oee-mini-list__item span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input,
|
||||||
|
.form-grid select,
|
||||||
|
.form-grid textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
display: flex;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
table button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-toast {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--status-production);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error,
|
||||||
|
.field-error {
|
||||||
|
color: var(--status-qualified);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--status-qualified);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ribbon {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-tabs {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-grid--split,
|
||||||
|
.module-grid--form-table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-shell {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.machine-card__top,
|
||||||
|
.machine-card__hero,
|
||||||
|
.machine-card__progress-head,
|
||||||
|
.cycle-panel__header,
|
||||||
|
.list li,
|
||||||
|
.timeline-list li {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-card__status-stack {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions select,
|
||||||
|
.panel-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ribbon,
|
||||||
|
.module-tabs,
|
||||||
|
.metric-row,
|
||||||
|
.oee-grid,
|
||||||
|
.machine-context,
|
||||||
|
.detail-columns,
|
||||||
|
.config-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
5
mqtt/mosquitto.conf
Normal file
5
mqtt/mosquitto.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
|
||||||
|
listener 9001
|
||||||
|
protocol websockets
|
||||||
236
plast-track-codex-prompt.md
Normal file
236
plast-track-codex-prompt.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Plast Track MVP - Codex Prompt & UI Instructions
|
||||||
|
|
||||||
|
## 1. Codex Task Prompt
|
||||||
|
|
||||||
|
Use this as the top-level prompt when opening a Codex session on this repository.
|
||||||
|
|
||||||
|
```text
|
||||||
|
You are working on Plast Track MVP - a lightweight MES/IoT dashboard for
|
||||||
|
supervising multi-brand injection molding machines. The stack is:
|
||||||
|
|
||||||
|
Backend FastAPI + PostgreSQL + Mosquitto MQTT (Python)
|
||||||
|
Frontend React + TypeScript (Vite, port 5173)
|
||||||
|
Infra Docker Compose
|
||||||
|
|
||||||
|
Repository layout:
|
||||||
|
/backend FastAPI app, MQTT consumer, OEE logic, WebSocket
|
||||||
|
/frontend React + TypeScript operator dashboard
|
||||||
|
/edge-simulator Publishes simulated MQTT machine events
|
||||||
|
/database init.sql + seed.sql
|
||||||
|
/mqtt mosquitto.conf
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Never send commands back to the machines. Acquisition is read-only / passive.
|
||||||
|
- All timestamps are UTC ISO-8601.
|
||||||
|
- OEE = Availability x Performance x Quality (see README for exact formulas).
|
||||||
|
- The WebSocket at /ws/dashboard pushes refresh notifications; the UI then
|
||||||
|
refetches via REST. Do not push full payloads over the socket.
|
||||||
|
- Machine status values: production | arret_non_qualifie | arret_qualifie |
|
||||||
|
reglage | hors_planning
|
||||||
|
|
||||||
|
Before writing code:
|
||||||
|
1. Read the relevant backend route or model file first.
|
||||||
|
2. Confirm the PostgreSQL schema in /database/init.sql for any DB changes.
|
||||||
|
3. Run `pytest` in /backend and `npm run build` in /frontend to verify changes.
|
||||||
|
4. Keep backend business logic inside service modules, not route handlers.
|
||||||
|
5. For frontend changes, follow the UI instructions below.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. UI Instructions for Codex
|
||||||
|
|
||||||
|
Paste these instructions into any Codex task that touches the frontend.
|
||||||
|
|
||||||
|
### 2.1 Design System - Non-Negotiable Tokens
|
||||||
|
|
||||||
|
All visual decisions must derive from these tokens. Do not introduce new color
|
||||||
|
hex values, font families, or border-radius values without updating this table.
|
||||||
|
|
||||||
|
#### Colors
|
||||||
|
|
||||||
|
| Token name | Hex | Role |
|
||||||
|
|------------------------|-----------|-----------------------------------------|
|
||||||
|
| `--bg-base` | `#F5F7FA` | Page background |
|
||||||
|
| `--bg-panel` | `#FFFFFF` | Panel and card surface |
|
||||||
|
| `--bg-panel-alt` | derived | Alternate very light gray surface |
|
||||||
|
| `--border` | derived | Anthracite tint border |
|
||||||
|
| `--teal-900` | `#0A2F5A` | Primary industrial blue structure |
|
||||||
|
| `--teal-700` | derived | Darker industrial blue |
|
||||||
|
| `--teal-100` | derived | Industrial blue tint |
|
||||||
|
| `--accent` | `#F28C28` | Technical orange accent and CTA |
|
||||||
|
| `--accent-light` | derived | Technical orange tint |
|
||||||
|
| `--text-primary` | `#2B2B2B` | Anthracite body text |
|
||||||
|
| `--text-secondary` | derived | Anthracite metadata tint |
|
||||||
|
| `--text-on-dark` | `#FFFFFF` | Text on dark surfaces |
|
||||||
|
| `--status-production` | `#0A2F5A` | Industrial blue, machine running |
|
||||||
|
| `--status-unqualified` | `#F28C28` | Technical orange, unqualified stop |
|
||||||
|
| `--status-qualified` | `#2B2B2B` | Anthracite, qualified stop |
|
||||||
|
| `--status-setup` | derived | Blue/orange mix, setup / reglage |
|
||||||
|
| `--status-off-plan` | derived | Anthracite tint, outside planning |
|
||||||
|
|
||||||
|
#### Typography
|
||||||
|
|
||||||
|
| Role | Font family | Weight | Usage |
|
||||||
|
|----------------|-------------------|--------|----------------------------------------|
|
||||||
|
| Display / KPI | `Space Grotesk` | 600 | Headings, large numbers, machine codes |
|
||||||
|
| Body / UI | `Instrument Sans` | 400 | Labels, descriptions, form fields |
|
||||||
|
| Body emphasis | `Instrument Sans` | 600 | Sub-headings, table headers |
|
||||||
|
| Caption / meta | `Instrument Sans` | 400 | Small labels, timestamps, hints |
|
||||||
|
|
||||||
|
Type scale:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
--text-3xl: 2rem;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Surfaces
|
||||||
|
|
||||||
|
```css
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(10, 47, 90, 0.06), 0 4px 12px rgba(10, 47, 90, 0.04);
|
||||||
|
```
|
||||||
|
|
||||||
|
Selected card:
|
||||||
|
|
||||||
|
```css
|
||||||
|
box-shadow: 0 4px 16px rgba(10, 47, 90, 0.12), 0 0 0 2px var(--accent);
|
||||||
|
```
|
||||||
|
|
||||||
|
Inset surfaces:
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--bg-panel-alt);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Layout Rules
|
||||||
|
|
||||||
|
The current UI uses a tabbed module workspace, not a fixed action sidebar.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hero Header
|
||||||
|
Summary Ribbon
|
||||||
|
Module Tabs
|
||||||
|
Current Module Panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Current modules:
|
||||||
|
|
||||||
|
- `Atelier`: machine cards, filters, sorting, quick actions, fullscreen workshop mode
|
||||||
|
- `Machine`: selected machine detail, cycle trend, events, downtime history
|
||||||
|
- `TRS`: daily OEE/TRS
|
||||||
|
- `OF`: production order creation and management
|
||||||
|
- `Arrets`: downtime qualification
|
||||||
|
- `Rebuts`: scrap declaration and scrap log
|
||||||
|
- `Config`: passive signal and timing configuration
|
||||||
|
|
||||||
|
Responsive rules:
|
||||||
|
|
||||||
|
- Desktop: centered workspace, full tab row, split form/list modules where useful.
|
||||||
|
- Tablet: tabs wrap, form/list modules stack.
|
||||||
|
- Mobile: single column, no horizontal page scroll.
|
||||||
|
|
||||||
|
### 2.3 Component Conventions
|
||||||
|
|
||||||
|
#### Machine Card
|
||||||
|
|
||||||
|
- Card uses `min-height: 180px`.
|
||||||
|
- Machine code uses `Space Grotesk 600`, `--text-xl`.
|
||||||
|
- Status badge uses tokenized status color and text label.
|
||||||
|
- State duration badge shows elapsed time since `status_since`.
|
||||||
|
- No active order state must be visually distinct.
|
||||||
|
- Quick actions route to OF, Rebuts, or Arrets modules.
|
||||||
|
- Selected state uses the elevated `box-shadow`; do not use outline for the selected ring.
|
||||||
|
|
||||||
|
#### Status Badge
|
||||||
|
|
||||||
|
Use `<StatusBadge status="production" />`. Always include a text label; color is never the only state indicator.
|
||||||
|
|
||||||
|
#### KPI Block
|
||||||
|
|
||||||
|
Use for summary and machine detail metrics:
|
||||||
|
|
||||||
|
- label: `--text-xs`, uppercase, `--text-secondary`
|
||||||
|
- value: `--text-2xl`, `Space Grotesk 600`
|
||||||
|
- optional sub-label: `--text-xs`, `--text-secondary`
|
||||||
|
|
||||||
|
#### Forms
|
||||||
|
|
||||||
|
- Fields use full width, `border-radius: 6px`, `border: 1px solid var(--border)`, `padding: 8px 12px`.
|
||||||
|
- Primary action button uses `var(--accent)`, `border-radius: 8px`, `Space Grotesk 600`.
|
||||||
|
- Inline validation appears below the field in `--status-qualified`.
|
||||||
|
- API errors appear inside the active module, not as global alerts.
|
||||||
|
- Success toasts appear inline and auto-dismiss after 3 seconds.
|
||||||
|
|
||||||
|
#### Cycle Bar Mini-Chart
|
||||||
|
|
||||||
|
- Use SVG directly; no third-party charting library.
|
||||||
|
- Newest cycle appears on the right.
|
||||||
|
- Theoretical cycle is a dashed `--teal-100` reference line.
|
||||||
|
- Bars at or below theoretical are green.
|
||||||
|
- Bars above theoretical by more than 10% are orange.
|
||||||
|
- Tooltip shows exact cycle time in seconds.
|
||||||
|
|
||||||
|
### 2.4 Interaction Rules
|
||||||
|
|
||||||
|
| Trigger | Expected behavior |
|
||||||
|
|-------------------------|-------------------------------------------------------|
|
||||||
|
| Click machine card body | Selects machine and opens Machine module |
|
||||||
|
| Click quick OF action | Selects machine and opens OF module |
|
||||||
|
| Click quick Rebut | Selects machine and opens Rebuts module |
|
||||||
|
| Click quick Arret | Selects machine and opens Arrets module |
|
||||||
|
| WebSocket refresh event | Re-fetch dashboard data without unmount flash |
|
||||||
|
| Submit downtime qualify | Validate inline, send REST request, refresh on success |
|
||||||
|
| Submit scrap | Validate inline, clear form on success |
|
||||||
|
| Create production order | Add order; do not auto-start unless user starts it |
|
||||||
|
|
||||||
|
Data fetching:
|
||||||
|
|
||||||
|
- REST calls go to `http://localhost:8000/api/...`.
|
||||||
|
- WebSocket connects to `ws://localhost:8000/ws/dashboard`.
|
||||||
|
- On WS disconnect, show a small reconnecting banner and retry with exponential backoff up to 30 seconds.
|
||||||
|
- Do not poll. Refresh is WebSocket-driven.
|
||||||
|
|
||||||
|
### 2.5 Accessibility Minimums
|
||||||
|
|
||||||
|
- Interactive elements must be reachable by keyboard.
|
||||||
|
- Status colors must have text labels.
|
||||||
|
- Touch targets should be at least 44px high.
|
||||||
|
- Respect `prefers-reduced-motion`.
|
||||||
|
- Body text on panel backgrounds must pass WCAG AA contrast.
|
||||||
|
|
||||||
|
### 2.6 File Conventions
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|-------------------------------|-----------------------------------------------|
|
||||||
|
| Global CSS variables / resets | `frontend/src/styles.css` |
|
||||||
|
| Root app layout | `frontend/src/App.tsx` |
|
||||||
|
| Machine card component | `frontend/src/components/MachineCard.tsx` |
|
||||||
|
| Reusable panel wrapper | `frontend/src/components/Panel.tsx` |
|
||||||
|
| New components | `frontend/src/components/<ComponentName>.tsx` |
|
||||||
|
| Types | `frontend/src/lib/types.ts` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- One component per file.
|
||||||
|
- No inline styles in TSX.
|
||||||
|
- No Tailwind.
|
||||||
|
- No UI component library.
|
||||||
|
- No charting library for cycle bars.
|
||||||
|
- No machine command/control path.
|
||||||
|
- No `localStorage` for production state.
|
||||||
|
|
||||||
|
### 2.7 Suggested Next Tasks
|
||||||
|
|
||||||
|
1. Add historian-style OEE and scrap reporting endpoints.
|
||||||
|
2. Add edge gateway buffering for MQTT outage periods.
|
||||||
|
3. Add a real passive gateway adapter specification with example payload mappings.
|
||||||
|
4. Add operator and supervisor views when role needs are defined.
|
||||||
|
5. Add richer downtime timelines and duration buckets.
|
||||||
643
prompt_mvp.md
Normal file
643
prompt_mvp.md
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
# Prompt Codex — Développer un MVP MES/IoT pour superviser des presses à injecter multi-marques par acquisition passive de signaux
|
||||||
|
|
||||||
|
## Rôle attendu de l'AI coder
|
||||||
|
|
||||||
|
Tu es un développeur senior full-stack + IoT industriel. Tu dois concevoir et développer un MVP de supervision de production pour des presses à injecter utilisées en plasturgie/injection plastique, quelle que soit leur marque.
|
||||||
|
|
||||||
|
Contrainte importante : le parc machine peut inclure plusieurs marques et modèles, sans garantie d'interface Euromap 63, Euromap 77, OPC UA ou autre protocole standard. Le MVP ne doit donc dépendre d'aucun protocole constructeur spécifique. L'acquisition doit se faire principalement par lecture passive de signaux électriques machine isolés, capteurs externes et saisie opérateur.
|
||||||
|
|
||||||
|
## Objectif du MVP
|
||||||
|
|
||||||
|
Développer une application web MES légère permettant de superviser les machines en temps réel et de calculer les indicateurs de production de base.
|
||||||
|
|
||||||
|
Le MVP doit permettre de suivre :
|
||||||
|
|
||||||
|
- état machine : production, arrêt, réglage, hors planning ;
|
||||||
|
- compteur cycles automatique ;
|
||||||
|
- temps de cycle réel ;
|
||||||
|
- OF en cours ;
|
||||||
|
- quantité produite ;
|
||||||
|
- quantité bonne ;
|
||||||
|
- quantité rebutée ;
|
||||||
|
- arrêts machine automatiques ;
|
||||||
|
- qualification des causes d'arrêt par opérateur ;
|
||||||
|
- saisie des rebuts ;
|
||||||
|
- calcul TRS/OEE ;
|
||||||
|
- dashboard atelier temps réel.
|
||||||
|
|
||||||
|
## Périmètre machine
|
||||||
|
|
||||||
|
Machines cibles : presses à injecter toutes marques.
|
||||||
|
|
||||||
|
Hypothèse technique :
|
||||||
|
|
||||||
|
- pas d'Euromap 63 garanti ;
|
||||||
|
- pas d'Euromap 77 garanti ;
|
||||||
|
- pas d'OPC UA machine garanti ;
|
||||||
|
- pas d'accès direct aux paramètres process avancés au démarrage ;
|
||||||
|
- acquisition basée sur signaux électriques isolés et/ou capteurs externes ;
|
||||||
|
- approche compatible avec différentes marques via une configuration par machine, sans dépendance à un automate ou protocole propriétaire.
|
||||||
|
|
||||||
|
## Acquisition machine par signaux passifs
|
||||||
|
|
||||||
|
### Option A — Lecture signaux électriques machine
|
||||||
|
|
||||||
|
Le MVP doit gérer une acquisition basée sur des entrées digitales issues de la machine, via relais d'interface ou optocoupleurs.
|
||||||
|
|
||||||
|
Ne jamais connecter directement la gateway IoT aux sorties automate ou aux circuits machine. Toute connexion doit être isolée électriquement. L'acquisition doit rester strictement passive et non intrusive.
|
||||||
|
|
||||||
|
Chaîne recommandée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sortie machine / contact sec / relais auxiliaire
|
||||||
|
→ relais d'interface ou optocoupleur
|
||||||
|
→ entrée digitale gateway IoT
|
||||||
|
→ service edge
|
||||||
|
→ MQTT
|
||||||
|
→ backend MES
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signaux machine minimum à supporter
|
||||||
|
|
||||||
|
Le système doit pouvoir configurer les signaux suivants par machine :
|
||||||
|
|
||||||
|
| Signal | Type | Utilisation |
|
||||||
|
|---|---|---|
|
||||||
|
| machine_power_on | digital input | machine sous tension |
|
||||||
|
| auto_mode | digital input | machine en mode automatique |
|
||||||
|
| cycle_signal | digital input | détection cycle ou fin de cycle |
|
||||||
|
| mold_open | digital input optionnel | validation fin de cycle |
|
||||||
|
| ejector_forward | digital input optionnel | comptage cycle fiable |
|
||||||
|
| general_alarm | digital input optionnel | détection arrêt ou alarme |
|
||||||
|
| pump_running | digital input optionnel | machine active |
|
||||||
|
|
||||||
|
Le MVP doit fonctionner avec seulement 2 signaux minimum :
|
||||||
|
|
||||||
|
1. machine_power_on ;
|
||||||
|
2. cycle_signal.
|
||||||
|
|
||||||
|
Les autres signaux doivent être optionnels.
|
||||||
|
|
||||||
|
## Logique de détection cycle
|
||||||
|
|
||||||
|
Le signal principal est `cycle_signal`.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- détecter un cycle uniquement sur front montant ou front descendant configurable ;
|
||||||
|
- appliquer un anti-rebond logiciel ;
|
||||||
|
- ignorer les impulsions trop proches ;
|
||||||
|
- calculer `cycle_time_sec` entre deux cycles valides ;
|
||||||
|
- publier un événement `cycle_completed` à chaque cycle valide.
|
||||||
|
|
||||||
|
Paramètres configurables par machine :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"cycle_signal_edge": "rising",
|
||||||
|
"debounce_ms": 300,
|
||||||
|
"min_cycle_time_sec": 5,
|
||||||
|
"max_cycle_time_sec": 300,
|
||||||
|
"stop_detection_delay_sec": 180
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemple événement cycle
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "cycle_completed",
|
||||||
|
"timestamp": "2026-06-14T10:32:15Z",
|
||||||
|
"cycle_time_sec": 18.7,
|
||||||
|
"source": "digital_input",
|
||||||
|
"input_name": "cycle_signal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logique de détection arrêt
|
||||||
|
|
||||||
|
Un arrêt est détecté automatiquement si aucun cycle valide n'est reçu pendant une durée configurable.
|
||||||
|
|
||||||
|
Règle par défaut :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Si aucun cycle depuis 180 secondes alors créer un arrêt machine automatique.
|
||||||
|
```
|
||||||
|
|
||||||
|
L'arrêt reste ouvert jusqu'au prochain cycle valide ou jusqu'à action opérateur.
|
||||||
|
|
||||||
|
À la reprise :
|
||||||
|
|
||||||
|
- fermer l'arrêt automatiquement ;
|
||||||
|
- calculer la durée ;
|
||||||
|
- laisser la cause vide si non qualifiée ;
|
||||||
|
- demander à l'opérateur de qualifier l'arrêt.
|
||||||
|
|
||||||
|
### Exemple événement arrêt
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "machine_stopped",
|
||||||
|
"timestamp": "2026-06-14T10:35:20Z",
|
||||||
|
"reason_code": null,
|
||||||
|
"auto_detected": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Causes d'arrêt initiales
|
||||||
|
|
||||||
|
Créer les causes suivantes :
|
||||||
|
|
||||||
|
- changement_moule ;
|
||||||
|
- reglage ;
|
||||||
|
- attente_matiere ;
|
||||||
|
- attente_operateur ;
|
||||||
|
- panne_machine ;
|
||||||
|
- panne_moule ;
|
||||||
|
- attente_qualite ;
|
||||||
|
- nettoyage ;
|
||||||
|
- pause ;
|
||||||
|
- micro_arret ;
|
||||||
|
- autre.
|
||||||
|
|
||||||
|
## Stack technique souhaitée
|
||||||
|
|
||||||
|
Développer une application simple, robuste et industrialisable.
|
||||||
|
|
||||||
|
Stack recommandée :
|
||||||
|
|
||||||
|
- Backend : Python FastAPI ;
|
||||||
|
- Frontend : React + TypeScript ;
|
||||||
|
- Base de données : PostgreSQL ;
|
||||||
|
- Temps réel : WebSocket ;
|
||||||
|
- Messaging IoT : MQTT ;
|
||||||
|
- Broker MQTT : Mosquitto ;
|
||||||
|
- Edge simulator : service Python simulant les entrées digitales ;
|
||||||
|
- Déploiement local : Docker Compose.
|
||||||
|
|
||||||
|
Le MVP doit être exécutable localement avec :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services à créer
|
||||||
|
|
||||||
|
Créer les services suivants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plast-track-mvp
|
||||||
|
/backend
|
||||||
|
/frontend
|
||||||
|
/edge-simulator
|
||||||
|
/mqtt
|
||||||
|
/database
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### backend
|
||||||
|
|
||||||
|
API FastAPI.
|
||||||
|
|
||||||
|
Responsabilités :
|
||||||
|
|
||||||
|
- recevoir les événements MQTT ;
|
||||||
|
- historiser les cycles ;
|
||||||
|
- historiser les états machine ;
|
||||||
|
- détecter ou clôturer les arrêts ;
|
||||||
|
- gérer les OF ;
|
||||||
|
- gérer les rebuts ;
|
||||||
|
- calculer TRS/OEE ;
|
||||||
|
- exposer API REST ;
|
||||||
|
- exposer WebSocket pour dashboard temps réel.
|
||||||
|
|
||||||
|
### frontend
|
||||||
|
|
||||||
|
Application React.
|
||||||
|
|
||||||
|
Écrans minimum :
|
||||||
|
|
||||||
|
1. Dashboard atelier ;
|
||||||
|
2. Détail machine ;
|
||||||
|
3. Lancement OF ;
|
||||||
|
4. Qualification arrêt ;
|
||||||
|
5. Saisie rebut ;
|
||||||
|
6. Vue TRS journalier.
|
||||||
|
|
||||||
|
### edge-simulator
|
||||||
|
|
||||||
|
Service Python permettant de simuler une ou plusieurs presses à injecter de marques différentes.
|
||||||
|
|
||||||
|
Fonctions :
|
||||||
|
|
||||||
|
- publier `machine_power_on` ;
|
||||||
|
- publier `cycle_completed` ;
|
||||||
|
- simuler temps de cycle variable ;
|
||||||
|
- simuler arrêt machine ;
|
||||||
|
- simuler reprise ;
|
||||||
|
- simuler alarme générale optionnelle.
|
||||||
|
|
||||||
|
Le simulateur doit publier sur MQTT.
|
||||||
|
|
||||||
|
Exemple topic :
|
||||||
|
|
||||||
|
```text
|
||||||
|
plasttrack/machines/INJ-01/events
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"event_type": "cycle_completed",
|
||||||
|
"timestamp": "2026-06-14T10:32:15Z",
|
||||||
|
"cycle_time_sec": 18.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modèle de données PostgreSQL
|
||||||
|
|
||||||
|
Créer au minimum les tables suivantes.
|
||||||
|
|
||||||
|
### machines
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- code ;
|
||||||
|
- name ;
|
||||||
|
- brand ;
|
||||||
|
- model ;
|
||||||
|
- status ;
|
||||||
|
- is_active ;
|
||||||
|
- created_at ;
|
||||||
|
- updated_at.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```text
|
||||||
|
code = INJ-01
|
||||||
|
brand = Haitian
|
||||||
|
model = Mars II
|
||||||
|
|
||||||
|
Les champs `brand` et `model` doivent rester totalement configurables pour supporter n'importe quelle machine du parc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### production_orders
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- order_number ;
|
||||||
|
- machine_id ;
|
||||||
|
- article_ref ;
|
||||||
|
- article_name ;
|
||||||
|
- mold_ref ;
|
||||||
|
- material_ref ;
|
||||||
|
- planned_qty ;
|
||||||
|
- cavities ;
|
||||||
|
- theoretical_cycle_time_sec ;
|
||||||
|
- status ;
|
||||||
|
- started_at ;
|
||||||
|
- ended_at ;
|
||||||
|
- created_at ;
|
||||||
|
- updated_at.
|
||||||
|
|
||||||
|
### machine_events
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- machine_id ;
|
||||||
|
- event_type ;
|
||||||
|
- timestamp ;
|
||||||
|
- payload JSONB ;
|
||||||
|
- created_at.
|
||||||
|
|
||||||
|
### cycles
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- machine_id ;
|
||||||
|
- production_order_id ;
|
||||||
|
- timestamp ;
|
||||||
|
- cycle_time_sec ;
|
||||||
|
- cavities ;
|
||||||
|
- produced_qty ;
|
||||||
|
- is_valid ;
|
||||||
|
- created_at.
|
||||||
|
|
||||||
|
### downtimes
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- machine_id ;
|
||||||
|
- production_order_id ;
|
||||||
|
- start_time ;
|
||||||
|
- end_time ;
|
||||||
|
- duration_sec ;
|
||||||
|
- reason_code ;
|
||||||
|
- comment ;
|
||||||
|
- auto_detected ;
|
||||||
|
- qualified_by ;
|
||||||
|
- created_at ;
|
||||||
|
- updated_at.
|
||||||
|
|
||||||
|
### scraps
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
|
||||||
|
- id ;
|
||||||
|
- machine_id ;
|
||||||
|
- production_order_id ;
|
||||||
|
- quantity ;
|
||||||
|
- reason_code ;
|
||||||
|
- comment ;
|
||||||
|
- operator_name ;
|
||||||
|
- timestamp ;
|
||||||
|
- created_at.
|
||||||
|
|
||||||
|
### reference tables
|
||||||
|
|
||||||
|
Créer aussi :
|
||||||
|
|
||||||
|
- downtime_reasons ;
|
||||||
|
- scrap_reasons ;
|
||||||
|
- operators optionnel.
|
||||||
|
|
||||||
|
## API REST minimum
|
||||||
|
|
||||||
|
Créer les endpoints suivants.
|
||||||
|
|
||||||
|
### Machines
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/machines
|
||||||
|
GET /api/machines/{machine_id}
|
||||||
|
GET /api/machines/{machine_id}/status
|
||||||
|
GET /api/machines/{machine_id}/events
|
||||||
|
GET /api/machines/{machine_id}/cycles
|
||||||
|
GET /api/machines/{machine_id}/downtimes
|
||||||
|
```
|
||||||
|
|
||||||
|
### OF
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/production-orders
|
||||||
|
POST /api/production-orders
|
||||||
|
POST /api/production-orders/{id}/start
|
||||||
|
POST /api/production-orders/{id}/pause
|
||||||
|
POST /api/production-orders/{id}/close
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrêts
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/downtimes/open
|
||||||
|
POST /api/downtimes/{id}/qualify
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload qualification :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reason_code": "attente_matiere",
|
||||||
|
"comment": "Matière non disponible au poste",
|
||||||
|
"qualified_by": "operateur_1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuts
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/scraps
|
||||||
|
GET /api/scraps
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload rebut :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_id": "INJ-01",
|
||||||
|
"production_order_id": 1,
|
||||||
|
"quantity": 12,
|
||||||
|
"reason_code": "bavure",
|
||||||
|
"comment": "Bavure côté plan de joint",
|
||||||
|
"operator_name": "operateur_1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TRS/OEE
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/oee/daily?date=2026-06-14
|
||||||
|
GET /api/oee/machines/{machine_id}?from=2026-06-14T00:00:00Z&to=2026-06-14T23:59:59Z
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calcul TRS/OEE
|
||||||
|
|
||||||
|
Formule :
|
||||||
|
|
||||||
|
```text
|
||||||
|
TRS = Disponibilité × Performance × Qualité
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disponibilité
|
||||||
|
|
||||||
|
```text
|
||||||
|
Disponibilité = Temps de marche / Temps planifié
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour le MVP :
|
||||||
|
|
||||||
|
- temps planifié = durée entre démarrage OF et fin OF, ou journée de production configurée ;
|
||||||
|
- temps arrêt = somme des downtimes ;
|
||||||
|
- temps marche = temps planifié - temps arrêt.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
```text
|
||||||
|
Performance = (Cycle théorique × Nombre de cycles valides) / Temps de marche
|
||||||
|
```
|
||||||
|
|
||||||
|
Limiter la performance à 100% par défaut pour éviter les valeurs aberrantes.
|
||||||
|
|
||||||
|
### Qualité
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qualité = Quantité bonne / Quantité produite totale
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quantité produite totale = cycles valides × nombre empreintes
|
||||||
|
Quantité bonne = quantité produite totale - rebuts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend attendu
|
||||||
|
|
||||||
|
### Dashboard atelier
|
||||||
|
|
||||||
|
Afficher des cartes machines.
|
||||||
|
|
||||||
|
Chaque carte doit montrer :
|
||||||
|
|
||||||
|
- code machine ;
|
||||||
|
- état courant ;
|
||||||
|
- couleur d'état ;
|
||||||
|
- OF en cours ;
|
||||||
|
- article ;
|
||||||
|
- moule ;
|
||||||
|
- cycle réel moyen ;
|
||||||
|
- cycle théorique ;
|
||||||
|
- quantité produite ;
|
||||||
|
- quantité rebutée ;
|
||||||
|
- avancement OF ;
|
||||||
|
- TRS du jour ;
|
||||||
|
- dernier arrêt.
|
||||||
|
|
||||||
|
Codes couleur :
|
||||||
|
|
||||||
|
| État | Couleur UI |
|
||||||
|
|---|---|
|
||||||
|
| production | vert |
|
||||||
|
| arret_non_qualifie | orange |
|
||||||
|
| arret_qualifie | rouge |
|
||||||
|
| reglage | bleu |
|
||||||
|
| hors_planning | gris |
|
||||||
|
|
||||||
|
### Détail machine
|
||||||
|
|
||||||
|
Afficher :
|
||||||
|
|
||||||
|
- état temps réel ;
|
||||||
|
- courbe des cycles récents ;
|
||||||
|
- historique des arrêts ;
|
||||||
|
- bouton qualifier arrêt ;
|
||||||
|
- bouton déclarer rebut ;
|
||||||
|
- OF en cours.
|
||||||
|
|
||||||
|
### Interface opérateur
|
||||||
|
|
||||||
|
L'interface doit être simple, utilisable sur tablette industrielle.
|
||||||
|
|
||||||
|
Boutons grands :
|
||||||
|
|
||||||
|
- Lancer OF ;
|
||||||
|
- Déclarer rebut ;
|
||||||
|
- Qualifier arrêt ;
|
||||||
|
- Appel maintenance optionnel ;
|
||||||
|
- Fin OF.
|
||||||
|
|
||||||
|
## Données de démonstration
|
||||||
|
|
||||||
|
Créer un seed avec :
|
||||||
|
|
||||||
|
Machines :
|
||||||
|
|
||||||
|
```text
|
||||||
|
INJ-01 — Haitian Mars II
|
||||||
|
INJ-02 — Engel e-victory
|
||||||
|
INJ-03 — Arburg Allrounder
|
||||||
|
```
|
||||||
|
|
||||||
|
Causes rebut :
|
||||||
|
|
||||||
|
- bavure ;
|
||||||
|
- manque_matiere ;
|
||||||
|
- brulure ;
|
||||||
|
- retassure ;
|
||||||
|
- deformation ;
|
||||||
|
- point_noir ;
|
||||||
|
- humidite ;
|
||||||
|
- couleur_non_conforme ;
|
||||||
|
- collage_moule ;
|
||||||
|
- casse_piece ;
|
||||||
|
- defaut_dimensionnel ;
|
||||||
|
- autre.
|
||||||
|
|
||||||
|
Créer au moins deux OF de démonstration.
|
||||||
|
|
||||||
|
## Exigences qualité
|
||||||
|
|
||||||
|
Le code doit être :
|
||||||
|
|
||||||
|
- clair ;
|
||||||
|
- typé autant que possible ;
|
||||||
|
- documenté ;
|
||||||
|
- modulaire ;
|
||||||
|
- prêt pour extension vers vraies gateways IoT ;
|
||||||
|
- sans dépendance à Euromap ni à un protocole propriétaire constructeur.
|
||||||
|
|
||||||
|
Ajouter :
|
||||||
|
|
||||||
|
- tests unitaires backend pour calcul OEE ;
|
||||||
|
- test de parsing événement MQTT ;
|
||||||
|
- README d'installation ;
|
||||||
|
- exemple de payload MQTT ;
|
||||||
|
- script seed database.
|
||||||
|
|
||||||
|
## Sécurité industrielle
|
||||||
|
|
||||||
|
Ajouter dans le README une section sécurité :
|
||||||
|
|
||||||
|
- ne jamais raccorder directement une sortie automate à une gateway ;
|
||||||
|
- utiliser relais d'interface, optocoupleurs ou modules d'entrées industrielles isolées ;
|
||||||
|
- valider tout câblage avec automaticien ou électricien industriel ;
|
||||||
|
- ne jamais modifier la logique automate machine pour le MVP ;
|
||||||
|
- l'acquisition doit rester passive et non intrusive ;
|
||||||
|
- le système MES ne doit jamais piloter la presse dans cette première version.
|
||||||
|
|
||||||
|
## Livrables attendus
|
||||||
|
|
||||||
|
Produire un repository complet avec :
|
||||||
|
|
||||||
|
```text
|
||||||
|
README.md
|
||||||
|
CONTRIBUTING.md optionnel
|
||||||
|
docker-compose.yml
|
||||||
|
/backend
|
||||||
|
/frontend
|
||||||
|
/edge-simulator
|
||||||
|
/database/init.sql
|
||||||
|
/database/seed.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Le README doit expliquer :
|
||||||
|
|
||||||
|
1. l'objectif du MVP ;
|
||||||
|
2. l'architecture ;
|
||||||
|
3. comment lancer le projet ;
|
||||||
|
4. comment simuler les machines ;
|
||||||
|
5. comment tester les endpoints ;
|
||||||
|
6. comment brancher plus tard une vraie gateway IoT ;
|
||||||
|
7. les limites du MVP.
|
||||||
|
|
||||||
|
## Critères d'acceptation
|
||||||
|
|
||||||
|
Le MVP est considéré terminé si :
|
||||||
|
|
||||||
|
- `docker compose up --build` lance tous les services ;
|
||||||
|
- le simulateur publie des cycles MQTT ;
|
||||||
|
- le backend reçoit et historise les cycles ;
|
||||||
|
- le dashboard affiche au moins 3 machines ;
|
||||||
|
- les cycles incrémentent la quantité produite ;
|
||||||
|
- un arrêt est créé automatiquement si absence de cycle ;
|
||||||
|
- un opérateur peut qualifier un arrêt ;
|
||||||
|
- un opérateur peut saisir un rebut ;
|
||||||
|
- le TRS/OEE journalier est calculé ;
|
||||||
|
- aucun composant ne dépend d'Euromap 63, 77 ou d'un protocole propriétaire constructeur.
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
Commencer par une version simple mais fonctionnelle. Ne pas développer de fonctionnalités avancées de pilotage machine. Le MVP doit uniquement superviser, historiser, calculer et afficher.
|
||||||
|
|
||||||
|
Ne pas implémenter de commande vers la presse. Le système est en lecture passive + saisie opérateur.
|
||||||
Reference in New Issue
Block a user