Files
HartOMat/backend/app/domains/orders/service.py
T
HartmutandClaude Sonnet 4.6 507858cf31 fix: medium audit items — CORS config, invoice description, path helper, node failure, order prefix
M3: cors_origins setting in config.py (env CORS_ORIGINS); main.py reads from settings.
M4: add build_order_line_step_render_dir() to render_paths.py; tasks.py drops placeholder.mp4 trick.
M5: unknown workflow graph nodes now fail the run (status="failed" + logger.error) instead of silently skipping.
M6: invoice line description is now "{product} — {output_type}" instead of bare UUID; eager-loads relations.
M7: order_number_prefix setting in config.py (env ORDER_NUMBER_PREFIX, default "SA").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 13:57:04 +02:00

104 lines
3.7 KiB
Python

"""Order service — order number generation and business logic."""
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, create_engine, update as sql_update
from sqlalchemy.orm import Session
from app.domains.orders.models import Order, OrderLine, OrderStatus
import logging
logger = logging.getLogger(__name__)
def _utcnow_naive() -> datetime:
"""Return UTC as a naive datetime for legacy TIMESTAMP WITHOUT TIME ZONE columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
async def generate_order_number(db: AsyncSession) -> str:
"""Generate next sequential order number: {ORDER_NUMBER_PREFIX}-YYYY-XXXXX."""
from sqlalchemy import text
from app.config import settings as _settings
year = datetime.now(timezone.utc).year
prefix = f"{_settings.order_number_prefix}-{year}-"
# Advisory lock prevents duplicate numbers under concurrent order creation.
# Released automatically when the surrounding transaction commits or rolls back.
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"order_number_seq_{year}"})
result = await db.execute(
select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%"))
)
max_num = result.scalar()
if max_num:
last_seq = int(max_num.split("-")[-1])
return f"{prefix}{last_seq + 1:05d}"
return f"{prefix}00001"
def check_order_completion(order_id: str) -> bool:
"""If all renderable lines are done, auto-advance order to completed.
Called from Celery tasks (sync context).
Returns True if the order was advanced to completed.
"""
from app.config import settings as app_settings
sync_url = app_settings.database_url.replace("+asyncpg", "")
engine = create_engine(sync_url)
try:
with Session(engine) as session:
# Get all lines that have an output type (i.e. renderable)
lines = session.execute(
select(OrderLine).where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.isnot(None),
)
).scalars().all()
if not lines:
return False
# Check if all renderable lines are in a terminal state
all_terminal = all(
line.render_status in ("completed", "failed", "cancelled")
for line in lines
)
if not all_terminal:
return False
# Check order is still in processing state
order = session.execute(
select(Order).where(Order.id == order_id)
).scalar_one_or_none()
if order is None or order.status != OrderStatus.processing:
return False
# Auto-advance to completed
now = _utcnow_naive()
session.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(
status=OrderStatus.completed,
completed_at=now,
updated_at=now,
)
)
session.commit()
logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)")
# Emit a single batch notification summarising all render results
try:
from app.domains.notifications.service import emit_batch_render_notification_sync
emit_batch_render_notification_sync(order_id)
except Exception:
logger.exception("Failed to emit batch render notification for order %s", order_id)
return True
finally:
engine.dispose()