Opgave 10: fortløbende ordrenummer pr. runde + afhentningsliste

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 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-29 00:21:45 +02:00
parent 3592fbf845
commit b6b14aefac
7 changed files with 175 additions and 5 deletions

View file

@ -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

View file

@ -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):

View file

@ -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 = [

View file

@ -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},
)

View file

@ -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 "",

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="utf-8">
<title>Afhentningsliste — {{ route_name }} — {{ round_name }}</title>
<style>
body { font-family: sans-serif; }
.meta-header { font-size: 0.8rem; color: #666; margin-bottom: 1cm; }
.order-page { page-break-before: always; position: relative; padding-top: 0.5cm; }
.order-page:first-of-type { page-break-before: avoid; }
.order-number { position: absolute; top: 0; right: 0; font-size: 2.5rem; font-weight: bold; }
h1 { margin-top: 0; margin-right: 3cm; }
table { width: 100%; margin-top: 0.5cm; border-collapse: collapse; }
td { padding: 2px 6px; }
.category-row td { font-weight: bold; padding-top: 10px; }
@media print {
.order-page { page-break-before: always; }
.order-page:first-of-type { page-break-before: avoid; }
}
</style>
</head>
<body>
<p class="meta-header">{{ route_name }} — {{ round_name }}</p>
{% for order in orders %}
<div class="order-page">
<div class="order-number">#{{ order.order_number }}</div>
<h1>{{ order.participant_name }}</h1>
<table>
<tr><td>Telefon:</td><td>{{ order.participant_phone or "" }}</td><td>email:</td><td>{{ order.participant_email }}</td></tr>
<tr><td>Flasker:</td><td>{{ order.total_bottles }}</td><td>Kasser:</td><td>{{ order.total_cases }}</td></tr>
</table>
{% for category_name, lines in order.categories %}
<table>
<tr class="category-row"><td colspan="2">{{ category_name }}</td></tr>
{% for line in lines %}
<tr><td>{{ line.quantity }}</td><td>{{ line.wine_name }}</td></tr>
{% endfor %}
</table>
{% endfor %}
</div>
{% endfor %}
</body>
</html>

View file

@ -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 ###