Job 1788122781206

Command: python -c "from pathlib import Path; p=Path(r'C:\\Users\\ash\\Documents\\ShopeeApi\\app\\main.py'); lines=p.read_text(encoding='utf-8',errors='replace').splitlines(); hits=[i for i,x in enumerate(lines) if 'arrange-all-ready-to-ship' in x or 'instant-delivery/feed' in x or 'def _is_instant' in x.lower() or 'instant delivery' in x.lower()]; ranges=[]; [(ranges.append((max(0,i-45),min(len(lines),i+180)))) for i in hits]; seen=set(); [(print('---HIT',a+1,b,'---'), [print(f'{j+1}: {lines[j]}') for j in range(a,b) if j not in seen and not seen.add(j)]) for a,b in ranges]"
Directory: projects
Status: SUCCESS
Exit code: 0

Cancel Job Rerun Command Refresh

---HIT 2162 2386 ---
2162:         package_number = str(item.get("package_number") or "").strip()
2163:         if not order_sn:
2164:             continue
2165:         # Shopee requires package_number to be omitted for some unsplit orders,
2166:         # while our local printable queue is keyed by the real package number.
2167:         # Recover that local key without changing the package sent to Shopee.
2168:         if not package_number:
2169:             order = db.get_order(order_sn, shop_id) or {}
2170:             local_packages = {
2171:                 str(package.get("package_number") or "").strip()
2172:                 for package in (order.get("packages") or [])
2173:                 if isinstance(package, dict) and str(package.get("package_number") or "").strip()
2174:             }
2175:             if len(local_packages) == 1:
2176:                 package_number = next(iter(local_packages))
2177:         key = (order_sn, package_number)
2178:         existing = unique.get(key) or {"order_sn": order_sn, "package_number": package_number, "tracking_number": ""}
2179:         if item.get("tracking_number"):
2180:             existing["tracking_number"] = str(item.get("tracking_number") or "").strip()
2181:         if "api_package_number" in item:
2182:             existing["api_package_number"] = str(item.get("api_package_number") or "").strip()
2183:         unique[key] = existing
2184:     payload = list(unique.values())
2185:     if not payload:
2186:         return None
2187:     job_id = db.create_sync_job(
2188:         shop_id,
2189:         "awb_prepare",
2190:         {"documents": payload, "force_download": False, "source": source},
2191:         len(payload),
2192:         actor="arrange_shipment",
2193:     )
2194:     started = start(job_id)
2195:     if not started:
2196:         db.update_job(job_id, status="failed", last_error="Worker awb_prepare selepas arrange shipment tidak dapat dimulakan.")
2197:     db.log_audit("admin", "awb_prepare_after_arrange", job_id, {"shop_id": shop_id, "requested": len(payload), "started": started, "source": source})
2198:     return {"job_id": job_id, "requested": len(payload), "started": bool(started)}
2199: 
2200: @app.get("/api/shipment/{order_sn}/parameters", dependencies=[Depends(require_admin)])
2201: def shipping_parameters(order_sn:str, package_number:str|None=None):
2202:     client=ShopeeClient()
2203:     try: return client.get_shipping_parameter(order_sn,package_number)
2204:     finally: client.close()
2205: 
2206: 
2207: @app.post("/api/fulfillment/arrange-all-ready-to-ship", dependencies=[Depends(require_admin)])
2208: def arrange_all_ready_to_ship(req: BatchShipmentRequest):
2209:     if req.dry_run is False:
2210:         if not settings.enable_shipment_writes:
2211:             raise HTTPException(403, "Shipment write masih dikunci. Set ENABLE_SHIPMENT_WRITES=true selepas yakin preview betul.")
2212:         if not (req.confirmed or req.confirmation == settings.shipment_confirmation_phrase):
2213:             raise HTTPException(400, "Sila tekan CONFIRM pada popup sebelum arrange shipment.")
2214:     db.clear_cache("ready")
2215:     packing_before = db.get_ready_to_ship_packing(source="auto", days=7, limit=req.limit)
2216:     candidates = db.list_ready_to_ship_for_arrange(req.limit)
2217:     results: list[dict[str, Any]] = []
2218:     awb_documents: list[dict[str, Any]] = []
2219:     client = ShopeeClient()
2220:     try:
2221:         for order in candidates:
2222:             order_sn = order.get("order_sn")
2223:             order_success = True
2224:             current_status = db.get_order_status(order_sn)
2225:             if current_status != "READY_TO_SHIP":
2226:                 results.append({
2227:                     "order_sn": order_sn,
2228:                     "package_number": None,
2229:                     "status": "skipped_not_ready",
2230:                     "current_status": current_status or "MISSING",
2231:                     "reason": f"Order sudah {current_status.lower() if current_status else 'hilang'} sebelum sempat arrange.",
2232:                 })
2233:                 continue
2234:             for package_number in (order.get("packages") or [None]):
2235:                 item: dict[str, Any] = {"order_sn": order_sn, "package_number": package_number, "status": "pending"}
2236:                 try:
2237:                     params = client.get_shipping_parameter(order_sn, package_number)
2238:                     db.save_order_raw(None, current_shop_id(), order_sn, "logistics/get_shipping_parameter/batch_arrange", params)
2239:                     method, ship_body = _build_auto_ship_body(order_sn, package_number, params, req.method)
2240:                     item["method"] = method
2241:                     item["request_preview"] = {k: ("***" if k in {"sender_real_name"} else v) for k, v in ship_body.items()}
2242:                     if req.dry_run:
2243:                         item["status"] = "preview_ok"
2244:                     else:
2245:                         try:
2246:                             ship_result = client.ship_order(ship_body)
2247:                         except Exception as ship_exc:
2248:                             # Shopee rejects package_number for unsplit orders. Retry once without it.
2249:                             msg = str(ship_exc).lower()
2250:                             payload = getattr(ship_exc, "payload", None)
2251:                             payload_text = str(payload or "").lower()
2252:                             package_rejected = any(
2253:                                 marker in f"{msg} {payload_text}"
2254:                                 for marker in (
2255:                                     "logistics.package_not_exist",
2256:                                     "package does not exist",
2257:                                     "invalid package number",
2258:                                     "package_number",
2259:                                     "forder_id",
2260:                                 )
2261:                             )
2262:                             if package_number and package_rejected:
2263:                                 retry_body = dict(ship_body)
2264:                                 retry_body.pop("package_number", None)
2265:                                 item["retry_without_package_number"] = True
2266:                                 ship_result = client.ship_order(retry_body)
2267:                                 ship_body = retry_body
2268:                             else:
2269:                                 raise
2270:                         db.save_order_raw(None, current_shop_id(), order_sn, "logistics/ship_order/batch_arrange", ship_result)
2271:                         item["status"] = "success"
2272:                         item["response"] = ship_result.get("response", ship_result)
2273:                         try:
2274:                             tracking_pkg = package_number if ship_body.get("package_number") else None
2275:                             tracking = client.get_tracking_number(order_sn, tracking_pkg)
2276:                             db.upsert_order_logistics(current_shop_id(), order_sn, tracking_pkg or "", "logistics/get_tracking_number", tracking)
2277:                             item["tracking"] = tracking.get("response", tracking)
2278:                             tracking_number = _tracking_number_from_payload(tracking)
2279:                         except Exception as track_exc:
2280:                             item["tracking_warning"] = str(track_exc)[:500]
2281:                             tracking_number = ""
2282:                         awb_documents.append({
2283:                             "order_sn": order_sn,
2284:                             "package_number": str(package_number or ""),
2285:                             "api_package_number": str(tracking_pkg or ""),
2286:                             "tracking_number": tracking_number,
2287:                         })
2288:                 except Exception as exc:
2289:                     order_success = False
2290:                     item["status"] = "failed"
2291:                     item["error"] = str(exc)[:1000]
2292:                     payload = getattr(exc, "payload", None)
2293:                     if payload:
2294:                         item["payload"] = payload
2295:                     db.save_order_error(None, current_shop_id(), order_sn, "batch_arrange_shipment", str(exc), getattr(exc, "payload", None))
2296:                 results.append(item)
2297:             if (not req.dry_run) and order_success:
2298:                 db.mark_order_processed_after_arrange(order_sn, "PROCESSED")
2299:                 db.log_audit("admin", "batch_arrange_shipment", str(order_sn), {"method": req.method, "packages": order.get("packages")})
2300:     finally:
2301:         client.close()
2302:     success = sum(1 for x in results if x.get("status") == "success")
2303:     failed = sum(1 for x in results if x.get("status") == "failed")
2304:     preview_ok = sum(1 for x in results if x.get("status") == "preview_ok")
2305:     skipped = sum(1 for x in results if str(x.get("status") or "").startswith("skipped"))
2306:     awb_job = None if req.dry_run else _start_arranged_awb_prepare(current_shop_id(), awb_documents, "batch_arrange")
2307:     return {
2308:         "ok": failed == 0,
2309:         "dry_run": req.dry_run,
2310:         "requested_orders": len(candidates),
2311:         "success_count": success,
2312:         "preview_ok_count": preview_ok,
2313:         "skipped_count": skipped,
2314:         "failed_count": failed,
2315:         "results": results,
2316:         "awb_prepare": awb_job,
2317:         "packing": packing_before,
2318:         "note": "Packing list ini diambil sebelum status order ditukar kepada PROCESSED, jadi boleh terus print untuk kerja packing.",
2319:     }
2320: 
2321: @app.post("/api/shipment/arrange", dependencies=[Depends(require_admin)])
2322: def arrange(req:ShipmentRequest):
2323:     if not settings.enable_shipment_writes: raise HTTPException(403,"Shipment write masih dikunci")
2324:     if not (req.confirmed or req.confirmation == settings.shipment_confirmation_phrase): raise HTTPException(400,"Sila tekan CONFIRM pada popup sebelum arrange shipment")
2325:     method=req.method.lower()
2326:     if method not in {"pickup","dropoff","non_integrated"}: raise HTTPException(422,"Kaedah shipment tidak sah")
2327:     details=getattr(req,method)
2328:     if not details: raise HTTPException(422,f"Maklumat {method} diperlukan")
2329:     body={"order_sn":req.order_sn,method:details}
2330:     if req.package_number: body["package_number"]=req.package_number
2331:     client=ShopeeClient()
2332:     try:
2333:         result=client.ship_order(body); db.log_audit("admin","arrange_shipment",req.order_sn,{"method":method,"package_number":req.package_number})
2334:         tracking_number=""
2335:         try:
2336:             tracking=client.get_tracking_number(req.order_sn,req.package_number or None)
2337:             db.upsert_order_logistics(current_shop_id(),req.order_sn,req.package_number or "","logistics/get_tracking_number/arrange",tracking)
2338:             tracking_number=_tracking_number_from_payload(tracking)
2339:         except Exception as exc:
2340:             db.save_order_error(None,current_shop_id(),req.order_sn,"arrange_tracking",str(exc),getattr(exc,"payload",None))
2341:         db.mark_order_processed_after_arrange(req.order_sn,"PROCESSED")
2342:         awb_job=_start_arranged_awb_prepare(current_shop_id(),[{"order_sn":req.order_sn,"package_number":req.package_number or "","api_package_number":req.package_number or "","tracking_number":tracking_number}],"single_arrange")
2343:         return {**result,"awb_prepare":awb_job}
2344:     finally: client.close()
2345: 
2346: 
2347: def _awb_document_key_payload(item: AwbDocumentKey) -> dict[str, str]:
2348:     return {
2349:         "order_sn": str(item.order_sn or "").strip(),
2350:         "package_number": str(item.package_number or "").strip(),
2351:     }
2352: 
2353: 
2354: def _dedupe_awb_document_keys(items: list[AwbDocumentKey]) -> list[dict[str, str]]:
2355:     seen: set[tuple[str, str]] = set()
2356:     documents: list[dict[str, str]] = []
2357:     for item in items:
2358:         payload = _awb_document_key_payload(item)
2359:         if not payload["order_sn"]:
2360:             continue
2361:         key = (payload["order_sn"], payload["package_number"])
2362:         if key in seen:
2363:             continue
2364:         seen.add(key)
2365:         documents.append(payload)
2366:     return documents
2367: 
2368: 
2369: def _awb_branding_payload(shop_id: int) -> dict[str, Any]:
2370:     branding = db.get_awb_shop_branding(shop_id) or {}
2371:     logo_path = awb_printing.branding_logo_path(shop_id)
2372:     has_logo = bool(branding) and bool(logo_path.exists())
2373:     updated_at = branding.get("updated_at") if branding else None
2374:     return {
2375:         "enabled": bool(branding.get("enabled")) if branding else False,
2376:         "has_logo": has_logo,
2377:         "logo_updated_at": updated_at,
2378:         "logo_sha256": branding.get("logo_sha256") if branding else "",
2379:         "original_filename": branding.get("original_filename") if branding else "",
2380:         "source_mime_type": branding.get("source_mime_type") if branding else "",
2381:     }
2382: 
2383: 
2384: def _awb_print_insert_payload(shop_id: int) -> dict[str, Any]:
2385:     row = db.get_awb_shop_print_insert(shop_id) or {}
2386:     image_path = awb_printing.print_insert_path(shop_id)
---HIT 3179 3403 ---
3179:     device = db.enroll_operations_device(
3180:         req.shop_id,
3181:         req.device_id,
3182:         hashlib.sha256(device_token.encode("utf-8")).hexdigest(),
3183:         req.device_name,
3184:         req.app_version,
3185:     )
3186:     db.log_audit(
3187:         actor=f"operations:{req.device_id}", action="operations_device_enrolled",
3188:         target=req.device_id, details={"shop_id": req.shop_id, "app_version": req.app_version},
3189:     )
3190:     return {"ok": True, "shop_id": req.shop_id, "device": device, "device_token": device_token}
3191: 
3192: 
3193: @app.get("/api/operations/status")
3194: def api_operations_status(device: dict[str, Any] = Depends(require_operations_device)):
3195:     shop_id = int(device["shop_id"])
3196:     alert_settings = db.operations_alert_settings(shop_id)
3197:     return {
3198:         "ok": True,
3199:         "shop_id": shop_id,
3200:         "device_id": device["device_id"],
3201:         "is_primary": str(alert_settings.get("primary_device_id") or "") == str(device["device_id"]),
3202:         "alert_settings": alert_settings,
3203:     }
3204: 
3205: 
3206: @app.put("/api/operations/primary-device")
3207: def api_operations_primary_device(
3208:     req: OperationsPrimaryRequest,
3209:     device: dict[str, Any] = Depends(require_operations_device),
3210: ):
3211:     if not req.confirmed:
3212:         raise HTTPException(409, "Confirmation diperlukan untuk menukar telefon utama")
3213:     try:
3214:         alert_settings = db.set_operations_primary_device(int(device["shop_id"]), str(device["device_id"]))
3215:     except ValueError as exc:
3216:         raise HTTPException(422, str(exc)) from exc
3217:     db.log_audit(
3218:         actor=f"operations:{device['device_id']}", action="instant_alert_primary_changed",
3219:         target=str(device["shop_id"]), details={"primary_device_id": device["device_id"]},
3220:     )
3221:     return {"ok": True, "alert_settings": alert_settings}
3222: 
3223: 
3224: @app.get("/api/operations/instant-delivery/feed")
3225: def api_operations_instant_delivery_feed(device: dict[str, Any] = Depends(require_operations_device)):
3226:     shop_id = int(device["shop_id"])
3227:     alert_settings = db.operations_alert_settings(shop_id)
3228:     is_primary = str(alert_settings.get("primary_device_id") or "") == str(device["device_id"])
3229:     overview = db.get_ready_to_ship_overview()
3230:     orders = overview.get("instant_delivery_orders") or []
3231:     acknowledgements = db.instant_delivery_acknowledgements(shop_id, [str(row.get("order_sn") or "") for row in orders])
3232:     payload_orders = []
3233:     for row in orders:
3234:         item = dict(row)
3235:         acknowledgement = acknowledgements.get(str(item.get("order_sn") or ""))
3236:         item["acknowledged"] = bool(acknowledgement)
3237:         item["acknowledged_at"] = acknowledgement.get("acknowledged_at") if acknowledgement else None
3238:         payload_orders.append(item)
3239:     return {
3240:         "ok": True,
3241:         "server_time": datetime.now(timezone.utc).isoformat(),
3242:         "shop_id": shop_id,
3243:         "device_id": device["device_id"],
3244:         "is_primary": is_primary,
3245:         "poll_after_seconds": int(alert_settings.get("poll_seconds") or 30),
3246:         "repeat_after_seconds": int(alert_settings.get("repeat_seconds") or 300),
3247:         "count": len(payload_orders),
3248:         "orders": payload_orders if is_primary else [],
3249:     }
3250: 
3251: 
3252: @app.post("/api/operations/instant-delivery/ack")
3253: def api_operations_instant_delivery_ack(
3254:     req: OperationsAckRequest,
3255:     device: dict[str, Any] = Depends(require_operations_device),
3256: ):
3257:     shop_id = int(device["shop_id"])
3258:     alert_settings = db.operations_alert_settings(shop_id)
3259:     if str(alert_settings.get("primary_device_id") or "") != str(device["device_id"]):
3260:         raise HTTPException(403, "Hanya telefon utama boleh mengesahkan Instant Delivery")
3261:     acknowledgement = db.acknowledge_instant_delivery(shop_id, req.order_sn, str(device["device_id"]))
3262:     db.log_audit(
3263:         actor=f"operations:{device['device_id']}", action="instant_delivery_ready_acknowledged",
3264:         target=req.order_sn, details={"shop_id": shop_id},
3265:     )
3266:     return {"ok": True, "acknowledgement": acknowledgement}
3267: 
3268: 
3269: def _scanner_catalog_revision(shop_id: int) -> str:
3270:     """Revision for registry/inventory entities copied to the phone."""
3271:     import hashlib as _hl
3272:     h = _hl.sha256()
3273:     with db.connect() as conn:
3274:         registry = conn.execute(
3275:             "SELECT COALESCE(MAX(updated_at),'0'), COUNT(*) FROM perfume_name_registry WHERE shop_id IN (0, ?)",
3276:             (shop_id,),
3277:         ).fetchone()
3278:         h.update(f"registry:{registry[0]}:{registry[1]}".encode())
3279:         canonical = conn.execute(
3280:             "SELECT COALESCE(MAX(updated_at),'0'),COUNT(*) FROM canonical_scents WHERE status='ACTIVE'"
3281:         ).fetchone()
3282:         canonical_alias = conn.execute(
3283:             "SELECT COALESCE(MAX(updated_at),'0'),COUNT(*) FROM canonical_scent_aliases"
3284:         ).fetchone()
3285:         h.update(
3286:             f"canonical:{canonical[0]}:{canonical[1]}:{canonical_alias[0]}:{canonical_alias[1]}".encode()
3287:         )
3288:         for table, timestamp_column in (
3289:             ("canonical_stickers", "updated_at"),
3290:             ("products", "synced_at"),
3291:             ("product_variations", "synced_at"),
3292:             ("shop_product_mappings", "updated_at"),
3293:         ):
3294:             row = conn.execute(
3295:                 f"SELECT COALESCE(MAX({timestamp_column}),'0'),COUNT(*) FROM {table} WHERE shop_id=?",
3296:                 (shop_id,),
3297:             ).fetchone()
3298:             h.update(f"{table}:{row[0]}:{row[1]}".encode())
3299:         for table in (
3300:             "perfume_aliases",
3301:             "canonical_products",
3302:             "canonical_aliases",
3303:             "perfume_storage",
3304:         ):
3305:             row = conn.execute(
3306:                 f"SELECT COALESCE(MAX(updated_at),'0'),COUNT(*) FROM {table}"
3307:             ).fetchone()
3308:             h.update(f"{table}:{row[0]}:{row[1]}".encode())
3309:         for table in (
3310:             "inventory_products",
3311:             "inventory_containers",
3312:             "inventory_batches",
3313:             "inventory_batch_items",
3314:             "label_printer_profiles",
3315:             "inventory_publish_policies",
3316:         ):
3317:             row = conn.execute(
3318:                 f"SELECT COALESCE(MAX(updated_at),'0'), COUNT(*) FROM {table} WHERE shop_id=?",
3319:                 (shop_id,),
3320:             ).fetchone()
3321:             h.update(f"{table}:{row[0]}:{row[1]}".encode())
3322:     return h.hexdigest()[:16]
3323: 
3324: 
3325: def _scanner_forecast_revision(shop_id: int) -> str:
3326:     import hashlib as _hl
3327:     state = db.get_scanner_forecast_revision(shop_id)
3328:     raw = (
3329:         f"{int(state.get('forecast_revision') or 0)}:"
3330:         f"{state.get('forecast_updated_at') or '0'}:"
3331:         f"{state.get('forecast_source') or ''}"
3332:     )
3333:     return _hl.sha256(raw.encode()).hexdigest()[:16]
3334: 
3335: 
3336: def _scanner_snapshot_revision(shop_id: int) -> str:
3337:     """Legacy combined revision retained for older APK versions."""
3338:     import hashlib as _hl
3339:     combined = f"{_scanner_catalog_revision(shop_id)}:{_scanner_forecast_revision(shop_id)}"
3340:     return _hl.sha256(combined.encode()).hexdigest()[:16]
3341: 
3342: 
3343: def _scanner_revisions(shop_id: int) -> dict[str, str]:
3344:     catalog = _scanner_catalog_revision(shop_id)
3345:     forecast = _scanner_forecast_revision(shop_id)
3346:     import hashlib as _hl
3347:     return {
3348:         "catalog_revision": catalog,
3349:         "forecast_revision": forecast,
3350:         "snapshot_revision": _hl.sha256(f"{catalog}:{forecast}".encode()).hexdigest()[:16],
3351:     }
3352: 
3353: 
3354: @app.get("/api/scanner/bootstrap", dependencies=[Depends(require_scanner)])
3355: def api_scanner_bootstrap():
3356:     sid = current_shop_id()
3357:     containers = db.list_inventory_containers(sid, status="all", limit=2000)
3358:     products = db.list_inventory_products(sid)
3359:     catalog = inventory_excel.canonical_catalog(sid)
3360:     name_products = db.list_scanner_name_products(sid)
3361:     profiles = [item for item in db.list_label_printer_profiles(sid) if str(item.get("calibration_status")) == "active"]
3362:     summary = {
3363:         "products": len(products),
3364:         "active": sum(1 for item in containers if item.get("status") == "active"),
3365:         "pending_location": sum(1 for item in containers if item.get("status") == "pending_location"),
3366:         "removed": sum(1 for item in containers if item.get("status") == "removed"),
3367:     }
3368:     batches = db.list_inventory_batches(sid, 50)
3369:     batch_items: list[dict[str, Any]] = []
3370:     for batch in batches:
3371:         batch_items.extend(db.list_inventory_batch_items(sid, str(batch.get("batch_id") or "")))
3372:     # Compute revisions only after every lazy catalog read/seed above, otherwise
3373:     # this response can immediately look stale and trigger a second bootstrap.
3374:     revisions = _scanner_revisions(sid)
3375:     return {
3376:         "ok": True,
3377:         "shop_id": sid,
3378:         **revisions,
3379:         "summary": summary,
3380:         "products": products,
3381:         # The scanner keeps this locally so a known container barcode can be
3382:         # resolved immediately without a round-trip for every scan.
3383:         "containers": containers,
3384:         "canonical_catalog": catalog,
3385:         "name_products": name_products,
3386:         "batches": batches,
3387:         "batch_items": batch_items,
3388:         "printer_profiles": profiles,
3389:         "workbook": inventory_excel.workbook_summary(sid),
3390:         "inventory_policy": inventory_publishing.get_policies(sid),
3391:         "forecast_input": compact_forecast.build_input(sid),
3392:     }
3393: 
3394: 
3395: @app.get("/api/scanner/instant-delivery-alerts", dependencies=[Depends(require_scanner)])
3396: def api_scanner_instant_delivery_alerts():
3397:     """Compact read-only feed used by the Android background monitor."""
3398:     overview = db.get_ready_to_ship_overview()
3399:     orders = overview.get("instant_delivery_orders") or []
3400:     return {
3401:         "ok": True,
3402:         "server_time": datetime.now(timezone.utc).isoformat(),
3403:         "poll_after_seconds": 30,
---HIT 3215 3439 ---
3404:         "repeat_after_seconds": 180,
3405:         "count": len(orders),
3406:         "orders": orders,
3407:     }
3408: 
3409: 
3410: @app.get("/api/scanner/forecast/input", dependencies=[Depends(require_scanner)])
3411: def api_scanner_forecast_input():
3412:     return {"ok": True, "forecast_input": compact_forecast.build_input(current_shop_id())}
3413: 
3414: 
3415: @app.post("/api/scanner/forecast/run", dependencies=[Depends(require_scanner)])
3416: def api_scanner_forecast_run(req: ScannerForecastRequest):
3417:     try:
3418:         result = compact_forecast.run_for_shop(
3419:             current_shop_id(),
3420:             start_date=req.start_date.isoformat(),
3421:             duration_days=req.duration_days,
3422:             service_level=req.service_level,
3423:             allocation_percent=req.allocation_percent,
3424:             concentrate_percent=req.concentrate_percent,
3425:         )
3426:         return {"ok": True, "result": result}
3427:     except ValueError as exc:
3428:         raise HTTPException(422, str(exc)) from exc
3429: 
3430: 
3431: @app.get("/api/scanner/forecast/leave/preview", dependencies=[Depends(require_scanner)])
3432: def api_scanner_forecast_leave_preview(
3433:     start_date: date = Query(...),
3434:     duration_days: int = Query(..., ge=1, le=90),
3435:     service_level: float = Query(0.99, ge=0.80, le=0.995),
3436:     allocation_percent: float | None = Query(default=None, ge=0, le=100),
3437:     concentrate_percent: float | None = Query(default=None, ge=0, le=100),
3438: ):
3439:     try: