feat(estimates): integrate inventory items
This commit is contained in:
parent
fb5c2cc26a
commit
663bdc41b1
15 changed files with 629 additions and 28 deletions
|
|
@ -89,6 +89,9 @@ def get_dashboard_summary(
|
|||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_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="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))),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ def list_items(
|
|||
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)
|
||||
def create_item(
|
||||
payload: InventoryItemCreate,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ class RepairEstimateItem(Base):
|
|||
|
||||
id: Mapped[int] = mapped_column(primary_key=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)
|
||||
item_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
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())
|
||||
|
||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
|
||||
inventory_item = relationship("InventoryItem", lazy="joined")
|
||||
|
||||
|
||||
class RepairEstimateEvent(Base):
|
||||
|
|
|
|||
|
|
@ -69,6 +69,32 @@ class InventoryRepository:
|
|||
)
|
||||
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
|
||||
def get_item(db: Session, item_id: int) -> InventoryItem | None:
|
||||
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))
|
||||
) 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
|
||||
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
|
||||
return list(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ def normalize_text(value: object) -> str:
|
|||
|
||||
class RepairEstimateItemPayload(BaseModel):
|
||||
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
|
||||
quantity: Decimal = Field(gt=Decimal("0"))
|
||||
unit: str = Field(default="Stk.", max_length=40)
|
||||
|
|
@ -38,6 +40,12 @@ class RepairEstimateItemPayload(BaseModel):
|
|||
return value.strip().replace(",", ".")
|
||||
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):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
|
|
@ -85,6 +93,11 @@ class RepairEstimateUpdate(RepairEstimatePayload):
|
|||
class RepairEstimateItemResponse(BaseModel):
|
||||
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
|
||||
item_type: EstimateItemType
|
||||
title: str
|
||||
|
|
@ -139,6 +152,10 @@ class RepairEstimateResponse(BaseModel):
|
|||
|
||||
class PublicEstimateItemResponse(BaseModel):
|
||||
item_type: EstimateItemType
|
||||
inventory_snapshot_name: str
|
||||
inventory_snapshot_sku: str
|
||||
inventory_snapshot_manufacturer: str | None
|
||||
inventory_snapshot_part_number: str | None
|
||||
title: str
|
||||
description: str | None
|
||||
quantity: Decimal
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ def action_title(action: str) -> str:
|
|||
"inventory.stock.reserve": "Bestand reserviert",
|
||||
"inventory.stock.release": "Reservierung aufgehoben",
|
||||
"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.update": "Lagerkategorie geändert",
|
||||
"inventory.categories.delete": "Lagerkategorie gelöscht",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
|
@ -8,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||
from starlette.requests import Request
|
||||
|
||||
from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier
|
||||
from app.models.repair_estimate import RepairEstimate
|
||||
from app.models.user import User
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
from app.schemas.inventory import (
|
||||
|
|
@ -194,6 +196,83 @@ class InventoryService:
|
|||
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)
|
||||
|
||||
@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
|
||||
def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory:
|
||||
category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description)
|
||||
|
|
@ -260,6 +339,18 @@ class InventoryService:
|
|||
if payload.quantity <= 0:
|
||||
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
|
||||
def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None:
|
||||
InventoryService._recalculate_available(item)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from starlette.requests import Request
|
|||
from app.models.repair import Repair
|
||||
from app.models.repair_estimate import RepairEstimate, RepairEstimateItem
|
||||
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_repository import RepairRepository
|
||||
from app.schemas.repair import RepairStatusUpdate
|
||||
|
|
@ -24,6 +25,7 @@ from app.schemas.repair_estimate import (
|
|||
calculate_tax,
|
||||
)
|
||||
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.system_settings_service import SystemSettingsService
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict:
|
|||
"approved_at": estimate.approved_at,
|
||||
"declined_at": estimate.declined_at,
|
||||
"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",
|
||||
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:
|
||||
db.add(estimate)
|
||||
db.flush()
|
||||
|
|
@ -92,6 +96,7 @@ class RepairEstimateService:
|
|||
after_data=_audit_estimate_data(estimate),
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -99,7 +104,24 @@ class RepairEstimateService:
|
|||
if estimate.status not in {"draft", "sent"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
|
||||
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)
|
||||
db.commit()
|
||||
db.refresh(estimate)
|
||||
|
|
@ -116,14 +138,27 @@ class RepairEstimateService:
|
|||
after_data=_audit_estimate_data(updated),
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
|
||||
if estimate.status not in {"draft", "cancelled"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden")
|
||||
if estimate.status not in {"draft", "sent", "cancelled"}:
|
||||
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)
|
||||
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)
|
||||
write_audit_log(
|
||||
db,
|
||||
|
|
@ -145,6 +180,10 @@ class RepairEstimateService:
|
|||
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")
|
||||
|
||||
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(
|
||||
db,
|
||||
repair,
|
||||
|
|
@ -155,6 +194,18 @@ class RepairEstimateService:
|
|||
public_status_url = link.public_status_path
|
||||
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)
|
||||
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)
|
||||
mail_sent = False
|
||||
if smtp_config.is_configured:
|
||||
|
|
@ -200,6 +251,9 @@ class RepairEstimateService:
|
|||
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
|
||||
if estimate.status in {"approved", "declined", "cancelled"}:
|
||||
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"
|
||||
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
|
||||
db.commit()
|
||||
|
|
@ -214,6 +268,17 @@ class RepairEstimateService:
|
|||
request=request,
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -233,6 +298,10 @@ class RepairEstimateService:
|
|||
items=[
|
||||
PublicEstimateItemResponse(
|
||||
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,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
|
|
@ -272,6 +341,7 @@ class RepairEstimateService:
|
|||
repair_status = "waiting_for_customer"
|
||||
note = "Kunde hat den Kostenvoranschlag abgelehnt"
|
||||
audit_action = "repair_estimates.decline"
|
||||
InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None)
|
||||
else:
|
||||
event_type = "question"
|
||||
repair_status = "waiting_for_customer"
|
||||
|
|
@ -291,17 +361,27 @@ class RepairEstimateService:
|
|||
request=request,
|
||||
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
|
||||
|
||||
@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.customer_message = payload.customer_message
|
||||
estimate.internal_note = payload.internal_note
|
||||
estimate.tax_rate_percent = payload.tax_rate_percent
|
||||
estimate.currency = payload.currency
|
||||
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)
|
||||
estimate.subtotal_cents = subtotal
|
||||
estimate.tax_cents = tax_cents
|
||||
|
|
@ -309,26 +389,121 @@ class RepairEstimateService:
|
|||
estimate.items = items
|
||||
|
||||
@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] = []
|
||||
subtotal = 0
|
||||
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
|
||||
items.append(
|
||||
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,
|
||||
item_type=payload.item_type,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
item_type=item_type,
|
||||
title=title,
|
||||
description=description,
|
||||
quantity=payload.quantity,
|
||||
unit=payload.unit,
|
||||
unit_price_cents=payload.unit_price_cents,
|
||||
unit=unit,
|
||||
unit_price_cents=unit_price_cents,
|
||||
total_cents=total,
|
||||
)
|
||||
)
|
||||
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
|
||||
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue