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
|
- **Order** — deltager + runde
|
||||||
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
|
- `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
|
- Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen
|
||||||
login). Admin-ordreoversigt + "markér betalt" (opgave 9): `GET
|
login). Admin-ordreoversigt + "markér betalt" (opgave 9): `GET
|
||||||
/orders` (filtre: `purchase_round_id`, `route_id`,
|
/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
|
800px, hvilket gav et stort tomt mellemrum mellem vinkatalog og
|
||||||
sidebar på smalle skærme.
|
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
|
## Mail-events
|
||||||
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
||||||
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
||||||
|
|
@ -203,7 +229,7 @@ logges/advares om i stedet.
|
||||||
|
|
||||||
## Fase 1 — nuværende scope
|
## Fase 1 — nuværende scope
|
||||||
|
|
||||||
**Færdige opgaver (1-9):**
|
**Færdige opgaver (1-10):**
|
||||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
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
|
`total_price_eur`. `POST /orders/{id}/mark-paid` er en almindelig
|
||||||
admin-handling (ikke superadmin-eleveret — hverken destruktiv eller
|
admin-handling (ikke superadmin-eleveret — hverken destruktiv eller
|
||||||
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
|
irreversibel) der afviser med `409` hvis ordren allerede er betalt.
|
||||||
|
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
||||||
|
og "Afhentningsliste" ovenfor.
|
||||||
|
|
||||||
**Resterende opgaver:**
|
**Resterende opgaver:**
|
||||||
10. Genskab afhentningsliste (HTML-udtræk til print) mod ny datamodel
|
|
||||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
||||||
12. Testkør en rigtig runde gennem hele flowet
|
12. Testkør en rigtig runde gennem hele flowet
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import Column, UniqueConstraint
|
||||||
from sqlalchemy import Enum as SAEnum
|
from sqlalchemy import Enum as SAEnum
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
@ -38,10 +38,12 @@ class OrderBase(SQLModel):
|
||||||
|
|
||||||
class Order(OrderBase, table=True):
|
class Order(OrderBase, table=True):
|
||||||
__tablename__ = "orders"
|
__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)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE")
|
participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE")
|
||||||
purchase_round_id: int = Field(foreign_key="purchase_round.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")
|
participant: "Participant" = Relationship(back_populates="orders")
|
||||||
purchase_round: "PurchaseRound" = Relationship(back_populates="orders")
|
purchase_round: "PurchaseRound" = Relationship(back_populates="orders")
|
||||||
|
|
@ -54,6 +56,7 @@ class OrderPublic(OrderBase):
|
||||||
id: int
|
id: int
|
||||||
participant_id: int
|
participant_id: int
|
||||||
purchase_round_id: int
|
purchase_round_id: int
|
||||||
|
order_number: int
|
||||||
|
|
||||||
|
|
||||||
class OrderConfirmation(OrderPublic):
|
class OrderConfirmation(OrderPublic):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Session, SQLModel, select
|
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)
|
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.add(order)
|
||||||
session.flush()
|
session.flush()
|
||||||
order_lines = [
|
order_lines = [
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Optional
|
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.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.db import SessionDep
|
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_log import MailLog, MailLogStatus
|
||||||
from app.models.mail_template import MailEventType, MailTemplate
|
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.participant import Participant
|
||||||
from app.models.purchase_round import (
|
from app.models.purchase_round import (
|
||||||
PurchaseRound,
|
PurchaseRound,
|
||||||
|
|
@ -29,6 +34,7 @@ from app.services.mail_rendering import format_datetime_da, render_template
|
||||||
from app.services.postal import PostalSendError, send_mail
|
from app.services.postal import PostalSendError, send_mail
|
||||||
|
|
||||||
router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"])
|
router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"])
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
def _commit_or_conflict(session: Session) -> None:
|
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()
|
session.commit()
|
||||||
|
|
||||||
return {"sent": sent, "failed": failed, "total_participants": len(participants)}
|
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,
|
"participant_name": participant.name,
|
||||||
"route_name": route.name,
|
"route_name": route.name,
|
||||||
"round_name": purchase_round.name,
|
"round_name": purchase_round.name,
|
||||||
|
"order_number": str(order.order_number),
|
||||||
"order_deadline_at": format_datetime_da(purchase_round.order_deadline_at)
|
"order_deadline_at": format_datetime_da(purchase_round.order_deadline_at)
|
||||||
if purchase_round.order_deadline_at
|
if purchase_round.order_deadline_at
|
||||||
else "",
|
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