Price items from the bot's canonical item_prices registry (smart-w ADR 0018)
Browse filesThe bot now keeps ONE authoritative selling price per item in
users/{mobile}/item_prices (base price + on-sale price/discount while a promo
runs). Reading prices from stock batches is what caused the July field-report
mismatch (dashboard-side USD2.56 vs the bot's updated USD4.00) β batches are
unordered copies and can be stale or depleted.
- fetch_user_ledger also reads item_prices; aggregate_ledger takes it.
- build_price_view: registry first; newest ACTIVE batch price_each only as a
legacy fallback (mirrors the bot's _lookup_item_price order).
- New payload fields: per-item "pricing" (price/basePrice/onSale/discountPct/
onHand) and "stockRetailValue" (on-hand at today's bot prices). stockValue
stays cost-based, matching the bot's accounting.
- Admin stats aggregate a global stockRetailValue.
Existing frontend fields are unchanged; the new ones are additive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .gitignore +2 -0
- analytics.py +84 -1
- main.py +18 -8
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
|
@@ -109,6 +109,66 @@ def _truthy(v: Any) -> bool:
|
|
| 109 |
return str(v).strip().lower() in ("true", "1", "yes", "y", "service")
|
| 110 |
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
# ββ Cost basis (COGS) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
def build_cost_map(stock_batches: List[Dict]) -> Dict[str, float]:
|
| 114 |
"""item name β mean known unit cost (cost_each) from stock batches."""
|
|
@@ -126,7 +186,8 @@ def build_cost_map(stock_batches: List[Dict]) -> Dict[str, float]:
|
|
| 126 |
# ββ Main ledger aggregation ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: List[Dict],
|
| 128 |
start: Optional[datetime] = None, end: Optional[datetime] = None,
|
| 129 |
-
default_currency: str = "USD"
|
|
|
|
| 130 |
"""Return per-currency financials + insight lists. Mirrors the bot's report math.
|
| 131 |
|
| 132 |
Standing figures (cash on hand, receivables, payables, stock value) are CURRENT β the
|
|
@@ -224,6 +285,26 @@ def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: Lis
|
|
| 224 |
payables = round(sum(money(c.get("payable", c.get("outstanding_change"))) for c in (customers or [])), 2)
|
| 225 |
stock_value = round(sum(money(b.get("quantity_remaining")) * money(b.get("cost_each")) for b in (stock_batches or [])), 2)
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
# leaderboards / lists
|
| 228 |
top_items = sorted(
|
| 229 |
({"item": nm, "quantity": round(item_qty.get(nm, 0.0), 2),
|
|
@@ -254,6 +335,8 @@ def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: Lis
|
|
| 254 |
"receivables": receivables,
|
| 255 |
"payables": payables,
|
| 256 |
"stockValue": stock_value,
|
|
|
|
|
|
|
| 257 |
"topItems": top_items,
|
| 258 |
"topCustomers": top_customers,
|
| 259 |
"topExpenses": top_expenses,
|
|
|
|
| 109 |
return str(v).strip().lower() in ("true", "1", "yes", "y", "service")
|
| 110 |
|
| 111 |
|
| 112 |
+
# ββ Selling prices (canonical registry, smart-w ADR 0018) ββββββββββββββββββββ
|
| 113 |
+
def _sorted_batches(stock_batches: List[Dict]) -> List[Dict]:
|
| 114 |
+
"""Newest-first, active before depleted β mirrors the bot's read order so a
|
| 115 |
+
stale price on an old empty batch never wins."""
|
| 116 |
+
return sorted(
|
| 117 |
+
stock_batches or [],
|
| 118 |
+
key=lambda b: (money(b.get("quantity_remaining")) > 0, str(b.get("stocked_at") or "")),
|
| 119 |
+
reverse=True,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def build_price_view(item_prices: List[Dict], stock_batches: List[Dict]) -> Dict[str, Dict]:
|
| 124 |
+
"""item name β current selling-price record.
|
| 125 |
+
|
| 126 |
+
The bot keeps ONE authoritative selling price per item in
|
| 127 |
+
users/{mobile}/item_prices (smart-w ADR 0018): base price, plus sale_price /
|
| 128 |
+
discount_pct while a promotion runs. The registry wins; the newest ACTIVE
|
| 129 |
+
stock batch's price_each is only a legacy fallback for items priced before
|
| 130 |
+
the registry existed. This must stay in lockstep with the bot's
|
| 131 |
+
_lookup_item_price, or the dashboard shows a different price than the bot
|
| 132 |
+
charges (the July-2026 USD2.56-vs-USD4.00 mismatch).
|
| 133 |
+
"""
|
| 134 |
+
view: Dict[str, Dict] = {}
|
| 135 |
+
for e in item_prices or []:
|
| 136 |
+
nm = str(e.get("name") or "").strip().lower()
|
| 137 |
+
if not nm:
|
| 138 |
+
continue
|
| 139 |
+
base = money_opt(e.get("price"))
|
| 140 |
+
sale = money_opt(e.get("sale_price")) if e.get("on_sale") else None
|
| 141 |
+
current = sale if (sale is not None and sale > 0) else base
|
| 142 |
+
if current is None or current <= 0:
|
| 143 |
+
continue
|
| 144 |
+
view[nm] = {
|
| 145 |
+
"price": round(current, 2),
|
| 146 |
+
"basePrice": round(base, 2) if (base is not None and base > 0) else None,
|
| 147 |
+
"onSale": bool(e.get("on_sale")),
|
| 148 |
+
"discountPct": e.get("discount_pct"),
|
| 149 |
+
"source": "registry",
|
| 150 |
+
}
|
| 151 |
+
for b in _sorted_batches(stock_batches):
|
| 152 |
+
nm = str(b.get("name") or "").strip().lower()
|
| 153 |
+
if not nm or nm in view:
|
| 154 |
+
continue
|
| 155 |
+
p = None
|
| 156 |
+
if b.get("on_sale"):
|
| 157 |
+
p = money_opt(b.get("sale_price_each"))
|
| 158 |
+
if p is None or p <= 0:
|
| 159 |
+
p = money_opt(b.get("price_each"))
|
| 160 |
+
if p is None or p <= 0:
|
| 161 |
+
continue
|
| 162 |
+
view[nm] = {
|
| 163 |
+
"price": round(p, 2),
|
| 164 |
+
"basePrice": round(p, 2),
|
| 165 |
+
"onSale": bool(b.get("on_sale")),
|
| 166 |
+
"discountPct": b.get("discount_pct"),
|
| 167 |
+
"source": "batch",
|
| 168 |
+
}
|
| 169 |
+
return view
|
| 170 |
+
|
| 171 |
+
|
| 172 |
# ββ Cost basis (COGS) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
def build_cost_map(stock_batches: List[Dict]) -> Dict[str, float]:
|
| 174 |
"""item name β mean known unit cost (cost_each) from stock batches."""
|
|
|
|
| 186 |
# ββ Main ledger aggregation ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: List[Dict],
|
| 188 |
start: Optional[datetime] = None, end: Optional[datetime] = None,
|
| 189 |
+
default_currency: str = "USD",
|
| 190 |
+
item_prices: Optional[List[Dict]] = None) -> Dict[str, Any]:
|
| 191 |
"""Return per-currency financials + insight lists. Mirrors the bot's report math.
|
| 192 |
|
| 193 |
Standing figures (cash on hand, receivables, payables, stock value) are CURRENT β the
|
|
|
|
| 285 |
payables = round(sum(money(c.get("payable", c.get("outstanding_change"))) for c in (customers or [])), 2)
|
| 286 |
stock_value = round(sum(money(b.get("quantity_remaining")) * money(b.get("cost_each")) for b in (stock_batches or [])), 2)
|
| 287 |
|
| 288 |
+
# Current selling prices (canonical registry first β smart-w ADR 0018) and the
|
| 289 |
+
# stock's value AT those prices. stockValue stays cost-based (accounting);
|
| 290 |
+
# stockRetailValue is what the shelf would fetch at today's bot prices.
|
| 291 |
+
price_view = build_price_view(item_prices, stock_batches)
|
| 292 |
+
on_hand: Dict[str, float] = defaultdict(float)
|
| 293 |
+
for b in stock_batches or []:
|
| 294 |
+
nm = str(b.get("name") or "").strip().lower()
|
| 295 |
+
if nm:
|
| 296 |
+
on_hand[nm] += money(b.get("quantity_remaining"))
|
| 297 |
+
stock_retail_value = round(sum(
|
| 298 |
+
qty * price_view[nm]["price"] for nm, qty in on_hand.items()
|
| 299 |
+
if qty > 0 and nm in price_view), 2)
|
| 300 |
+
pricing = sorted(
|
| 301 |
+
({"item": nm,
|
| 302 |
+
"onHand": round(on_hand.get(nm, 0.0), 2),
|
| 303 |
+
"price": v["price"], "basePrice": v.get("basePrice"),
|
| 304 |
+
"onSale": v.get("onSale", False), "discountPct": v.get("discountPct")}
|
| 305 |
+
for nm, v in price_view.items()),
|
| 306 |
+
key=lambda x: -(x["onHand"] * x["price"]))[:20]
|
| 307 |
+
|
| 308 |
# leaderboards / lists
|
| 309 |
top_items = sorted(
|
| 310 |
({"item": nm, "quantity": round(item_qty.get(nm, 0.0), 2),
|
|
|
|
| 335 |
"receivables": receivables,
|
| 336 |
"payables": payables,
|
| 337 |
"stockValue": stock_value,
|
| 338 |
+
"stockRetailValue": stock_retail_value,
|
| 339 |
+
"pricing": pricing,
|
| 340 |
"topItems": top_items,
|
| 341 |
"topCustomers": top_customers,
|
| 342 |
"topExpenses": top_expenses,
|
|
@@ -109,12 +109,16 @@ def parse_date_range(req):
|
|
| 109 |
|
| 110 |
|
| 111 |
def fetch_user_ledger(bot_data_id):
|
| 112 |
-
"""Read a bot user's v2 ledger: (transactions, stock_batches, customers
|
|
|
|
|
|
|
|
|
|
| 113 |
ref = db.collection('users').document(bot_data_id)
|
| 114 |
txns = [d.to_dict() for d in ref.collection('transactions').stream()]
|
| 115 |
batches = [d.to_dict() for d in ref.collection('stock_batches').stream()]
|
| 116 |
customers = [d.to_dict() for d in ref.collection('customers').stream()]
|
| 117 |
-
|
|
|
|
| 118 |
|
| 119 |
# -----------------------------------------------------------------------------
|
| 120 |
# 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
|
|
@@ -272,9 +276,10 @@ def get_user_dashboard():
|
|
| 272 |
default_currency = user_data.get('defaultCurrency', 'USD')
|
| 273 |
bot_data_id = phone_number.lstrip('+')
|
| 274 |
|
| 275 |
-
txns, batches, customers = fetch_user_ledger(bot_data_id)
|
| 276 |
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 277 |
-
default_currency=default_currency
|
|
|
|
| 278 |
|
| 279 |
# Legacy-compatible per-currency maps (so existing UI keeps working during the switch)
|
| 280 |
by_cur = agg['byCurrency']
|
|
@@ -294,6 +299,8 @@ def get_user_dashboard():
|
|
| 294 |
'receivables': agg['receivables'],
|
| 295 |
'payables': agg['payables'],
|
| 296 |
'stockValue': agg['stockValue'],
|
|
|
|
|
|
|
| 297 |
'topItems': agg['topItems'],
|
| 298 |
'topCustomers': agg['topCustomers'],
|
| 299 |
'dailySeries': agg['dailySeries'],
|
|
@@ -863,16 +870,17 @@ def get_admin_dashboard_stats():
|
|
| 863 |
item_rev = _dd(lambda: _dd(float)) # item -> cur -> revenue
|
| 864 |
exp_cat = _dd(lambda: _dd(float)) # category -> cur -> amount
|
| 865 |
user_rev = _dd(lambda: _dd(float)) # phone -> cur -> revenue
|
| 866 |
-
g_cash = g_recv = g_pay = g_stock = 0.0
|
| 867 |
|
| 868 |
for phone in phone_to_user:
|
| 869 |
try:
|
| 870 |
-
txns, batches, customers = fetch_user_ledger(phone.lstrip('+'))
|
| 871 |
except Exception as e:
|
| 872 |
logging.error(f"Admin stats: ledger read failed for {phone}: {e}")
|
| 873 |
continue
|
| 874 |
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 875 |
-
default_currency=phone_to_user[phone]['defaultCurrency']
|
|
|
|
| 876 |
for cur, m in agg['byCurrency'].items():
|
| 877 |
gcur[cur]['sales'] += m['sales']; gcur[cur]['cogs'] += m['cogs']
|
| 878 |
gcur[cur]['expenses'] += m['expenses']; gcur[cur]['salesCount'] += m['salesCount']
|
|
@@ -885,6 +893,7 @@ def get_admin_dashboard_stats():
|
|
| 885 |
exp_cat[ex['category']][cur] += v
|
| 886 |
g_cash += agg['cashOnHand']; g_recv += agg['receivables']
|
| 887 |
g_pay += agg['payables']; g_stock += agg['stockValue']
|
|
|
|
| 888 |
|
| 889 |
all_currencies = set(gcur)
|
| 890 |
system_by_cur, total_count = {}, 0
|
|
@@ -922,7 +931,8 @@ def get_admin_dashboard_stats():
|
|
| 922 |
'totalNetProfitByCurrency': {c: v['netProfit'] for c, v in system_by_cur.items()},
|
| 923 |
'totalSalesCount': total_count,
|
| 924 |
'cashOnHand': round(g_cash, 2), 'receivables': round(g_recv, 2),
|
| 925 |
-
'payables': round(g_pay, 2), 'stockValue': round(g_stock, 2)
|
|
|
|
| 926 |
'leaderboards': leaderboards,
|
| 927 |
}), 200
|
| 928 |
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
def fetch_user_ledger(bot_data_id):
|
| 112 |
+
"""Read a bot user's v2 ledger: (transactions, stock_batches, customers,
|
| 113 |
+
item_prices) as dicts. item_prices is the bot's canonical selling-price
|
| 114 |
+
registry (smart-w ADR 0018) β the dashboard must price items from it, not
|
| 115 |
+
from batch copies, or it disagrees with what the bot charges."""
|
| 116 |
ref = db.collection('users').document(bot_data_id)
|
| 117 |
txns = [d.to_dict() for d in ref.collection('transactions').stream()]
|
| 118 |
batches = [d.to_dict() for d in ref.collection('stock_batches').stream()]
|
| 119 |
customers = [d.to_dict() for d in ref.collection('customers').stream()]
|
| 120 |
+
item_prices = [d.to_dict() for d in ref.collection('item_prices').stream()]
|
| 121 |
+
return txns, batches, customers, item_prices
|
| 122 |
|
| 123 |
# -----------------------------------------------------------------------------
|
| 124 |
# 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
|
|
|
|
| 276 |
default_currency = user_data.get('defaultCurrency', 'USD')
|
| 277 |
bot_data_id = phone_number.lstrip('+')
|
| 278 |
|
| 279 |
+
txns, batches, customers, item_prices = fetch_user_ledger(bot_data_id)
|
| 280 |
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 281 |
+
default_currency=default_currency,
|
| 282 |
+
item_prices=item_prices)
|
| 283 |
|
| 284 |
# Legacy-compatible per-currency maps (so existing UI keeps working during the switch)
|
| 285 |
by_cur = agg['byCurrency']
|
|
|
|
| 299 |
'receivables': agg['receivables'],
|
| 300 |
'payables': agg['payables'],
|
| 301 |
'stockValue': agg['stockValue'],
|
| 302 |
+
'stockRetailValue': agg['stockRetailValue'],
|
| 303 |
+
'pricing': agg['pricing'],
|
| 304 |
'topItems': agg['topItems'],
|
| 305 |
'topCustomers': agg['topCustomers'],
|
| 306 |
'dailySeries': agg['dailySeries'],
|
|
|
|
| 870 |
item_rev = _dd(lambda: _dd(float)) # item -> cur -> revenue
|
| 871 |
exp_cat = _dd(lambda: _dd(float)) # category -> cur -> amount
|
| 872 |
user_rev = _dd(lambda: _dd(float)) # phone -> cur -> revenue
|
| 873 |
+
g_cash = g_recv = g_pay = g_stock = g_stock_retail = 0.0
|
| 874 |
|
| 875 |
for phone in phone_to_user:
|
| 876 |
try:
|
| 877 |
+
txns, batches, customers, item_prices = fetch_user_ledger(phone.lstrip('+'))
|
| 878 |
except Exception as e:
|
| 879 |
logging.error(f"Admin stats: ledger read failed for {phone}: {e}")
|
| 880 |
continue
|
| 881 |
agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
|
| 882 |
+
default_currency=phone_to_user[phone]['defaultCurrency'],
|
| 883 |
+
item_prices=item_prices)
|
| 884 |
for cur, m in agg['byCurrency'].items():
|
| 885 |
gcur[cur]['sales'] += m['sales']; gcur[cur]['cogs'] += m['cogs']
|
| 886 |
gcur[cur]['expenses'] += m['expenses']; gcur[cur]['salesCount'] += m['salesCount']
|
|
|
|
| 893 |
exp_cat[ex['category']][cur] += v
|
| 894 |
g_cash += agg['cashOnHand']; g_recv += agg['receivables']
|
| 895 |
g_pay += agg['payables']; g_stock += agg['stockValue']
|
| 896 |
+
g_stock_retail += agg.get('stockRetailValue', 0.0)
|
| 897 |
|
| 898 |
all_currencies = set(gcur)
|
| 899 |
system_by_cur, total_count = {}, 0
|
|
|
|
| 931 |
'totalNetProfitByCurrency': {c: v['netProfit'] for c, v in system_by_cur.items()},
|
| 932 |
'totalSalesCount': total_count,
|
| 933 |
'cashOnHand': round(g_cash, 2), 'receivables': round(g_recv, 2),
|
| 934 |
+
'payables': round(g_pay, 2), 'stockValue': round(g_stock, 2),
|
| 935 |
+
'stockRetailValue': round(g_stock_retail, 2)},
|
| 936 |
'leaderboards': leaderboards,
|
| 937 |
}), 200
|
| 938 |
|