Skip to content

Commit 0d2cce9

Browse files
committed
fix: add cardmarket order functionality
1 parent e9efe7c commit 0d2cce9

38 files changed

Lines changed: 2762 additions & 47 deletions

api/core/database.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
from sqlalchemy.orm import Session, sessionmaker
77

88
from config import get_settings
9-
from models import Article, Base, Image, MarginSettings, User # noqa: F401
9+
from models import ( # noqa: F401
10+
Article,
11+
Base,
12+
CardmarketOrder,
13+
CardmarketOrderLine,
14+
Image,
15+
MarginSettings,
16+
User,
17+
)
1018

1119
_settings = get_settings()
1220
engine = create_engine(

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from routes import settings_route
2828
from routes import shipping_route
2929
from routes import stats_route
30+
from routes import orders as orders_routes
3031
from routes import users as users_routes
3132

3233
from core.win32_asyncio import ensure_proactor_event_loop
@@ -100,6 +101,7 @@ def health() -> dict[str, str]:
100101
app.include_router(users_routes.router)
101102
app.include_router(access_requests_routes.router)
102103
app.include_router(articles_routes.router)
104+
app.include_router(orders_routes.router)
103105
app.include_router(settings_route.router)
104106
app.include_router(ebay_route.router)
105107
app.include_router(ebay_market_route.router)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- Cardmarket purchase orders (PDF import) and link to articles
2+
3+
CREATE TABLE IF NOT EXISTS cardmarket_orders (
4+
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
5+
user_id BIGINT NOT NULL,
6+
external_order_id VARCHAR(32) NOT NULL,
7+
seller_username VARCHAR(255) NULL,
8+
seller_display_name VARCHAR(255) NULL,
9+
seller_country_code CHAR(2) NULL,
10+
paid_at DATETIME(6) NULL,
11+
shipped_at DATETIME(6) NULL,
12+
delivered_at DATETIME(6) NULL,
13+
items_subtotal DECIMAL(12, 2) NOT NULL DEFAULT 0,
14+
shipping_fee DECIMAL(12, 2) NOT NULL DEFAULT 0,
15+
order_total DECIMAL(12, 2) NOT NULL DEFAULT 0,
16+
source_filename VARCHAR(512) NULL,
17+
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
18+
UNIQUE KEY uq_cardmarket_orders_user_external (user_id, external_order_id),
19+
CONSTRAINT fk_cardmarket_orders_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
20+
INDEX idx_cardmarket_orders_user_paid (user_id, paid_at)
21+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
22+
23+
CREATE TABLE IF NOT EXISTS cardmarket_order_lines (
24+
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
25+
order_id BIGINT NOT NULL,
26+
line_index INT NOT NULL DEFAULT 0,
27+
quantity INT NOT NULL DEFAULT 1,
28+
raw_label VARCHAR(1024) NOT NULL,
29+
pokemon_key VARCHAR(255) NULL,
30+
card_number VARCHAR(64) NULL,
31+
language_code VARCHAR(8) NULL,
32+
condition_label VARCHAR(32) NULL,
33+
set_code VARCHAR(64) NULL,
34+
variant_token VARCHAR(32) NULL,
35+
unit_price_eur DECIMAL(12, 2) NOT NULL,
36+
UNIQUE KEY uq_cardmarket_order_lines_order_idx (order_id, line_index),
37+
CONSTRAINT fk_cardmarket_order_lines_order FOREIGN KEY (order_id) REFERENCES cardmarket_orders (id) ON DELETE CASCADE,
38+
INDEX idx_cardmarket_order_lines_match (pokemon_key, set_code, card_number)
39+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
40+
41+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS order_line_id BIGINT NULL;
42+
43+
ALTER TABLE articles
44+
ADD CONSTRAINT fk_articles_order_line
45+
FOREIGN KEY (order_line_id) REFERENCES cardmarket_order_lines (id)
46+
ON DELETE SET NULL;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Longer seller remarks after variant (e.g. "CHR bit of whitening in the back")
2+
3+
ALTER TABLE cardmarket_order_lines
4+
MODIFY COLUMN language_code VARCHAR(16) NULL,
5+
MODIFY COLUMN condition_label VARCHAR(128) NULL,
6+
MODIFY COLUMN variant_token VARCHAR(512) NULL;

api/models/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@
22

33
from models.article import Article
44
from models.base import Base
5+
from models.cardmarket_order import CardmarketOrder
6+
from models.cardmarket_order_line import CardmarketOrderLine
57
from models.image import Image
68
from models.margin_settings import MarginSettings
79
from models.user import User
810

9-
__all__ = ["Article", "Base", "Image", "MarginSettings", "User"]
11+
__all__ = [
12+
"Article",
13+
"Base",
14+
"CardmarketOrder",
15+
"CardmarketOrderLine",
16+
"Image",
17+
"MarginSettings",
18+
"User",
19+
]

api/models/article.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ class Article(Base):
6464
DateTime(timezone=True),
6565
nullable=True,
6666
)
67+
order_line_id: Mapped[int | None] = mapped_column(
68+
ForeignKey("cardmarket_order_lines.id", ondelete="SET NULL"),
69+
nullable=True,
70+
index=True,
71+
)
6772

6873
user: Mapped["User"] = relationship(back_populates="articles")
6974
images: Mapped[list["Image"]] = relationship(back_populates="article", cascade="all, delete-orphan")
75+
order_line: Mapped["CardmarketOrderLine | None"] = relationship("CardmarketOrderLine", back_populates="articles")

api/models/cardmarket_order.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Cardmarket purchase order (imported from PDF)."""
2+
3+
from __future__ import annotations
4+
5+
import datetime as dt
6+
from decimal import Decimal
7+
8+
from sqlalchemy import DateTime, ForeignKey, Numeric, String
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
10+
11+
from models.base import Base
12+
13+
14+
class CardmarketOrder(Base):
15+
"""A Cardmarket order snapshot for one user."""
16+
17+
__tablename__ = "cardmarket_orders"
18+
19+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
20+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
21+
external_order_id: Mapped[str] = mapped_column(String(32), index=True)
22+
seller_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
23+
seller_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
24+
seller_country_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
25+
paid_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
26+
shipped_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
27+
delivered_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
28+
items_subtotal: Mapped[Decimal] = mapped_column(Numeric(12, 2))
29+
shipping_fee: Mapped[Decimal] = mapped_column(Numeric(12, 2))
30+
order_total: Mapped[Decimal] = mapped_column(Numeric(12, 2))
31+
source_filename: Mapped[str | None] = mapped_column(String(512), nullable=True)
32+
created_at: Mapped[dt.datetime] = mapped_column(
33+
DateTime(timezone=True),
34+
default=lambda: dt.datetime.now(dt.UTC),
35+
)
36+
37+
lines: Mapped[list["CardmarketOrderLine"]] = relationship(
38+
"CardmarketOrderLine",
39+
back_populates="order",
40+
cascade="all, delete-orphan",
41+
order_by="CardmarketOrderLine.line_index",
42+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Single line item inside a Cardmarket order."""
2+
3+
from __future__ import annotations
4+
5+
from decimal import Decimal
6+
7+
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text
8+
from sqlalchemy.orm import Mapped, mapped_column, relationship
9+
10+
from models.base import Base
11+
12+
13+
class CardmarketOrderLine(Base):
14+
"""One purchased card row from a Cardmarket PDF."""
15+
16+
__tablename__ = "cardmarket_order_lines"
17+
18+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
19+
order_id: Mapped[int] = mapped_column(ForeignKey("cardmarket_orders.id", ondelete="CASCADE"), index=True)
20+
line_index: Mapped[int] = mapped_column(Integer(), default=0)
21+
quantity: Mapped[int] = mapped_column(Integer(), default=1)
22+
raw_label: Mapped[str] = mapped_column(Text())
23+
pokemon_key: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
24+
card_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
25+
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
26+
condition_label: Mapped[str | None] = mapped_column(String(128), nullable=True)
27+
set_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
28+
variant_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
29+
unit_price_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2))
30+
31+
order: Mapped["CardmarketOrder"] = relationship("CardmarketOrder", back_populates="lines")
32+
articles: Mapped[list["Article"]] = relationship("Article", back_populates="order_line")

