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:
parent
3592fbf845
commit
b6b14aefac
7 changed files with 175 additions and 5 deletions
31
CLAUDE.md
31
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
43
app/templates/pickup_list.html
Normal file
43
app/templates/pickup_list.html
Normal 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>
|
||||
35
migrations/versions/7b45a4b8221a_add_order_order_number.py
Normal file
35
migrations/versions/7b45a4b8221a_add_order_order_number.py
Normal 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 ###
|
||||
Loading…
Add table
Reference in a new issue