feat(estimates): integrate inventory items
This commit is contained in:
parent
fb5c2cc26a
commit
663bdc41b1
15 changed files with 629 additions and 28 deletions
|
|
@ -221,7 +221,7 @@ Tabellen:
|
||||||
|
|
||||||
## Lager / Ersatzteile
|
## Lager / Ersatzteile
|
||||||
|
|
||||||
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet.
|
Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen.
|
||||||
|
|
||||||
Datenfluss:
|
Datenfluss:
|
||||||
|
|
||||||
|
|
@ -238,6 +238,9 @@ Kernregeln:
|
||||||
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
|
- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert.
|
||||||
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
|
- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation.
|
||||||
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
|
- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht.
|
||||||
|
- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft.
|
||||||
|
- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird.
|
||||||
|
- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert.
|
||||||
|
|
||||||
Tabellen:
|
Tabellen:
|
||||||
|
|
||||||
|
|
@ -271,7 +274,16 @@ RBAC-Permissions:
|
||||||
|
|
||||||
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
|
Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten.
|
||||||
|
|
||||||
Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
|
Reservierungslogik:
|
||||||
|
|
||||||
|
- Entwurf: keine Reservierung.
|
||||||
|
- Senden: verknuepfte Lagerartikel werden reserviert.
|
||||||
|
- Freigabe: Reservierung bleibt bestehen.
|
||||||
|
- Ablehnung: Reservierung wird freigegeben.
|
||||||
|
- Storno: Reservierung wird freigegeben.
|
||||||
|
- Loeschung eines gesendeten KV: Reservierung wird freigegeben.
|
||||||
|
|
||||||
|
Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures.
|
||||||
|
|
||||||
Hermes-Endpunkte:
|
Hermes-Endpunkte:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ SECRET_KEY=local-check uv run alembic upgrade head
|
||||||
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
|
Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen:
|
||||||
|
|
||||||
- `/api/inventory/items`
|
- `/api/inventory/items`
|
||||||
|
- `/api/inventory/items/search`
|
||||||
- `/api/inventory/items/[id]`
|
- `/api/inventory/items/[id]`
|
||||||
- `/api/inventory/items/[id]/stock/adjust`
|
- `/api/inventory/items/[id]/stock/adjust`
|
||||||
- `/api/inventory/items/[id]/stock/reserve`
|
- `/api/inventory/items/[id]/stock/reserve`
|
||||||
|
|
@ -251,10 +252,19 @@ Bestandslogik:
|
||||||
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
|
- Artikel mit Bewegungen werden deaktiviert statt hart geloescht.
|
||||||
- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`.
|
- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`.
|
||||||
|
|
||||||
|
KV-Integration ab v0.8.8:
|
||||||
|
|
||||||
|
- Lagerartikel koennen im KV-Dialog gesucht und als Position uebernommen werden.
|
||||||
|
- Hermes uebernimmt Verkaufspreis, Einheit, Artikelname, SKU und Hersteller serverseitig.
|
||||||
|
- `repair_estimate_items` speichert Snapshot-Felder, damit spaetere Lageraenderungen alte KVs nicht veraendern.
|
||||||
|
- Nur gesendete KVs reservieren Bestand.
|
||||||
|
- Ablehnung, Storno und Loeschung eines gesendeten KV geben Reservierungen frei.
|
||||||
|
- Freigegebene KVs behalten die Reservierung.
|
||||||
|
- Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, aber noch nicht aktiv.
|
||||||
|
|
||||||
Folgefeatures:
|
Folgefeatures:
|
||||||
|
|
||||||
- Integration in Kostenvoranschlaege folgt in v0.8.8.
|
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv.
|
||||||
- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv.
|
|
||||||
|
|
||||||
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
|
|
|
||||||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -162,12 +162,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
|
- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8
|
||||||
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
|
- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion
|
||||||
|
|
||||||
## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant
|
## v0.8.8 - Lagerintegration in Kostenvoranschläge
|
||||||
|
|
||||||
- Lagerartikel als KV-Position ueber Suche auswaehlen
|
- Lagerartikel als KV-Position ueber Suche auswaehlen
|
||||||
- Verkaufspreis aus `selling_price_cents` uebernehmen
|
- Verkaufspreis aus `selling_price_cents` uebernehmen
|
||||||
- Reservierungen aus KV-/Reparaturprozess vorbereiten
|
- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen
|
||||||
- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten
|
- Reservierung beim Senden eines KV
|
||||||
|
- Freigabe behaelt Reservierung
|
||||||
|
- Ablehnung, Storno und Loeschung geben Reservierungen frei
|
||||||
|
- Bestandsbewegungen mit `reference_type=repair_estimate`
|
||||||
|
- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand
|
||||||
|
- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung
|
||||||
|
- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv
|
||||||
|
- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures
|
||||||
|
|
||||||
## v0.8.9 - Kundenportal, geplant
|
## v0.8.9 - Kundenportal, geplant
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""extend repair estimate items inventory
|
||||||
|
|
||||||
|
Revision ID: f4a9c2d7e118
|
||||||
|
Revises: ef8a2d5c9017
|
||||||
|
Create Date: 2026-07-05 13:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "f4a9c2d7e118"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True))
|
||||||
|
op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True))
|
||||||
|
op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_repair_estimate_items_inventory_item_id",
|
||||||
|
"repair_estimate_items",
|
||||||
|
"inventory_items",
|
||||||
|
["inventory_item_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey")
|
||||||
|
op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_part_number")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_sku")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_snapshot_name")
|
||||||
|
op.drop_column("repair_estimate_items", "inventory_item_id")
|
||||||
|
|
@ -89,6 +89,9 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
||||||
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
|
MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)),
|
||||||
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
|
MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)),
|
||||||
|
MetricCard(label="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)),
|
||||||
|
MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)),
|
||||||
|
MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)),
|
||||||
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
|
MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,18 @@ def list_items(
|
||||||
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
|
return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/search", response_model=list[InventoryItemResponse])
|
||||||
|
def search_items(
|
||||||
|
q: str | None = None,
|
||||||
|
category: int | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("inventory.read")),
|
||||||
|
):
|
||||||
|
return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_item(
|
def create_item(
|
||||||
payload: InventoryItemCreate,
|
payload: InventoryItemCreate,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ class RepairEstimateItem(Base):
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
|
||||||
|
inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="")
|
||||||
|
inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
|
inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
position: Mapped[int] = mapped_column(Integer)
|
position: Mapped[int] = mapped_column(Integer)
|
||||||
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
|
@ -64,6 +69,7 @@ class RepairEstimateItem(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
||||||
|
inventory_item = relationship("InventoryItem", lazy="joined")
|
||||||
|
|
||||||
|
|
||||||
class RepairEstimateEvent(Base):
|
class RepairEstimateEvent(Base):
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,32 @@ class InventoryRepository:
|
||||||
)
|
)
|
||||||
return items, total
|
return items, total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_items(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
q: str | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
manufacturer: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[InventoryItem]:
|
||||||
|
query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True))
|
||||||
|
if q:
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
query = query.where(or_(
|
||||||
|
InventoryItem.sku.ilike(pattern),
|
||||||
|
InventoryItem.name.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer.ilike(pattern),
|
||||||
|
InventoryItem.manufacturer_part_number.ilike(pattern),
|
||||||
|
InventoryItem.supplier_part_number.ilike(pattern),
|
||||||
|
InventoryItem.compatible_devices.ilike(pattern),
|
||||||
|
))
|
||||||
|
if category_id is not None:
|
||||||
|
query = query.where(InventoryItem.category_id == category_id)
|
||||||
|
if manufacturer:
|
||||||
|
query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%"))
|
||||||
|
return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||||
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
|
||||||
|
|
@ -176,6 +202,22 @@ class InventoryRepository:
|
||||||
.where(InventoryItem.purchase_price_cents.is_not(None))
|
.where(InventoryItem.purchase_price_cents.is_not(None))
|
||||||
) or 0
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_reserved_items(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.count(InventoryItem.id))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.quantity_reserved > 0)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserved_stock_value_cents(db: Session) -> int:
|
||||||
|
return db.scalar(
|
||||||
|
select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0))
|
||||||
|
.where(InventoryItem.is_active.is_(True))
|
||||||
|
.where(InventoryItem.selling_price_cents.is_not(None))
|
||||||
|
) or 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||||
return list(
|
return list(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ def normalize_text(value: object) -> str:
|
||||||
|
|
||||||
class RepairEstimateItemPayload(BaseModel):
|
class RepairEstimateItemPayload(BaseModel):
|
||||||
item_type: EstimateItemType = "other"
|
item_type: EstimateItemType = "other"
|
||||||
title: str = Field(min_length=1, max_length=255)
|
inventory_item_id: int | None = None
|
||||||
|
inventory_price_overridden: bool = False
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
quantity: Decimal = Field(gt=Decimal("0"))
|
quantity: Decimal = Field(gt=Decimal("0"))
|
||||||
unit: str = Field(default="Stk.", max_length=40)
|
unit: str = Field(default="Stk.", max_length=40)
|
||||||
|
|
@ -38,6 +40,12 @@ class RepairEstimateItemPayload(BaseModel):
|
||||||
return value.strip().replace(",", ".")
|
return value.strip().replace(",", ".")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_inventory_or_title(self):
|
||||||
|
if self.inventory_item_id is None and not self.title.strip():
|
||||||
|
raise ValueError("Titel ist erforderlich")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class RepairEstimatePayload(BaseModel):
|
class RepairEstimatePayload(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|
@ -85,6 +93,11 @@ class RepairEstimateUpdate(RepairEstimatePayload):
|
||||||
class RepairEstimateItemResponse(BaseModel):
|
class RepairEstimateItemResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
estimate_id: int
|
estimate_id: int
|
||||||
|
inventory_item_id: int | None
|
||||||
|
inventory_snapshot_name: str
|
||||||
|
inventory_snapshot_sku: str
|
||||||
|
inventory_snapshot_manufacturer: str | None
|
||||||
|
inventory_snapshot_part_number: str | None
|
||||||
position: int
|
position: int
|
||||||
item_type: EstimateItemType
|
item_type: EstimateItemType
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -139,6 +152,10 @@ class RepairEstimateResponse(BaseModel):
|
||||||
|
|
||||||
class PublicEstimateItemResponse(BaseModel):
|
class PublicEstimateItemResponse(BaseModel):
|
||||||
item_type: EstimateItemType
|
item_type: EstimateItemType
|
||||||
|
inventory_snapshot_name: str
|
||||||
|
inventory_snapshot_sku: str
|
||||||
|
inventory_snapshot_manufacturer: str | None
|
||||||
|
inventory_snapshot_part_number: str | None
|
||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
quantity: Decimal
|
quantity: Decimal
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,11 @@ def action_title(action: str) -> str:
|
||||||
"inventory.stock.reserve": "Bestand reserviert",
|
"inventory.stock.reserve": "Bestand reserviert",
|
||||||
"inventory.stock.release": "Reservierung aufgehoben",
|
"inventory.stock.release": "Reservierung aufgehoben",
|
||||||
"inventory.stock.consume": "Bestand verbraucht",
|
"inventory.stock.consume": "Bestand verbraucht",
|
||||||
|
"inventory.estimate.reserve": "KV reserviert Lagerbestand",
|
||||||
|
"inventory.estimate.release": "KV-Reservierung aufgehoben",
|
||||||
|
"inventory.estimate.consume": "Reservierung verbraucht",
|
||||||
|
"inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben",
|
||||||
|
"inventory.stock.low": "Lagerbestand knapp",
|
||||||
"inventory.categories.create": "Lagerkategorie erstellt",
|
"inventory.categories.create": "Lagerkategorie erstellt",
|
||||||
"inventory.categories.update": "Lagerkategorie geändert",
|
"inventory.categories.update": "Lagerkategorie geändert",
|
||||||
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from decimal import Decimal
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
@ -8,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||||
|
from app.models.repair_estimate import RepairEstimate
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.inventory_repository import InventoryRepository
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
from app.schemas.inventory import (
|
from app.schemas.inventory import (
|
||||||
|
|
@ -194,6 +196,83 @@ class InventoryService:
|
||||||
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id)
|
||||||
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
|
return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None or not item.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv")
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
if quantity > item.quantity_available:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}",
|
||||||
|
)
|
||||||
|
item.quantity_reserved += quantity
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="reservation",
|
||||||
|
quantity=quantity,
|
||||||
|
reason="estimate_reserved",
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
item.quantity_reserved = max(0, item.quantity_reserved - quantity)
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="release",
|
||||||
|
quantity=quantity,
|
||||||
|
reason="estimate_released",
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None:
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None:
|
||||||
|
continue
|
||||||
|
item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position)
|
||||||
|
if quantity > item.quantity_reserved or quantity > item.quantity_on_hand:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus")
|
||||||
|
item.quantity_reserved -= quantity
|
||||||
|
item.quantity_on_hand -= quantity
|
||||||
|
InventoryService._recalculate_available(item)
|
||||||
|
InventoryRepository.create_movement(
|
||||||
|
db,
|
||||||
|
item_id=item.id,
|
||||||
|
movement_type="consumption",
|
||||||
|
quantity=quantity,
|
||||||
|
reason="estimate_consumed",
|
||||||
|
reference_type="repair_estimate",
|
||||||
|
reference_id=estimate.id,
|
||||||
|
note=f"Kostenvoranschlag {estimate.estimate_number}",
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||||
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||||
|
|
@ -260,6 +339,18 @@ class InventoryService:
|
||||||
if payload.quantity <= 0:
|
if payload.quantity <= 0:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int:
|
||||||
|
if quantity != quantity.to_integral_value():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Lagerposition {position} muss eine ganze Menge verwenden",
|
||||||
|
)
|
||||||
|
normalized = int(quantity)
|
||||||
|
if normalized <= 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0")
|
||||||
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||||
InventoryService._recalculate_available(item)
|
InventoryService._recalculate_available(item)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from starlette.requests import Request
|
||||||
from app.models.repair import Repair
|
from app.models.repair import Repair
|
||||||
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.repositories.inventory_repository import InventoryRepository
|
||||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
from app.schemas.repair import RepairStatusUpdate
|
from app.schemas.repair import RepairStatusUpdate
|
||||||
|
|
@ -24,6 +25,7 @@ from app.schemas.repair_estimate import (
|
||||||
calculate_tax,
|
calculate_tax,
|
||||||
)
|
)
|
||||||
from app.services.audit_service import write_audit_log
|
from app.services.audit_service import write_audit_log
|
||||||
|
from app.services.inventory_service import InventoryService
|
||||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||||
from app.services.system_settings_service import SystemSettingsService
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
||||||
"approved_at": estimate.approved_at,
|
"approved_at": estimate.approved_at,
|
||||||
"declined_at": estimate.declined_at,
|
"declined_at": estimate.declined_at,
|
||||||
"created_by_user_id": estimate.created_by_user_id,
|
"created_by_user_id": estimate.created_by_user_id,
|
||||||
|
"inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +69,8 @@ class RepairEstimateService:
|
||||||
status="draft",
|
status="draft",
|
||||||
created_by_user_id=actor.id,
|
created_by_user_id=actor.id,
|
||||||
)
|
)
|
||||||
RepairEstimateService._apply_payload(estimate, payload)
|
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||||
|
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||||
try:
|
try:
|
||||||
db.add(estimate)
|
db.add(estimate)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
@ -92,6 +96,7 @@ class RepairEstimateService:
|
||||||
after_data=_audit_estimate_data(estimate),
|
after_data=_audit_estimate_data(estimate),
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
)
|
)
|
||||||
|
RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request)
|
||||||
return estimate
|
return estimate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -99,7 +104,24 @@ class RepairEstimateService:
|
||||||
if estimate.status not in {"draft", "sent"}:
|
if estimate.status not in {"draft", "sent"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
||||||
before_data = _audit_estimate_data(estimate)
|
before_data = _audit_estimate_data(estimate)
|
||||||
RepairEstimateService._apply_payload(estimate, payload)
|
price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items)
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
|
RepairEstimateService._apply_payload(db, estimate, payload)
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.reserve",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(estimate)
|
db.refresh(estimate)
|
||||||
|
|
@ -116,14 +138,27 @@ class RepairEstimateService:
|
||||||
after_data=_audit_estimate_data(updated),
|
after_data=_audit_estimate_data(updated),
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
)
|
)
|
||||||
|
RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||||
if estimate.status not in {"draft", "cancelled"}:
|
if estimate.status not in {"draft", "sent", "cancelled"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden")
|
||||||
before_data = _audit_estimate_data(estimate)
|
before_data = _audit_estimate_data(estimate)
|
||||||
label = _estimate_label(estimate)
|
label = _estimate_label(estimate)
|
||||||
|
if estimate.status == "sent":
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=label,
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
RepairEstimateRepository.delete(db, estimate)
|
RepairEstimateRepository.delete(db, estimate)
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
|
|
@ -145,6 +180,10 @@ class RepairEstimateService:
|
||||||
if not repair.customer_email:
|
if not repair.customer_email:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
|
||||||
|
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if not was_sent:
|
||||||
|
InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id)
|
||||||
|
|
||||||
link = RepairPublicLinkService.create_with_audit(
|
link = RepairPublicLinkService.create_with_audit(
|
||||||
db,
|
db,
|
||||||
repair,
|
repair,
|
||||||
|
|
@ -155,6 +194,18 @@ class RepairEstimateService:
|
||||||
public_status_url = link.public_status_path
|
public_status_url = link.public_status_path
|
||||||
estimate = RepairEstimateRepository.mark_sent(db, estimate)
|
estimate = RepairEstimateRepository.mark_sent(db, estimate)
|
||||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
|
||||||
|
if not was_sent:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.reserve",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
|
RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request)
|
||||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
mail_sent = False
|
mail_sent = False
|
||||||
if smtp_config.is_configured:
|
if smtp_config.is_configured:
|
||||||
|
|
@ -200,6 +251,9 @@ class RepairEstimateService:
|
||||||
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||||
if estimate.status in {"approved", "declined", "cancelled"}:
|
if estimate.status in {"approved", "declined", "cancelled"}:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
|
||||||
|
was_sent = estimate.status == "sent"
|
||||||
|
if was_sent:
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id)
|
||||||
estimate.status = "cancelled"
|
estimate.status = "cancelled"
|
||||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -214,6 +268,17 @@ class RepairEstimateService:
|
||||||
request=request,
|
request=request,
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
)
|
)
|
||||||
|
if was_sent:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number},
|
||||||
|
)
|
||||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -233,6 +298,10 @@ class RepairEstimateService:
|
||||||
items=[
|
items=[
|
||||||
PublicEstimateItemResponse(
|
PublicEstimateItemResponse(
|
||||||
item_type=item.item_type,
|
item_type=item.item_type,
|
||||||
|
inventory_snapshot_name=item.inventory_snapshot_name,
|
||||||
|
inventory_snapshot_sku=item.inventory_snapshot_sku,
|
||||||
|
inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer,
|
||||||
|
inventory_snapshot_part_number=item.inventory_snapshot_part_number,
|
||||||
title=item.title,
|
title=item.title,
|
||||||
description=item.description,
|
description=item.description,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
|
|
@ -272,6 +341,7 @@ class RepairEstimateService:
|
||||||
repair_status = "waiting_for_customer"
|
repair_status = "waiting_for_customer"
|
||||||
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
||||||
audit_action = "repair_estimates.decline"
|
audit_action = "repair_estimates.decline"
|
||||||
|
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
|
||||||
else:
|
else:
|
||||||
event_type = "question"
|
event_type = "question"
|
||||||
repair_status = "waiting_for_customer"
|
repair_status = "waiting_for_customer"
|
||||||
|
|
@ -291,17 +361,27 @@ class RepairEstimateService:
|
||||||
request=request,
|
request=request,
|
||||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||||
)
|
)
|
||||||
|
if decision == "decline":
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.release",
|
||||||
|
entity_type="repair_estimates",
|
||||||
|
entity_id=estimate.id,
|
||||||
|
entity_label=_estimate_label(estimate),
|
||||||
|
request=request,
|
||||||
|
metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
|
||||||
|
)
|
||||||
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
|
||||||
estimate.title = payload.title
|
estimate.title = payload.title
|
||||||
estimate.customer_message = payload.customer_message
|
estimate.customer_message = payload.customer_message
|
||||||
estimate.internal_note = payload.internal_note
|
estimate.internal_note = payload.internal_note
|
||||||
estimate.tax_rate_percent = payload.tax_rate_percent
|
estimate.tax_rate_percent = payload.tax_rate_percent
|
||||||
estimate.currency = payload.currency
|
estimate.currency = payload.currency
|
||||||
estimate.valid_until = payload.valid_until
|
estimate.valid_until = payload.valid_until
|
||||||
items, subtotal = RepairEstimateService._build_items(payload.items)
|
items, subtotal = RepairEstimateService._build_items(db, payload.items)
|
||||||
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
|
||||||
estimate.subtotal_cents = subtotal
|
estimate.subtotal_cents = subtotal
|
||||||
estimate.tax_cents = tax_cents
|
estimate.tax_cents = tax_cents
|
||||||
|
|
@ -309,26 +389,121 @@ class RepairEstimateService:
|
||||||
estimate.items = items
|
estimate.items = items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]:
|
def _build_items(
|
||||||
|
db: Session,
|
||||||
|
payload_items: list[RepairEstimateItemPayload],
|
||||||
|
) -> tuple[list[RepairEstimateItem], int]:
|
||||||
items: list[RepairEstimateItem] = []
|
items: list[RepairEstimateItem] = []
|
||||||
subtotal = 0
|
subtotal = 0
|
||||||
for index, payload in enumerate(payload_items, start=1):
|
for index, payload in enumerate(payload_items, start=1):
|
||||||
total = calculate_item_total(payload.quantity, payload.unit_price_cents)
|
item_type = payload.item_type
|
||||||
|
title = payload.title
|
||||||
|
description = payload.description
|
||||||
|
unit = payload.unit
|
||||||
|
unit_price_cents = payload.unit_price_cents
|
||||||
|
inventory_item_id = payload.inventory_item_id
|
||||||
|
inventory_snapshot_name = ""
|
||||||
|
inventory_snapshot_sku = ""
|
||||||
|
inventory_snapshot_manufacturer = None
|
||||||
|
inventory_snapshot_part_number = None
|
||||||
|
if inventory_item_id is not None:
|
||||||
|
inventory_item = InventoryRepository.get_item(db, inventory_item_id)
|
||||||
|
if inventory_item is None or not inventory_item.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden")
|
||||||
|
item_type = "part"
|
||||||
|
title = inventory_item.name
|
||||||
|
unit = inventory_item.unit
|
||||||
|
inventory_snapshot_name = inventory_item.name
|
||||||
|
inventory_snapshot_sku = inventory_item.sku
|
||||||
|
inventory_snapshot_manufacturer = inventory_item.manufacturer
|
||||||
|
inventory_snapshot_part_number = inventory_item.manufacturer_part_number
|
||||||
|
if not payload.inventory_price_overridden:
|
||||||
|
unit_price_cents = inventory_item.selling_price_cents or 0
|
||||||
|
total = calculate_item_total(payload.quantity, unit_price_cents)
|
||||||
subtotal += total
|
subtotal += total
|
||||||
items.append(
|
items.append(
|
||||||
RepairEstimateItem(
|
RepairEstimateItem(
|
||||||
|
inventory_item_id=inventory_item_id,
|
||||||
|
inventory_snapshot_name=inventory_snapshot_name,
|
||||||
|
inventory_snapshot_sku=inventory_snapshot_sku,
|
||||||
|
inventory_snapshot_manufacturer=inventory_snapshot_manufacturer,
|
||||||
|
inventory_snapshot_part_number=inventory_snapshot_part_number,
|
||||||
position=index,
|
position=index,
|
||||||
item_type=payload.item_type,
|
item_type=item_type,
|
||||||
title=payload.title,
|
title=title,
|
||||||
description=payload.description,
|
description=description,
|
||||||
quantity=payload.quantity,
|
quantity=payload.quantity,
|
||||||
unit=payload.unit,
|
unit=unit,
|
||||||
unit_price_cents=payload.unit_price_cents,
|
unit_price_cents=unit_price_cents,
|
||||||
total_cents=total,
|
total_cents=total,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items, subtotal
|
return items, subtotal
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]:
|
||||||
|
overrides: list[dict] = []
|
||||||
|
for payload in payload_items:
|
||||||
|
if payload.inventory_item_id is None or not payload.inventory_price_overridden:
|
||||||
|
continue
|
||||||
|
inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id)
|
||||||
|
if inventory_item is None:
|
||||||
|
continue
|
||||||
|
default_price = inventory_item.selling_price_cents or 0
|
||||||
|
if payload.unit_price_cents != default_price:
|
||||||
|
overrides.append({
|
||||||
|
"inventory_item_id": inventory_item.id,
|
||||||
|
"label": f"{inventory_item.sku} · {inventory_item.name}",
|
||||||
|
"default_price_cents": default_price,
|
||||||
|
"override_price_cents": payload.unit_price_cents,
|
||||||
|
})
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None:
|
||||||
|
for override in overrides:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.estimate.price_override",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=override["inventory_item_id"],
|
||||||
|
entity_label=override["label"],
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"estimate_id": estimate.id,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"default_price_cents": override["default_price_cents"],
|
||||||
|
"override_price_cents": override["override_price_cents"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||||
|
seen_item_ids: set[int] = set()
|
||||||
|
for estimate_item in estimate.items:
|
||||||
|
if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids:
|
||||||
|
continue
|
||||||
|
seen_item_ids.add(estimate_item.inventory_item_id)
|
||||||
|
inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id)
|
||||||
|
if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level:
|
||||||
|
continue
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="inventory.stock.low",
|
||||||
|
entity_type="inventory_items",
|
||||||
|
entity_id=inventory_item.id,
|
||||||
|
entity_label=f"{inventory_item.sku} · {inventory_item.name}",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"estimate_id": estimate.id,
|
||||||
|
"estimate_number": estimate.estimate_number,
|
||||||
|
"quantity_available": inventory_item.quantity_available,
|
||||||
|
"reorder_level": inventory_item.reorder_level,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
7
frontend/athena/app/api/inventory/items/search/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react";
|
import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
|
@ -17,6 +17,10 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
InventoryCategory,
|
||||||
|
InventoryItem,
|
||||||
|
} from "@/types/inventory";
|
||||||
import type {
|
import type {
|
||||||
RepairEstimate,
|
RepairEstimate,
|
||||||
RepairEstimateItemType,
|
RepairEstimateItemType,
|
||||||
|
|
@ -89,6 +93,12 @@ function dateTime(value: string | null) {
|
||||||
|
|
||||||
type EstimateFormItem = {
|
type EstimateFormItem = {
|
||||||
item_type: RepairEstimateItemType;
|
item_type: RepairEstimateItemType;
|
||||||
|
inventory_item_id: number | null;
|
||||||
|
inventory_snapshot_name: string;
|
||||||
|
inventory_snapshot_sku: string;
|
||||||
|
inventory_snapshot_manufacturer: string | null;
|
||||||
|
inventory_snapshot_part_number: string | null;
|
||||||
|
inventory_price_overridden: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
|
|
@ -118,6 +128,12 @@ function centsToEuroInput(cents: number): string {
|
||||||
function emptyItem(): EstimateFormItem {
|
function emptyItem(): EstimateFormItem {
|
||||||
return {
|
return {
|
||||||
item_type: "labor",
|
item_type: "labor",
|
||||||
|
inventory_item_id: null,
|
||||||
|
inventory_snapshot_name: "",
|
||||||
|
inventory_snapshot_sku: "",
|
||||||
|
inventory_snapshot_manufacturer: null,
|
||||||
|
inventory_snapshot_part_number: null,
|
||||||
|
inventory_price_overridden: false,
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
quantity: "1.00",
|
quantity: "1.00",
|
||||||
|
|
@ -167,6 +183,14 @@ export default function RepairEstimatesSection({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RepairEstimate | null>(null);
|
||||||
|
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||||
|
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||||
|
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||||
|
const [inventorySearch, setInventorySearch] = useState("");
|
||||||
|
const [inventoryCategory, setInventoryCategory] = useState("all");
|
||||||
|
const [inventoryManufacturer, setInventoryManufacturer] = useState("");
|
||||||
|
const [inventoryLoading, setInventoryLoading] = useState(false);
|
||||||
|
const [inventoryError, setInventoryError] = useState("");
|
||||||
|
|
||||||
const loadEstimates = useCallback(async () => {
|
const loadEstimates = useCallback(async () => {
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
|
|
@ -191,6 +215,38 @@ export default function RepairEstimatesSection({
|
||||||
});
|
});
|
||||||
}, [loadEstimates]);
|
}, [loadEstimates]);
|
||||||
|
|
||||||
|
const searchInventoryItems = useCallback(async () => {
|
||||||
|
setInventoryLoading(true);
|
||||||
|
setInventoryError("");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("limit", "20");
|
||||||
|
if (inventorySearch.trim()) params.set("q", inventorySearch.trim());
|
||||||
|
if (inventoryCategory !== "all") params.set("category", inventoryCategory);
|
||||||
|
if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim());
|
||||||
|
const [itemsResponse, categoriesResponse] = await Promise.all([
|
||||||
|
api.get<InventoryItem[]>(`/inventory/items/search?${params.toString()}`),
|
||||||
|
api.get<InventoryCategory[]>("/inventory/categories"),
|
||||||
|
]);
|
||||||
|
setInventoryItems(itemsResponse.data);
|
||||||
|
setInventoryCategories(categoriesResponse.data);
|
||||||
|
} catch (err) {
|
||||||
|
setInventoryError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setInventoryLoading(false);
|
||||||
|
}
|
||||||
|
}, [inventoryCategory, inventoryManufacturer, inventorySearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inventoryDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
void searchInventoryItems();
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [inventoryDialogOpen, searchInventoryItems]);
|
||||||
|
|
||||||
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => {
|
||||||
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
const quantity = Number(item.quantity.replace(",", ".")) || 0;
|
||||||
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0;
|
||||||
|
|
@ -215,6 +271,12 @@ export default function RepairEstimatesSection({
|
||||||
valid_until: estimate.valid_until,
|
valid_until: estimate.valid_until,
|
||||||
items: estimate.items.map((item) => ({
|
items: estimate.items.map((item) => ({
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
|
inventory_item_id: item.inventory_item_id ?? null,
|
||||||
|
inventory_snapshot_name: item.inventory_snapshot_name,
|
||||||
|
inventory_snapshot_sku: item.inventory_snapshot_sku,
|
||||||
|
inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer,
|
||||||
|
inventory_snapshot_part_number: item.inventory_snapshot_part_number,
|
||||||
|
inventory_price_overridden: false,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description ?? "",
|
description: item.description ?? "",
|
||||||
quantity: String(item.quantity),
|
quantity: String(item.quantity),
|
||||||
|
|
@ -232,6 +294,25 @@ export default function RepairEstimatesSection({
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addInventoryItem(item: InventoryItem) {
|
||||||
|
const formItem: EstimateFormItem = {
|
||||||
|
item_type: "part",
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
inventory_snapshot_name: item.name,
|
||||||
|
inventory_snapshot_sku: item.sku,
|
||||||
|
inventory_snapshot_manufacturer: item.manufacturer,
|
||||||
|
inventory_snapshot_part_number: item.manufacturer_part_number,
|
||||||
|
inventory_price_overridden: false,
|
||||||
|
title: item.name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
quantity: "1",
|
||||||
|
unit: item.unit,
|
||||||
|
unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0),
|
||||||
|
};
|
||||||
|
setPayload((current) => ({ ...current, items: [...current.items, formItem] }));
|
||||||
|
setInventoryDialogOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEstimate() {
|
async function saveEstimate() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -254,6 +335,8 @@ export default function RepairEstimatesSection({
|
||||||
valid_until: payload.valid_until || null,
|
valid_until: payload.valid_until || null,
|
||||||
items: payload.items.map((item) => ({
|
items: payload.items.map((item) => ({
|
||||||
item_type: item.item_type,
|
item_type: item.item_type,
|
||||||
|
inventory_item_id: item.inventory_item_id,
|
||||||
|
inventory_price_overridden: item.inventory_price_overridden,
|
||||||
title: item.title.trim(),
|
title: item.title.trim(),
|
||||||
description: item.description?.trim() || null,
|
description: item.description?.trim() || null,
|
||||||
quantity: item.quantity.replace(",", "."),
|
quantity: item.quantity.replace(",", "."),
|
||||||
|
|
@ -371,7 +454,15 @@ export default function RepairEstimatesSection({
|
||||||
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
<p className="font-medium text-slate-950">{item.position}. {item.title}</p>
|
||||||
<p className="text-xs text-slate-500">{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}</p>
|
<p className="text-xs text-slate-500">
|
||||||
|
{item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""}
|
||||||
|
{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}
|
||||||
|
</p>
|
||||||
|
{item.inventory_item_id && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
<p className="font-medium text-slate-950">{money(item.total_cents, estimate.currency)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -409,18 +500,32 @@ export default function RepairEstimatesSection({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
<h4 className="font-semibold text-slate-950">Positionen</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setInventoryDialogOpen(true)}><PackageSearch />Lagerartikel auswählen</Button>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
<Button type="button" variant="outline" size="sm" onClick={() => setPayload((current) => ({ ...current, items: [...current.items, emptyItem()] }))}><Plus />Position</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{payload.items.map((item, index) => (
|
{payload.items.map((item, index) => (
|
||||||
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
<div key={index} className="rounded-lg border bg-slate-50 p-3">
|
||||||
|
{item.inventory_item_id && (
|
||||||
|
<div className="mb-3 flex flex-col gap-2 rounded-lg border bg-white p-3 text-sm text-slate-600 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-slate-950">Lagerartikel</span> · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||||
|
<input type="checkbox" checked={item.inventory_price_overridden} onChange={(event) => updateItem(index, { inventory_price_overridden: event.target.checked })} />
|
||||||
|
Preis manuell überschreiben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
<div className="grid gap-3 md:grid-cols-[1fr_1fr_0.7fr_0.7fr_0.8fr_auto]">
|
||||||
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={item.item_type} disabled={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}>
|
||||||
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
{Object.entries(itemTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<Input placeholder="Titel" value={item.title} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
<Input placeholder="Titel" value={item.title} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { title: event.target.value })} />
|
||||||
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
<Input placeholder="Menge" value={item.quantity} onChange={(event) => updateItem(index, { quantity: event.target.value })} />
|
||||||
<Input placeholder="Einheit" value={item.unit} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
<Input placeholder="Einheit" value={item.unit} readOnly={Boolean(item.inventory_item_id)} onChange={(event) => updateItem(index, { unit: event.target.value })} />
|
||||||
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
<Input inputMode="decimal" placeholder="Einzelpreis €" value={item.unit_price_euros} readOnly={Boolean(item.inventory_item_id && !item.inventory_price_overridden)} onChange={(event) => updateItem(index, { unit_price_euros: event.target.value })} />
|
||||||
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
<Button type="button" variant="ghost" size="icon" disabled={payload.items.length === 1} onClick={() => setPayload((current) => ({ ...current, items: current.items.filter((_, itemIndex) => itemIndex !== index) }))} aria-label="Position entfernen"><Trash2 /></Button>
|
||||||
</div>
|
</div>
|
||||||
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
<Input className="mt-3" placeholder="Beschreibung optional" value={item.description ?? ""} onChange={(event) => updateItem(index, { description: event.target.value })} />
|
||||||
|
|
@ -442,6 +547,65 @@ export default function RepairEstimatesSection({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={inventoryDialogOpen} onOpenChange={setInventoryDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Lagerartikel auswählen</DialogTitle>
|
||||||
|
<DialogDescription>Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1fr_220px_180px]">
|
||||||
|
<Input placeholder="SKU, Name, Hersteller oder Gerät suchen" value={inventorySearch} onChange={(event) => setInventorySearch(event.target.value)} />
|
||||||
|
<select className="h-8 rounded-lg border border-input bg-white px-2 text-sm" value={inventoryCategory} onChange={(event) => setInventoryCategory(event.target.value)}>
|
||||||
|
<option value="all">Alle Kategorien</option>
|
||||||
|
{inventoryCategories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input placeholder="Hersteller" value={inventoryManufacturer} onChange={(event) => setInventoryManufacturer(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inventoryError ? (
|
||||||
|
<p className="rounded-lg border bg-red-50 p-3 text-sm text-red-700">{inventoryError}</p>
|
||||||
|
) : inventoryLoading ? (
|
||||||
|
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Lagerartikel werden geladen...</p>
|
||||||
|
) : inventoryItems.length === 0 ? (
|
||||||
|
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">Keine aktiven Lagerartikel gefunden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[48vh] divide-y overflow-y-auto rounded-lg border">
|
||||||
|
{inventoryItems.map((item) => {
|
||||||
|
const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className="grid w-full gap-2 p-3 text-left hover:bg-slate-50 md:grid-cols-[1fr_auto]"
|
||||||
|
onClick={() => addInventoryItem(item)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-950">{item.sku} · {item.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{item.manufacturer || "Hersteller nicht angegeben"} · Verfügbar {item.quantity_available} {item.unit} · {item.location?.name ?? "Kein Lagerort"}
|
||||||
|
</p>
|
||||||
|
{lowAfterSelection && (
|
||||||
|
<p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-amber-700">
|
||||||
|
<AlertTriangle size={14} /> Auswahl erreicht oder unterschreitet den Mindestbestand.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-slate-950">{money(item.selling_price_cents ?? 0, item.currency)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setInventoryDialogOpen(false)}>Schließen</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
title="Kostenvoranschlag löschen?"
|
title="Kostenvoranschlag löschen?"
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ export interface RepairDocument {
|
||||||
|
|
||||||
export interface RepairEstimateItemPayload {
|
export interface RepairEstimateItemPayload {
|
||||||
item_type: RepairEstimateItemType;
|
item_type: RepairEstimateItemType;
|
||||||
|
inventory_item_id?: number | null;
|
||||||
|
inventory_price_overridden?: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
|
|
@ -186,6 +188,10 @@ export interface RepairEstimatePayload {
|
||||||
export interface RepairEstimateItem extends RepairEstimateItemPayload {
|
export interface RepairEstimateItem extends RepairEstimateItemPayload {
|
||||||
id: number;
|
id: number;
|
||||||
estimate_id: number;
|
estimate_id: number;
|
||||||
|
inventory_snapshot_name: string;
|
||||||
|
inventory_snapshot_sku: string;
|
||||||
|
inventory_snapshot_manufacturer: string | null;
|
||||||
|
inventory_snapshot_part_number: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
total_cents: number;
|
total_cents: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue