Skip to content

Commit d2534fb

Browse files
committed
feat: articles v2
1 parent 0a4f491 commit d2534fb

16 files changed

Lines changed: 506 additions & 157 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ api/*.pyc
66
api/.env
77
web/.env
88
web/goupixdex.key
9-
web/goupixdex.key.pub
9+
web/goupixdex.key.pub
10+
dashboard-main
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Realized sale amount and channel (Vinted / eBay).
2+
3+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS sold_price DECIMAL(12, 2) NULL;
4+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS sale_source VARCHAR(16) NULL;

api/models/article.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class Article(Base):
2525
condition: Mapped[str] = mapped_column(String(64), default="Near Mint")
2626
purchase_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
2727
sell_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
28+
# Actual proceeds; sale_source is vinted | ebay
29+
sold_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
30+
sale_source: Mapped[str | None] = mapped_column(String(16), nullable=True)
2831
is_sold: Mapped[bool] = mapped_column(Boolean(), default=False)
2932
created_at: Mapped[dt.datetime] = mapped_column(
3033
DateTime(timezone=True),

api/routes/articles.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,8 @@ def mark_sold(
555555
raise HTTPException(status_code=404, detail="Article not found")
556556
article.is_sold = True
557557
article.sold_at = dt.datetime.now(dt.UTC)
558-
article.sell_price = body.sell_price
558+
article.sold_price = body.sold_price
559+
article.sale_source = body.sale_source
559560
db.commit()
560561
db.refresh(article)
561562
return article_service.article_to_dict(article)

api/schemas/articles.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from decimal import Decimal
2+
from typing import Literal
23

34
from pydantic import BaseModel, Field
45

@@ -15,7 +16,10 @@ class ArticleUpdate(BaseModel):
1516

1617

1718
class SoldPatch(BaseModel):
18-
sell_price: Decimal = Field(ge=0)
19+
"""Mark as sold: actual proceeds and channel (listing price ``sell_price`` is unchanged)."""
20+
21+
sold_price: Decimal = Field(ge=0)
22+
sale_source: Literal["vinted", "ebay"] = "vinted"
1923

2024

2125
class BulkIdsBody(BaseModel):

api/seeders/article_seeder.py

Lines changed: 133 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_DEV_PREFIX = "[DEV] "
1717

1818

19-
class _ArticleSeed(TypedDict):
19+
class _ArticleSeed(TypedDict, total=False):
2020
title: str
2121
description: str
2222
pokemon_name: str | None
@@ -25,8 +25,12 @@ class _ArticleSeed(TypedDict):
2525
condition: str
2626
purchase_price: Decimal
2727
sell_price: Decimal | None
28+
sold_price: Decimal | None
29+
sale_source: str | None
2830
is_sold: bool
2931
sold_at: dt.datetime | None
32+
published_on_vinted: bool
33+
published_on_ebay: bool
3034
images: list[str]
3135

3236

@@ -38,6 +42,121 @@ def _load_dotenv() -> None:
3842
load_dotenv(_ROOT / ".env")
3943

4044

45+
def _specs() -> list[tuple[str, str, str]]:
46+
"""(pokemon, set_code, card_number) — long list for pagination / dashboard tests."""
47+
return [
48+
("Pikachu", "SV8", "025/162"),
49+
("Mewtwo", "M1L", "131/086"),
50+
("Herbizarre", "M1L", "065/063"),
51+
("Évoli", "SV-P", "040"),
52+
("Dracaufeu", "SV3", "125/198"),
53+
("Tortank", "SV3", "184/198"),
54+
("Florizarre", "SV3", "003/198"),
55+
("Lucario", "SV4", "078/182"),
56+
("Amphinobi", "SV2", "054/198"),
57+
("Garchomp", "SV4", "096/182"),
58+
("Tyranocif", "SV4", "066/182"),
59+
("Métalosse", "SV4", "112/182"),
60+
("Zacian", "SSH", "138/202"),
61+
("Zamazenta", "SSH", "139/202"),
62+
("Éthernatos", "SV4", "141/182"),
63+
("Palkia", "SV5", "204/191"),
64+
("Dialga", "SV5", "205/191"),
65+
("Rayquaza", "SV6", "029/167"),
66+
("Latias", "SV6", "073/167"),
67+
("Latios", "SV6", "074/167"),
68+
("Mew", "SV4", "053/182"),
69+
("Celebi", "SV4", "004/182"),
70+
("Ho-Oh", "SV4", "144/182"),
71+
("Lugia", "SV4", "145/182"),
72+
("Kyogre", "SV5", "032/191"),
73+
("Groudon", "SV5", "033/191"),
74+
("Giratina", "SV5", "130/191"),
75+
("Arceus", "SV5", "166/191"),
76+
("Darkrai", "SV5", "077/191"),
77+
("Genesect", "SV5", "181/191"),
78+
("Volcanion", "SV6", "136/167"),
79+
("Magearna", "SV6", "131/167"),
80+
("Marshadow", "SV6", "080/167"),
81+
("Zeraora", "SV6", "152/167"),
82+
("Évoli V", "SWSH", "065/203"),
83+
("Évoli VMAX", "SWSH", "066/203"),
84+
("Pikachu V", "SWSH", "043/172"),
85+
("Pikachu VMAX", "SWSH", "044/172"),
86+
("Raichu", "SV1", "026/198"),
87+
("Raichu Alola", "SV1", "027/198"),
88+
("Mimiqui", "SV2", "097/198"),
89+
("Boréas", "SV3", "144/198"),
90+
("Fulguris", "SV3", "145/198"),
91+
("Démétéros", "SV3", "146/198"),
92+
("Kyurem", "SV3", "047/198"),
93+
("Reshiram", "SV3", "048/198"),
94+
("Zekrom", "SV3", "049/198"),
95+
("Nymphali", "SV8", "075/162"),
96+
("Aquali", "SV8", "076/162"),
97+
("Pyroli", "SV8", "077/162"),
98+
("Voltali", "SV8", "078/162"),
99+
("Phyllali", "SV8", "079/162"),
100+
("Givrali", "SV8", "080/162"),
101+
("Noctali", "SV8", "081/162"),
102+
("Mentali", "SV8", "082/162"),
103+
("Dimoret", "SV8", "083/162"),
104+
("Nigosier", "SV8", "084/162"),
105+
("Couverdure", "SV8", "085/162"),
106+
("Motisma", "SV8", "086/162"),
107+
("Motisma", "SV8", "087/162"),
108+
("Motisma", "SV8", "088/162"),
109+
]
110+
111+
112+
def _build_samples(now: dt.datetime) -> list[_ArticleSeed]:
113+
specs = _specs()
114+
out: list[_ArticleSeed] = []
115+
for i, (pokemon, set_code, card_number) in enumerate(specs):
116+
n = i + 1
117+
purchase = Decimal("2.00") + Decimal(i % 17) * Decimal("1.25")
118+
sell = (purchase * Decimal("1.35")).quantize(Decimal("0.01"))
119+
# Mix: many sold vs in-stock; varied marketplace flags
120+
sold = i % 5 != 0 and i % 7 != 1
121+
vinted_pub = not sold and (i % 3 == 0 or i % 11 == 2)
122+
ebay_pub = not sold and (i % 4 == 1) and (i % 6 != 0)
123+
124+
sold_at: dt.datetime | None = None
125+
sold_price: Decimal | None = None
126+
sale_src: str | None = None
127+
if sold:
128+
sold_at = now - dt.timedelta(days=(i % 90) + 1)
129+
sale_src = "ebay" if i % 3 == 0 else "vinted"
130+
# Realized price sometimes slightly below listed price
131+
sold_price = (sell - Decimal("0.50")) if i % 5 == 0 else sell
132+
133+
seed_url = f"https://picsum.photos/seed/goupix-dev-{n}/400/560"
134+
imgs: list[str] = [] if i % 9 == 0 else [seed_url]
135+
if i % 13 == 0:
136+
imgs = [seed_url, f"https://picsum.photos/seed/goupix-dev-{n}b/400/560"]
137+
138+
row: _ArticleSeed = {
139+
"title": f"{_DEV_PREFIX}{pokemon}{set_code} {card_number}",
140+
"description": f"Seed dev #{n}\nSérie {set_code}\nÉtat Near Mint",
141+
"pokemon_name": pokemon,
142+
"set_code": set_code,
143+
"card_number": card_number,
144+
"condition": "Near Mint",
145+
"purchase_price": purchase,
146+
"sell_price": None if sold and i % 8 == 3 else sell,
147+
"sold_price": sold_price,
148+
"sale_source": sale_src,
149+
"is_sold": sold,
150+
"sold_at": sold_at,
151+
"published_on_vinted": vinted_pub,
152+
"published_on_ebay": ebay_pub,
153+
"images": imgs,
154+
}
155+
out.append(row)
156+
157+
return out
158+
159+
41160
def main() -> None:
42161
_load_dotenv()
43162

@@ -75,78 +194,26 @@ def main() -> None:
75194
print(f"Removed {removed} previous dev article(s).")
76195

77196
now = dt.datetime.now(dt.UTC)
78-
samples: list[_ArticleSeed] = [
79-
{
80-
"title": f"{_DEV_PREFIX}Pikachu — Couronne Stellaire SV8 025/162 NM",
81-
"description": "Langue : Japonais\nSérie : Couronne Stellaire\nÉtat : Near Mint\nCarte de test seed.",
82-
"pokemon_name": "Pikachu",
83-
"set_code": "SV8",
84-
"card_number": "025/162",
85-
"condition": "Near Mint",
86-
"purchase_price": Decimal("3.50"),
87-
"sell_price": None,
88-
"is_sold": False,
89-
"sold_at": None,
90-
"images": ["https://picsum.photos/seed/goupix-pika/400/560"],
91-
},
92-
{
93-
"title": f"{_DEV_PREFIX}Mewtwo ex — M1L 131/086",
94-
"description": "Exemple listing FR / test données.\nNuméro : 131/086\nÉtat : Near Mint",
95-
"pokemon_name": "Mewtwo",
96-
"set_code": "M1L",
97-
"card_number": "131/086",
98-
"condition": "Near Mint",
99-
"purchase_price": Decimal("12.00"),
100-
"sell_price": Decimal("22.90"),
101-
"is_sold": True,
102-
"sold_at": now,
103-
"images": [
104-
"https://picsum.photos/seed/goupix-m2a/400/560",
105-
"https://picsum.photos/seed/goupix-m2b/400/560",
106-
],
107-
},
108-
{
109-
"title": f"{_DEV_PREFIX}Herbizarre AR — Mega Brave 065/063",
110-
"description": "Langue : Japonais\nNom : Herbizarre / Ivysaur\nNuméro : 065/063 AR",
111-
"pokemon_name": "Herbizarre",
112-
"set_code": "M1L",
113-
"card_number": "065/063",
114-
"condition": "Near Mint",
115-
"purchase_price": Decimal("8.25"),
116-
"sell_price": None,
117-
"is_sold": False,
118-
"sold_at": None,
119-
"images": [],
120-
},
121-
{
122-
"title": f"{_DEV_PREFIX}Évoli promo — SV-P 040",
123-
"description": "Carte promo seed.\nÉtat : Near Mint",
124-
"pokemon_name": "Évoli",
125-
"set_code": "SV-P",
126-
"card_number": "040",
127-
"condition": "Near Mint",
128-
"purchase_price": Decimal("1.00"),
129-
"sell_price": None,
130-
"is_sold": False,
131-
"sold_at": None,
132-
"images": ["https://picsum.photos/seed/goupix-eevee/400/560"],
133-
},
134-
]
197+
samples = _build_samples(now)
135198

136199
for s in samples:
137-
imgs = s["images"]
200+
imgs = s.get("images") or []
138201
article = Article(
139202
user_id=user.id,
140203
title=s["title"],
141204
description=s["description"],
142-
pokemon_name=s["pokemon_name"],
143-
set_code=s["set_code"],
144-
card_number=s["card_number"],
145-
condition=s["condition"],
205+
pokemon_name=s.get("pokemon_name"),
206+
set_code=s.get("set_code"),
207+
card_number=s.get("card_number"),
208+
condition=s.get("condition") or "Near Mint",
146209
purchase_price=s["purchase_price"],
147-
sell_price=s["sell_price"],
148-
is_sold=s["is_sold"],
149-
sold_at=s["sold_at"],
210+
sell_price=s.get("sell_price"),
211+
sold_price=s.get("sold_price"),
212+
sale_source=s.get("sale_source"),
213+
is_sold=bool(s.get("is_sold")),
214+
sold_at=s.get("sold_at"),
215+
published_on_vinted=bool(s.get("published_on_vinted")),
216+
published_on_ebay=bool(s.get("published_on_ebay")),
150217
)
151218
db.add(article)
152219
db.flush()

api/services/article_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def article_to_dict(article: Article) -> dict[str, Any]:
4242
"condition": article.condition,
4343
"purchase_price": float(article.purchase_price),
4444
"sell_price": float(article.sell_price) if article.sell_price is not None else None,
45+
"sold_price": float(article.sold_price) if article.sold_price is not None else None,
46+
"sale_source": article.sale_source,
4547
"is_sold": article.is_sold,
4648
"published_on_vinted": bool(article.published_on_vinted),
4749
"vinted_published_at": article.vinted_published_at.isoformat()

api/services/desktop_stubs_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def article_from_api_dict(d: dict[str, Any]) -> Article:
2626
a.purchase_price = Decimal(str(d["purchase_price"]))
2727
sp = d.get("sell_price")
2828
a.sell_price = Decimal(str(sp)) if sp is not None else None
29+
sdp = d.get("sold_price")
30+
a.sold_price = Decimal(str(sdp)) if sdp is not None else None
31+
a.sale_source = d.get("sale_source")
2932
a.is_sold = bool(d.get("is_sold", False))
3033
return a
3134

api/services/stats_service.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,22 @@ def _as_utc(value: dt.datetime | None) -> dt.datetime | None:
2020
return value.astimezone(dt.UTC)
2121

2222

23+
def _sale_proceeds_eur(article: Article) -> float | None:
24+
"""Proceeds: ``sold_price`` when set, otherwise legacy fallback to ``sell_price``."""
25+
if article.sold_price is not None:
26+
return float(article.sold_price)
27+
if article.sell_price is not None:
28+
return float(article.sell_price)
29+
return None
30+
31+
2332
def _profit_eur(article: Article) -> float:
24-
if not article.is_sold or article.sell_price is None:
33+
if not article.is_sold:
34+
return 0.0
35+
proceeds = _sale_proceeds_eur(article)
36+
if proceeds is None:
2537
return 0.0
26-
return float(article.sell_price - article.purchase_price)
38+
return proceeds - float(article.purchase_price)
2739

2840

2941
def _hours_to_sell(article: Article) -> float | None:
@@ -62,7 +74,7 @@ def in_month(a: Article) -> bool:
6274
profit_total = sum(_profit_eur(a) for a in sold)
6375

6476
vinted_revenue = sum(
65-
float(a.sell_price) for a in sold if a.sell_price is not None
77+
p for a in sold for p in [_sale_proceeds_eur(a)] if p is not None
6678
)
6779

6880
top_profitable = sorted(sold, key=_profit_eur, reverse=True)[:5]
@@ -81,10 +93,11 @@ def in_month(a: Article) -> bool:
8193
monthly_revenue: dict[str, float] = {}
8294
for a in sold:
8395
sold_at = _as_utc(a.sold_at)
84-
if sold_at is None or a.sell_price is None:
96+
proceeds = _sale_proceeds_eur(a)
97+
if sold_at is None or proceeds is None:
8598
continue
8699
key = sold_at.strftime("%Y-%m")
87-
monthly_revenue[key] = monthly_revenue.get(key, 0.0) + float(a.sell_price)
100+
monthly_revenue[key] = monthly_revenue.get(key, 0.0) + proceeds
88101
revenue_timeline: list[dict[str, Any]] = []
89102
cumulative_rev = 0.0
90103
for month_key in sorted(monthly_revenue.keys()):
@@ -103,18 +116,23 @@ def in_month(a: Article) -> bool:
103116
key=lambda a: _as_utc(a.sold_at) or now,
104117
reverse=True,
105118
)[:30]
106-
recent_sales_payload = [
107-
{
108-
"article_id": a.id,
109-
"title": a.title,
110-
"pokemon_name": a.pokemon_name,
111-
"sold_at": a.sold_at.isoformat() if a.sold_at else None,
112-
"sell_price_eur": float(a.sell_price) if a.sell_price is not None else None,
113-
"purchase_price_eur": float(a.purchase_price),
114-
"profit_eur": round(_profit_eur(a), 2),
115-
}
116-
for a in recent_sales
117-
]
119+
recent_sales_payload = []
120+
for a in recent_sales:
121+
proceeds = _sale_proceeds_eur(a)
122+
recent_sales_payload.append(
123+
{
124+
"article_id": a.id,
125+
"title": a.title,
126+
"pokemon_name": a.pokemon_name,
127+
"sold_at": a.sold_at.isoformat() if a.sold_at else None,
128+
"listing_price_eur": float(a.sell_price) if a.sell_price is not None else None,
129+
"sold_price_eur": float(a.sold_price) if a.sold_price is not None else None,
130+
"realized_price_eur": round(proceeds, 2) if proceeds is not None else None,
131+
"sale_source": a.sale_source,
132+
"purchase_price_eur": float(a.purchase_price),
133+
"profit_eur": round(_profit_eur(a), 2),
134+
}
135+
)
118136

119137
market_sum: float | None = None
120138
market_sum_unsold: float | None = None

0 commit comments

Comments
 (0)