Skip to content

Commit dcf9622

Browse files
Leoglmecursoragent
andcommitted
feat: cardmarket order fixes, edit, reimport, and article linking
Fix PDF parser with fixed Cardmarket columns. Add PATCH order lines, PDF reimport by line_index, linkable lines API. Normalize Vinted title uppercase runs on article prefill (VSTAR to Vstar). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0ecbc6b commit dcf9622

11 files changed

Lines changed: 1172 additions & 105 deletions

File tree

api/routes/orders.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,26 @@
44

55
from typing import Annotated, Any
66

7-
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
7+
import json
8+
9+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
810
from sqlalchemy.exc import IntegrityError
911
from sqlalchemy.orm import Session
1012

1113
from core.database import get_db
1214
from core.deps import get_current_user
1315
from models.cardmarket_order import CardmarketOrder
1416
from models.user import User
17+
from schemas.orders import OrderLineUpdate
1518
from services.cardmarket_order_service import (
1619
get_order_detail,
20+
get_order_line_for_user,
21+
list_linkable_order_lines,
1722
list_orders_summary,
1823
match_order_lines,
1924
persist_order_from_parsed,
25+
reimport_order_from_parsed,
26+
update_order_line,
2027
)
2128
from services.cardmarket_pdf_parser import parse_cardmarket_pdf_bytes
2229

@@ -56,6 +63,20 @@ def list_imported_external_ids(
5663
return [r[0] for r in rows]
5764

5865

66+
@router.get("/lines/linkable")
67+
def list_lines_linkable(
68+
db: Annotated[Session, Depends(get_db)],
69+
user: Annotated[User, Depends(get_current_user)],
70+
search: str | None = None,
71+
) -> list[dict[str, Any]]:
72+
"""
73+
Purchase lines that still have free link slots (manual article assignment).
74+
75+
Optional ``search``: space-separated tokens matched on order id, card name, set, number, etc.
76+
"""
77+
return list_linkable_order_lines(db, user.id, search=search)
78+
79+
5980
@router.get("/match")
6081
def match_lines(
6182
db: Annotated[Session, Depends(get_db)],
@@ -93,6 +114,94 @@ def get_order(
93114
return row
94115

95116

117+
def _order_value_error_to_http(exc: ValueError) -> HTTPException:
118+
code = str(exc)
119+
if code == "purchase_line_not_found":
120+
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Purchase line not found.")
121+
if code == "order_not_found":
122+
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found.")
123+
if code == "quantity_below_linked_articles":
124+
return HTTPException(
125+
status_code=status.HTTP_400_BAD_REQUEST,
126+
detail="Quantity cannot be lower than the number of linked articles on this line.",
127+
)
128+
if code == "pdf_order_id_mismatch":
129+
return HTTPException(
130+
status_code=status.HTTP_400_BAD_REQUEST,
131+
detail="This PDF belongs to a different Cardmarket order.",
132+
)
133+
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=code)
134+
135+
136+
@router.patch("/lines/{line_id}")
137+
def patch_order_line(
138+
line_id: int,
139+
body: OrderLineUpdate,
140+
db: Annotated[Session, Depends(get_db)],
141+
user: Annotated[User, Depends(get_current_user)],
142+
) -> dict[str, Any]:
143+
"""Update one purchase line (manual correction after import)."""
144+
ln = get_order_line_for_user(db, user.id, line_id)
145+
if ln is None:
146+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Purchase line not found.")
147+
try:
148+
update_order_line(db, user.id, line_id, body)
149+
except ValueError as exc:
150+
raise _order_value_error_to_http(exc) from exc
151+
detail = get_order_detail(db, user.id, ln.order_id)
152+
if detail is None:
153+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found.")
154+
return detail
155+
156+
157+
@router.post("/{order_id}/reimport")
158+
async def reimport_order_pdf(
159+
order_id: int,
160+
db: Annotated[Session, Depends(get_db)],
161+
user: Annotated[User, Depends(get_current_user)],
162+
file: Annotated[UploadFile, File()],
163+
confirm_linked_line_indexes: str | None = Form(None),
164+
) -> dict[str, Any]:
165+
"""
166+
Re-merge a Cardmarket PDF into an existing order (by line_index).
167+
168+
Optional form field ``confirm_linked_line_indexes``: JSON array of line indexes
169+
to overwrite even when articles are linked.
170+
"""
171+
raw = await file.read()
172+
if not raw:
173+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file.")
174+
try:
175+
parsed = parse_cardmarket_pdf_bytes(raw)
176+
except ValueError as exc:
177+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
178+
179+
confirm: set[int] = set()
180+
if confirm_linked_line_indexes and confirm_linked_line_indexes.strip():
181+
try:
182+
parsed_indexes = json.loads(confirm_linked_line_indexes)
183+
if not isinstance(parsed_indexes, list):
184+
raise ValueError("expected JSON array")
185+
confirm = {int(x) for x in parsed_indexes}
186+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
187+
raise HTTPException(
188+
status_code=status.HTTP_400_BAD_REQUEST,
189+
detail="Invalid confirm_linked_line_indexes (expected JSON array of integers).",
190+
) from exc
191+
192+
try:
193+
return reimport_order_from_parsed(
194+
db,
195+
user.id,
196+
order_id,
197+
parsed,
198+
file.filename,
199+
confirm_linked_line_indexes=confirm,
200+
)
201+
except ValueError as exc:
202+
raise _order_value_error_to_http(exc) from exc
203+
204+
96205
@router.post("/import", status_code=status.HTTP_201_CREATED)
97206
async def import_order_pdf(
98207
db: Annotated[Session, Depends(get_db)],

api/schemas/orders.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Pydantic schemas for Cardmarket order endpoints."""
2+
3+
from __future__ import annotations
4+
5+
from decimal import Decimal
6+
7+
from pydantic import BaseModel, Field, field_validator
8+
9+
10+
class OrderLineUpdate(BaseModel):
11+
"""PATCH body for a single purchase line."""
12+
13+
pokemon_name: str | None = None
14+
set_code: str | None = None
15+
card_number: str | None = None
16+
language_code: str | None = None
17+
condition_label: str | None = None
18+
unit_price_eur: Decimal | None = Field(None, ge=0)
19+
quantity: int | None = Field(None, ge=1)
20+
21+
@field_validator("pokemon_name", "set_code", "card_number", "language_code", "condition_label")
22+
@classmethod
23+
def strip_optional_strings(cls, v: str | None) -> str | None:
24+
if v is None:
25+
return None
26+
s = v.strip()
27+
return s if s else None
28+
29+
30+
class OrderReimportBody(BaseModel):
31+
"""Optional JSON fields alongside PDF upload on reimport."""
32+
33+
confirm_linked_line_indexes: list[int] = Field(default_factory=list)

0 commit comments

Comments
 (0)