refactor(B1): migrate to domain-driven project structure
Move all models/schemas/services/routers into app/domains/. Keep backward-compat shims in old locations for imports. Preserves domains/rendering/tasks.py from Phase A. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Order service — order number generation and business logic."""
|
||||
from datetime import datetime
|
||||
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__)
|
||||
|
||||
|
||||
async def generate_order_number(db: AsyncSession) -> str:
|
||||
"""Generate next sequential order number: SA-2026-XXXXX."""
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"SA-{year}-"
|
||||
|
||||
# Use MAX to find the highest existing sequence number this year.
|
||||
# COUNT-based approach breaks when orders are deleted (produces duplicates).
|
||||
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 = datetime.utcnow()
|
||||
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)")
|
||||
|
||||
# Notify order creator
|
||||
try:
|
||||
from app.domains.notifications.service import emit_notification_sync
|
||||
emit_notification_sync(
|
||||
actor_user_id=None,
|
||||
target_user_id=str(order.created_by),
|
||||
action="order.completed",
|
||||
entity_type="order",
|
||||
entity_id=str(order_id),
|
||||
details={"order_number": order.order_number},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to emit order.completed notification")
|
||||
|
||||
return True
|
||||
|
||||
finally:
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user