From b6b14aefac5b4f95ba81aa1b4743c19bfcfcb839 Mon Sep 17 00:00:00 2001 From: carsten Date: Tue, 29 Sep 2026 00:21:45 +0200 Subject: [PATCH] =?UTF-8?q?Opgave=2010:=20fortl=C3=B8bende=20ordrenummer?= =?UTF-8?q?=20pr.=20runde=20+=20afhentningsliste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order.order_number tildeles atomisk i POST /public/orders via en SELECT ... FOR UPDATE-lås på rundens række + MAX+1, så samtidige bestillinger lige efter en runde åbner ikke kan kollidere. Nummeret starter på 1 for hver runde (UNIQUE(purchase_round_id, order_number)) og er nu tilgængeligt som {{order_number}} i mail-skabeloner. GET /purchase-rounds/{id}/pickup-list genskaber den gamle systems afhentningsliste (delt af brugeren som reference) mod den nye datamodel: én printside pr. ordre, sorteret efter order_number (vist stort i øverste højre hjørne), kontaktinfo, flaske-/kasseantal (pænt afrundet — originalen viste et urundet float), og vinlinjer grupperet pr. kategori. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 31 ++++++++++- app/models/order.py | 5 +- app/routers/public_orders.py | 12 ++++- app/routers/purchase_rounds.py | 53 ++++++++++++++++++- app/services/order_mail.py | 1 + app/templates/pickup_list.html | 43 +++++++++++++++ .../7b45a4b8221a_add_order_order_number.py | 35 ++++++++++++ 7 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 app/templates/pickup_list.html create mode 100644 migrations/versions/7b45a4b8221a_add_order_order_number.py diff --git a/CLAUDE.md b/CLAUDE.md index 924d6c3..15f740f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,14 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun - **Order** — deltager + runde - `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at` + - `order_number` (opgave 10) — fortløbende nummer **pr. runde** + (starter på 1 for hver runde), `UNIQUE(purchase_round_id, + order_number)`. Tildeles atomisk i `POST /public/orders` via en + `SELECT ... FOR UPDATE`-lås på rundens række + `MAX+1`, så + samtidige bestillinger lige efter en runde åbner ikke kan kollidere + eller give huller i nummerrækken. Bruges som sorteringsnøgle og + stort synligt nummer på afhentningslisten (se nedenfor), og er + tilgængelig som `{{order_number}}` i mail-skabeloner - Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen login). Admin-ordreoversigt + "markér betalt" (opgave 9): `GET /orders` (filtre: `purchase_round_id`, `route_id`, @@ -171,6 +179,24 @@ udleder "hvilken rute/runde gælder denne request". 800px, hvilket gav et stort tomt mellemrum mellem vinkatalog og sidebar på smalle skærme. +## Afhentningsliste (opgave 10) + +`GET /purchase-rounds/{round_id}/pickup-list` — admin, org-scoped via +den eksisterende `get_round_in_organization`. Genskaber det gamle +systems eksporterede afhentningsliste (delt af brugeren som +reference: `Bestillinger.html`) mod den nye datamodel: én printside +pr. ordre (`page-break-before` i CSS), sorteret efter `order_number` +(se Order ovenfor), med ordrenummeret stort i øverste højre hjørne af +hver side. Pr. ordre vises kontaktinfo, flaske-/kasseantal (`Kasser = +flasker / 6`, afrundet til 2 decimaler — originalen viste et urundet +float som fx `0.8333333333333334`, det er rettet), og én tabel pr. +vinkategori (kategorinavn + antal+vinnavn pr. linje, ingen priser — +det er en pakkeliste, ikke en kvittering). Almindelig `HTMLResponse`; +admin printer siden fra browseren. Renderes via +`app/templates/pickup_list.html` (samme Jinja2-mønster som den +offentlige side, egen `Jinja2Templates`-instans i +`purchase_rounds.py`). + ## Mail-events 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** (opgave 7a-c): skabelon pr. rute (`MailTemplate`, @@ -203,7 +229,7 @@ logges/advares om i stedet. ## Fase 1 — nuværende scope -**Færdige opgaver (1-9):** +**Færdige opgaver (1-10):** 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 2. ✅ Datamodellerne (Organization/Route-hierarki) 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, @@ -249,9 +275,10 @@ logges/advares om i stedet. `total_price_eur`. `POST /orders/{id}/mark-paid` er en almindelig admin-handling (ikke superadmin-eleveret — hverken destruktiv eller irreversibel) der afviser med `409` hvis ordren allerede er betalt. +10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order + og "Afhentningsliste" ovenfor. **Resterende opgaver:** -10. Genskab afhentningsliste (HTML-udtræk til print) mod ny datamodel 11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 12. Testkør en rigtig runde gennem hele flowet diff --git a/app/models/order.py b/app/models/order.py index caa5395..a1a6299 100644 --- a/app/models/order.py +++ b/app/models/order.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from enum import Enum from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import Column +from sqlalchemy import Column, UniqueConstraint from sqlalchemy import Enum as SAEnum from sqlmodel import Field, Relationship, SQLModel @@ -38,10 +38,12 @@ class OrderBase(SQLModel): class Order(OrderBase, table=True): __tablename__ = "orders" + __table_args__ = (UniqueConstraint("purchase_round_id", "order_number", name="uq_order_round_order_number"),) id: Optional[int] = Field(default=None, primary_key=True) participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE") purchase_round_id: int = Field(foreign_key="purchase_round.id", ondelete="CASCADE") + order_number: int = Field() participant: "Participant" = Relationship(back_populates="orders") purchase_round: "PurchaseRound" = Relationship(back_populates="orders") @@ -54,6 +56,7 @@ class OrderPublic(OrderBase): id: int participant_id: int purchase_round_id: int + order_number: int class OrderConfirmation(OrderPublic): diff --git a/app/routers/public_orders.py b/app/routers/public_orders.py index e020a48..d144dc0 100644 --- a/app/routers/public_orders.py +++ b/app/routers/public_orders.py @@ -5,6 +5,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Request, status from pydantic import field_validator +from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlmodel import Field, Session, SQLModel, select @@ -249,7 +250,16 @@ def submit_order(payload: PublicOrderSubmission, request: Request, session: Sess participant = _get_or_create_participant(session, route.id, payload.participant) - order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id) + # Lås rundens række så to samtidige bestillinger ikke kan få samme order_number + session.exec(select(PurchaseRound).where(PurchaseRound.id == purchase_round.id).with_for_update()).one() + next_order_number = ( + session.exec( + select(func.max(Order.order_number)).where(Order.purchase_round_id == purchase_round.id) + ).first() + or 0 + ) + 1 + + order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id, order_number=next_order_number) session.add(order) session.flush() order_lines = [ diff --git a/app/routers/purchase_rounds.py b/app/routers/purchase_rounds.py index 8d287ff..e0353b4 100644 --- a/app/routers/purchase_rounds.py +++ b/app/routers/purchase_rounds.py @@ -1,8 +1,11 @@ from datetime import datetime, timezone from typing import Annotated, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload from sqlmodel import Session, select from app.db import SessionDep @@ -15,6 +18,8 @@ from app.dependencies import ( ) from app.models.mail_log import MailLog, MailLogStatus from app.models.mail_template import MailEventType, MailTemplate +from app.models.order import Order +from app.models.order_line import OrderLine from app.models.participant import Participant from app.models.purchase_round import ( PurchaseRound, @@ -29,6 +34,7 @@ from app.services.mail_rendering import format_datetime_da, render_template from app.services.postal import PostalSendError, send_mail router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"]) +templates = Jinja2Templates(directory="app/templates") def _commit_or_conflict(session: Session) -> None: @@ -215,3 +221,48 @@ def announce_purchase_round(round_id: int, session: SessionDep, current_user: Cu session.commit() return {"sent": sent, "failed": failed, "total_participants": len(participants)} + + +@router.get("/{round_id}/pickup-list", response_class=HTMLResponse) +def get_pickup_list(round_id: int, request: Request, session: SessionDep, current_user: CurrentUser) -> HTMLResponse: + purchase_round = get_round_in_organization(session, round_id, current_user.organization_id) + + orders = session.exec( + select(Order) + .where(Order.purchase_round_id == round_id) + .options( + selectinload(Order.participant), + selectinload(Order.order_lines).selectinload(OrderLine.wine_offering).selectinload(WineOffering.category), + ) + .order_by(Order.order_number) + ).all() + + rows = [] + for order in orders: + lines_sorted = sorted( + order.order_lines, key=lambda ol: (ol.wine_offering.category.sort_order, ol.wine_offering.id) + ) + lines_by_category: dict[str, list[dict]] = {} + total_bottles = 0 + for ol in lines_sorted: + lines_by_category.setdefault(ol.wine_offering.category.name, []).append( + {"quantity": ol.quantity, "wine_name": ol.wine_offering.name} + ) + total_bottles += ol.quantity + rows.append( + { + "order_number": order.order_number, + "participant_name": order.participant.name, + "participant_phone": order.participant.phone, + "participant_email": order.participant.email, + "total_bottles": total_bottles, + "total_cases": f"{total_bottles / 6:.2f}", + "categories": list(lines_by_category.items()), + } + ) + + return templates.TemplateResponse( + request, + "pickup_list.html", + {"route_name": purchase_round.route.name, "round_name": purchase_round.name, "orders": rows}, + ) diff --git a/app/services/order_mail.py b/app/services/order_mail.py index 5bd2285..52ddefc 100644 --- a/app/services/order_mail.py +++ b/app/services/order_mail.py @@ -68,6 +68,7 @@ def send_order_event_mail( "participant_name": participant.name, "route_name": route.name, "round_name": purchase_round.name, + "order_number": str(order.order_number), "order_deadline_at": format_datetime_da(purchase_round.order_deadline_at) if purchase_round.order_deadline_at else "", diff --git a/app/templates/pickup_list.html b/app/templates/pickup_list.html new file mode 100644 index 0000000..d28924a --- /dev/null +++ b/app/templates/pickup_list.html @@ -0,0 +1,43 @@ + + + + + Afhentningsliste — {{ route_name }} — {{ round_name }} + + + +

{{ route_name }} — {{ round_name }}

+ {% for order in orders %} +
+
#{{ order.order_number }}
+

{{ order.participant_name }}

+ + + +
Telefon:{{ order.participant_phone or "" }}email:{{ order.participant_email }}
Flasker:{{ order.total_bottles }}Kasser:{{ order.total_cases }}
+ {% for category_name, lines in order.categories %} + + + {% for line in lines %} + + {% endfor %} +
{{ category_name }}
{{ line.quantity }}{{ line.wine_name }}
+ {% endfor %} +
+ {% endfor %} + + diff --git a/migrations/versions/7b45a4b8221a_add_order_order_number.py b/migrations/versions/7b45a4b8221a_add_order_order_number.py new file mode 100644 index 0000000..18d3bc4 --- /dev/null +++ b/migrations/versions/7b45a4b8221a_add_order_order_number.py @@ -0,0 +1,35 @@ +"""add order order_number + +Revision ID: 7b45a4b8221a +Revises: 6ec72407c24e +Create Date: 2026-09-29 00:19:14.418977 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '7b45a4b8221a' +down_revision: Union[str, Sequence[str], None] = '6ec72407c24e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('orders', sa.Column('order_number', sa.Integer(), nullable=False)) + op.create_unique_constraint('uq_order_round_order_number', 'orders', ['purchase_round_id', 'order_number']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_order_round_order_number', 'orders', type_='unique') + op.drop_column('orders', 'order_number') + # ### end Alembic commands ###