commit 3bf4c622d8024b36fd4eef86cf20496c00a8a0b7 Author: masterdev Date: Mon Jun 15 13:35:22 2026 +0100 Initial Plast Track MVP diff --git a/.codexignore b/.codexignore new file mode 100644 index 0000000..f582c11 --- /dev/null +++ b/.codexignore @@ -0,0 +1,5 @@ +./archive/* +frontend/node_modules +frontend/dist +**/__pycache__ +backend/.pytest_cache diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64d83e1 --- /dev/null +++ b/.gitignore @@ -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/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..34dfd40 --- /dev/null +++ b/README.md @@ -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 diff --git a/UI.md b/UI.md new file mode 100644 index 0000000..bda53bf --- /dev/null +++ b/UI.md @@ -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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d0b50c1 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..d21a1ca --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Plast Track MVP backend package.""" diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0960f88 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..aaca5b4 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1a013a5 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..865400f --- /dev/null +++ b/backend/app/models.py @@ -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()) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..2e48583 --- /dev/null +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..32136f4 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Backend service modules.""" diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py new file mode 100644 index 0000000..263415f --- /dev/null +++ b/backend/app/services/dashboard.py @@ -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], + } diff --git a/backend/app/services/events.py b/backend/app/services/events.py new file mode 100644 index 0000000..f5b26de --- /dev/null +++ b/backend/app/services/events.py @@ -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 diff --git a/backend/app/services/mqtt_listener.py b/backend/app/services/mqtt_listener.py new file mode 100644 index 0000000..3d9ecf8 --- /dev/null +++ b/backend/app/services/mqtt_listener.py @@ -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) diff --git a/backend/app/services/oee.py b/backend/app/services/oee.py new file mode 100644 index 0000000..4372d40 --- /dev/null +++ b/backend/app/services/oee.py @@ -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(), + } diff --git a/backend/app/services/realtime.py b/backend/app/services/realtime.py new file mode 100644 index 0000000..bf4a442 --- /dev/null +++ b/backend/app/services/realtime.py @@ -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() diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..27a0ff9 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py new file mode 100644 index 0000000..c3914c5 --- /dev/null +++ b/backend/tests/test_events.py @@ -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" diff --git a/backend/tests/test_oee.py b/backend/tests/test_oee.py new file mode 100644 index 0000000..2ce388a --- /dev/null +++ b/backend/tests/test_oee.py @@ -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() diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..dda1142 --- /dev/null +++ b/database/init.sql @@ -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); diff --git a/database/seed.sql b/database/seed.sql new file mode 100644 index 0000000..613d05a --- /dev/null +++ b/database/seed.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..923204e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/edge-simulator/Dockerfile b/edge-simulator/Dockerfile new file mode 100644 index 0000000..daaf180 --- /dev/null +++ b/edge-simulator/Dockerfile @@ -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"] diff --git a/edge-simulator/requirements.txt b/edge-simulator/requirements.txt new file mode 100644 index 0000000..96a2716 --- /dev/null +++ b/edge-simulator/requirements.txt @@ -0,0 +1 @@ +paho-mqtt==2.1.0 diff --git a/edge-simulator/simulator.py b/edge-simulator/simulator.py new file mode 100644 index 0000000..e358db6 --- /dev/null +++ b/edge-simulator/simulator.py @@ -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() diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..741090c --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b33b486 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Plast Track MVP + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..71353d5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1382 @@ +{ + "name": "plast-track-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plast-track-frontend", + "version": "0.1.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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5dca436 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0f55450 --- /dev/null +++ b/frontend/src/App.tsx @@ -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>; +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(null); + const [orders, setOrders] = useState([]); + const [openDowntimes, setOpenDowntimes] = useState([]); + const [scraps, setScraps] = useState([]); + const [references, setReferences] = useState(null); + const [dailyOee, setDailyOee] = useState(null); + const [selectedMachineId, setSelectedMachineId] = useState(null); + const [activeModule, setActiveModule] = useState("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({ events: [], cycles: [], downtimes: [] }); + const [connectionStatus, setConnectionStatus] = useState("Connecting"); + const [errorMessage, setErrorMessage] = useState(null); + const [formErrors, setFormErrors] = useState(initialFormErrors); + const [actionErrors, setActionErrors] = useState>({ + config: null, + order: null, + downtime: null, + scrap: null, + }); + const [toasts, setToasts] = useState>({ + config: null, + order: null, + downtime: null, + scrap: null, + }); + const refreshTimer = useRef(null); + const toastTimers = useRef>({}); + + async function loadDashboard() { + const snapshot = await fetchJson("/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("/api/production-orders"), + fetchJson("/api/downtimes/open"), + fetchJson("/api/scraps"), + fetchJson("/api/reference-data"), + fetchJson(`/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(`/api/machines/${machineId}/events`), + fetchJson(`/api/machines/${machineId}/cycles`), + fetchJson(`/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 = {}; + 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 ? {message} : null; + } + + function actionError(key: ToastKey) { + return actionErrors[key] ?
{actionErrors[key]}
: null; + } + + async function submitOrder(event: FormEvent) { + 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) { + 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) { + 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) { + 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] ?
{toasts[key]}
: null; + } + + function renderModule(): ReactNode { + if (activeModule === "atelier") { + return ( + + + + + + } + > +
+ {visibleMachines.map((machine) => ( + selectMachine(machine.id, "machine")} + onCreateOrder={() => selectMachine(machine.id, "orders")} + onDeclareScrap={() => selectMachine(machine.id, "scrap")} + onQualifyDowntime={() => selectMachine(machine.id, "downtimes")} + /> + ))} +
+ {visibleMachines.length === 0 ?
No machines match the current filter.
: null} +
+ ); + } + + if (activeModule === "machine") { + return ( + + {selectedMachine ? ( +
+
+ } /> + + + +
+ +
+
+ Machine + + {selectedMachine.brand} {selectedMachine.model} + +
+
+ Signal setup + + {selectedMachine.config.cycle_signal_edge} edge / {selectedMachine.config.debounce_ms} ms /{" "} + {selectedMachine.config.stop_detection_delay_sec} s + +
+
+ Good parts + {selectedMachine.produced_qty - selectedMachine.scrap_qty} +
+
+ +
+
+

Recent cycles

+ {detail.cycles.length} samples +
+ +
+ +
+
+

Recent events

+
    + {detail.events.slice(0, 8).map((item) => ( +
  • + {renderEventLabel(item)} + {new Date(item.timestamp).toLocaleString()} +
  • + ))} +
+
+
+

Downtime history

+
    + {detail.downtimes.slice(0, 8).map((item) => ( +
  • + {item.reason_code ?? "Waiting qualification"} + {new Date(item.start_time).toLocaleString()} +
  • + ))} +
+
+
+
+ ) : ( +

No machine selected.

+ )} +
+ ); + } + + if (activeModule === "trs") { + return ( + + {dailyOee ? ( +
+
+ + + + +
+
+ {machines.map((machine) => ( + + ))} +
+
+ ) : null} +
+ ); + } + + if (activeModule === "orders") { + return ( + +
+
handleFormBlur("order", event.currentTarget)} + onInputCapture={(event) => validateForm(event.currentTarget, "order")} + onChangeCapture={(event) => validateForm(event.currentTarget, "order")} + noValidate + > + + {fieldError("order", "order_number")} + + {fieldError("order", "machine_id")} + + {fieldError("order", "article_ref")} + + {fieldError("order", "article_name")} + + + + {fieldError("order", "planned_qty")} + + {fieldError("order", "cavities")} + + {fieldError("order", "theoretical_cycle_time_sec")} + + {actionError("order")} + {toast("order")} +
+ +
+ + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + ))} + +
OFMachineArticleStatusActions
{order.order_number}{order.machine_id}{order.article_name}{order.status} + + + +
+
+
+
+ ); + } + + if (activeModule === "downtimes") { + return ( + +
+
handleFormBlur("downtime", event.currentTarget)} + onInputCapture={(event) => validateForm(event.currentTarget, "downtime")} + onChangeCapture={(event) => validateForm(event.currentTarget, "downtime")} + noValidate + > + + {fieldError("downtime", "downtime_id")} + + {fieldError("downtime", "reason_code")} + + {fieldError("downtime", "qualified_by")} +