rairo Claude Opus 4.8 commited on
Commit
32af057
Β·
1 Parent(s): d2d004c

Price items from the bot's canonical item_prices registry (smart-w ADR 0018)

Browse files

The 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>

Files changed (3) hide show
  1. .gitignore +2 -0
  2. analytics.py +84 -1
  3. main.py +18 -8
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
analytics.py CHANGED
@@ -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") -> Dict[str, Any]:
 
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,
main.py CHANGED
@@ -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) as dicts."""
 
 
 
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
- return txns, batches, customers
 
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