api/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ vinted-scraper>=3.0.0
3030

3131
# Shipping label PDF generation (Avery L7173 — 8 labels per A4)
3232
reportlab>=4.0.0
33+
34+
# Cardmarket order PDF text extraction
35+
pypdf>=5.0.0

api/routes/articles.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
VintedBatchStartBody,
2828
)
2929
from services import article_service
30+
from services.cardmarket_order_service import assign_article_order_line
3031
from services.combined_marketplace_service import CombinedMarketplaceService
3132
from services.ebay_background_service import EbayBackgroundService
3233
from services.user_settings_service import ebay_listing_config_complete, get_or_create_user_settings
@@ -53,6 +54,43 @@ def _parse_decimal_required(value: str) -> Decimal:
5354
return Decimal(str(value).strip())
5455

5556

57+
def _parse_optional_order_line_id(raw: str | None) -> int | None:
58+
"""Parse optional multipart ``order_line_id`` field."""
59+
if raw is None or not str(raw).strip():
60+
return None
61+
try:
62+
return int(str(raw).strip())
63+
except ValueError as exc:
64+
raise HTTPException(
65+
status_code=status.HTTP_400_BAD_REQUEST,
66+
detail="Invalid order_line_id (expected integer).",
67+
) from exc
68+
69+
70+
def _apply_order_line_assignment(
71+
db: Session,
72+
user: User,
73+
article: Article,
74+
order_line_id: int | None,
75+
) -> None:
76+
"""Attach purchase line or translate validation errors."""
77+
try:
78+
assign_article_order_line(db, user.id, article, order_line_id)
79+
except ValueError as exc:
80+
code = str(exc)
81+
if code == "purchase_line_not_found":
82+
raise HTTPException(
83+
status_code=status.HTTP_400_BAD_REQUEST,
84+
detail="Ligne d'achat introuvable ou non autorisée.",
85+
) from exc
86+
if code == "purchase_line_full":
87+
raise HTTPException(
88+
status_code=status.HTTP_400_BAD_REQUEST,
89+
detail="Plus de quantité disponible sur cette ligne d'achat.",
90+
) from exc
91+
raise
92+
93+
5694
def _form_bool(value: str | None) -> bool:
5795
if value is None or str(value).strip() == "":
5896
return False
@@ -466,6 +504,7 @@ async def create_article(
466504
graded_grader_value_id: str | None = Form(None),
467505
graded_grade_value_id: str | None = Form(None),
468506
graded_cert_number: str | None = Form(None),
507+
order_line_id: str | None = Form(None),
469508
images: list[UploadFile] | None = File(None),
470509
) -> dict[str, Any]:
471510
settings = get_settings()
@@ -519,6 +558,7 @@ async def create_article(
519558
sold_at=sold_at_dt if sold_flag else None,
520559
)
521560
_validate_graded_article_or_raise(article)
561+
_apply_order_line_assignment(db, user, article, _parse_optional_order_line_id(order_line_id))
522562

523563
if _form_bool(wardrobe_vinted_listed):
524564
article.published_on_vinted = True
@@ -644,6 +684,9 @@ def update_article(
644684
raise HTTPException(status_code=404, detail="Article not found")
645685
article_service.update_article_from_body(article, body)
646686
_validate_graded_article_or_raise(article)
687+
unset = body.model_dump(exclude_unset=True)
688+
if "order_line_id" in unset:
689+
_apply_order_line_assignment(db, user, article, body.order_line_id)
647690
db.commit()
648691
db.refresh(article)
649692
return article_service.article_to_dict(article)

0 commit comments

Comments
 (0)