feat(navigation): add prominent validation quickstart action
This commit is contained in:
parent
0bbcaba211
commit
9c6b184e39
28614 changed files with 4356173 additions and 23 deletions
Binary file not shown.
Binary file not shown.
|
|
@ -53,12 +53,16 @@ from app.schemas.domain import (
|
|||
ValidationRead,
|
||||
ValidationReview,
|
||||
ValidationUpdate,
|
||||
QuickStartCreateRequest,
|
||||
QuickStartCustomerData,
|
||||
QuickStartValidationSummary,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserPasswordResetRequest,
|
||||
)
|
||||
from app.services.domain_service import CrudService, DomainServices
|
||||
from app.services.quickstart_service import QuickStartService
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
||||
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
||||
|
|
@ -204,6 +208,14 @@ def delete_customer(item_id: str, session: Session = Depends(get_session)):
|
|||
return commit_delete(session, DomainServices(session).customers, item_id)
|
||||
|
||||
|
||||
@router.get("/customers/{item_id}/quickstart-data", response_model=QuickStartCustomerData)
|
||||
def customer_quickstart_data(item_id: str, session: Session = Depends(get_session)):
|
||||
try:
|
||||
return QuickStartService(session).customer_data(item_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/locations", response_model=PaginatedResponse[LocationRead])
|
||||
def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)):
|
||||
return DomainServices(session).locations.list(**params)
|
||||
|
|
@ -338,11 +350,35 @@ def get_validation(item_id: str, session: Session = Depends(get_session)):
|
|||
return item
|
||||
|
||||
|
||||
@router.get("/devices/{item_id}/last-validation", response_model=QuickStartValidationSummary | None)
|
||||
def last_device_validation(item_id: str, session: Session = Depends(get_session)):
|
||||
try:
|
||||
return QuickStartService(session).device_last_validation(item_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/validations", response_model=ValidationRead, status_code=201)
|
||||
def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)):
|
||||
return commit_create(session, DomainServices(session).validations, payload)
|
||||
|
||||
|
||||
@router.post("/validations/quick-start", response_model=ValidationRead, status_code=201)
|
||||
def create_quick_start_validation(
|
||||
payload: QuickStartCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
me: User = Depends(current_user),
|
||||
):
|
||||
try:
|
||||
validation = QuickStartService(session).create_validation(payload, me)
|
||||
session.commit()
|
||||
session.refresh(validation)
|
||||
return validation
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/validations/{item_id}", response_model=ValidationRead)
|
||||
def update_validation(
|
||||
item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
|||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.services.demo_data import DemoDataService
|
||||
from app.services.reference_masterdata import ReferenceMasterdataImportService
|
||||
|
||||
|
||||
|
|
@ -83,6 +84,20 @@ def cmd_create_admin(_: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_demo_data(args: argparse.Namespace) -> int:
|
||||
with SessionLocal() as session:
|
||||
summary = DemoDataService(session).run(
|
||||
customers=args.customers,
|
||||
devices=args.devices,
|
||||
validations=args.validations,
|
||||
reports=args.reports,
|
||||
images=args.images,
|
||||
reset=args.reset,
|
||||
)
|
||||
print(json.dumps(summary, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.cli")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
|
@ -101,6 +116,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
|
||||
admin_parser = subparsers.add_parser("create-admin")
|
||||
admin_parser.set_defaults(func=cmd_create_admin)
|
||||
|
||||
demo_parser = subparsers.add_parser("demo-data")
|
||||
demo_parser.add_argument("--customers", type=int, default=10)
|
||||
demo_parser.add_argument("--devices", type=int, default=10)
|
||||
demo_parser.add_argument("--validations", type=int, default=20)
|
||||
demo_parser.add_argument("--reports", action="store_true")
|
||||
demo_parser.add_argument("--images", action="store_true")
|
||||
demo_parser.add_argument("--reset", action="store_true")
|
||||
demo_parser.set_defaults(func=cmd_demo_data)
|
||||
return parser
|
||||
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.customer import Customer
|
||||
|
|
@ -23,7 +23,22 @@ class UserRepository(Repository[User]):
|
|||
|
||||
class CustomerRepository(Repository[Customer]):
|
||||
model = Customer
|
||||
search_columns = ("name", "city", "email", "phone")
|
||||
|
||||
def _search_statement(self, search: str | None = None):
|
||||
statement = select(Customer)
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
or_(
|
||||
Customer.name.ilike(term),
|
||||
Customer.city.ilike(term),
|
||||
Customer.postal_code.ilike(term),
|
||||
Customer.email.ilike(term),
|
||||
Customer.phone.ilike(term),
|
||||
Customer.contacts.any(Contact.full_name.ilike(term)),
|
||||
)
|
||||
)
|
||||
return statement
|
||||
|
||||
|
||||
class LocationRepository(Repository[Location]):
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -269,3 +269,31 @@ class UserRead(UserBase, EntityRead):
|
|||
|
||||
class UserPasswordResetRequest(ORMModel):
|
||||
temporary_password: str | None = None
|
||||
|
||||
|
||||
class QuickStartValidationSummary(ORMModel):
|
||||
id: str
|
||||
device_id: str
|
||||
report_number: str
|
||||
performed_on: date | None = None
|
||||
result: str | None = None
|
||||
next_validation_on: date | None = None
|
||||
status: str
|
||||
validation_type: str | None = None
|
||||
equipment_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class QuickStartCustomerData(ORMModel):
|
||||
customer: CustomerRead
|
||||
locations: list[LocationRead] = Field(default_factory=list)
|
||||
contacts: list[ContactRead] = Field(default_factory=list)
|
||||
devices: list[DeviceRead] = Field(default_factory=list)
|
||||
validations: list[QuickStartValidationSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class QuickStartCreateRequest(ORMModel):
|
||||
customer_id: UUID
|
||||
location_id: UUID | None = None
|
||||
contact_id: UUID | None = None
|
||||
device_id: UUID | None = None
|
||||
validation_type: str
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
757
validation-suite/backend/mercury/app/services/demo_data.py
Normal file
757
validation-suite/backend/mercury/app/services/demo_data.py
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer, CustomerType
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment, EquipmentKind, EquipmentStatus
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation, ValidationStatus
|
||||
from app.modules.orion.service import OrionReportService
|
||||
from app.modules.orion.template_service import ReportTemplateService
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
||||
DEMO_TAG = "DEMO_DATA"
|
||||
DEMO_REPORT_PREFIX = "DEMO-VAL"
|
||||
DEMO_DEVICE_PREFIX = "DEMO-DEV"
|
||||
DEMO_EQUIPMENT_PREFIX = "DEMO-EQ"
|
||||
DEMO_UPLOAD_ROOT = Path("/app/uploads/demo-data")
|
||||
DEMO_REPORT_ROOT = Path("/app/reports")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoCustomerTemplate:
|
||||
name: str
|
||||
specialty: str
|
||||
street: str
|
||||
postal_code: str
|
||||
city: str
|
||||
phone: str
|
||||
email: str
|
||||
hygiene_officer: str
|
||||
quality_manager: str
|
||||
operator: str
|
||||
locations: list[dict[str, Any]]
|
||||
contacts: list[dict[str, Any]]
|
||||
device: dict[str, Any]
|
||||
|
||||
|
||||
CUSTOMER_TEMPLATES: list[DemoCustomerTemplate] = [
|
||||
DemoCustomerTemplate(
|
||||
name="Zahnzentrum Alster",
|
||||
specialty="Zahnarzt",
|
||||
street="Alsterufer 18",
|
||||
postal_code="20354",
|
||||
city="Hamburg",
|
||||
phone="040 5550010",
|
||||
email="kontakt@zahnzentrum-alster.schubamed.de",
|
||||
hygiene_officer="Dr. Jana Peters",
|
||||
quality_manager="M. Kruse",
|
||||
operator="Dr. Jana Peters",
|
||||
locations=[
|
||||
{"name": "Hauptpraxis", "street": "Alsterufer 18", "postal_code": "20354", "city": "Hamburg", "room": "Steri 1"},
|
||||
{"name": "OP-Bereich", "street": "Alsterufer 18", "postal_code": "20354", "city": "Hamburg", "room": "OP 2"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Jana Peters", "function": "Betreiberin", "email": "jana.peters@schubamed.de", "phone": "040 5550011"},
|
||||
{"full_name": "M. Kruse", "function": "QM-Beauftragte", "email": "qm.zahnzentrum.alster@schubamed.de", "phone": "040 5550012"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "Euronda SpA",
|
||||
"model": "E10.7",
|
||||
"serial_number": "SN-DEMO-001",
|
||||
"year_built": 2023,
|
||||
"commissioned_on": date(2023, 6, 14),
|
||||
"chamber_volume_liters": 24,
|
||||
"steam_generation": "Eigendampferzeugung",
|
||||
"water_treatment": "Vollentsalzung",
|
||||
"documentation": "CF-Karte und PDF-Protokoll",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Urologie Zentrum Elbe",
|
||||
specialty="Urologie",
|
||||
street="Mönckebergstr. 11",
|
||||
postal_code="20095",
|
||||
city="Hamburg",
|
||||
phone="040 5550020",
|
||||
email="kontakt@urologie-elbe.schubamed.de",
|
||||
hygiene_officer="Dr. Felix Brandt",
|
||||
quality_manager="S. Lange",
|
||||
operator="Dr. Felix Brandt",
|
||||
locations=[
|
||||
{"name": "Praxis Mitte", "street": "Mönckebergstr. 11", "postal_code": "20095", "city": "Hamburg", "room": "Steri A"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Felix Brandt", "function": "Betreiber", "email": "felix.brandt@schubamed.de", "phone": "040 5550021"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "Euronda SpA",
|
||||
"model": "E9",
|
||||
"serial_number": "SN-DEMO-002",
|
||||
"year_built": 2022,
|
||||
"commissioned_on": date(2022, 11, 2),
|
||||
"chamber_volume_liters": 18,
|
||||
"steam_generation": "Generator integriert",
|
||||
"water_treatment": "Wasserkonditionierung",
|
||||
"documentation": "Digitale Dokumentation",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Orthopädie am Park",
|
||||
specialty="Orthopädie",
|
||||
street="Königsallee 44",
|
||||
postal_code="40212",
|
||||
city="Düsseldorf",
|
||||
phone="0211 5550030",
|
||||
email="kontakt@orthopaedie-park.schubamed.de",
|
||||
hygiene_officer="Dr. Lea Sommer",
|
||||
quality_manager="A. Hoffmann",
|
||||
operator="Dr. Lea Sommer",
|
||||
locations=[
|
||||
{"name": "Ambulanz", "street": "Königsallee 44", "postal_code": "40212", "city": "Düsseldorf", "room": "Steri 1"},
|
||||
{"name": "Behandlungszentrum", "street": "Kaiserswerther Str. 99", "postal_code": "40474", "city": "Düsseldorf", "room": "Steri 2"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Lea Sommer", "function": "Betreiberin", "email": "lea.sommer@schubamed.de", "phone": "0211 5550031"},
|
||||
{"full_name": "A. Hoffmann", "function": "Hygienebeauftragte", "email": "hygiene.orthopaedie@schubamed.de", "phone": "0211 5550032"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "MELAG",
|
||||
"model": "Vacuklav 41 B+",
|
||||
"serial_number": "SN-DEMO-003",
|
||||
"year_built": 2021,
|
||||
"commissioned_on": date(2021, 9, 8),
|
||||
"chamber_volume_liters": 22,
|
||||
"steam_generation": "Dampferzeuger intern",
|
||||
"water_treatment": "VE-Wasser",
|
||||
"documentation": "CF-Karte, USB-Export",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Dermatologie Rhein",
|
||||
specialty="Dermatologie",
|
||||
street="Breite Str. 8",
|
||||
postal_code="50667",
|
||||
city="Köln",
|
||||
phone="0221 5550040",
|
||||
email="kontakt@dermatologie-rhein.schubamed.de",
|
||||
hygiene_officer="Dr. Sophie Keller",
|
||||
quality_manager="D. Neumann",
|
||||
operator="Dr. Sophie Keller",
|
||||
locations=[
|
||||
{"name": "Hautpraxis", "street": "Breite Str. 8", "postal_code": "50667", "city": "Köln", "room": "Steri"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Sophie Keller", "function": "Betreiberin", "email": "sophie.keller@schubamed.de", "phone": "0221 5550041"},
|
||||
{"full_name": "D. Neumann", "function": "QM", "email": "qm.dermatologie.rhein@schubamed.de", "phone": "0221 5550042"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "MELAG",
|
||||
"model": "Vacuklav 44 B+",
|
||||
"serial_number": "SN-DEMO-004",
|
||||
"year_built": 2020,
|
||||
"commissioned_on": date(2020, 4, 16),
|
||||
"chamber_volume_liters": 24,
|
||||
"steam_generation": "Eigendampferzeugung",
|
||||
"water_treatment": "Osmose",
|
||||
"documentation": "Digitale Dokumentation und Ausdruck",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="HNO Zentrum Forum",
|
||||
specialty="HNO",
|
||||
street="Theatinerstr. 19",
|
||||
postal_code="80333",
|
||||
city="München",
|
||||
phone="089 5550050",
|
||||
email="kontakt@hno-forum.schubamed.de",
|
||||
hygiene_officer="Dr. Tom Berger",
|
||||
quality_manager="R. Wagner",
|
||||
operator="Dr. Tom Berger",
|
||||
locations=[
|
||||
{"name": "Forum Praxis", "street": "Theatinerstr. 19", "postal_code": "80333", "city": "München", "room": "Steri 1"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Tom Berger", "function": "Betreiber", "email": "tom.berger@schubamed.de", "phone": "089 5550051"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "MELAG",
|
||||
"model": "PrimeLine",
|
||||
"serial_number": "SN-DEMO-005",
|
||||
"year_built": 2024,
|
||||
"commissioned_on": date(2024, 3, 1),
|
||||
"chamber_volume_liters": 29,
|
||||
"steam_generation": "Generator integriert",
|
||||
"water_treatment": "VE-Wasser",
|
||||
"documentation": "USB-Export",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Gynäkologie Marienhof",
|
||||
specialty="Gynäkologie",
|
||||
street="Marienplatz 3",
|
||||
postal_code="86150",
|
||||
city="Augsburg",
|
||||
phone="0821 5550060",
|
||||
email="kontakt@gyn-marienhof.schubamed.de",
|
||||
hygiene_officer="Dr. Miriam Wolf",
|
||||
quality_manager="K. Braun",
|
||||
operator="Dr. Miriam Wolf",
|
||||
locations=[
|
||||
{"name": "Frauenpraxis", "street": "Marienplatz 3", "postal_code": "86150", "city": "Augsburg", "room": "Steri"},
|
||||
{"name": "Ambulanz Süd", "street": "Bürgermeister-Fischer-Str. 12", "postal_code": "86150", "city": "Augsburg", "room": "Steri 2"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Miriam Wolf", "function": "Betreiberin", "email": "miriam.wolf@schubamed.de", "phone": "0821 5550061"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "MELAG",
|
||||
"model": "ProLine",
|
||||
"serial_number": "SN-DEMO-006",
|
||||
"year_built": 2019,
|
||||
"commissioned_on": date(2019, 8, 23),
|
||||
"chamber_volume_liters": 18,
|
||||
"steam_generation": "Kompaktgenerator",
|
||||
"water_treatment": "VE-Wasser",
|
||||
"documentation": "CF-Card",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Chirurgie Ost",
|
||||
specialty="Chirurgie",
|
||||
street="Lindenstr. 7",
|
||||
postal_code="04109",
|
||||
city="Leipzig",
|
||||
phone="0341 5550070",
|
||||
email="kontakt@chirurgie-ost.schubamed.de",
|
||||
hygiene_officer="Dr. Paul Richter",
|
||||
quality_manager="F. Scholz",
|
||||
operator="Dr. Paul Richter",
|
||||
locations=[
|
||||
{"name": "OP-Zentrum", "street": "Lindenstr. 7", "postal_code": "04109", "city": "Leipzig", "room": "Steri OP"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Paul Richter", "function": "Betreiber", "email": "paul.richter@schubamed.de", "phone": "0341 5550071"},
|
||||
{"full_name": "F. Scholz", "function": "Hygiene", "email": "hygiene.chirurgie.ost@schubamed.de", "phone": "0341 5550072"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "W&H",
|
||||
"model": "Lara XL",
|
||||
"serial_number": "SN-DEMO-007",
|
||||
"year_built": 2023,
|
||||
"commissioned_on": date(2023, 2, 15),
|
||||
"chamber_volume_liters": 17,
|
||||
"steam_generation": "Dampferzeuger intern",
|
||||
"water_treatment": "Mikrofiltration",
|
||||
"documentation": "USB und Ausdruck",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Allgemeinmedizin West",
|
||||
specialty="Allgemeinmedizin",
|
||||
street="Bergstr. 25",
|
||||
postal_code="70173",
|
||||
city="Stuttgart",
|
||||
phone="0711 5550080",
|
||||
email="kontakt@allgemeinmedizin-west.schubamed.de",
|
||||
hygiene_officer="Dr. Nina Beck",
|
||||
quality_manager="J. Hartmann",
|
||||
operator="Dr. Nina Beck",
|
||||
locations=[
|
||||
{"name": "Hausarztpraxis", "street": "Bergstr. 25", "postal_code": "70173", "city": "Stuttgart", "room": "Steri"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Nina Beck", "function": "Betreiberin", "email": "nina.beck@schubamed.de", "phone": "0711 5550081"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "W&H",
|
||||
"model": "Lisa",
|
||||
"serial_number": "SN-DEMO-008",
|
||||
"year_built": 2022,
|
||||
"commissioned_on": date(2022, 7, 12),
|
||||
"chamber_volume_liters": 22,
|
||||
"steam_generation": "Eigendampferzeugung",
|
||||
"water_treatment": "VE-Wasser",
|
||||
"documentation": "Digital und Papier",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="Augenärzte am Ring",
|
||||
specialty="Augenarzt",
|
||||
street="Ringstr. 52",
|
||||
postal_code="50672",
|
||||
city="Köln",
|
||||
phone="0221 5550090",
|
||||
email="kontakt@augenaerzte-ring.schubamed.de",
|
||||
hygiene_officer="Dr. Karin Otto",
|
||||
quality_manager="M. Franke",
|
||||
operator="Dr. Karin Otto",
|
||||
locations=[
|
||||
{"name": "Augenzentrum", "street": "Ringstr. 52", "postal_code": "50672", "city": "Köln", "room": "Steri 1"},
|
||||
{"name": "Laserzentrum", "street": "Ringstr. 54", "postal_code": "50672", "city": "Köln", "room": "Steri 2"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Karin Otto", "function": "Betreiberin", "email": "karin.otto@schubamed.de", "phone": "0221 5550091"},
|
||||
{"full_name": "M. Franke", "function": "QM", "email": "qm.augen@schubamed.de", "phone": "0221 5550092"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "Mocom",
|
||||
"model": "B Classic",
|
||||
"serial_number": "SN-DEMO-009",
|
||||
"year_built": 2021,
|
||||
"commissioned_on": date(2021, 5, 20),
|
||||
"chamber_volume_liters": 12,
|
||||
"steam_generation": "Kompaktgenerator",
|
||||
"water_treatment": "Vollentsalzung",
|
||||
"documentation": "USB-Export",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
DemoCustomerTemplate(
|
||||
name="MVZ Medikon",
|
||||
specialty="MVZ",
|
||||
street="Forum 9",
|
||||
postal_code="90402",
|
||||
city="Nürnberg",
|
||||
phone="0911 5550100",
|
||||
email="kontakt@mvz-medikon.schubamed.de",
|
||||
hygiene_officer="Dr. Lara Stein",
|
||||
quality_manager="C. Meier",
|
||||
operator="Dr. Lara Stein",
|
||||
locations=[
|
||||
{"name": "Hauptstandort", "street": "Forum 9", "postal_code": "90402", "city": "Nürnberg", "room": "Steri A"},
|
||||
{"name": "Nebenstandort", "street": "Forum 11", "postal_code": "90402", "city": "Nürnberg", "room": "Steri B"},
|
||||
],
|
||||
contacts=[
|
||||
{"full_name": "Dr. Lara Stein", "function": "Betreiberin", "email": "lara.stein@schubamed.de", "phone": "0911 5550101"},
|
||||
{"full_name": "C. Meier", "function": "Hygiene", "email": "hygiene.mvz@schubamed.de", "phone": "0911 5550102"},
|
||||
],
|
||||
device={
|
||||
"manufacturer": "Kronos",
|
||||
"model": "B23",
|
||||
"serial_number": "SN-DEMO-010",
|
||||
"year_built": 2024,
|
||||
"commissioned_on": date(2024, 1, 11),
|
||||
"chamber_volume_liters": 23,
|
||||
"steam_generation": "Eigendampferzeugung",
|
||||
"water_treatment": "VE-Wasser",
|
||||
"documentation": "Hybrid",
|
||||
"supplier": "schubamed Medizintechnik",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
EQUIPMENT_TEMPLATES: list[dict[str, Any]] = [
|
||||
{
|
||||
"kind": EquipmentKind.temperature_logger,
|
||||
"manufacturer": "EBRO",
|
||||
"model": "EBI 11",
|
||||
"serial_number": "DEMO-EBI11-001",
|
||||
"calibrated_on": date(2025, 1, 17),
|
||||
"calibration_due_on": date(2026, 1, 17),
|
||||
"status": EquipmentStatus.green,
|
||||
"notes": "Demo-Temperaturlogger",
|
||||
},
|
||||
{
|
||||
"kind": EquipmentKind.temperature_logger,
|
||||
"manufacturer": "EBRO",
|
||||
"model": "EBI 11",
|
||||
"serial_number": "DEMO-EBI11-002",
|
||||
"calibrated_on": date(2025, 2, 2),
|
||||
"calibration_due_on": date(2026, 2, 2),
|
||||
"status": EquipmentStatus.green,
|
||||
"notes": "Demo-Temperaturlogger",
|
||||
},
|
||||
{
|
||||
"kind": EquipmentKind.pressure_logger,
|
||||
"manufacturer": "EBRO",
|
||||
"model": "Drucklogger",
|
||||
"serial_number": "DEMO-PR-001",
|
||||
"calibrated_on": date(2025, 1, 21),
|
||||
"calibration_due_on": date(2026, 1, 21),
|
||||
"status": EquipmentStatus.green,
|
||||
"notes": "Demo-Drucklogger",
|
||||
},
|
||||
{
|
||||
"kind": EquipmentKind.sensor,
|
||||
"manufacturer": "Mettler Toledo",
|
||||
"model": "Waage",
|
||||
"serial_number": "DEMO-SCALE-001",
|
||||
"calibrated_on": date(2024, 12, 12),
|
||||
"calibration_due_on": date(2025, 12, 12),
|
||||
"status": EquipmentStatus.yellow,
|
||||
"notes": "Demo-Waage",
|
||||
},
|
||||
{
|
||||
"kind": EquipmentKind.sensor,
|
||||
"manufacturer": "Mettler Toledo",
|
||||
"model": "Leitwertmessgerät",
|
||||
"serial_number": "DEMO-COND-001",
|
||||
"calibrated_on": date(2025, 3, 4),
|
||||
"calibration_due_on": date(2026, 3, 4),
|
||||
"status": EquipmentStatus.green,
|
||||
"notes": "Demo-Leitwertmessgerät",
|
||||
},
|
||||
{
|
||||
"kind": EquipmentKind.sensor,
|
||||
"manufacturer": "Trotec",
|
||||
"model": "Raumklimamessgerät",
|
||||
"serial_number": "DEMO-CLIMATE-001",
|
||||
"calibrated_on": date(2024, 11, 1),
|
||||
"calibration_due_on": date(2025, 11, 1),
|
||||
"status": EquipmentStatus.red,
|
||||
"notes": "Demo-Raumklimamessgerät",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class DemoDataService:
|
||||
def __init__(self, session: Session, upload_root: Path | None = None, report_root: Path | None = None) -> None:
|
||||
self.session = session
|
||||
self.upload_root = upload_root or DEMO_UPLOAD_ROOT.parent
|
||||
self.report_root = report_root or DEMO_REPORT_ROOT
|
||||
self.demo_upload_root = self.upload_root / "demo-data"
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
customers: int = 10,
|
||||
devices: int = 10,
|
||||
validations: int = 20,
|
||||
reports: bool = False,
|
||||
images: bool = False,
|
||||
reset: bool = False,
|
||||
) -> dict[str, int]:
|
||||
if reset:
|
||||
self.reset()
|
||||
created_customers = self.ensure_customers(max(customers, 10))
|
||||
created_equipment = self.ensure_equipment()
|
||||
created_devices = self.ensure_devices(max(devices, 10), created_customers)
|
||||
created_validations = self.ensure_validations(max(validations, 20), created_customers, created_devices, images=images)
|
||||
self.session.commit()
|
||||
if reports:
|
||||
self._generate_reports(created_validations)
|
||||
return {
|
||||
"customers": len(created_customers),
|
||||
"devices": len(created_devices),
|
||||
"validations": len(created_validations),
|
||||
"equipment": len(created_equipment),
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
demo_validation_ids = self.session.scalars(
|
||||
select(Validation.id).where(
|
||||
Validation.report_number.like(f"{DEMO_REPORT_PREFIX}%")
|
||||
| Validation.notes.like(f"%{DEMO_TAG}%")
|
||||
)
|
||||
).all()
|
||||
if demo_validation_ids:
|
||||
self.session.execute(delete(Validation).where(Validation.id.in_(demo_validation_ids)))
|
||||
|
||||
self.session.execute(
|
||||
delete(Equipment).where(Equipment.serial_number.like("DEMO-%") | Equipment.notes.like(f"%{DEMO_TAG}%"))
|
||||
)
|
||||
self.session.execute(
|
||||
delete(Device).where(Device.serial_number.like("SN-DEMO-%") | Device.notes.like(f"%{DEMO_TAG}%"))
|
||||
)
|
||||
self.session.execute(
|
||||
delete(Contact).where(
|
||||
Contact.email.like("demo-%@schubamed.de") | Contact.notes.like(f"%{DEMO_TAG}%")
|
||||
)
|
||||
)
|
||||
self.session.execute(
|
||||
delete(Customer).where(
|
||||
Customer.email.like("demo-%@schubamed.de") | Customer.notes.like(f"%{DEMO_TAG}%")
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
if self.demo_upload_root.exists():
|
||||
shutil.rmtree(self.demo_upload_root)
|
||||
self.report_root.mkdir(parents=True, exist_ok=True)
|
||||
for pdf in self.report_root.glob(f"{DEMO_REPORT_PREFIX}*.pdf"):
|
||||
pdf.unlink()
|
||||
|
||||
def ensure_customers(self, count: int) -> list[Customer]:
|
||||
ensured: list[Customer] = []
|
||||
for index in range(count):
|
||||
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)]
|
||||
occurrence = index // len(CUSTOMER_TEMPLATES)
|
||||
customer = self._ensure_customer(template, occurrence, index)
|
||||
ensured.append(customer)
|
||||
return ensured
|
||||
|
||||
def ensure_equipment(self) -> list[Equipment]:
|
||||
items: list[Equipment] = []
|
||||
for template in EQUIPMENT_TEMPLATES:
|
||||
equipment = self.session.scalar(
|
||||
select(Equipment).where(Equipment.serial_number == template["serial_number"])
|
||||
)
|
||||
if equipment is None:
|
||||
equipment = Equipment(**template)
|
||||
equipment.notes = f"{template['notes']} {DEMO_TAG}"
|
||||
self.session.add(equipment)
|
||||
self.session.flush()
|
||||
items.append(equipment)
|
||||
return items
|
||||
|
||||
def ensure_devices(self, count: int, customers: list[Customer]) -> list[Device]:
|
||||
devices: list[Device] = []
|
||||
all_locations = {customer.id: list(self.session.scalars(select(Location).where(Location.customer_id == customer.id))) for customer in customers}
|
||||
for index in range(count):
|
||||
customer = customers[index % len(customers)]
|
||||
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)].device
|
||||
occurrence = index // len(CUSTOMER_TEMPLATES)
|
||||
serial_number = template["serial_number"] if occurrence == 0 else f"{template['serial_number']}-{occurrence + 1}"
|
||||
device = self.session.scalar(select(Device).where(Device.serial_number == serial_number))
|
||||
if device is None:
|
||||
location_candidates = all_locations.get(customer.id, [])
|
||||
location_id = location_candidates[0].id if location_candidates else None
|
||||
device_payload = deepcopy(template)
|
||||
device_payload["serial_number"] = serial_number
|
||||
device_payload["customer_id"] = customer.id
|
||||
device_payload["location_id"] = location_id
|
||||
device = Device(**device_payload)
|
||||
device.notes = f"{DEMO_TAG} {customer.name}"
|
||||
self.session.add(device)
|
||||
self.session.flush()
|
||||
devices.append(device)
|
||||
return devices
|
||||
|
||||
def ensure_validations(
|
||||
self,
|
||||
count: int,
|
||||
customers: list[Customer],
|
||||
devices: list[Device],
|
||||
*,
|
||||
images: bool = False,
|
||||
) -> list[Validation]:
|
||||
validations: list[Validation] = []
|
||||
report_number_index = 1
|
||||
for index in range(count):
|
||||
template = CUSTOMER_TEMPLATES[index % len(CUSTOMER_TEMPLATES)]
|
||||
customer = customers[index % len(customers)]
|
||||
device = devices[index % len(devices)]
|
||||
locations = list(self.session.scalars(select(Location).where(Location.customer_id == customer.id)))
|
||||
contacts = list(self.session.scalars(select(Contact).where(Contact.customer_id == customer.id)))
|
||||
location = locations[index % len(locations)] if locations else None
|
||||
contact = contacts[index % len(contacts)] if contacts else None
|
||||
report_number = f"{DEMO_REPORT_PREFIX}-{report_number_index:03d}"
|
||||
report_number_index += 1
|
||||
validation = self.session.scalar(select(Validation).where(Validation.report_number == report_number))
|
||||
if validation is None:
|
||||
validation = Validation(
|
||||
report_number=report_number,
|
||||
customer_id=customer.id,
|
||||
location_id=location.id if location else None,
|
||||
contact_id=contact.id if contact else None,
|
||||
device_id=device.id,
|
||||
validation_type="Erstvalidierung" if index % 2 == 0 else "Revalidierung",
|
||||
performed_on=date.today() - relativedelta(months=6 + index),
|
||||
examiner_name="Dr. Lara Stein",
|
||||
operator_name=template.operator,
|
||||
participants=f"{customer.name} - Team",
|
||||
status=[
|
||||
ValidationStatus.draft.value,
|
||||
ValidationStatus.approved.value,
|
||||
ValidationStatus.completed.value,
|
||||
][index % 3],
|
||||
result="bestanden" if index % 3 else "bestanden mit Auflagen",
|
||||
revalidation_interval_months=24,
|
||||
equipment_ids=[equipment.id for equipment in self.session.scalars(select(Equipment).limit(3))],
|
||||
environment_conditions={
|
||||
"room_temperature": 22 + (index % 3),
|
||||
"humidity": 48 + (index % 5),
|
||||
"test_time": f"{8 + (index % 3)}:30",
|
||||
"checks": [
|
||||
{"text": "Raumbedingungen stabil", "value": "yes", "comment": ""},
|
||||
{"text": "Aufstellort frei zugänglich", "value": "yes", "comment": ""},
|
||||
],
|
||||
},
|
||||
documentation_checklist=self._checklist("documentation_control", index),
|
||||
performance_checklist=self._checklist("sterilizer_description", index),
|
||||
programs=[
|
||||
{"name": "Vakuumtest", "selected": True, "custom": False},
|
||||
{"name": "Bowie-Dick / Leerkammerprofil", "selected": index % 2 == 0, "custom": False},
|
||||
{"name": "134 C hohl verpackt", "selected": True, "custom": False},
|
||||
],
|
||||
loading_patterns=self._loading_patterns(index),
|
||||
measurement_data=self._measurement_data(index),
|
||||
drying={"start_weight": 12.5, "end_weight": 12.1, "difference": 0.4, "assessment": "in Ordnung", "comment": ""},
|
||||
recommendations=[
|
||||
{"number": 1, "text": "Routinekontrolle dokumentieren", "deadline": "6 Monate", "status": "offen"}
|
||||
] if index % 4 == 0 else [],
|
||||
attachments=self._attachments_for_validation(index, images=images),
|
||||
notes=DEMO_TAG,
|
||||
)
|
||||
self.session.add(validation)
|
||||
self.session.flush()
|
||||
ValidationWorkflowService(self.session).apply_revalidation_date(validation)
|
||||
validations.append(validation)
|
||||
return validations
|
||||
|
||||
def _ensure_customer(self, template: DemoCustomerTemplate, occurrence: int, index: int) -> Customer:
|
||||
email = template.email if occurrence == 0 else f"demo-{index + 1:02d}@schubamed.de"
|
||||
customer = self.session.scalar(select(Customer).where(Customer.email == email))
|
||||
if customer is None:
|
||||
customer = Customer(
|
||||
customer_type=CustomerType.practice,
|
||||
name=template.name if occurrence == 0 else f"{template.name} {occurrence + 1}",
|
||||
street=template.street,
|
||||
postal_code=template.postal_code,
|
||||
city=template.city,
|
||||
phone=template.phone,
|
||||
email=email,
|
||||
hygiene_officer=template.hygiene_officer,
|
||||
quality_manager=template.quality_manager,
|
||||
notes=f"{DEMO_TAG} {template.specialty}",
|
||||
)
|
||||
self.session.add(customer)
|
||||
self.session.flush()
|
||||
for loc_index, location_payload in enumerate(template.locations, start=1):
|
||||
location = self.session.scalar(
|
||||
select(Location).where(
|
||||
Location.customer_id == customer.id,
|
||||
Location.name == location_payload["name"],
|
||||
)
|
||||
)
|
||||
if location is None:
|
||||
self.session.add(Location(customer_id=customer.id, **location_payload))
|
||||
for contact_payload in template.contacts:
|
||||
contact = self.session.scalar(
|
||||
select(Contact).where(
|
||||
Contact.customer_id == customer.id,
|
||||
Contact.full_name == contact_payload["full_name"],
|
||||
)
|
||||
)
|
||||
if contact is None:
|
||||
self.session.add(
|
||||
Contact(
|
||||
customer_id=customer.id,
|
||||
full_name=contact_payload["full_name"],
|
||||
function=contact_payload["function"],
|
||||
email=contact_payload["email"],
|
||||
phone=contact_payload["phone"],
|
||||
notes=DEMO_TAG,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
return customer
|
||||
|
||||
def _checklist(self, key: str, index: int) -> list[dict[str, Any]]:
|
||||
bundle = ReportTemplateService(self.session).ensure_default_template()
|
||||
template = next((item for item in bundle.checklists if item.checklist_key == key), None)
|
||||
if template is None:
|
||||
return []
|
||||
values = ["yes", "yes", "na"] if index % 2 == 0 else ["yes", "na", "yes"]
|
||||
return [
|
||||
{
|
||||
"number": item["number"],
|
||||
"text": item["text"],
|
||||
"value": values[(item["number"] - 1) % len(values)],
|
||||
"comment": "",
|
||||
}
|
||||
for item in template.items
|
||||
]
|
||||
|
||||
def _loading_patterns(self, index: int) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"run": 1,
|
||||
"pattern": "Standard",
|
||||
"description": f"Demo-Beladung {index + 1}",
|
||||
"images": [],
|
||||
},
|
||||
{
|
||||
"run": 2,
|
||||
"pattern": "Standard",
|
||||
"description": "",
|
||||
"images": [],
|
||||
},
|
||||
{
|
||||
"run": 3,
|
||||
"pattern": "Standard",
|
||||
"description": "",
|
||||
"images": [],
|
||||
},
|
||||
]
|
||||
|
||||
def _measurement_data(self, index: int) -> list[dict[str, Any]]:
|
||||
base_temperature = 132 + (index % 3)
|
||||
return [
|
||||
{
|
||||
"test_run": "Vakuumtest",
|
||||
"start_time": "08:00",
|
||||
"end_time": "08:08",
|
||||
"duration": "00:08",
|
||||
"leak_rate": "0.4",
|
||||
"result": "bestanden",
|
||||
},
|
||||
{
|
||||
"test_run": "Testlauf 1",
|
||||
"start_time": "09:00",
|
||||
"end_time": "09:31",
|
||||
"duration": "00:31",
|
||||
"minimum_temperature": base_temperature,
|
||||
"maximum_temperature": base_temperature + 2,
|
||||
"temperature_band": "2.0",
|
||||
"holding_time": "3:00",
|
||||
"pressure": "2.1",
|
||||
"result": "bestanden",
|
||||
},
|
||||
]
|
||||
|
||||
def _attachments_for_validation(self, index: int, *, images: bool) -> list[dict[str, Any]]:
|
||||
if not images or index >= 5:
|
||||
return []
|
||||
image_dir = self.demo_upload_root / "images"
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
image_path = image_dir / f"demo-figure-{index + 1}.svg"
|
||||
if not image_path.exists():
|
||||
image_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800">',
|
||||
'<rect width="1200" height="800" fill="#f6f7f2"/>',
|
||||
'<rect x="60" y="60" width="1080" height="680" rx="18" fill="#ffffff" stroke="#bfc5bd" stroke-width="3"/>',
|
||||
'<text x="100" y="150" font-family="Arial, sans-serif" font-size="44" fill="#2f3b35">SCHUBAMED Demo-Bild</text>',
|
||||
f'<text x="100" y="230" font-family="Arial, sans-serif" font-size="32" fill="#4d5b54">Validierung {index + 1}</text>',
|
||||
"</svg>",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return [
|
||||
{
|
||||
"category": "Beladung",
|
||||
"filename": image_path.name,
|
||||
"content_type": "image/svg+xml",
|
||||
"description": f"Demo-Abbildung {index + 1}",
|
||||
"order": 1,
|
||||
"storage_path": str(image_path),
|
||||
}
|
||||
]
|
||||
|
||||
def _generate_reports(self, validations: list[Validation]) -> None:
|
||||
self.report_root.mkdir(parents=True, exist_ok=True)
|
||||
ReportTemplateService(self.session).ensure_default_template()
|
||||
for validation in validations:
|
||||
OrionReportService(self.session, self.report_root).render_pdf(validation.id)
|
||||
|
|
@ -36,11 +36,13 @@ class CrudService:
|
|||
safe_page = max(page, 1)
|
||||
safe_page_size = min(max(page_size, 1), 100)
|
||||
offset = (safe_page - 1) * safe_page_size
|
||||
total = self.repository.count(search)
|
||||
return {
|
||||
"items": self.repository.list(safe_page_size, offset, search),
|
||||
"total": self.repository.count(search),
|
||||
"total": total,
|
||||
"page": safe_page,
|
||||
"page_size": safe_page_size,
|
||||
"pages": max((total + safe_page_size - 1) // safe_page_size, 1),
|
||||
}
|
||||
|
||||
def create(self, data: dict) -> ModelT:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment, EquipmentKind
|
||||
from app.models.location import Location
|
||||
from app.models.user import User
|
||||
from app.models.validation import Validation, ValidationStatus
|
||||
from app.modules.orion.template_service import ReportTemplateService
|
||||
from app.schemas.domain import QuickStartCreateRequest, QuickStartCustomerData, QuickStartValidationSummary
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
||||
|
||||
class QuickStartService:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def customer_data(self, customer_id: str) -> QuickStartCustomerData:
|
||||
customer = self._customer(customer_id)
|
||||
locations = list(
|
||||
self.session.scalars(
|
||||
select(Location).where(Location.customer_id == customer.id).order_by(Location.name.asc())
|
||||
)
|
||||
)
|
||||
contacts = list(
|
||||
self.session.scalars(
|
||||
select(Contact).where(Contact.customer_id == customer.id).order_by(Contact.full_name.asc())
|
||||
)
|
||||
)
|
||||
devices = list(
|
||||
self.session.scalars(
|
||||
select(Device).where(Device.customer_id == customer.id).order_by(Device.serial_number.asc())
|
||||
)
|
||||
)
|
||||
validations = self._validation_summaries([device.id for device in devices])
|
||||
return QuickStartCustomerData(
|
||||
customer=customer,
|
||||
locations=locations,
|
||||
contacts=contacts,
|
||||
devices=devices,
|
||||
validations=validations,
|
||||
)
|
||||
|
||||
def device_last_validation(self, device_id: str) -> QuickStartValidationSummary | None:
|
||||
return self._last_validation(device_id)
|
||||
|
||||
def create_validation(self, payload: QuickStartCreateRequest, examiner: User) -> Validation:
|
||||
customer = self._customer(str(payload.customer_id))
|
||||
devices = list(
|
||||
self.session.scalars(
|
||||
select(Device).where(Device.customer_id == customer.id).order_by(Device.serial_number.asc())
|
||||
)
|
||||
)
|
||||
device = self._device(str(payload.device_id)) if payload.device_id else self._single_or_default(devices)
|
||||
if device is None:
|
||||
raise ValueError("Kein Gerät verfügbar.")
|
||||
locations = list(
|
||||
self.session.scalars(
|
||||
select(Location).where(Location.customer_id == customer.id).order_by(Location.name.asc())
|
||||
)
|
||||
)
|
||||
contacts = list(
|
||||
self.session.scalars(
|
||||
select(Contact).where(Contact.customer_id == customer.id).order_by(Contact.full_name.asc())
|
||||
)
|
||||
)
|
||||
location = self._location(str(payload.location_id)) if payload.location_id else self._single_or_default(locations)
|
||||
contact = self._contact(str(payload.contact_id)) if payload.contact_id else self._single_or_default(contacts)
|
||||
|
||||
base = self._base_validation(device.id, payload.validation_type)
|
||||
bundle = ReportTemplateService(self.session).ensure_default_template()
|
||||
validation = Validation(
|
||||
report_number=self._next_report_number(),
|
||||
customer_id=customer.id,
|
||||
location_id=location.id if location else (locations[0].id if locations else None),
|
||||
contact_id=contact.id if contact else None,
|
||||
device_id=device.id,
|
||||
validation_type=payload.validation_type,
|
||||
performed_on=date.today(),
|
||||
examiner_name=examiner.full_name,
|
||||
examiner_id=examiner.id,
|
||||
operator_name=customer.quality_manager or customer.hygiene_officer or examiner.full_name,
|
||||
status=ValidationStatus.draft.value,
|
||||
result="offen",
|
||||
revalidation_interval_months=base.revalidation_interval_months if base else 24,
|
||||
next_validation_manually_overridden=False,
|
||||
version=1 if base is None else base.version + 1,
|
||||
previous_validation_id=base.id if base else None,
|
||||
equipment_ids=list(base.equipment_ids) if base and base.equipment_ids else self._default_equipment_ids(),
|
||||
environment_conditions=deepcopy(base.environment_conditions) if base else {},
|
||||
documentation_checklist=deepcopy(base.documentation_checklist) if base else self._template_checklist(bundle, "documentation_checklist"),
|
||||
performance_checklist=deepcopy(base.performance_checklist) if base else self._template_checklist(bundle, "performance_checklist"),
|
||||
programs=deepcopy(base.programs) if base else self._default_programs(),
|
||||
loading_patterns=deepcopy(base.loading_patterns) if base else self._default_loading_patterns(),
|
||||
measurement_data=[],
|
||||
drying={},
|
||||
recommendations=deepcopy(base.recommendations) if base else [],
|
||||
attachments=[],
|
||||
)
|
||||
self.session.add(validation)
|
||||
self.session.flush()
|
||||
ValidationWorkflowService(self.session).apply_revalidation_date(validation)
|
||||
return validation
|
||||
|
||||
def _customer(self, customer_id: str) -> Customer:
|
||||
customer = self.session.get(Customer, customer_id)
|
||||
if customer is None:
|
||||
raise ValueError("Kunde nicht gefunden.")
|
||||
return customer
|
||||
|
||||
def _device(self, device_id: str) -> Device:
|
||||
device = self.session.get(Device, device_id)
|
||||
if device is None:
|
||||
raise ValueError("Gerät nicht gefunden.")
|
||||
return device
|
||||
|
||||
def _location(self, location_id: str) -> Location:
|
||||
location = self.session.get(Location, location_id)
|
||||
if location is None:
|
||||
raise ValueError("Standort nicht gefunden.")
|
||||
return location
|
||||
|
||||
def _contact(self, contact_id: str) -> Contact:
|
||||
contact = self.session.get(Contact, contact_id)
|
||||
if contact is None:
|
||||
raise ValueError("Ansprechpartner nicht gefunden.")
|
||||
return contact
|
||||
|
||||
def _single_or_default(self, items):
|
||||
if len(items) == 1:
|
||||
return items[0]
|
||||
return None
|
||||
|
||||
def _last_validation(self, device_id: str) -> QuickStartValidationSummary | None:
|
||||
validation = self.session.scalar(
|
||||
select(Validation)
|
||||
.where(
|
||||
and_(
|
||||
Validation.device_id == device_id,
|
||||
Validation.status.in_([ValidationStatus.approved.value, ValidationStatus.completed.value]),
|
||||
)
|
||||
)
|
||||
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
|
||||
)
|
||||
if validation is None:
|
||||
return None
|
||||
return self._summary(validation)
|
||||
|
||||
def _validation_summaries(self, device_ids: list[str]) -> list[QuickStartValidationSummary]:
|
||||
if not device_ids:
|
||||
return []
|
||||
validations = list(
|
||||
self.session.scalars(
|
||||
select(Validation)
|
||||
.where(Validation.device_id.in_(device_ids))
|
||||
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
)
|
||||
return [self._summary(item) for item in validations]
|
||||
|
||||
def _summary(self, validation: Validation) -> QuickStartValidationSummary:
|
||||
return QuickStartValidationSummary(
|
||||
id=validation.id,
|
||||
device_id=validation.device_id,
|
||||
report_number=validation.report_number,
|
||||
performed_on=validation.performed_on,
|
||||
result=validation.result,
|
||||
next_validation_on=validation.next_validation_on,
|
||||
status=validation.status,
|
||||
validation_type=validation.validation_type,
|
||||
equipment_ids=list(validation.equipment_ids or []),
|
||||
)
|
||||
|
||||
def _base_validation(self, device_id: str, validation_type: str) -> Validation | None:
|
||||
if validation_type not in {"Revalidierung", "Leistungsbeurteilung"}:
|
||||
return None
|
||||
return self.session.scalar(
|
||||
select(Validation)
|
||||
.where(
|
||||
and_(
|
||||
Validation.device_id == device_id,
|
||||
Validation.status.in_([ValidationStatus.approved.value, ValidationStatus.completed.value]),
|
||||
)
|
||||
)
|
||||
.order_by(Validation.performed_on.desc().nulls_last(), Validation.updated_at.desc())
|
||||
)
|
||||
|
||||
def _next_report_number(self) -> str:
|
||||
total = self.session.scalar(select(func.count()).select_from(Validation)) or 0
|
||||
return f"VAL-{total + 1:05d}"
|
||||
|
||||
def _default_equipment_ids(self) -> list[str]:
|
||||
equipment = list(
|
||||
self.session.scalars(
|
||||
select(Equipment).where(Equipment.kind == EquipmentKind.temperature_logger).order_by(Equipment.serial_number.asc())
|
||||
)
|
||||
)
|
||||
equipment.extend(
|
||||
list(
|
||||
self.session.scalars(
|
||||
select(Equipment).where(Equipment.kind == EquipmentKind.pressure_logger).order_by(Equipment.serial_number.asc())
|
||||
)
|
||||
)
|
||||
)
|
||||
selected: list[str] = []
|
||||
temp_count = 0
|
||||
pressure_count = 0
|
||||
for item in equipment:
|
||||
if item.kind == EquipmentKind.temperature_logger and temp_count < 5:
|
||||
selected.append(item.id)
|
||||
temp_count += 1
|
||||
elif item.kind == EquipmentKind.pressure_logger and pressure_count < 1:
|
||||
selected.append(item.id)
|
||||
pressure_count += 1
|
||||
return selected
|
||||
|
||||
def _template_checklist(self, bundle, key: str) -> list[dict]:
|
||||
checklist_key = {
|
||||
"documentation_checklist": "documentation_control",
|
||||
"performance_checklist": "sterilizer_description",
|
||||
}.get(key, key)
|
||||
template = next((item for item in bundle.checklists if item.checklist_key == checklist_key), None)
|
||||
if template is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"template_key": template.checklist_key,
|
||||
"template_title": template.title,
|
||||
"text": item,
|
||||
"value": "na",
|
||||
"comment": "",
|
||||
}
|
||||
for item in template.items
|
||||
]
|
||||
|
||||
def _default_programs(self) -> list[dict]:
|
||||
return [
|
||||
{"name": "Vakuumtest", "selected": True, "custom": False},
|
||||
{"name": "Bowie-Dick / Leerkammerprofil", "selected": True, "custom": False},
|
||||
{"name": "134 C hohl verpackt", "selected": True, "custom": False},
|
||||
]
|
||||
|
||||
def _default_loading_patterns(self) -> list[dict]:
|
||||
return [
|
||||
{"run": 1, "pattern": "Standardbeladung", "description": "", "images": []},
|
||||
{"run": 2, "pattern": "Standardbeladung", "description": "", "images": []},
|
||||
{"run": 3, "pattern": "Standardbeladung", "description": "", "images": []},
|
||||
]
|
||||
|
|
@ -7,7 +7,7 @@ from datetime import date, datetime
|
|||
from uuid import uuid4
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.contact import Contact
|
||||
|
|
@ -224,9 +224,17 @@ class ValidationWorkflowService:
|
|||
statement = statement.order_by(sort_column.asc())
|
||||
else:
|
||||
statement = statement.order_by(sort_column.desc())
|
||||
total = len(list(self.session.scalars(count_statement)))
|
||||
total = self.session.scalar(
|
||||
select(func.count()).select_from(count_statement.order_by(None).subquery())
|
||||
) or 0
|
||||
items = list(self.session.scalars(statement.offset((page - 1) * page_size).limit(page_size)))
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": max((total + page_size - 1) // page_size, 1),
|
||||
}
|
||||
|
||||
def revalidation_status(self, validation: Validation) -> str:
|
||||
if not validation.next_validation_on:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,4 @@
|
|||
set -eu
|
||||
|
||||
alembic upgrade head
|
||||
python -m app.db.seed
|
||||
exec "$@"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
99
validation-suite/backend/mercury/tests/test_demo_data_cli.py
Normal file
99
validation-suite/backend/mercury/tests/test_demo_data_cli.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer, CustomerType
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.validation import Validation
|
||||
from app import cli as app_cli
|
||||
from app.services.demo_data import DemoDataService
|
||||
|
||||
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_demo_data_cli_parser_accepts_expected_options():
|
||||
args = app_cli.build_parser().parse_args(
|
||||
[
|
||||
"demo-data",
|
||||
"--customers",
|
||||
"10",
|
||||
"--devices",
|
||||
"10",
|
||||
"--validations",
|
||||
"20",
|
||||
"--reports",
|
||||
"--images",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.command == "demo-data"
|
||||
assert args.customers == 10
|
||||
assert args.devices == 10
|
||||
assert args.validations == 20
|
||||
assert args.reports is True
|
||||
assert args.images is True
|
||||
|
||||
|
||||
def test_demo_data_service_is_idempotent_and_creates_realistic_data(tmp_path):
|
||||
db = session()
|
||||
service = DemoDataService(db, upload_root=tmp_path / "uploads", report_root=tmp_path / "reports")
|
||||
|
||||
summary_first = service.run(customers=10, devices=10, validations=20, images=True)
|
||||
|
||||
assert summary_first["customers"] == 10
|
||||
assert summary_first["devices"] == 10
|
||||
assert summary_first["validations"] == 20
|
||||
assert db.scalar(select(func.count()).select_from(Customer)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Device)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Equipment)) >= 6
|
||||
assert db.scalar(select(func.count()).select_from(Validation)) == 20
|
||||
assert db.scalar(select(func.count()).select_from(Contact)) >= 10
|
||||
assert list((tmp_path / "uploads").rglob("*.svg"))
|
||||
|
||||
summary_second = service.run(customers=10, devices=10, validations=20, images=True)
|
||||
|
||||
assert summary_second["customers"] == 10
|
||||
assert db.scalar(select(func.count()).select_from(Customer)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Device)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Validation)) == 20
|
||||
|
||||
|
||||
def test_demo_data_reset_removes_only_demo_rows(tmp_path):
|
||||
db = session()
|
||||
real_customer = Customer(
|
||||
customer_type=CustomerType.practice,
|
||||
name="Reale Praxis",
|
||||
email="real@example.com",
|
||||
)
|
||||
db.add(real_customer)
|
||||
db.flush()
|
||||
|
||||
service = DemoDataService(db, upload_root=tmp_path / "uploads", report_root=tmp_path / "reports")
|
||||
service.run(customers=10, devices=10, validations=20, images=True)
|
||||
service.reset()
|
||||
|
||||
assert db.scalar(select(Customer).where(Customer.email == "real@example.com")) is not None
|
||||
assert db.scalar(select(Customer).where(Customer.notes.like("%DEMO_DATA%"))) is None
|
||||
assert db.scalar(select(Device).where(Device.notes.like("%DEMO_DATA%"))) is None
|
||||
assert db.scalar(select(Validation).where(Validation.notes.like("%DEMO_DATA%"))) is None
|
||||
|
||||
|
||||
def test_demo_data_cli_command_uses_sessionlocal(monkeypatch):
|
||||
db = session()
|
||||
monkeypatch.setattr(app_cli, "SessionLocal", lambda: db)
|
||||
|
||||
args = app_cli.build_parser().parse_args(["demo-data", "--customers", "10", "--devices", "10", "--validations", "20"])
|
||||
exit_code = app_cli.cmd_demo_data(args)
|
||||
|
||||
assert exit_code == 0
|
||||
assert db.scalar(select(func.count()).select_from(Customer)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Device)) == 10
|
||||
assert db.scalar(select(func.count()).select_from(Validation)) == 20
|
||||
|
|
@ -20,10 +20,11 @@ from app.models.validation import Validation, ValidationStatus
|
|||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.reference_masterdata import ReferenceMasterdataImportService
|
||||
from app.services.quickstart_service import QuickStartService
|
||||
from app.api.v1.auth import login as auth_login
|
||||
from app.api.v1 import domain as domain_api
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.domain import UserCreate, UserUpdate, ValidationCreate
|
||||
from app.schemas.domain import QuickStartCreateRequest, UserCreate, UserUpdate, ValidationCreate
|
||||
from app.modules.orion.service import OrionReportService
|
||||
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
||||
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
|
||||
|
|
@ -277,6 +278,36 @@ def test_user_list_pagination_and_filters_work():
|
|||
assert role_filtered["items"][0].role == UserRole.ADMIN.value
|
||||
|
||||
|
||||
def test_paginated_lists_expose_pages_for_masterdata_and_validations():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
db.add(valid_validation(customer, location, device))
|
||||
db.flush()
|
||||
|
||||
customers = domain_api.list_customers(params={"page": 1, "page_size": 20, "search": None}, session=db)
|
||||
devices = domain_api.list_devices(params={"page": 1, "page_size": 20, "search": None}, session=db)
|
||||
validations = domain_api.list_validations(
|
||||
search=None,
|
||||
page=1,
|
||||
page_size=20,
|
||||
sort_by="created_at",
|
||||
sort_order="desc",
|
||||
status=None,
|
||||
customer_id=None,
|
||||
device_id=None,
|
||||
validation_type=None,
|
||||
result=None,
|
||||
date_from=None,
|
||||
date_to=None,
|
||||
overdue_only=False,
|
||||
session=db,
|
||||
)
|
||||
|
||||
assert customers["pages"] == 1
|
||||
assert devices["pages"] == 1
|
||||
assert validations["pages"] == 1
|
||||
|
||||
|
||||
def test_admin_can_create_users_with_supported_roles():
|
||||
db = session()
|
||||
admin = User(
|
||||
|
|
@ -636,6 +667,144 @@ def test_reference_masterdata_import_can_create_validation():
|
|||
assert validation.status == ValidationStatus.draft.value
|
||||
|
||||
|
||||
def test_quickstart_customer_data_and_validation_creation():
|
||||
db = session()
|
||||
reference = next(
|
||||
candidate
|
||||
for parent in Path(__file__).resolve().parents
|
||||
if (candidate := parent / "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx").exists()
|
||||
)
|
||||
ReferenceMasterdataImportService(db).import_reference_docx(reference, create_validation=True)
|
||||
customer = db.scalar(select(Customer).where(Customer.name == "Urologische Praxis Dr. Durmaz"))
|
||||
assert customer is not None
|
||||
customer_data = QuickStartService(db).customer_data(customer.id)
|
||||
assert customer_data.locations
|
||||
assert customer_data.contacts
|
||||
assert customer_data.devices
|
||||
assert customer_data.validations
|
||||
|
||||
admin = User(
|
||||
email="quickstart-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
created = QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
location_id=customer_data.locations[0].id,
|
||||
contact_id=customer_data.contacts[0].id,
|
||||
device_id=customer_data.devices[0].id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
db.commit()
|
||||
assert created.id is not None
|
||||
assert created.status == ValidationStatus.draft.value
|
||||
assert created.customer_id == customer.id
|
||||
assert created.examiner_id == admin.id
|
||||
assert created.documentation_checklist
|
||||
assert created.programs
|
||||
assert created.loading_patterns
|
||||
|
||||
|
||||
def test_quickstart_auto_selects_single_customer_masterdata():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
contact = seed_contact(db, customer)
|
||||
admin = User(
|
||||
email="single-data-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
created = QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
validation_type="Erstvalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
assert created.customer_id == customer.id
|
||||
assert created.location_id == location.id
|
||||
assert created.contact_id == contact.id
|
||||
assert created.device_id == device.id
|
||||
|
||||
|
||||
def test_quickstart_device_last_validation_returns_latest_completed_validation():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
draft = valid_validation(customer, location, device)
|
||||
draft.status = ValidationStatus.draft.value
|
||||
completed = valid_validation(customer, location, device)
|
||||
completed.report_number = "VAL-PAST"
|
||||
completed.status = ValidationStatus.completed.value
|
||||
completed.performed_on = date(2026, 1, 1)
|
||||
db.add_all([draft, completed])
|
||||
db.flush()
|
||||
|
||||
summary = QuickStartService(db).device_last_validation(device.id)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.report_number == "VAL-PAST"
|
||||
assert summary.device_id == device.id
|
||||
|
||||
|
||||
def test_quickstart_revalidation_copies_structured_data_but_not_measurements():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
admin = User(
|
||||
email="revalidation-admin@schubamed.de",
|
||||
first_name="Quickstart",
|
||||
last_name="Admin",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
base = valid_validation(customer, location, device)
|
||||
base.status = ValidationStatus.approved.value
|
||||
base.documentation_checklist = [{"text": "Prüfung A", "value": "yes"}]
|
||||
base.performance_checklist = [{"text": "Prüfung B", "value": "no"}]
|
||||
base.programs = [{"name": "Vakuumtest", "selected": True}]
|
||||
base.loading_patterns = [{"run": 1, "pattern": "Standard", "description": "Basis", "images": ["x"]}]
|
||||
base.measurement_data = [{"field": "temperatur"}]
|
||||
base.attachments = [{"filename": "bild.png"}]
|
||||
db.add_all([admin, base])
|
||||
db.flush()
|
||||
|
||||
created = QuickStartService(db).create_validation(
|
||||
QuickStartCreateRequest(
|
||||
customer_id=customer.id,
|
||||
device_id=device.id,
|
||||
validation_type="Revalidierung",
|
||||
),
|
||||
examiner=admin,
|
||||
)
|
||||
|
||||
assert created.previous_validation_id == base.id
|
||||
assert created.version == 2
|
||||
assert created.documentation_checklist == base.documentation_checklist
|
||||
assert created.performance_checklist == base.performance_checklist
|
||||
assert created.programs == base.programs
|
||||
assert created.loading_patterns == base.loading_patterns
|
||||
assert created.measurement_data == []
|
||||
assert created.attachments == []
|
||||
|
||||
|
||||
def test_revalidation_date_uses_calendar_months():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue