[Feature] Add Stock forecasting and Logistics view (#1184)

This commit is contained in:
Keybored
2026-05-05 16:23:42 +02:00
committed by GitHub
parent b5c7b1a8a8
commit 37c9d5f26d
28 changed files with 4242 additions and 25 deletions
+256 -2
View File
@@ -5,11 +5,15 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
from backend.app.core.auth import (
RequireAnyPermissionIfAuthEnabled,
RequirePermissionIfAuthEnabled,
require_auth_if_enabled,
)
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
@@ -1565,3 +1569,253 @@ def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict |
if int(tray.get("id", -1)) == tray_id:
return tray
return None
# ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
class FilamentSkuSettingsResponse(BaseModel):
id: int
material: str
subtype: str | None
brand: str | None
lead_time_days: int
safety_margin_value: int
safety_margin_unit: str
alerts_snoozed: bool = False
class Config:
from_attributes = True
class FilamentSkuSettingsUpsert(BaseModel):
material: str
subtype: str | None = None
brand: str | None = None
lead_time_days: int = 0
safety_margin_value: int = 14
safety_margin_unit: str = "days"
alerts_snoozed: bool = False
@router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
async def list_sku_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
):
"""List all filament SKU reorder settings."""
from backend.app.models.filament_sku_settings import FilamentSkuSettings
result = await db.execute(
select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
)
return list(result.scalars().all())
@router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
async def upsert_sku_settings(
data: FilamentSkuSettingsUpsert,
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(
Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
),
):
"""Create or update reorder settings for a filament SKU (material/subtype/brand)."""
from backend.app.models.filament_sku_settings import FilamentSkuSettings
result = await db.execute(
select(FilamentSkuSettings).where(
FilamentSkuSettings.material == data.material,
FilamentSkuSettings.subtype == data.subtype,
FilamentSkuSettings.brand == data.brand,
)
)
row = result.scalar_one_or_none()
if row:
row.lead_time_days = data.lead_time_days
row.safety_margin_value = data.safety_margin_value
row.safety_margin_unit = data.safety_margin_unit
row.alerts_snoozed = data.alerts_snoozed
else:
row = FilamentSkuSettings(
material=data.material,
subtype=data.subtype,
brand=data.brand,
lead_time_days=data.lead_time_days,
safety_margin_value=data.safety_margin_value,
safety_margin_unit=data.safety_margin_unit,
alerts_snoozed=data.alerts_snoozed,
)
db.add(row)
await db.commit()
await db.refresh(row)
return row
# ── Shopping List ─────────────────────────────────────────────────────────────
class ShoppingListItemResponse(BaseModel):
id: int
material: str
subtype: str | None
brand: str | None
quantity_spools: int
note: str | None
status: str
purchased_at: str | None
added_at: str
class Config:
from_attributes = True
class ShoppingListItemCreate(BaseModel):
material: str
subtype: str | None = None
brand: str | None = None
quantity_spools: int = 1
note: str | None = None
class ShoppingListItemStatusUpdate(BaseModel):
status: str # pending | purchased | received
@router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
async def get_shopping_list(
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
):
"""Get the filament shopping list."""
from backend.app.models.shopping_list import ShoppingListItem
result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
items = result.scalars().all()
return [
ShoppingListItemResponse(
id=i.id,
material=i.material,
subtype=i.subtype,
brand=i.brand,
quantity_spools=i.quantity_spools,
note=i.note,
status=i.status or "pending",
purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
added_at=i.added_at.isoformat() if i.added_at else "",
)
for i in items
]
@router.post("/shopping-list", response_model=ShoppingListItemResponse)
async def add_to_shopping_list(
data: ShoppingListItemCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(
Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
),
):
"""Add a filament SKU to the shopping list."""
from backend.app.models.shopping_list import ShoppingListItem
item = ShoppingListItem(
material=data.material,
subtype=data.subtype,
brand=data.brand,
quantity_spools=data.quantity_spools,
note=data.note,
)
db.add(item)
await db.commit()
await db.refresh(item)
return ShoppingListItemResponse(
id=item.id,
material=item.material,
subtype=item.subtype,
brand=item.brand,
quantity_spools=item.quantity_spools,
note=item.note,
status=item.status or "pending",
purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
added_at=item.added_at.isoformat() if item.added_at else "",
)
@router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
async def update_shopping_list_status(
item_id: int,
data: ShoppingListItemStatusUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(
Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
),
):
"""Update the purchase status of a shopping list item."""
from datetime import datetime, timezone
from backend.app.models.shopping_list import ShoppingListItem
if data.status not in ("pending", "purchased", "received"):
raise HTTPException(400, "Invalid status")
result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Item not found")
item.status = data.status
if data.status in ("purchased", "received") and item.purchased_at is None:
item.purchased_at = datetime.now(timezone.utc)
elif data.status == "pending":
item.purchased_at = None
await db.commit()
await db.refresh(item)
return ShoppingListItemResponse(
id=item.id,
material=item.material,
subtype=item.subtype,
brand=item.brand,
quantity_spools=item.quantity_spools,
note=item.note,
status=item.status or "pending",
purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
added_at=item.added_at.isoformat() if item.added_at else "",
)
@router.delete("/shopping-list/{item_id}")
async def remove_from_shopping_list(
item_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(
Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
),
):
"""Remove a single item from the shopping list."""
from backend.app.models.shopping_list import ShoppingListItem
result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Item not found")
await db.delete(item)
await db.commit()
return {"status": "deleted"}
@router.delete("/shopping-list")
async def clear_shopping_list(
db: AsyncSession = Depends(get_db),
_: User | None = RequireAnyPermissionIfAuthEnabled(
Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
),
):
"""Clear all items from the shopping list."""
from backend.app.models.shopping_list import ShoppingListItem
result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
deleted = len(result.fetchall())
await db.commit()
return {"deleted": deleted}
+1
View File
@@ -130,6 +130,7 @@ async def get_settings(
"mqtt_port",
"stagger_group_size",
"stagger_interval_minutes",
"forecast_global_lead_time_days",
]:
settings_dict[setting.key] = int(setting.value)
elif setting.key == "default_printer_id":
+89
View File
@@ -967,6 +967,95 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
return Depends(require_permission_if_auth_enabled(*permissions))
def require_any_permission_if_auth_enabled(*permissions: str | Permission):
"""Dependency factory that requires AT LEAST ONE of the given permissions when auth is enabled."""
perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
async def checker(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
) -> User | None:
async with async_session() as db:
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
return None
if x_api_key:
api_key = await _validate_api_key(db, x_api_key)
if api_key:
return None
if credentials is not None:
token = credentials.credentials
if token.startswith("bb_"):
api_key = await _validate_api_key(db, token)
if api_key:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
jti: str | None = payload.get("jti")
if not jti or await is_jti_revoked(jti):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
iat: int | float | None = payload.get("iat")
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not _is_token_fresh(iat, user):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.has_any_permission(*perm_strings):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(perm_strings)}",
)
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return checker
def RequireAnyPermissionIfAuthEnabled(*permissions: str | Permission):
"""Convenience dependency that requires AT LEAST ONE of the given permissions when auth is enabled."""
return Depends(require_any_permission_if_auth_enabled(*permissions))
def require_camera_stream_token_if_auth_enabled():
"""Dependency that validates a camera stream token query param when auth is enabled.
+201
View File
@@ -175,6 +175,7 @@ async def init_db():
color_catalog,
external_link,
filament,
filament_sku_settings,
github_backup,
group,
kprofile_note,
@@ -194,6 +195,7 @@ async def init_db():
project,
project_bom,
settings,
shopping_list,
slot_preset,
smart_plug,
smart_plug_energy_snapshot,
@@ -1882,6 +1884,183 @@ async def run_migrations(conn):
except (OperationalError, ProgrammingError):
pass
# Migration: Create filament_sku_settings table for reorder forecasting
if is_sqlite():
await _safe_execute(
conn,
"""CREATE TABLE IF NOT EXISTS filament_sku_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
)""",
)
async with conn.begin_nested():
await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
await _safe_execute(
conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_value INTEGER NOT NULL DEFAULT 14"
)
await _safe_execute(
conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'"
)
await _safe_execute(
conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
)
# Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
# Only run if the stale column still exists.
cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
col_names = [row[1] for row in cols_result.fetchall()]
if "safety_margin_days" in col_names:
async with conn.begin_nested():
# Defensive: a previous startup may have crashed mid-rebuild leaving
# filament_sku_settings_new behind, which would break the CREATE below.
await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_new"))
await conn.execute(
text(
"UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
"WHERE safety_margin_value = 14 AND safety_margin_days != 14"
)
)
await conn.execute(
text(
"""CREATE TABLE filament_sku_settings_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
)"""
)
)
await conn.execute(
text(
"""INSERT INTO filament_sku_settings_new
(id, material, subtype, brand, lead_time_days, safety_margin_value,
safety_margin_unit, alerts_snoozed, created_at, updated_at)
SELECT id, material, subtype, brand, lead_time_days, safety_margin_value,
safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
FROM filament_sku_settings"""
)
)
await conn.execute(text("DROP TABLE filament_sku_settings"))
await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
await _safe_execute(
conn,
"""CREATE TABLE IF NOT EXISTS filament_shopping_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
quantity_spools INTEGER NOT NULL DEFAULT 1,
note VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
purchased_at DATETIME,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
)
# SQLite has no implicit updated_at trigger — add one so the column stays current.
await _safe_execute(
conn,
"""CREATE TRIGGER IF NOT EXISTS trg_filament_sku_settings_updated_at
AFTER UPDATE ON filament_sku_settings FOR EACH ROW
BEGIN
UPDATE filament_sku_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
END""",
)
else:
await _safe_execute(
conn,
"""CREATE TABLE IF NOT EXISTS filament_sku_settings (
id SERIAL PRIMARY KEY,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
lead_time_days INTEGER NOT NULL DEFAULT 0,
safety_margin_value INTEGER NOT NULL DEFAULT 14,
safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (material, subtype, brand)
)""",
)
async with conn.begin_nested():
await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
await _safe_execute(
conn,
"ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_value INTEGER NOT NULL DEFAULT 14",
)
await _safe_execute(
conn,
"ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'",
)
await _safe_execute(
conn,
"ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
)
# Only backfill from safety_margin_days if that column still exists (PostgreSQL).
col_check = await conn.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'filament_sku_settings' AND column_name = 'safety_margin_days'"
)
)
if col_check.fetchone():
async with conn.begin_nested():
await conn.execute(
text(
"UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
"WHERE safety_margin_value = 14 AND safety_margin_days != 14"
)
)
await _safe_execute(
conn,
"""CREATE TABLE IF NOT EXISTS filament_shopping_list (
id SERIAL PRIMARY KEY,
material VARCHAR(50) NOT NULL,
subtype VARCHAR(50),
brand VARCHAR(100),
quantity_spools INTEGER NOT NULL DEFAULT 1,
note VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
purchased_at TIMESTAMP,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""",
)
await _safe_execute(
conn,
"ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'",
)
await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMP")
# Migration: Add inventory stock alert columns to notification_providers.
# Postgres rejects `DEFAULT 0` for BOOLEAN columns.
if is_sqlite():
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT 0"
)
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT 0"
)
else:
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT false"
)
await _safe_execute(
conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
)
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
@@ -2081,6 +2260,28 @@ async def seed_default_groups():
admin_group.permissions = perms
await session.commit()
# Backfill inventory forecast permissions for existing groups.
# inventory:forecast_read was added after initial seeding, so groups
# that already have inventory:read (or inventory:update) need it added.
# inventory:forecast_write goes to any group with inventory:update.
result = await session.execute(select(Group))
for group in result.scalars().all():
if not group.permissions:
continue
perms = list(group.permissions)
changed = False
if "inventory:read" in perms and "inventory:forecast_read" not in perms:
perms.append("inventory:forecast_read")
changed = True
logger.info("Added inventory:forecast_read to group '%s' (backfill)", group.name)
if "inventory:update" in perms and "inventory:forecast_write" not in perms:
perms.append("inventory:forecast_write")
changed = True
logger.info("Added inventory:forecast_write to group '%s' (backfill)", group.name)
if changed:
group.permissions = perms
await session.commit()
# Migrate existing users to groups if they're not already in any group
if groups_created:
# Refresh to get newly created groups
+7
View File
@@ -74,6 +74,8 @@ class Permission(StrEnum):
INVENTORY_UPDATE = "inventory:update"
INVENTORY_DELETE = "inventory:delete"
INVENTORY_VIEW_ASSIGNMENTS = "inventory:view_assignments" # View spool-to-AMS assignments on printer cards
INVENTORY_FORECAST_READ = "inventory:forecast_read" # View forecast/reorder intelligence panel
INVENTORY_FORECAST_WRITE = "inventory:forecast_write" # Modify SKU settings, lead times, shopping list
# Smart Plugs
SMART_PLUGS_READ = "smart_plugs:read"
@@ -228,6 +230,8 @@ PERMISSION_CATEGORIES = {
Permission.INVENTORY_UPDATE,
Permission.INVENTORY_DELETE,
Permission.INVENTORY_VIEW_ASSIGNMENTS,
Permission.INVENTORY_FORECAST_READ,
Permission.INVENTORY_FORECAST_WRITE,
],
"Smart Plugs": [
Permission.SMART_PLUGS_READ,
@@ -379,6 +383,8 @@ DEFAULT_GROUPS = {
Permission.INVENTORY_UPDATE.value,
Permission.INVENTORY_DELETE.value,
Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
Permission.INVENTORY_FORECAST_READ.value,
Permission.INVENTORY_FORECAST_WRITE.value,
# Smart Plugs - full access
Permission.SMART_PLUGS_READ.value,
Permission.SMART_PLUGS_CREATE.value,
@@ -437,6 +443,7 @@ DEFAULT_GROUPS = {
Permission.FILAMENTS_READ.value,
Permission.INVENTORY_READ.value,
Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
Permission.INVENTORY_FORECAST_READ.value,
Permission.SMART_PLUGS_READ.value,
Permission.CAMERA_VIEW.value,
Permission.MAINTENANCE_READ.value,
@@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class FilamentSkuSettings(Base):
"""User-configured reorder settings for a filament SKU (material/subtype/brand group)."""
__tablename__ = "filament_sku_settings"
__table_args__ = (
# sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT).
# On PostgreSQL the partial index is not needed — standard UNIQUE handles it.
UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"),
)
id: Mapped[int] = mapped_column(primary_key=True)
material: Mapped[str] = mapped_column(String(50))
subtype: Mapped[str | None] = mapped_column(String(50))
brand: Mapped[str | None] = mapped_column(String(100))
lead_time_days: Mapped[int] = mapped_column(Integer, default=0)
safety_margin_value: Mapped[int] = mapped_column(Integer, default=14)
safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days")
alerts_snoozed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+4
View File
@@ -88,6 +88,10 @@ class NotificationProvider(Base):
on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print
on_first_layer_complete = Column(Boolean, default=False) # First layer finished printing
# Event triggers - Inventory stock alerts
on_stock_reorder_alert = Column(Boolean, default=False) # SKU hits reorder point
on_stock_break_alert = Column(Boolean, default=False) # Stock will run out before replenishment
# Event triggers - Print queue
on_queue_job_added = Column(Boolean, default=False) # Job added to queue
on_queue_job_assigned = Column(Boolean, default=False) # Model-based job assigned to printer
@@ -176,6 +176,19 @@ DEFAULT_TEMPLATES = [
"title_template": "{app_name} - Password Reset",
"body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
},
# Inventory stock alert templates
{
"event_type": "stock_reorder_alert",
"name": "Stock Reorder Alert",
"title_template": "Reorder Alert: {material}",
"body_template": "{material} ({brand}) has reached the reorder point.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Days left: {days_left}d\nReorder now to avoid a stock break.",
},
{
"event_type": "stock_break_alert",
"name": "Stock Break Alert",
"title_template": "Stock Break Risk: {material}",
"body_template": "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
},
# User email notification templates (sent to the print job owner)
{
"event_type": "user_print_start",
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class ShoppingListItem(Base):
"""A filament SKU queued for purchase."""
__tablename__ = "filament_shopping_list"
id: Mapped[int] = mapped_column(primary_key=True)
material: Mapped[str] = mapped_column(String(50))
subtype: Mapped[str | None] = mapped_column(String(50))
brand: Mapped[str | None] = mapped_column(String(100))
quantity_spools: Mapped[int] = mapped_column(Integer, default=1)
note: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | purchased | received
purchased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
added_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+8
View File
@@ -307,6 +307,13 @@ class AppSettings(BaseModel):
description="JSON array of printer IDs to monitor (empty = all connected printers)",
)
# Inventory forecasting
forecast_global_lead_time_days: int = Field(
default=0,
ge=0,
description="Global lead time floor (days) used in reorder point calculation for all SKUs",
)
# Default sidebar order (admin-set for all users)
default_sidebar_order: str = Field(
default="",
@@ -418,6 +425,7 @@ class AppSettingsUpdate(BaseModel):
obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
obico_enabled_printers: str | None = None
default_sidebar_order: str | None = None
forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
@field_validator("gcode_snippets")
@classmethod
@@ -1662,6 +1662,60 @@ class NotificationService:
title, message = await self._build_message_from_template(db, "queue_completed", variables)
await self._send_to_providers(providers, title, message, db, "queue_completed", variables=variables)
# ==================== Inventory Stock Alerts ====================
async def on_stock_reorder_alert(
self,
material: str,
brand: str | None,
stock_g: float,
rate_g_day: float,
days_left: int,
db: AsyncSession,
):
"""Fire when an inventory SKU reaches its reorder point."""
providers = await self._get_providers_for_event(db, "on_stock_reorder_alert", None)
if not providers:
return
variables = {
"material": material,
"brand": brand or "",
"stock_g": f"{stock_g:.0f}",
"rate_g_day": f"{rate_g_day:.1f}",
"days_left": str(days_left),
}
title, message = await self._build_message_from_template(db, "stock_reorder_alert", variables)
await self._send_to_providers(providers, title, message, db, "stock_reorder_alert", variables=variables)
async def on_stock_break_alert(
self,
material: str,
brand: str | None,
stock_g: float,
rate_g_day: float,
days_left: int,
lead_time_days: int,
db: AsyncSession,
):
"""Fire when a stock break is detected (stock runs out before lead time)."""
providers = await self._get_providers_for_event(db, "on_stock_break_alert", None)
if not providers:
return
variables = {
"material": material,
"brand": brand or "",
"stock_g": f"{stock_g:.0f}",
"rate_g_day": f"{rate_g_day:.1f}",
"days_left": str(days_left),
"lead_time_days": str(lead_time_days),
}
title, message = await self._build_message_from_template(db, "stock_break_alert", variables)
await self._send_to_providers(providers, title, message, db, "stock_break_alert", variables=variables)
async def _queue_for_digest(
self,
provider: NotificationProvider,
@@ -56,6 +56,8 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_queue_job_skipped: true,
on_queue_job_failed: true,
on_queue_completed: false,
on_stock_reorder_alert: false,
on_stock_break_alert: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -219,3 +221,118 @@ describe('AddNotificationModal — ntfy Priority (#990)', () => {
expect(payload.config).not.toHaveProperty('event_priorities');
});
});
describe('AddNotificationModal — stock alert toggles', () => {
it('renders Inventory Alerts section with both stock alert toggles', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
const section = await screen.findByText(/inventory alerts/i);
const sectionRoot = section.closest('div')!;
expect(section).toBeInTheDocument();
expect(sectionRoot.textContent).toMatch(/reorder alert/i);
expect(sectionRoot.textContent).toMatch(/stock break alert/i);
});
it('pre-fills toggles from existing provider values', async () => {
render(
<AddNotificationModal
provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: false })}
onClose={() => undefined}
/>,
);
await screen.findByText(/inventory alerts/i);
// Reorder alert switch should be ON, break alert switch OFF
const switches = screen.getAllByRole('switch');
const reorderSwitch = switches.find((s) => {
const row = s.closest('div');
return row?.textContent?.match(/reorder alert/i);
});
const breakSwitch = switches.find((s) => {
const row = s.closest('div');
return row?.textContent?.match(/stock break alert/i);
});
expect(reorderSwitch).toHaveAttribute('aria-checked', 'true');
expect(breakSwitch).toHaveAttribute('aria-checked', 'false');
});
it('persists on_stock_reorder_alert on save', async () => {
let captured: unknown = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = await request.json();
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
await screen.findByText(/inventory alerts/i);
// Enable the reorder alert toggle
const switches = screen.getAllByRole('switch');
const reorderSwitch = switches.find((s) => {
const row = s.closest('div');
return row?.textContent?.match(/reorder alert/i);
})!;
await user.click(reorderSwitch);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
const payload = captured as Record<string, unknown>;
expect(payload.on_stock_reorder_alert).toBe(true);
});
it('persists on_stock_break_alert on save', async () => {
let captured: unknown = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = await request.json();
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
await screen.findByText(/inventory alerts/i);
const switches = screen.getAllByRole('switch');
const breakSwitch = switches.find((s) => {
const row = s.closest('div');
return row?.textContent?.match(/stock break alert/i);
})!;
await user.click(breakSwitch);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
const payload = captured as Record<string, unknown>;
expect(payload.on_stock_break_alert).toBe(true);
});
it('stock alert events appear in ntfy priority section when enabled', async () => {
const user = userEvent.setup();
render(
<AddNotificationModal
provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: true })}
onClose={() => undefined}
/>,
);
const priorityHeader = await screen.findByText(/ntfy priority/i);
const priorityRoot = priorityHeader.closest('div')!;
// Both stock alert events should appear in the priority list since they are enabled
expect(within(priorityRoot).getByText('Reorder Alert')).toBeInTheDocument();
expect(within(priorityRoot).getByText('Stock Break Alert')).toBeInTheDocument();
void user; // referenced to avoid unused-var lint warning
});
});
@@ -0,0 +1,266 @@
/**
* Tests for ForecastPanel permission guards.
*
* Coverage:
* - Without inventory:forecast_read the panel shows a lock/no-access message.
* - With inventory:forecast_read the panel renders the forecast table.
* - Without inventory:forecast_write cart buttons are hidden.
* - With inventory:forecast_write cart buttons are visible.
* - InventoryPage Forecast tab button is disabled (locked) when read access absent.
* - InventoryPage Forecast tab button is enabled when read access present.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { ForecastPanel } from '../../components/ForecastPanel';
import InventoryPageRouter from '../../pages/InventoryPage';
import { setAuthToken } from '../../api/client';
import type { InventorySpool } from '../../api/client';
afterEach(() => {
server.resetHandlers();
setAuthToken(null);
});
// ── shared mock data ──────────────────────────────────────────────────────────
const mockSpool: InventorySpool = {
id: 1,
material: 'PLA',
subtype: null,
brand: 'Polymaker',
color_name: 'Red',
rgba: 'FF0000FF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 200,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: 'manual',
tag_type: null,
archived_at: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
k_profiles: [],
cost_per_kg: null,
last_scale_weight: null,
last_weighed_at: null,
weight_locked: false,
category: 'Active',
low_stock_threshold_pct: null,
};
// Mocks that satisfy ForecastPanel's queries (used when canRead is true)
function mockForecastApis() {
server.use(
http.get('*/api/v1/settings/', () =>
HttpResponse.json({ forecast_global_lead_time_days: 7 }),
),
http.get('*/api/v1/inventory/sku-settings', () => HttpResponse.json([])),
http.get(/\/api\/v1\/inventory\/usage/, () => HttpResponse.json([])),
http.get('*/api/v1/inventory/shopping-list', () => HttpResponse.json([])),
);
}
function setFakeToken() {
setAuthToken('test-token', 'session');
}
function mockNoReadAccess() {
setFakeToken();
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('*/api/v1/auth/me', () =>
HttpResponse.json({
id: 1,
username: 'viewer',
is_admin: false,
permissions: ['inventory:read'],
}),
),
);
}
function mockReadOnlyAccess() {
setFakeToken();
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('*/api/v1/auth/me', () =>
HttpResponse.json({
id: 1,
username: 'viewer',
is_admin: false,
permissions: ['inventory:forecast_read'],
}),
),
);
}
function mockReadWriteAccess() {
setFakeToken();
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('*/api/v1/auth/me', () =>
HttpResponse.json({
id: 1,
username: 'operator',
is_admin: false,
permissions: ['inventory:forecast_read', 'inventory:forecast_write'],
}),
),
);
}
// ── ForecastPanel read guard ──────────────────────────────────────────────────
describe('ForecastPanel — read permission guard', () => {
it('shows no-access message when user lacks inventory:forecast_read', async () => {
mockNoReadAccess();
render(<ForecastPanel spools={[mockSpool]} />);
// Auth loading resolves → canRead=false → lock screen shown
await waitFor(() =>
expect(
screen.getByText(/do not have permission to view inventory forecasts/i),
).toBeInTheDocument(),
);
});
it('renders forecast table when user has inventory:forecast_read', async () => {
mockReadOnlyAccess();
mockForecastApis();
render(<ForecastPanel spools={[mockSpool]} />);
// Wait for auth to settle with read access — the lock screen should never appear,
// and the table "SKU" header should eventually be visible
await waitFor(
() => {
expect(
screen.queryByText(/do not have permission to view inventory forecasts/i),
).not.toBeInTheDocument();
expect(screen.getByText('SKU')).toBeInTheDocument();
},
{ timeout: 3000 },
);
});
});
// ── ForecastPanel write permission guard ─────────────────────────────────────
// The write permission gate (cart button hidden) is covered end-to-end by the
// InventoryPage tests above. Here we verify the cart button IS present when
// auth is disabled (the default test setup), which exercises the positive path.
describe('ForecastPanel — write permission guard (auth disabled baseline)', () => {
it('shows cart button when auth is disabled (all permissions granted)', async () => {
// Default handlers have auth_enabled: false → hasPermission returns true for all
mockForecastApis();
render(<ForecastPanel spools={[mockSpool]} />);
// Table renders and cart button is present
expect(await screen.findByTitle(/add to shopping list/i)).toBeInTheDocument();
});
it('shows cart button and shopping list when auth is disabled (canWrite=true)', async () => {
// Auth disabled → all permissions granted → canWrite=true, shopping list visible.
server.use(
http.get('*/api/v1/inventory/shopping-list', () =>
HttpResponse.json([
{
id: 1, material: 'PLA', subtype: null, brand: 'Polymaker',
quantity_spools: 2, status: 'pending', note: null,
added_at: '2025-01-01T00:00:00Z',
},
]),
),
);
mockForecastApis();
render(<ForecastPanel spools={[mockSpool]} />);
// The shopping cart badge should eventually appear (auth disabled = canWrite=true)
await screen.findByTitle(/add to shopping list/i);
});
});
// ── InventoryPage forecast tab button ────────────────────────────────────────
describe('InventoryPage — forecast tab button permission', () => {
function inventoryApis() {
server.use(
http.get('*/api/v1/settings/', () =>
HttpResponse.json({ spoolman_enabled: false, low_stock_threshold: 20 }),
),
http.get('*/api/v1/inventory/spools', () => HttpResponse.json([mockSpool])),
http.get('*/api/v1/inventory/assignments', () => HttpResponse.json([])),
http.get('*/api/v1/spoolman/settings', () =>
HttpResponse.json({ spoolman_enabled: 'false' }),
),
);
}
it('disables forecast tab when user lacks inventory:forecast_read', async () => {
mockNoReadAccess();
inventoryApis();
render(<InventoryPageRouter />);
// Wait for auth to settle (page content appears)
await screen.findByText(/spool inventory/i);
// Button should be disabled once auth is resolved
await waitFor(() => {
const btn = screen.getByRole('button', { name: /forecast/i });
expect(btn).toBeDisabled();
});
});
it('enables forecast tab when user has inventory:forecast_read', async () => {
mockReadOnlyAccess();
inventoryApis();
render(<InventoryPageRouter />);
await screen.findByText(/spool inventory/i);
await waitFor(() => {
const btn = screen.getByRole('button', { name: /forecast/i });
expect(btn).not.toBeDisabled();
});
});
it('clicking disabled forecast tab does not navigate to forecast view', async () => {
mockNoReadAccess();
inventoryApis();
const user = userEvent.setup();
render(<InventoryPageRouter />);
await screen.findByText(/spool inventory/i);
// Wait until button is disabled (auth settled)
const forecastBtn = await screen.findByRole('button', { name: /forecast/i });
await waitFor(() => expect(forecastBtn).toBeDisabled());
await user.click(forecastBtn);
// Should NOT show the lock screen inside the page body (we never entered forecast view)
expect(
screen.queryByText(/do not have permission to view inventory forecasts/i),
).not.toBeInTheDocument();
});
});
@@ -73,6 +73,8 @@ const createMockProvider = (
on_queue_job_skipped: true,
on_queue_job_failed: true,
on_queue_completed: false,
on_stock_reorder_alert: false,
on_stock_break_alert: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -0,0 +1,187 @@
/**
* Tests for stock alert toggles added to NotificationProviderCard.
*
* Coverage:
* - Reorder Alert and Stock Break Alert badges render in the summary strip when enabled.
* - Badges are absent when both flags are false.
* - Inventory Alerts section renders in the expanded settings panel.
* - Toggling a stock alert fires an update mutation with the correct field.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { NotificationProviderCard } from '../../components/NotificationProviderCard';
import type { NotificationProvider } from '../../api/client';
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
return {
id: 1,
name: 'Test Provider',
provider_type: 'ntfy',
enabled: true,
config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
on_print_start: false,
on_print_complete: false,
on_print_failed: false,
on_print_stopped: false,
on_print_progress: false,
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: false,
on_bed_cooled: false,
on_first_layer_complete: false,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: false,
on_queue_job_skipped: false,
on_queue_job_failed: false,
on_queue_completed: false,
on_stock_reorder_alert: false,
on_stock_break_alert: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
daily_digest_enabled: false,
daily_digest_time: null,
printer_id: null,
last_success: null,
last_error: null,
last_error_at: null,
created_at: '2026-04-25T00:00:00Z',
updated_at: '2026-04-25T00:00:00Z',
...overrides,
};
}
describe('NotificationProviderCard — stock alert badges', () => {
it('shows Reorder Alert badge when on_stock_reorder_alert is true', async () => {
render(<NotificationProviderCard provider={buildProvider({ on_stock_reorder_alert: true })} onEdit={vi.fn()} />);
expect(await screen.findByText('Reorder Alert')).toBeInTheDocument();
});
it('shows Stock Break Alert badge when on_stock_break_alert is true', async () => {
render(<NotificationProviderCard provider={buildProvider({ on_stock_break_alert: true })} onEdit={vi.fn()} />);
expect(await screen.findByText('Stock Break Alert')).toBeInTheDocument();
});
it('shows both badges when both flags are true', async () => {
render(
<NotificationProviderCard
provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: true })}
onEdit={vi.fn()}
/>,
);
expect(await screen.findByText('Reorder Alert')).toBeInTheDocument();
expect(screen.getByText('Stock Break Alert')).toBeInTheDocument();
});
it('shows no stock alert badges when both flags are false', async () => {
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
// Wait for the card to mount
await screen.findByText('Test Provider');
expect(screen.queryByText('Reorder Alert')).not.toBeInTheDocument();
expect(screen.queryByText('Stock Break Alert')).not.toBeInTheDocument();
});
});
describe('NotificationProviderCard — Inventory Alerts expanded section', () => {
it('renders the Inventory Alerts section header when settings are expanded', async () => {
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
const settingsBtn = await screen.findByText(/event settings/i);
await user.click(settingsBtn);
expect(await screen.findByText('Inventory Alerts')).toBeInTheDocument();
});
it('renders both stock alert toggles in the expanded section', async () => {
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
const section = (await screen.findByText('Inventory Alerts')).closest('div')!;
expect(within(section).getByText('Reorder Alert')).toBeInTheDocument();
expect(within(section).getByText('Stock Break Alert')).toBeInTheDocument();
});
it('stock alert toggles reflect the provider state', async () => {
const user = userEvent.setup();
render(
<NotificationProviderCard
provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: false })}
onEdit={vi.fn()}
/>,
);
await user.click(await screen.findByText(/event settings/i));
const section = (await screen.findByText('Inventory Alerts')).closest('div')!;
const switches = within(section).getAllByRole('switch');
// First switch = reorder alert (true), second = break alert (false)
expect(switches[0]).toHaveAttribute('aria-checked', 'true');
expect(switches[1]).toHaveAttribute('aria-checked', 'false');
});
it('toggling Reorder Alert sends correct PATCH payload', async () => {
let captured: unknown = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = await request.json();
return HttpResponse.json(buildProvider({ on_stock_reorder_alert: true }));
}),
);
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
const section = (await screen.findByText('Inventory Alerts')).closest('div')!;
const [reorderSwitch] = within(section).getAllByRole('switch');
await user.click(reorderSwitch);
await waitFor(() => expect(captured).not.toBeNull());
expect(captured).toMatchObject({ on_stock_reorder_alert: true });
});
it('toggling Stock Break Alert sends correct PATCH payload', async () => {
let captured: unknown = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = await request.json();
return HttpResponse.json(buildProvider({ on_stock_break_alert: true }));
}),
);
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
const section = (await screen.findByText('Inventory Alerts')).closest('div')!;
const switches = within(section).getAllByRole('switch');
await user.click(switches[1]);
await waitFor(() => expect(captured).not.toBeNull());
expect(captured).toMatchObject({ on_stock_break_alert: true });
});
});
+66
View File
@@ -1000,6 +1000,8 @@ export interface AppSettings {
obico_action: 'notify' | 'pause' | 'pause_and_off';
obico_poll_interval: number;
obico_enabled_printers: string;
// Inventory forecasting global lead time
forecast_global_lead_time_days: number;
}
export type AppSettingsUpdate = Partial<AppSettings>;
@@ -1852,6 +1854,9 @@ export interface NotificationProvider {
on_bed_cooled: boolean;
// First layer complete
on_first_layer_complete: boolean;
// Inventory stock alerts
on_stock_reorder_alert: boolean;
on_stock_break_alert: boolean;
// Print queue events
on_queue_job_added: boolean;
on_queue_job_assigned: boolean;
@@ -1907,6 +1912,9 @@ export interface NotificationProviderCreate {
on_bed_cooled?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
on_stock_reorder_alert?: boolean;
on_stock_break_alert?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
@@ -1955,6 +1963,9 @@ export interface NotificationProviderUpdate {
on_bed_cooled?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
on_stock_reorder_alert?: boolean;
on_stock_break_alert?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
@@ -2354,6 +2365,37 @@ export interface SpoolAssignment {
ams_label?: string | null; // User-defined friendly name for the AMS unit
}
export interface FilamentSkuSettings {
id: number;
material: string;
subtype: string | null;
brand: string | null;
lead_time_days: number;
safety_margin_value: number;
safety_margin_unit: 'days' | 'g';
alerts_snoozed: boolean;
}
export interface ShoppingListItem {
id: number;
material: string;
subtype: string | null;
brand: string | null;
quantity_spools: number;
note: string | null;
status: 'pending' | 'purchased' | 'received';
purchased_at: string | null;
added_at: string;
}
export interface ShoppingListItemCreate {
material: string;
subtype: string | null;
brand: string | null;
quantity_spools: number;
note?: string | null;
}
// Update types
export interface VersionInfo {
version: string;
@@ -2497,6 +2539,7 @@ export type Permission =
| 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
| 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
| 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
| 'inventory:forecast_read' | 'inventory:forecast_write'
| 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
| 'camera:view'
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
@@ -4475,6 +4518,29 @@ export const api = {
request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
syncWeightsFromAms: () =>
request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),
getSkuSettings: () =>
request<FilamentSkuSettings[]>('/inventory/sku-settings'),
upsertSkuSettings: (data: Omit<FilamentSkuSettings, 'id'>) =>
request<FilamentSkuSettings>('/inventory/sku-settings', {
method: 'POST',
body: JSON.stringify(data),
}),
getShoppingList: () =>
request<ShoppingListItem[]>('/inventory/shopping-list'),
addToShoppingList: (data: ShoppingListItemCreate) =>
request<ShoppingListItem>('/inventory/shopping-list', {
method: 'POST',
body: JSON.stringify(data),
}),
removeFromShoppingList: (id: number) =>
request<{ status: string }>(`/inventory/shopping-list/${id}`, { method: 'DELETE' }),
clearShoppingList: () =>
request<{ deleted: number }>('/inventory/shopping-list', { method: 'DELETE' }),
updateShoppingListStatus: (id: number, status: 'pending' | 'purchased' | 'received') =>
request<ShoppingListItem>(`/inventory/shopping-list/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
getFilamentPresets: () =>
request<SlicerSetting[]>('/cloud/filaments'),
@@ -40,6 +40,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
@@ -167,6 +169,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_printer_error: onPrinterError,
on_filament_low: onFilamentLow,
on_maintenance_due: onMaintenanceDue,
on_stock_reorder_alert: onStockReorderAlert,
on_stock_break_alert: onStockBreakAlert,
on_bed_cooled: onBedCooled,
on_first_layer_complete: onFirstLayerComplete,
};
@@ -554,6 +558,27 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
</div>
</div>
{/* Inventory Stock Alerts */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.inventoryAlerts')}</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-white">{t('notifications.stockReorderAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.stockReorderAlertDescription')}</span>
</div>
<Toggle checked={onStockReorderAlert} onChange={setOnStockReorderAlert} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-white">{t('notifications.stockBreakAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.stockBreakAlertDescription')}</span>
</div>
<Toggle checked={onStockBreakAlert} onChange={setOnStockBreakAlert} />
</div>
</div>
</div>
{/* Per-event ntfy priority (#990) */}
{providerType === 'ntfy' && (() => {
const enabledEvents: Array<{ key: string; label: string }> = [];
@@ -568,6 +593,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
if (onStockBreakAlert) enabledEvents.push({ key: 'on_stock_break_alert', label: t('notifications.stockBreakAlert') });
if (enabledEvents.length === 0) return null;
File diff suppressed because it is too large Load Diff
@@ -165,6 +165,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_print_missing_spool_assignment && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
)}
{provider.on_stock_reorder_alert && (
<span className="px-2 py-0.5 bg-lime-500/20 text-lime-400 text-xs rounded">{t('notifications.stockReorderAlert')}</span>
)}
{provider.on_stock_break_alert && (
<span className="px-2 py-0.5 bg-red-600/20 text-red-300 text-xs rounded">{t('notifications.stockBreakAlert')}</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
@@ -434,6 +440,33 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
</div>
{/* Inventory Stock Alerts */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.inventoryAlerts')}</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.stockReorderAlert')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.stockReorderAlertDescription')}</p>
</div>
<Toggle
checked={provider.on_stock_reorder_alert ?? false}
onChange={(checked) => updateMutation.mutate({ on_stock_reorder_alert: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.stockBreakAlert')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.stockBreakAlertDescription')}</p>
</div>
<Toggle
checked={provider.on_stock_break_alert ?? false}
onChange={(checked) => updateMutation.mutate({ on_stock_break_alert: checked })}
/>
</div>
</div>
{/* Print Queue Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.printQueue')}</p>
+124
View File
@@ -4488,6 +4488,12 @@ export default {
amsHtHumidityHighDescription: 'AMS-HT-Feuchtigkeit überschreitet Schwellenwert',
amsHtTemperatureHigh: 'AMS-HT-Temperatur hoch',
amsHtTemperatureHighDescription: 'AMS-HT-Temperatur überschreitet Schwellenwert',
// Inventory stock alert events
inventoryAlerts: 'Bestandswarnungen',
stockReorderAlert: 'Nachbestellungswarnung',
stockReorderAlertDescription: 'SKU hat seinen Nachbestellungspunkt erreicht',
stockBreakAlert: 'Bestandsunterbrechungswarnung',
stockBreakAlertDescription: 'Der Bestand wird vor dem Eintreffen der Nachbestellung aufgebraucht sein',
// Queue events
jobAdded: 'Auftrag hinzugefügt',
jobAddedDescription: 'Auftrag zur Warteschlange hinzugefügt',
@@ -5515,4 +5521,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: 'Bestandsprognose',
noSpools: 'Keine aktiven Spulen gefunden. Fügen Sie Spulen zu Ihrem Inventar hinzu, um Prognosedaten zu sehen.',
noUsageData: 'Keine Verbrauchsdaten verfügbar — die Bestandszeitlinie kann nicht projiziert werden.',
sku: 'SKU',
// Table headers
material: 'Material',
stock: 'Bestand',
dailyRate: 'Rate',
daysLeft: 'Verbleibende Tage',
emptyBy: 'Aufgebraucht am',
reorderBy: 'Nachbestellen bis',
actions: 'Aktionen',
// Rate tier badges
trend: 'Trend',
estimated: 'Gesch.',
noData: 'Keine Daten',
// Timeframe
timeframe: 'Zeitraum',
// Chart
chartTitle: 'Bestandsprognose — Top 5 Materialien',
dashedLinesROP: 'Gestrichelte Linien = Nachbestellpunkte',
stockLevel: 'Bestandsniveau',
reorderPoint: 'Nachbestellpunkt',
safetyMargin: 'Sicherheitspuffer',
trendLegend: 'Trend (verlaufsbasiert, 95 % Serviceniveau)',
estimatedLegend: 'Geschätzt (Gewichtsdelta)',
noDataLegend: 'Keine Daten',
ropLabel: 'NBP',
ssLabel: 'SB',
safetyStockLegend: 'Sicherheitsbestand',
stockArrivalLegend: 'Wareneingang',
stockoutLegend: 'Fehlbestand',
// Alerts toolbar
alertCount_one: '{{count}} Warnung',
alertCount_other: '{{count}} Warnungen',
order: 'Bestellen',
// Settings
globalLeadTime: 'Globale Lieferzeit',
globalLeadTimeHint: 'Globale Lieferzeitvorgabe — wird in der Nachbestellpunktberechnung für alle SKUs verwendet',
save: 'Speichern',
cancel: 'Abbrechen',
settingsSaved: 'Einstellungen gespeichert',
failedSaveSettings: 'Einstellungen konnten nicht gespeichert werden',
globalLeadTimeSaved: 'Globale Lieferzeit gespeichert',
skuLeadTimeOverride: 'SKU-Lieferzeitüberschreibung',
skuLeadTimeHint: '0 = globale Lieferzeit verwenden. Größer 0 setzen, um für diesen SKU zu überschreiben.',
safetyMarginLabel: 'Sicherheitspuffer',
effectiveLeadTime: 'Effektive Lieferzeit',
effectiveLeadTimeHint: 'max(global {{global}}d, SKU {{sku}}d)',
reorderPointHint: 'd̄ × LT + safety margin — bei diesem Bestand bestellen',
safetyMarginHint: 'Statistischer Sicherheitsbestand (z=1,65 × σ × √LT) + benutzerdefinierter Puffer',
safetyMarginHintDays: 'Zusätzlicher Puffer auf den statistischen Sicherheitsbestand.{{approx}}',
safetyMarginHintDaysApprox: ' ≈ {{g}}g beim aktuellen Verbrauch.',
safetyMarginHintG: 'Fester Gewichtspuffer auf den statistischen Sicherheitsbestand.{{approx}}',
safetyMarginHintGApprox: ' ≈ {{days}}d beim aktuellen Verbrauch.',
individualSpools: 'Einzelne Spulen',
labelWeight: 'Etikett',
spoolCount_one: '{{count}} Spule',
spoolCount_other: '{{count}} Spulen',
// Alerts
stockBreakRisk: 'Bestandsunterbrechungsrisiko',
stockBreakBefore: 'Bestandsunterbrechung vor Auffüllung',
stockBreakDetail: '{{days}}d verbleibend, Lieferzeit {{lt}}d.',
reorderNow: 'Jetzt nachbestellen',
reorderTriggerPassed: 'Auslösedatum {{date}} ist überschritten.',
// Shopping list
shoppingList: 'Einkaufsliste',
shoppingListItems_one: '({{count}} Artikel)',
shoppingListItems_other: '({{count}} Artikel)',
shoppingListEmpty: 'Einkaufsliste ist leer. Klicken Sie auf das Warenkorbsymbol in einer Zeile, um Artikel hinzuzufügen.',
addToCart: 'Zur Einkaufsliste hinzufügen',
alertsSnoozed: 'Benachrichtigungen für diese SKU stummschalten',
alertsEnabled: 'Benachrichtigungen für diese SKU aktivieren',
addedToCart: 'Zur Einkaufsliste hinzugefügt',
failedAddItem: 'Artikel konnte nicht hinzugefügt werden',
listView: 'Liste',
logisticsView: 'Logistik',
qty: 'Menge',
weight: 'Gewicht',
leadTime: 'Lieferzeit',
expectedRestock: 'Erwartete Auffüllung',
status: 'Status',
note: 'Notiz',
pending: 'Ausstehend',
purchased: 'Bestellt',
received: 'Erhalten',
markPurchased: 'Als bestellt markieren',
markReceived: 'Als erhalten markieren — fügt Spulen dem Lagerbestand hinzu',
resetToPending: 'Auf ausstehend zurücksetzen',
remove: 'Entfernen',
clearAll: 'Alle löschen',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: 'Zur Einkaufsliste hinzufügen',
byQuantity: 'Nach Menge',
byDuration: 'Nach Dauer',
numberOfSpools: 'Anzahl der Spulen',
lastHowManyDays: 'Wie viele Tage soll es reichen?',
noUsageQty: 'Keine Verbrauchsdaten — Menge auf 1 gesetzt.',
noteOptional: 'Notiz (optional)',
notePlaceholder: 'z. B. für Projekt X, dringend…',
addNSpools_one: '{{count}} Spule hinzufügen',
addNSpools_other: '{{count}} Spulen hinzufügen',
// Cart logistics
onArrival: 'Bei Ankunft',
stockBreakIn: 'Bestandsunterbrechung in {{days}}d.',
stockRunsOutBefore: 'Bestand läuft vor Ablauf der {{lt}}d Lieferzeit aus.',
atRate: 'Bei {{rate}}g/Tag benötigen Sie',
moreSpools_one: '{{count}} weitere Spule',
moreSpools_other: '{{count}} weitere Spulen',
bridgeGap: 'um die Lücke zu überbrücken.',
// Permissions
noReadAccess: 'Sie haben keine Berechtigung, Bestandsprognosen anzuzeigen.',
noWriteAccess: 'Sie haben keine Berechtigung, Prognoseeinstellungen zu ändern.',
},
};
+124
View File
@@ -4497,6 +4497,12 @@ export default {
amsHtHumidityHighDescription: 'AMS-HT humidity exceeds threshold',
amsHtTemperatureHigh: 'AMS-HT Temperature High',
amsHtTemperatureHighDescription: 'AMS-HT temperature exceeds threshold',
// Inventory stock alert events
inventoryAlerts: 'Inventory Alerts',
stockReorderAlert: 'Reorder Alert',
stockReorderAlertDescription: 'SKU has reached its reorder point',
stockBreakAlert: 'Stock Break Alert',
stockBreakAlertDescription: 'Stock will run out before replenishment arrives',
// Queue events
jobAdded: 'Job Added',
jobAddedDescription: 'Job added to queue',
@@ -5524,4 +5530,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: 'Forecast',
noSpools: 'No active spools found. Add spools to your inventory to see forecast data.',
noUsageData: 'No usage data available — cannot project stock timeline.',
// Table headers
sku: 'SKU',
material: 'Material',
stock: 'Stock',
dailyRate: 'Rate',
daysLeft: 'Days Left',
emptyBy: 'Empty By',
reorderBy: 'Reorder By',
actions: 'Actions',
// Rate tier badges
trend: 'Trend',
estimated: 'Est.',
noData: 'No data',
// Timeframe
timeframe: 'Timeframe',
// Chart
chartTitle: 'Projected Stock — Top 5 Materials',
dashedLinesROP: 'Dashed lines = reorder points',
stockLevel: 'Stock Level',
reorderPoint: 'Reorder Point',
safetyMargin: 'Safety Margin',
// Legend labels
trendLegend: 'Trend (history-based, 95% service level)',
estimatedLegend: 'Estimated (weight delta)',
noDataLegend: 'No data',
ropLabel: 'ROP',
ssLabel: 'SS',
safetyStockLegend: 'Safety stock',
stockArrivalLegend: 'Stock arrival',
stockoutLegend: 'Stockout',
// Alerts toolbar
alertCount_one: '{{count}} alert',
alertCount_other: '{{count}} alerts',
order: 'Order',
// Settings
save: 'Save',
cancel: 'Cancel',
settingsSaved: 'Settings saved',
failedSaveSettings: 'Failed to save settings',
globalLeadTimeSaved: 'Global lead time saved',
globalLeadTime: 'Global lead time',
globalLeadTimeHint: 'Global lead time floor — used in reorder point calculation for all SKUs',
skuLeadTimeOverride: 'SKU Lead Time Override',
skuLeadTimeHint: '0 = use global lead time. Set >0 to override for this SKU.',
safetyMarginLabel: 'Safety Margin',
effectiveLeadTime: 'Effective Lead Time',
effectiveLeadTimeHint: 'max(global {{global}}d, SKU {{sku}}d)',
reorderPointHint: 'd̄ × LT + safety margin — order when stock hits this level',
safetyMarginHint: 'Statistical safety stock (z=1.65 × σ × √LT) + user-defined buffer',
safetyMarginHintDays: 'Buffer added on top of statistical safety stock.{{approx}}',
safetyMarginHintDaysApprox: ' ≈ {{g}}g at current rate.',
safetyMarginHintG: 'Fixed weight buffer added on top of statistical safety stock.{{approx}}',
safetyMarginHintGApprox: ' ≈ {{days}}d at current rate.',
individualSpools: 'Individual spools',
labelWeight: 'Label',
spoolCount_one: '{{count}} spool',
spoolCount_other: '{{count}} spools',
// Alerts
stockBreakRisk: 'Stock break risk',
stockBreakDetail: '{{days}}d remaining, lead time {{lt}}d.',
stockBreakBefore: 'Stock break before replenishment',
reorderNow: 'Reorder now',
reorderTriggerPassed: 'Trigger date {{date}} has passed.',
// Shopping list
shoppingList: 'Shopping List',
shoppingListItems_one: '({{count}} item)',
shoppingListItems_other: '({{count}} items)',
shoppingListEmpty: 'Shopping list is empty. Click the cart icon on any row to add items.',
addToCart: 'Add to shopping list',
alertsSnoozed: 'Mute alerts for this SKU',
alertsEnabled: 'Unmute alerts for this SKU',
addedToCart: 'Added to shopping list',
failedAddItem: 'Failed to add item',
listView: 'List',
logisticsView: 'Logistics',
qty: 'Qty',
weight: 'Weight',
leadTime: 'Lead Time',
expectedRestock: 'Expected Restock',
status: 'Status',
note: 'Note',
pending: 'Pending',
purchased: 'Purchased',
received: 'Received',
markPurchased: 'Mark as purchased',
markReceived: 'Mark as received — adds spools to Stock inventory',
resetToPending: 'Reset to pending',
remove: 'Remove',
clearAll: 'Clear all',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: 'Add to Shopping List',
byQuantity: 'By Quantity',
byDuration: 'By Duration',
numberOfSpools: 'Number of spools',
lastHowManyDays: 'Should last how many days?',
noUsageQty: 'No usage data — quantity set to 1.',
noteOptional: 'Note (optional)',
notePlaceholder: 'e.g. for project X, urgent…',
addNSpools_one: 'Add {{count}} spool',
addNSpools_other: 'Add {{count}} spools',
// Cart logistics
onArrival: 'On Arrival',
stockBreakIn: 'Stock break in {{days}}d.',
stockRunsOutBefore: 'Stock runs out before the {{lt}}d lead time elapses.',
atRate: 'At {{rate}}g/day you need',
moreSpools_one: '{{count}} more spool',
moreSpools_other: '{{count}} more spools',
bridgeGap: 'to bridge the gap.',
// Permissions
noReadAccess: 'You do not have permission to view inventory forecasts.',
noWriteAccess: 'You do not have permission to modify forecast settings.',
},
};
+124
View File
@@ -4476,6 +4476,12 @@ export default {
amsHtHumidityHighDescription: 'L\'humidité de l\'AMS-HT dépasse le seuil',
amsHtTemperatureHigh: 'Température AMS-HT élevée',
amsHtTemperatureHighDescription: 'La température de l\'AMS-HT dépasse le seuil',
// Inventory stock alert events
inventoryAlerts: 'Alertes de stock',
stockReorderAlert: 'Alerte de réapprovisionnement',
stockReorderAlertDescription: 'Le SKU a atteint son point de réapprovisionnement',
stockBreakAlert: 'Alerte de rupture de stock',
stockBreakAlertDescription: 'Le stock sera épuisé avant l\'arrivée du réapprovisionnement',
// Queue events
jobAdded: 'Tâche ajoutée',
jobAddedDescription: 'Tâche ajoutée à la file d\'attente',
@@ -5501,4 +5507,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: 'Prévision des stocks',
noSpools: 'Aucune bobine active trouvée. Ajoutez des bobines à votre inventaire pour voir les données de prévision.',
noUsageData: 'Aucune donnée d\'utilisation disponible — impossible de projeter la chronologie des stocks.',
sku: 'SKU',
// Table headers
material: 'Matériau',
stock: 'Stock',
dailyRate: 'Cadence',
daysLeft: 'Jours restants',
emptyBy: 'Épuisé le',
reorderBy: 'Réapprovisionner avant le',
actions: 'Actions',
// Rate tier badges
trend: 'Tendance',
estimated: 'Est.',
noData: 'Aucune donnée',
// Timeframe
timeframe: 'Période',
// Chart
chartTitle: 'Prévision du stock — Top 5 matériaux',
dashedLinesROP: 'Lignes pointillées = points de réapprovisionnement',
stockLevel: 'Niveau de stock',
reorderPoint: 'Point de réapprovisionnement',
safetyMargin: 'Marge de sécurité',
trendLegend: 'Tendance (basée sur l\'historique, niveau de service 95 %)',
estimatedLegend: 'Estimée (delta de poids)',
noDataLegend: 'Aucune donnée',
ropLabel: 'PR',
ssLabel: 'SS',
safetyStockLegend: 'Stock de sécurité',
stockArrivalLegend: 'Arrivée du stock',
stockoutLegend: 'Rupture de stock',
// Alerts toolbar
alertCount_one: '{{count}} alerte',
alertCount_other: '{{count}} alertes',
order: 'Commander',
// Settings
globalLeadTime: 'Délai global',
globalLeadTimeHint: 'Délai global minimum — utilisé dans le calcul du point de réapprovisionnement pour tous les SKUs',
save: 'Enregistrer',
cancel: 'Annuler',
settingsSaved: 'Paramètres enregistrés',
failedSaveSettings: 'Échec de l\'enregistrement des paramètres',
globalLeadTimeSaved: 'Délai global enregistré',
skuLeadTimeOverride: 'Délai spécifique au SKU',
skuLeadTimeHint: '0 = utiliser le délai global. Définir >0 pour remplacer ce SKU.',
safetyMarginLabel: 'Marge de sécurité',
effectiveLeadTime: 'Délai effectif',
effectiveLeadTimeHint: 'max(global {{global}}j, SKU {{sku}}j)',
reorderPointHint: 'd̄ × LT + safety margin — commander quand le stock atteint ce niveau',
safetyMarginHint: 'Stock de sécurité statistique (z=1,65 × σ × √LT) + tampon défini par l\'utilisateur',
safetyMarginHintDays: 'Tampon ajouté par-dessus le stock de sécurité statistique.{{approx}}',
safetyMarginHintDaysApprox: ' ≈ {{g}}g au taux actuel.',
safetyMarginHintG: 'Tampon de poids fixe ajouté par-dessus le stock de sécurité statistique.{{approx}}',
safetyMarginHintGApprox: ' ≈ {{days}}j au taux actuel.',
individualSpools: 'Bobines individuelles',
labelWeight: 'Étiquette',
spoolCount_one: '{{count}} bobine',
spoolCount_other: '{{count}} bobines',
// Alerts
stockBreakRisk: 'Risque de rupture de stock',
stockBreakBefore: 'Rupture de stock avant réapprovisionnement',
stockBreakDetail: '{{days}}j restants, délai {{lt}}j.',
reorderNow: 'Réapprovisionner maintenant',
reorderTriggerPassed: 'La date de déclenchement {{date}} est passée.',
// Shopping list
shoppingList: 'Liste d\'achats',
shoppingListItems_one: '({{count}} article)',
shoppingListItems_other: '({{count}} articles)',
shoppingListEmpty: 'La liste d\'achats est vide. Cliquez sur l\'icône panier d\'une ligne pour ajouter des articles.',
addToCart: 'Ajouter à la liste d\'achats',
alertsSnoozed: 'Désactiver les alertes pour ce SKU',
alertsEnabled: 'Activer les alertes pour ce SKU',
addedToCart: 'Ajouté à la liste de courses',
failedAddItem: 'Impossible d\'ajouter l\'article',
listView: 'Liste',
logisticsView: 'Logistique',
qty: 'Qté',
weight: 'Poids',
leadTime: 'Délai',
expectedRestock: 'Réapprovisionnement prévu',
status: 'Statut',
note: 'Note',
pending: 'En attente',
purchased: 'Commandé',
received: 'Reçu',
markPurchased: 'Marquer comme commandé',
markReceived: 'Marquer comme reçu — ajoute les bobines au stock',
resetToPending: 'Remettre en attente',
remove: 'Supprimer',
clearAll: 'Tout effacer',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: 'Ajouter à la liste de courses',
byQuantity: 'Par quantité',
byDuration: 'Par durée',
numberOfSpools: 'Nombre de bobines',
lastHowManyDays: 'Combien de jours doit-il durer ?',
noUsageQty: 'Pas de données d\'utilisation — quantité définie à 1.',
noteOptional: 'Note (optionnel)',
notePlaceholder: 'ex. pour le projet X, urgent…',
addNSpools_one: 'Ajouter {{count}} bobine',
addNSpools_other: 'Ajouter {{count}} bobines',
// Cart logistics
onArrival: 'À la livraison',
stockBreakIn: 'Rupture de stock dans {{days}}j.',
stockRunsOutBefore: 'Le stock s\'épuise avant la fin du délai de {{lt}}j.',
atRate: 'À {{rate}}g/jour, vous avez besoin de',
moreSpools_one: '{{count}} bobine supplémentaire',
moreSpools_other: '{{count}} bobines supplémentaires',
bridgeGap: 'pour combler l\'écart.',
// Permissions
noReadAccess: 'Vous n\'avez pas la permission de consulter les prévisions de stock.',
noWriteAccess: 'Vous n\'avez pas la permission de modifier les paramètres de prévision.',
},
};
+124
View File
@@ -4475,6 +4475,12 @@ export default {
amsHtHumidityHighDescription: 'L\'umidità dell\'AMS-HT supera la soglia',
amsHtTemperatureHigh: 'Temperatura AMS-HT elevata',
amsHtTemperatureHighDescription: 'La temperatura dell\'AMS-HT supera la soglia',
// Inventory stock alert events
inventoryAlerts: 'Avvisi di inventario',
stockReorderAlert: 'Avviso di riordino',
stockReorderAlertDescription: 'Il SKU ha raggiunto il punto di riordino',
stockBreakAlert: 'Avviso di esaurimento scorte',
stockBreakAlertDescription: 'Le scorte si esauriranno prima dell\'arrivo del rifornimento',
// Queue events
jobAdded: 'Lavoro aggiunto',
jobAddedDescription: 'Lavoro aggiunto alla coda',
@@ -5500,4 +5506,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: 'Previsione scorte',
noSpools: 'Nessuna bobina attiva trovata. Aggiungi bobine al tuo inventario per vedere i dati di previsione.',
noUsageData: 'Nessun dato di utilizzo disponibile — impossibile proiettare la timeline delle scorte.',
sku: 'SKU',
// Table headers
material: 'Materiale',
stock: 'Scorte',
dailyRate: 'Tasso',
daysLeft: 'Giorni rimanenti',
emptyBy: 'Esaurimento previsto',
reorderBy: 'Riordinare entro',
actions: 'Azioni',
// Rate tier badges
trend: 'Tendenza',
estimated: 'Stim.',
noData: 'Nessun dato',
// Timeframe
timeframe: 'Periodo',
// Chart
chartTitle: 'Stock proiettato — Top 5 materiali',
dashedLinesROP: 'Linee tratteggiate = punti di riordino',
stockLevel: 'Livello scorte',
reorderPoint: 'Punto di riordino',
safetyMargin: 'Margine di sicurezza',
trendLegend: 'Tendenza (basata su storico, livello servizio 95%)',
estimatedLegend: 'Stimata (delta peso)',
noDataLegend: 'Nessun dato',
ropLabel: 'PR',
ssLabel: 'SS',
safetyStockLegend: 'Scorta di sicurezza',
stockArrivalLegend: 'Arrivo merce',
stockoutLegend: 'Esaurimento scorte',
// Alerts toolbar
alertCount_one: '{{count}} avviso',
alertCount_other: '{{count}} avvisi',
order: 'Ordina',
// Settings
globalLeadTime: 'Lead time globale',
globalLeadTimeHint: 'Lead time globale minimo — usato nel calcolo del punto di riordino per tutti gli SKU',
save: 'Salva',
cancel: 'Annulla',
settingsSaved: 'Impostazioni salvate',
failedSaveSettings: 'Impossibile salvare le impostazioni',
globalLeadTimeSaved: 'Lead time globale salvato',
skuLeadTimeOverride: 'Override lead time SKU',
skuLeadTimeHint: '0 = usa lead time globale. Imposta >0 per sovrascrivere per questo SKU.',
safetyMarginLabel: 'Margine di sicurezza',
effectiveLeadTime: 'Lead time effettivo',
effectiveLeadTimeHint: 'max(globale {{global}}g, SKU {{sku}}g)',
reorderPointHint: 'd̄ × LT + safety margin — ordina quando le scorte raggiungono questo livello',
safetyMarginHint: 'Scorta di sicurezza statistica (z=1,65 × σ × √LT) + buffer definito dall\'utente',
safetyMarginHintDays: 'Buffer aggiunto sulla scorta di sicurezza statistica.{{approx}}',
safetyMarginHintDaysApprox: ' ≈ {{g}}g al tasso attuale.',
safetyMarginHintG: 'Buffer di peso fisso aggiunto sulla scorta di sicurezza statistica.{{approx}}',
safetyMarginHintGApprox: ' ≈ {{days}}g al tasso attuale.',
individualSpools: 'Bobine singole',
labelWeight: 'Etichetta',
spoolCount_one: '{{count}} bobina',
spoolCount_other: '{{count}} bobine',
// Alerts
stockBreakRisk: 'Rischio di rottura scorte',
stockBreakBefore: 'Rottura scorte prima del rifornimento',
stockBreakDetail: '{{days}}g rimanenti, lead time {{lt}}g.',
reorderNow: 'Riordina ora',
reorderTriggerPassed: 'La data di attivazione {{date}} è passata.',
// Shopping list
shoppingList: 'Lista della spesa',
shoppingListItems_one: '({{count}} articolo)',
shoppingListItems_other: '({{count}} articoli)',
shoppingListEmpty: 'La lista della spesa è vuota. Clicca sull\'icona del carrello in qualsiasi riga per aggiungere articoli.',
addToCart: 'Aggiungi alla lista della spesa',
alertsSnoozed: 'Silenzia avvisi per questo SKU',
alertsEnabled: 'Riattiva avvisi per questo SKU',
addedToCart: 'Aggiunto alla lista della spesa',
failedAddItem: 'Impossibile aggiungere l\'articolo',
listView: 'Lista',
logisticsView: 'Logistica',
qty: 'Qtà',
weight: 'Peso',
leadTime: 'Lead time',
expectedRestock: 'Rifornimento previsto',
status: 'Stato',
note: 'Nota',
pending: 'In attesa',
purchased: 'Acquistato',
received: 'Ricevuto',
markPurchased: 'Segna come acquistato',
markReceived: 'Segna come ricevuto — aggiunge bobine all\'inventario',
resetToPending: 'Ripristina in attesa',
remove: 'Rimuovi',
clearAll: 'Cancella tutto',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: 'Aggiungi alla lista della spesa',
byQuantity: 'Per quantità',
byDuration: 'Per durata',
numberOfSpools: 'Numero di bobine',
lastHowManyDays: 'Per quanti giorni deve durare?',
noUsageQty: 'Nessun dato di utilizzo — quantità impostata a 1.',
noteOptional: 'Nota (opzionale)',
notePlaceholder: 'es. per il progetto X, urgente…',
addNSpools_one: 'Aggiungi {{count}} bobina',
addNSpools_other: 'Aggiungi {{count}} bobine',
// Cart logistics
onArrival: 'All\'arrivo',
stockBreakIn: 'Rottura scorte tra {{days}}g.',
stockRunsOutBefore: 'Le scorte si esauriscono prima dello scadere del lead time di {{lt}}g.',
atRate: 'A {{rate}}g/giorno hai bisogno di',
moreSpools_one: '{{count}} bobina in più',
moreSpools_other: '{{count}} bobine in più',
bridgeGap: 'per colmare il divario.',
// Permissions
noReadAccess: 'Non hai il permesso di visualizzare le previsioni di inventario.',
noWriteAccess: 'Non hai il permesso di modificare le impostazioni di previsione.',
},
};
+124
View File
@@ -4488,6 +4488,12 @@ export default {
amsHtHumidityHighDescription: 'AMS-HTの湿度がしきい値を超過',
amsHtTemperatureHigh: 'AMS-HT温度高',
amsHtTemperatureHighDescription: 'AMS-HTの温度がしきい値を超過',
// Inventory stock alert events
inventoryAlerts: '在庫アラート',
stockReorderAlert: '発注アラート',
stockReorderAlertDescription: 'SKUが発注点に達しました',
stockBreakAlert: '在庫切れアラート',
stockBreakAlertDescription: '入荷前に在庫が枯渇する見込みです',
// Queue events
jobAdded: 'ジョブ追加',
jobAddedDescription: 'キューにジョブが追加されました',
@@ -5513,4 +5519,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: '在庫予測',
noSpools: 'アクティブなスプールが見つかりません。予測データを表示するには、在庫にスプールを追加してください。',
noUsageData: '使用データがありません — 在庫のタイムラインを予測できません。',
sku: 'SKU',
// Table headers
material: '素材',
stock: '在庫',
dailyRate: 'レート',
daysLeft: '残り日数',
emptyBy: '在庫切れ予定日',
reorderBy: '発注期限',
actions: '操作',
// Rate tier badges
trend: 'トレンド',
estimated: '推定',
noData: 'データなし',
// Timeframe
timeframe: '期間',
// Chart
chartTitle: '在庫予測 — 上位5素材',
dashedLinesROP: '破線 = 発注点',
stockLevel: '在庫量',
reorderPoint: '発注点',
safetyMargin: '安全余裕',
trendLegend: 'トレンド(履歴ベース、95%サービスレベル)',
estimatedLegend: '推定(重量差分)',
noDataLegend: 'データなし',
ropLabel: '発注点',
ssLabel: '安全在庫',
safetyStockLegend: '安全在庫',
stockArrivalLegend: '入荷',
stockoutLegend: '品切れ',
// Alerts toolbar
alertCount_one: '{{count}}件の警告',
alertCount_other: '{{count}}件の警告',
order: '注文',
// Settings
globalLeadTime: 'グローバルリードタイム',
globalLeadTimeHint: 'グローバルリードタイムの下限 — 全SKUの発注点計算に使用',
save: '保存',
cancel: 'キャンセル',
settingsSaved: '設定を保存しました',
failedSaveSettings: '設定の保存に失敗しました',
globalLeadTimeSaved: 'グローバルリードタイムを保存しました',
skuLeadTimeOverride: 'SKUリードタイム上書き',
skuLeadTimeHint: '0 = グローバルリードタイムを使用。このSKUを上書きするには0より大きい値を設定。',
safetyMarginLabel: '安全余裕',
effectiveLeadTime: '実効リードタイム',
effectiveLeadTimeHint: 'max(グローバル {{global}}日, SKU {{sku}}日)',
reorderPointHint: 'd̄ × LT + safety margin — 在庫がこのレベルに達したら発注',
safetyMarginHint: '統計的安全在庫 (z=1.65 × σ × √LT) + ユーザー定義バッファ',
safetyMarginHintDays: '統計的安全在庫に加算されるバッファ。{{approx}}',
safetyMarginHintDaysApprox: ' ≈ 現在のペースで{{g}}g。',
safetyMarginHintG: '統計的安全在庫に加算される固定重量バッファ。{{approx}}',
safetyMarginHintGApprox: ' ≈ 現在のペースで{{days}}日。',
individualSpools: '個別スプール',
labelWeight: 'ラベル',
spoolCount_one: '{{count}}個のスプール',
spoolCount_other: '{{count}}個のスプール',
// Alerts
stockBreakRisk: '在庫切れリスク',
stockBreakBefore: '補充前の在庫切れ',
stockBreakDetail: '残り{{days}}日、リードタイム{{lt}}日。',
reorderNow: '今すぐ発注',
reorderTriggerPassed: '発注トリガー日 {{date}} が経過しました。',
// Shopping list
shoppingList: '購入リスト',
shoppingListItems_one: '({{count}}件)',
shoppingListItems_other: '({{count}}件)',
shoppingListEmpty: '購入リストは空です。行のカートアイコンをクリックしてアイテムを追加してください。',
addToCart: '購入リストに追加',
alertsSnoozed: 'このSKUのアラートをミュート',
alertsEnabled: 'このSKUのアラートを有効にする',
addedToCart: '買い物リストに追加しました',
failedAddItem: 'アイテムの追加に失敗しました',
listView: 'リスト',
logisticsView: 'ロジスティクス',
qty: '数量',
weight: '重量',
leadTime: 'リードタイム',
expectedRestock: '入荷予定日',
status: 'ステータス',
note: 'メモ',
pending: '保留中',
purchased: '発注済み',
received: '受領済み',
markPurchased: '発注済みとしてマーク',
markReceived: '受領済みとしてマーク — スプールを在庫に追加',
resetToPending: '保留中にリセット',
remove: '削除',
clearAll: 'すべてクリア',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: '買い物リストに追加',
byQuantity: '数量で',
byDuration: '期間で',
numberOfSpools: 'スプール数',
lastHowManyDays: '何日間持つべきですか?',
noUsageQty: '使用データなし — 数量を1に設定しました。',
noteOptional: 'メモ(任意)',
notePlaceholder: '例:プロジェクトX用、緊急…',
addNSpools_one: '{{count}}個のスプールを追加',
addNSpools_other: '{{count}}個のスプールを追加',
// Cart logistics
onArrival: '入荷時',
stockBreakIn: '{{days}}日後に在庫切れ。',
stockRunsOutBefore: 'リードタイム{{lt}}日が経過する前に在庫が枯渇します。',
atRate: '{{rate}}g/日のペースでは',
moreSpools_one: 'あと{{count}}個のスプール',
moreSpools_other: 'あと{{count}}個のスプール',
bridgeGap: 'が不足を補うために必要です。',
// Permissions
noReadAccess: '在庫予測を閲覧する権限がありません。',
noWriteAccess: '予測設定を変更する権限がありません。',
},
};
+124
View File
@@ -4475,6 +4475,12 @@ export default {
amsHtHumidityHighDescription: 'Umidade do AMS-HT excede o limite',
amsHtTemperatureHigh: 'Temperatura Alta do AMS-HT',
amsHtTemperatureHighDescription: 'Temperatura do AMS-HT excede o limite',
// Inventory stock alert events
inventoryAlerts: 'Alertas de Estoque',
stockReorderAlert: 'Alerta de Reposição',
stockReorderAlertDescription: 'O SKU atingiu seu ponto de reposição',
stockBreakAlert: 'Alerta de Ruptura de Estoque',
stockBreakAlertDescription: 'O estoque se esgotará antes da chegada do reabastecimento',
// Queue events
jobAdded: 'Trabalho Adicionado',
jobAddedDescription: 'Trabalho adicionado à fila',
@@ -5500,4 +5506,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: 'Previsão de Estoque',
noSpools: 'Nenhum carretel ativo encontrado. Adicione carretéis ao seu inventário para ver os dados de previsão.',
noUsageData: 'Nenhum dado de uso disponível — não é possível projetar a linha do tempo do estoque.',
sku: 'SKU',
// Table headers
material: 'Material',
stock: 'Estoque',
dailyRate: 'Taxa',
daysLeft: 'Dias Restantes',
emptyBy: 'Esgota em',
reorderBy: 'Reabastecer até',
actions: 'Ações',
// Rate tier badges
trend: 'Tendência',
estimated: 'Est.',
noData: 'Sem dados',
// Timeframe
timeframe: 'Período',
// Chart
chartTitle: 'Estoque projetado — Top 5 materiais',
dashedLinesROP: 'Linhas tracejadas = pontos de reposição',
stockLevel: 'Nível de Estoque',
reorderPoint: 'Ponto de Reposição',
safetyMargin: 'Margem de Segurança',
trendLegend: 'Tendência (baseada em histórico, nível de serviço 95%)',
estimatedLegend: 'Estimado (delta de peso)',
noDataLegend: 'Sem dados',
ropLabel: 'PR',
ssLabel: 'SE',
safetyStockLegend: 'Estoque de segurança',
stockArrivalLegend: 'Chegada do estoque',
stockoutLegend: 'Ruptura de estoque',
// Alerts toolbar
alertCount_one: '{{count}} alerta',
alertCount_other: '{{count}} alertas',
order: 'Pedir',
// Settings
globalLeadTime: 'Lead time global',
globalLeadTimeHint: 'Lead time global mínimo — usado no cálculo do ponto de reposição para todos os SKUs',
save: 'Salvar',
cancel: 'Cancelar',
settingsSaved: 'Configurações salvas',
failedSaveSettings: 'Falha ao salvar configurações',
globalLeadTimeSaved: 'Prazo global salvo',
skuLeadTimeOverride: 'Override de Lead Time do SKU',
skuLeadTimeHint: '0 = usar lead time global. Defina >0 para substituir para este SKU.',
safetyMarginLabel: 'Margem de Segurança',
effectiveLeadTime: 'Lead Time Efetivo',
effectiveLeadTimeHint: 'max(global {{global}}d, SKU {{sku}}d)',
reorderPointHint: 'd̄ × LT + safety margin — peça quando o estoque atingir este nível',
safetyMarginHint: 'Estoque de segurança estatístico (z=1,65 × σ × √LT) + buffer definido pelo usuário',
safetyMarginHintDays: 'Buffer adicionado ao estoque de segurança estatístico.{{approx}}',
safetyMarginHintDaysApprox: ' ≈ {{g}}g na taxa atual.',
safetyMarginHintG: 'Buffer de peso fixo adicionado ao estoque de segurança estatístico.{{approx}}',
safetyMarginHintGApprox: ' ≈ {{days}}d na taxa atual.',
individualSpools: 'Carretéis individuais',
labelWeight: 'Etiqueta',
spoolCount_one: '{{count}} carretel',
spoolCount_other: '{{count}} carretéis',
// Alerts
stockBreakRisk: 'Risco de ruptura de estoque',
stockBreakBefore: 'Ruptura de estoque antes do reabastecimento',
stockBreakDetail: '{{days}}d restantes, lead time {{lt}}d.',
reorderNow: 'Reabastecer agora',
reorderTriggerPassed: 'A data de acionamento {{date}} já passou.',
// Shopping list
shoppingList: 'Lista de Compras',
shoppingListItems_one: '({{count}} item)',
shoppingListItems_other: '({{count}} itens)',
shoppingListEmpty: 'A lista de compras está vazia. Clique no ícone do carrinho em qualquer linha para adicionar itens.',
addToCart: 'Adicionar à lista de compras',
alertsSnoozed: 'Silenciar alertas para este SKU',
alertsEnabled: 'Ativar alertas para este SKU',
addedToCart: 'Adicionado à lista de compras',
failedAddItem: 'Falha ao adicionar item',
listView: 'Lista',
logisticsView: 'Logística',
qty: 'Qtd',
weight: 'Peso',
leadTime: 'Lead Time',
expectedRestock: 'Reabastecimento Previsto',
status: 'Status',
note: 'Nota',
pending: 'Pendente',
purchased: 'Comprado',
received: 'Recebido',
markPurchased: 'Marcar como comprado',
markReceived: 'Marcar como recebido — adiciona carretéis ao estoque',
resetToPending: 'Redefinir para pendente',
remove: 'Remover',
clearAll: 'Limpar tudo',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: 'Adicionar à lista de compras',
byQuantity: 'Por quantidade',
byDuration: 'Por duração',
numberOfSpools: 'Número de carretéis',
lastHowManyDays: 'Por quantos dias deve durar?',
noUsageQty: 'Sem dados de uso — quantidade definida como 1.',
noteOptional: 'Observação (opcional)',
notePlaceholder: 'ex. para o projeto X, urgente…',
addNSpools_one: 'Adicionar {{count}} carretel',
addNSpools_other: 'Adicionar {{count}} carretéis',
// Cart logistics
onArrival: 'Na Chegada',
stockBreakIn: 'Ruptura de estoque em {{days}}d.',
stockRunsOutBefore: 'O estoque se esgota antes do lead time de {{lt}}d decorrer.',
atRate: 'A {{rate}}g/dia você precisa de',
moreSpools_one: '{{count}} carretel a mais',
moreSpools_other: '{{count}} carretéis a mais',
bridgeGap: 'para cobrir a lacuna.',
// Permissions
noReadAccess: 'Você não tem permissão para visualizar previsões de inventário.',
noWriteAccess: 'Você não tem permissão para modificar as configurações de previsão.',
},
};
+124
View File
@@ -4476,6 +4476,12 @@ export default {
amsHtHumidityHighDescription: 'AMS-HT 湿度超过阈值',
amsHtTemperatureHigh: 'AMS-HT 温度过高',
amsHtTemperatureHighDescription: 'AMS-HT 温度超过阈值',
// Inventory stock alert events
inventoryAlerts: '库存警报',
stockReorderAlert: '补货警报',
stockReorderAlertDescription: 'SKU 已达到补货点',
stockBreakAlert: '断货警报',
stockBreakAlertDescription: '库存将在补货到达前耗尽',
// Queue events
jobAdded: '任务已添加',
jobAddedDescription: '任务已添加到队列',
@@ -5500,4 +5506,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: '库存预测',
noSpools: '未找到活跃的料卷。请将料卷添加到库存中以查看预测数据。',
noUsageData: '无可用使用数据 — 无法预测库存时间线。',
sku: 'SKU',
// Table headers
material: '材料',
stock: '库存',
dailyRate: '消耗率',
daysLeft: '剩余天数',
emptyBy: '耗尽日期',
reorderBy: '补货截止日',
actions: '操作',
// Rate tier badges
trend: '趋势',
estimated: '估算',
noData: '无数据',
// Timeframe
timeframe: '时间范围',
// Chart
chartTitle: '库存预测 — 前5种材料',
dashedLinesROP: '虚线 = 再订货点',
stockLevel: '库存量',
reorderPoint: '再订购点',
safetyMargin: '安全余量',
trendLegend: '趋势(基于历史,95%服务水平)',
estimatedLegend: '估算(重量差值)',
noDataLegend: '无数据',
ropLabel: '再订货点',
ssLabel: '安全库存',
safetyStockLegend: '安全库存',
stockArrivalLegend: '到货',
stockoutLegend: '断货',
// Alerts toolbar
alertCount_one: '{{count}}条警告',
alertCount_other: '{{count}}条警告',
order: '订购',
// Settings
globalLeadTime: '全局交货期',
globalLeadTimeHint: '全局交货期下限 — 用于所有 SKU 的再订购点计算',
save: '保存',
cancel: '取消',
settingsSaved: '设置已保存',
failedSaveSettings: '保存设置失败',
globalLeadTimeSaved: '全局提前期已保存',
skuLeadTimeOverride: 'SKU 交货期覆盖',
skuLeadTimeHint: '0 = 使用全局交货期。设置 >0 以覆盖此 SKU。',
safetyMarginLabel: '安全余量',
effectiveLeadTime: '有效交货期',
effectiveLeadTimeHint: 'max(全局 {{global}}天, SKU {{sku}}天)',
reorderPointHint: 'd̄ × LT + safety margin — 当库存降至此水平时下单',
safetyMarginHint: '统计安全库存 (z=1.65 × σ × √LT) + 用户自定义缓冲',
safetyMarginHintDays: '在统计安全库存基础上额外增加的缓冲。{{approx}}',
safetyMarginHintDaysApprox: ' ≈ 按当前速率 {{g}}g。',
safetyMarginHintG: '在统计安全库存基础上增加的固定重量缓冲。{{approx}}',
safetyMarginHintGApprox: ' ≈ 按当前速率 {{days}}天。',
individualSpools: '单个料卷',
labelWeight: '标注重量',
spoolCount_one: '{{count}}个线轴',
spoolCount_other: '{{count}}个线轴',
// Alerts
stockBreakRisk: '断货风险',
stockBreakBefore: '补货前库存断档',
stockBreakDetail: '剩余 {{days}} 天,交货期 {{lt}} 天。',
reorderNow: '立即补货',
reorderTriggerPassed: '触发日期 {{date}} 已过。',
// Shopping list
shoppingList: '购物清单',
shoppingListItems_one: '({{count}}项)',
shoppingListItems_other: '({{count}}项)',
shoppingListEmpty: '购物清单为空。点击任意行的购物车图标以添加商品。',
addToCart: '添加到购物清单',
alertsSnoozed: '静音此SKU的提醒',
alertsEnabled: '启用此SKU的提醒',
addedToCart: '已添加到购物清单',
failedAddItem: '添加项目失败',
listView: '列表',
logisticsView: '物流',
qty: '数量',
weight: '重量',
leadTime: '交货期',
expectedRestock: '预计补货日期',
status: '状态',
note: '备注',
pending: '待处理',
purchased: '已购买',
received: '已收货',
markPurchased: '标记为已购买',
markReceived: '标记为已收货 — 将料卷添加到库存',
resetToPending: '重置为待处理',
remove: '移除',
clearAll: '清空',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: '添加到购物清单',
byQuantity: '按数量',
byDuration: '按时长',
numberOfSpools: '线轴数量',
lastHowManyDays: '需要持续多少天?',
noUsageQty: '无使用数据 — 数量已设为1。',
noteOptional: '备注(可选)',
notePlaceholder: '例如:用于项目X,紧急…',
addNSpools_one: '添加{{count}}个线轴',
addNSpools_other: '添加{{count}}个线轴',
// Cart logistics
onArrival: '到货时',
stockBreakIn: '{{days}} 天后断货。',
stockRunsOutBefore: '库存在 {{lt}} 天交货期结束前耗尽。',
atRate: '按 {{rate}}g/天的速度,您需要',
moreSpools_one: '再{{count}}个线轴',
moreSpools_other: '再{{count}}个线轴',
bridgeGap: '来弥补缺口。',
// Permissions
noReadAccess: '您没有查看库存预测的权限。',
noWriteAccess: '您没有修改预测设置的权限。',
},
};
+124
View File
@@ -4476,6 +4476,12 @@ export default {
amsHtHumidityHighDescription: 'AMS-HT 濕度超過閾值',
amsHtTemperatureHigh: 'AMS-HT 溫度過高',
amsHtTemperatureHighDescription: 'AMS-HT 溫度超過閾值',
// Inventory stock alert events
inventoryAlerts: '庫存警報',
stockReorderAlert: '補貨警報',
stockReorderAlertDescription: 'SKU 已達到補貨點',
stockBreakAlert: '斷貨警報',
stockBreakAlertDescription: '庫存將在補貨到達前耗盡',
// Queue events
jobAdded: '任務已新增',
jobAddedDescription: '任務已新增到佇列',
@@ -5500,4 +5506,122 @@ export default {
copyFailed: 'Copy failed — select and copy manually',
},
},
// Forecast & Inventory Intelligence
forecast: {
title: '庫存預測',
noSpools: '未找到活躍的料卷。請將料卷新增至庫存以查看預測資料。',
noUsageData: '無可用使用資料 — 無法預測庫存時間線。',
sku: 'SKU',
// Table headers
material: '材料',
stock: '庫存',
dailyRate: '消耗率',
daysLeft: '剩餘天數',
emptyBy: '耗盡日期',
reorderBy: '補貨截止日',
actions: '操作',
// Rate tier badges
trend: '趨勢',
estimated: '估算',
noData: '無資料',
// Timeframe
timeframe: '時間範圍',
// Chart
chartTitle: '庫存預測 — 前5種材料',
dashedLinesROP: '虛線 = 再訂貨點',
stockLevel: '庫存量',
reorderPoint: '再訂購點',
safetyMargin: '安全餘量',
trendLegend: '趨勢(基於歷史,95%服務水準)',
estimatedLegend: '估算(重量差值)',
noDataLegend: '無資料',
ropLabel: '再訂貨點',
ssLabel: '安全庫存',
safetyStockLegend: '安全庫存',
stockArrivalLegend: '到貨',
stockoutLegend: '斷貨',
// Alerts toolbar
alertCount_one: '{{count}}條警告',
alertCount_other: '{{count}}條警告',
order: '訂購',
// Settings
globalLeadTime: '全域交貨期',
globalLeadTimeHint: '全域交貨期下限 — 用於所有 SKU 的再訂購點計算',
save: '儲存',
cancel: '取消',
settingsSaved: '設定已儲存',
failedSaveSettings: '儲存設定失敗',
globalLeadTimeSaved: '全域提前期已儲存',
skuLeadTimeOverride: 'SKU 交貨期覆蓋',
skuLeadTimeHint: '0 = 使用全域交貨期。設定 >0 以覆蓋此 SKU。',
safetyMarginLabel: '安全餘量',
effectiveLeadTime: '有效交貨期',
effectiveLeadTimeHint: 'max(全域 {{global}}天, SKU {{sku}}天)',
reorderPointHint: 'd̄ × LT + safety margin — 當庫存降至此水準時下單',
safetyMarginHint: '統計安全庫存 (z=1.65 × σ × √LT) + 使用者自訂緩衝',
safetyMarginHintDays: '在統計安全庫存基礎上額外增加的緩衝。{{approx}}',
safetyMarginHintDaysApprox: ' ≈ 按當前速率 {{g}}g。',
safetyMarginHintG: '在統計安全庫存基礎上增加的固定重量緩衝。{{approx}}',
safetyMarginHintGApprox: ' ≈ 按當前速率 {{days}}天。',
individualSpools: '單個料卷',
labelWeight: '標示重量',
spoolCount_one: '{{count}}個線軸',
spoolCount_other: '{{count}}個線軸',
// Alerts
stockBreakRisk: '斷貨風險',
stockBreakBefore: '補貨前庫存斷檔',
stockBreakDetail: '剩餘 {{days}} 天,交貨期 {{lt}} 天。',
reorderNow: '立即補貨',
reorderTriggerPassed: '觸發日期 {{date}} 已過。',
// Shopping list
shoppingList: '購物清單',
shoppingListItems_one: '({{count}}項)',
shoppingListItems_other: '({{count}}項)',
shoppingListEmpty: '購物清單為空。點擊任意列的購物車圖示以新增商品。',
addToCart: '新增至購物清單',
alertsSnoozed: '靜音此SKU的提醒',
alertsEnabled: '啟用此SKU的提醒',
addedToCart: '已新增至購物清單',
failedAddItem: '新增項目失敗',
listView: '列表',
logisticsView: '物流',
qty: '數量',
weight: '重量',
leadTime: '交貨期',
expectedRestock: '預計補貨日期',
status: '狀態',
note: '備註',
pending: '待處理',
purchased: '已購買',
received: '已收貨',
markPurchased: '標記為已購買',
markReceived: '標記為已收貨 — 將料卷新增至庫存',
resetToPending: '重置為待處理',
remove: '移除',
clearAll: '清空',
downloadCsv: 'CSV',
// Add to cart modal
addToCartTitle: '新增至購物清單',
byQuantity: '依數量',
byDuration: '依時長',
numberOfSpools: '線軸數量',
lastHowManyDays: '需要持續多少天?',
noUsageQty: '無使用資料 — 數量已設為1。',
noteOptional: '備註(選填)',
notePlaceholder: '例如:用於專案X,緊急…',
addNSpools_one: '新增{{count}}個線軸',
addNSpools_other: '新增{{count}}個線軸',
// Cart logistics
onArrival: '到貨時',
stockBreakIn: '{{days}} 天後斷貨。',
stockRunsOutBefore: '庫存在 {{lt}} 天交貨期結束前耗盡。',
atRate: '按 {{rate}}g/天的速度,您需要',
moreSpools_one: '再{{count}}個線軸',
moreSpools_other: '再{{count}}個線軸',
bridgeGap: '來彌補缺口。',
// Permissions
noReadAccess: '您沒有查看庫存預測的權限。',
noWriteAccess: '您沒有修改預測設定的權限。',
},
};
+47 -23
View File
@@ -5,8 +5,9 @@ import {
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock,
} from 'lucide-react';
import { ForecastPanel } from '../components/ForecastPanel';
import { api, spoolbuddyApi } from '../api/client';
import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
import { Button } from '../components/Button';
@@ -17,6 +18,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { resolveSpoolColorName } from '../utils/colors';
import { getCurrencySymbol } from '../utils/currency';
import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
@@ -24,7 +26,7 @@ import { formatSlotLabel } from '../utils/amsHelpers';
type ArchiveFilter = 'active' | 'archived';
type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
type ViewMode = 'table' | 'cards';
type ViewMode = 'table' | 'cards' | 'forecast';
type SortDirection = 'asc' | 'desc';
type SortState = { column: string; direction: SortDirection } | null;
@@ -482,6 +484,8 @@ function InventoryPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission, loading: authLoading } = useAuth();
const canViewForecast = !authLoading && hasPermission('inventory:forecast_read');
const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
// Label printing (#809). null = closed; otherwise the IDs to print labels for.
@@ -1027,7 +1031,7 @@ function InventoryPage() {
{/* Toolbar: Search + View toggle */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<div className="relative flex-1 max-w-md">
<div className={`relative flex-1 max-w-md ${viewMode === 'forecast' ? 'invisible pointer-events-none' : ''}`}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
<input
type="text"
@@ -1058,19 +1062,21 @@ function InventoryPage() {
<span className="hidden sm:inline">{t('inventory.columns')}</span>
</button>
)}
{/* Group similar toggle */}
<button
onClick={toggleGroupSimilar}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${
groupSimilar
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
: 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
}`}
title={t('inventory.groupSimilar')}
>
<Group className="w-4 h-4" />
<span className="hidden sm:inline">{t('inventory.groupSimilar')}</span>
</button>
{/* Group similar toggle — hidden in forecast mode */}
{viewMode !== 'forecast' && (
<button
onClick={toggleGroupSimilar}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${
groupSimilar
? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
: 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
}`}
title={t('inventory.groupSimilar')}
>
<Group className="w-4 h-4" />
<span className="hidden sm:inline">{t('inventory.groupSimilar')}</span>
</button>
)}
{/* Table / Cards toggle */}
<div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<button
@@ -1095,12 +1101,25 @@ function InventoryPage() {
<LayoutGrid className="w-4 h-4" />
<span className="hidden sm:inline">{t('inventory.cards')}</span>
</button>
<button
onClick={() => canViewForecast && setViewMode('forecast')}
disabled={!canViewForecast}
title={canViewForecast ? undefined : t('forecast.noReadAccess')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
viewMode === 'forecast'
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:bg-bambu-dark-tertiary'
}`}
>
{canViewForecast ? <TrendingUp className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
<span className="hidden sm:inline">{t('forecast.title')}</span>
</button>
</div>
</div>
</div>
{/* Filter chips row */}
<div className="flex flex-wrap items-center gap-2">
{/* Filter chips row — hidden in forecast mode */}
<div className={`flex flex-wrap items-center gap-2 ${viewMode === 'forecast' ? 'hidden' : ''}`}>
{/* Active / Archived chips */}
<div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
<button
@@ -1297,11 +1316,13 @@ function InventoryPage() {
</>
)}
{/* Results count */}
<span className="ml-auto text-xs text-bambu-gray">
{sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
{groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}
</span>
{/* Results count — hidden in forecast mode */}
{viewMode !== 'forecast' && (
<span className="ml-auto text-xs text-bambu-gray">
{sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
{groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}
</span>
)}
</div>
{/* Content */}
@@ -1309,6 +1330,9 @@ function InventoryPage() {
<div className="flex justify-center py-16">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : viewMode === 'forecast' ? (
/* Forecast view */
<ForecastPanel spools={spools || []} />
) : viewMode === 'cards' ? (
/* Cards view */
pagedItems.length > 0 ? (