173 lines
6.2 KiB
Python
173 lines
6.2 KiB
Python
"""Typed data contracts for the pricing pipeline.
|
|
|
|
These mirror the master-tracker columns the Pricing Analyst owns (playbook §11)
|
|
and the Definition of Done (playbook §13). Keeping them as pydantic models means
|
|
every hop in the LangGraph state machine is validated.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class Decision(str, Enum):
|
|
APPROVED = "APPROVED" # -> tracker Status "Pricing Approved"
|
|
BLOCKED = "BLOCKED" # -> tracker Status "Pricing Blocked"
|
|
NEEDS_REVIEW = "NEEDS_REVIEW"
|
|
|
|
|
|
class BuyBoxStatus(str, Enum):
|
|
WON = "WON" # we are the Featured Offer
|
|
LOST_PRICE = "LOST_PRICE" # lost Featured Offer on price
|
|
LOST_ELIGIBILITY = "LOST_ELIGIBILITY" # lost on account/listing eligibility, not price
|
|
SUPPRESSED = "SUPPRESSED" # Buy Box hidden entirely (price significantly high)
|
|
UNKNOWN = "UNKNOWN"
|
|
|
|
|
|
class SkuInput(BaseModel):
|
|
"""One row read from the master tracker before pricing."""
|
|
|
|
sku: str
|
|
asin: str | None = None
|
|
product_name: str = ""
|
|
marketplace: str = "US"
|
|
category: str = ""
|
|
landed_cost: float = Field(..., description="COGS from Inventory Planner, per unit")
|
|
target_price: float = Field(..., description="Proposed / current selling price to evaluate")
|
|
# Optional inputs; if absent we fall back to the fee API / mock.
|
|
fba_fee: float | None = None
|
|
referral_pct: float | None = Field(
|
|
default=None, description="Modeled/negotiated referral fraction, e.g. 0.12"
|
|
)
|
|
dimensions: str | None = None
|
|
weight: str | None = None
|
|
|
|
|
|
class FeeQuote(BaseModel):
|
|
"""Normalized per-unit fee/cost breakdown from a data provider at a given price.
|
|
|
|
COSMOS fills this from `takehome-calculator`; the mock fills it synthetically.
|
|
Dollar amounts are per unit at `selling_price`.
|
|
"""
|
|
|
|
selling_price: float
|
|
landed_cost: float # COGS (purchase + freight + duty)
|
|
fba_fee: float = 0.0
|
|
referral_amt: float = 0.0 # Amazon referral fee, in dollars
|
|
returns_reserve: float = 0.0
|
|
other_fees: float = 0.0 # inbound placement, low-inventory, EPR, VAT, handling…
|
|
net_takehome: float | None = None # provider's authoritative net profit/unit, if given
|
|
source: str = "unknown"
|
|
|
|
|
|
class FeeStack(BaseModel):
|
|
"""Full per-unit fee stack + derived margin figures (playbook SOP A)."""
|
|
|
|
selling_price: float
|
|
landed_cost: float
|
|
fba_fee: float
|
|
referral_pct: float
|
|
referral_amt: float
|
|
returns_reserve: float
|
|
storage_alloc: float
|
|
total_cost: float
|
|
profit: float
|
|
contribution_margin_pct: float
|
|
break_even_price: float
|
|
map_floor: float
|
|
# The referral basis used to derive this stack ("modeled" or "worst_case").
|
|
referral_basis: str = "worst_case"
|
|
|
|
|
|
class CompetitorOffer(BaseModel):
|
|
"""One seller offer on the ASIN's Amazon listing (from Apify offers / Buy Box)."""
|
|
|
|
seller_name: str = "Unknown seller"
|
|
seller_id: str | None = None
|
|
price: float | None = None
|
|
is_buy_box: bool = False
|
|
condition: str | None = None
|
|
# True when seller_id matches APIFY_SELLER_ID — this is our own offer, not a rival.
|
|
# Our own offers are excluded from the competitive band.
|
|
is_ours: bool = False
|
|
|
|
|
|
class CompetitiveSnapshot(BaseModel):
|
|
"""Result of the competitive / Buy Box scan (playbook SOP B)."""
|
|
|
|
asin: str | None = None
|
|
buy_box_status: BuyBoxStatus = BuyBoxStatus.UNKNOWN
|
|
buy_box_price: float | None = None
|
|
buy_box_seller_name: str | None = None
|
|
buy_box_seller_id: str | None = None
|
|
competitive_low: float | None = None
|
|
competitive_median: float | None = None
|
|
competitive_high: float | None = None
|
|
is_suppressed: bool = False
|
|
reason: str = ""
|
|
# Named sellers + prices on this ASIN (featured first when present).
|
|
offers: list[CompetitorOffer] = Field(default_factory=list)
|
|
|
|
|
|
class PricingDecision(BaseModel):
|
|
"""The proposal presented to the human and (on approval) written to the tracker."""
|
|
|
|
sku: str
|
|
asin: str | None = None
|
|
decision: Decision
|
|
recommended_price: float
|
|
fee_stack: FeeStack
|
|
competitive: CompetitiveSnapshot
|
|
# Deterministic gate reasons (from evaluate node).
|
|
gate_reasons: list[str] = Field(default_factory=list)
|
|
# LLM-written, human-readable narrative (rationale node).
|
|
rationale: str = ""
|
|
competitor_summary: str = ""
|
|
suppression_note: str = ""
|
|
recommended_action: str = ""
|
|
|
|
def tracker_row(self) -> dict:
|
|
"""Map to the master-tracker columns this role owns (playbook §11)."""
|
|
fs = self.fee_stack
|
|
status = {
|
|
Decision.APPROVED: "Pricing Approved",
|
|
Decision.BLOCKED: "Pricing Blocked",
|
|
Decision.NEEDS_REVIEW: "Pricing Review",
|
|
}[self.decision]
|
|
return {
|
|
"SKU": self.sku,
|
|
"ASIN": self.asin or "",
|
|
"Landed Cost": round(fs.landed_cost, 2),
|
|
"FBA Fee": round(fs.fba_fee, 2),
|
|
"Referral Fee %": round(fs.referral_pct * 100, 2),
|
|
"Referral Fee $": round(fs.referral_amt, 2),
|
|
"Break-even Price": round(fs.break_even_price, 2),
|
|
"MAP Floor": round(fs.map_floor, 2),
|
|
"Target Selling Price": round(self.recommended_price, 2),
|
|
"Contribution Margin %": round(fs.contribution_margin_pct * 100, 2),
|
|
"Buy Box Status": self.competitive.buy_box_status.value,
|
|
"Buy Box Seller": self.competitive.buy_box_seller_name or "",
|
|
"Buy Box Price": (
|
|
round(self.competitive.buy_box_price, 2)
|
|
if self.competitive.buy_box_price is not None else ""
|
|
),
|
|
"Competitor Offers": "; ".join(
|
|
f"{o.seller_name} @ "
|
|
f"{('$' + format(o.price, '.2f')) if o.price is not None else 'n/a'}"
|
|
+ (" [BB]" if o.is_buy_box else "")
|
|
for o in self.competitive.offers[:8]
|
|
),
|
|
"Suppression Risk": "YES" if self.competitive.is_suppressed else "NO",
|
|
"Status": status,
|
|
}
|
|
|
|
|
|
class LlmNarrative(BaseModel):
|
|
"""Structured output schema the LLM must return (no numbers invented here)."""
|
|
|
|
rationale: str
|
|
competitor_summary: str
|
|
suppression_note: str
|
|
recommended_action: str
|