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 }}
+
+
+
+
+ {% 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 %}
+
+ | {{ category_name }} |
+ {% for line in lines %}
+ | {{ line.quantity }} | {{ line.wine_name }} |
+ {% endfor %}
+
+ {% 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 ###