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(); keys=['class BatchShipmentRequest','class Operations','def require_operations_device','def _build_auto_ship_body','shipment_confirmation_phrase']; hits=[i for i,x in enumerate(lines) if any(k in x for k in keys)]; seen=set();[(print('---',max(1,i-35)+1,min(len(lines),i+100),'---'),[print(f'{j+1}: {lines[j]}') for j in range(max(0,i-35),min(len(lines),i+100)) if j not in seen and not seen.add(j)]) for i in hits]"
Directory: projects
Status: SUCCESS
Exit code: 0
Cancel Job Rerun Command Refresh
--- 202 336 ---
202: x_admin_token: str = Header(default="", alias="X-Admin-Token"),
203: ) -> None:
204: expected = settings.llm_service_token or settings.admin_token
205: if expected and x_llm_service_token and hmac.compare_digest(x_llm_service_token, expected):
206: return
207: if settings.admin_token and x_admin_token and hmac.compare_digest(x_admin_token, settings.admin_token):
208: return
209: if _read_session_cookie(request.cookies.get(SESSION_COOKIE)):
210: return
211: raise HTTPException(401, "Credential servis LLM tidak sah")
212:
213:
214: def require_scanner(
215: request: Request,
216: x_device_token: str = Header(default="", alias="X-Device-Token"),
217: x_admin_token: str = Header(default="", alias="X-Admin-Token"),
218: x_shop_id: int | None = Header(default=None, alias="X-Shop-Id"),
219: ) -> None:
220: if settings.device_token and x_device_token and hmac.compare_digest(x_device_token, settings.device_token):
221: return
222: if x_device_token:
223: token_hash = hashlib.sha256(x_device_token.encode("utf-8")).hexdigest()
224: selected_shop = int(x_shop_id or settings.shop_id or 0)
225: device = db.authenticate_scanner_device(token_hash, selected_shop)
226: if not device:
227: device = db.authenticate_operations_device(token_hash, selected_shop)
228: if device:
229: return
230: if settings.admin_token and x_admin_token and hmac.compare_digest(x_admin_token, settings.admin_token):
231: return
232: if _read_session_cookie(request.cookies.get(SESSION_COOKIE)):
233: return
234: raise HTTPException(401, "Credential scanner tidak sah")
235:
236:
237: def require_operations_device(
238: x_device_token: str = Header(default="", alias="X-Device-Token"),
239: x_shop_id: int | None = Header(default=None, alias="X-Shop-Id"),
240: ) -> dict[str, Any]:
241: shop_id = int(x_shop_id or 0)
242: if not x_device_token or shop_id <= 0:
243: raise HTTPException(401, "Credential Shopee Operations tidak lengkap")
244: device = db.authenticate_operations_device(hashlib.sha256(x_device_token.encode("utf-8")).hexdigest(), shop_id)
245: if not device:
246: raise HTTPException(401, "Device Shopee Operations tidak sah")
247: return device
248:
249: class OrderSyncRequest(BaseModel):
250: from_date: date
251: to_date: date
252: include_details: bool = True
253: include_escrow: bool = True
254: include_logistics_preview: bool = False
255:
256: class ProductSyncRequest(BaseModel):
257: statuses: list[str] = Field(default_factory=lambda: ["NORMAL","UNLIST","BANNED"])
258:
259:
260: class ProductCostRequest(BaseModel):
261: item_id: int = Field(gt=0)
262: size_ml: Literal[10, 35]
263: unit_cost: float = Field(ge=0)
264: effective_from: date
265: currency: str = Field(default="MYR", min_length=3, max_length=3)
266:
267:
268: class UniversalCostRequest(BaseModel):
269: cost_10ml: float = Field(ge=0)
270: cost_35ml: float = Field(ge=0)
271: effective_from: date
272: currency: str = Field(default="MYR", min_length=3, max_length=3)
273:
274:
275: class FinanceSyncRequest(BaseModel):
276: lookback_days: int = Field(default=45, ge=1, le=3650)
277:
278:
279: class LlmContextRequest(BaseModel):
280: query: str = Field(min_length=1, max_length=2000)
281: product_hint: str = Field(default="", max_length=500)
282: order_sn: str = Field(default="", max_length=80)
283: limit: int = Field(default=5, ge=1, le=5)
284:
285: class ShopUpsertRequest(BaseModel):
286: shop_id: int
287: name: str = ""
288:
289:
290: class PerfumeRegistryRequest(BaseModel):
291: id: int | None = None
292: shop_id: int | None = None
293: canonical_name: str = Field(default="", max_length=255)
294: listing_name: str = Field(default="", max_length=512)
295: variant_name: str = Field(default="", max_length=512)
296: source_mode: str = Field(default="product", max_length=32)
297: platform: str = Field(default="SHOPEE", max_length=32)
298: source: str = Field(default="manual", max_length=128)
299:
300:
301: class PerfumeRegistryDeleteRequest(BaseModel):
302: confirmed: bool = False
303: shop_id: int | None = None
304:
305:
306: class CanonicalScentRequest(BaseModel):
307: canonical_name: str = Field(min_length=1, max_length=255)
308: aliases: list[str] = Field(default_factory=list, max_length=100)
309: gender: Literal["", "M", "W", "U"] = ""
310: status: Literal["ACTIVE", "INACTIVE"] = "ACTIVE"
311: source: str = Field(default="manual", max_length=128)
312:
313:
314: class ProductMappingRequest(BaseModel):
315: scent_id: str | None = Field(default=None, max_length=128)
316: status: Literal["SUGGESTED", "CONFIRMED", "CONFLICT"] = "CONFIRMED"
317: confidence: float | None = Field(default=None, ge=0, le=1)
318: source: str = Field(default="manual", max_length=128)
319: review_note: str = Field(default="", max_length=1000)
320: candidates: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
321:
322:
323: class ProductMappingBulkConfirmRequest(BaseModel):
324: mapping_ids: list[str] = Field(min_length=1, max_length=500)
325: confirmed: bool = False
326:
327:
328: class ProductMappingDeactivateRequest(BaseModel):
329: confirmed: bool = False
330: reason: str = Field(default="", max_length=1000)
331:
332:
333: class LoginRequest(BaseModel):
334: username: str
335: password: str
336: remember: bool = True
--- 313 447 ---
337:
338: class ShipmentRequest(BaseModel):
339: order_sn: str
340: package_number: str | None = None
341: method: str
342: pickup: dict[str, Any] | None = None
343: dropoff: dict[str, Any] | None = None
344: non_integrated: dict[str, Any] | None = None
345: confirmation: str = ""
346: confirmed: bool = False
347:
348: class BatchShipmentRequest(BaseModel):
349: method: str = "auto"
350: limit: int = Field(default=100, ge=1, le=200)
351: confirmation: str = ""
352: confirmed: bool = False
353: dry_run: bool = False
354:
355:
356: class AwbDocumentKey(BaseModel):
357: order_sn: str
358: package_number: str = ""
359:
360:
361: class AwbPrepareRequest(BaseModel):
362: documents: list[AwbDocumentKey] = Field(default_factory=list)
363: force_download: bool = False
364: confirmed: bool = False
365:
366:
367: class AwbBatchPdfRequest(BaseModel):
368: documents: list[AwbDocumentKey] = Field(default_factory=list)
369: format: str = "official"
370:
371:
372: class AwbMarkPrintedRequest(BaseModel):
373: documents: list[AwbDocumentKey] = Field(default_factory=list)
374: confirmed: bool = False
375:
376:
377: class AwbDirectPrintRequest(BaseModel):
378: documents: list[AwbDocumentKey] = Field(default_factory=list)
379: format: str = "custom"
380: printer_name: str = Field(default="Xprinter XP-420B", min_length=1, max_length=255)
381: confirmed: bool = False
382:
383:
384: class AwbDirectPrintCalibrationRequest(BaseModel):
385: printer_name: str = Field(default="Xprinter XP-420B", min_length=1, max_length=255)
386: confirmed: bool = False
387:
388:
389: class AwbBrandingDeleteRequest(BaseModel):
390: confirmed: bool = False
391:
392:
393: class LabelPrintItemRequest(BaseModel):
394: container_code: str = Field(min_length=4, max_length=80)
395: canonical_name: str = Field(min_length=1, max_length=180)
396: canonical_id: str | None = Field(default=None, max_length=128)
397:
398:
399: class LabelProfileRequest(BaseModel):
400: profile_id: str | None = None
401: agent_id: str
402: printer_name: str = Field(min_length=1, max_length=255)
403: dpi: int = Field(default=203)
404: label_width_mm: float = Field(default=20.57, gt=0, le=108)
405: label_height_mm: float = Field(default=9.65, gt=0, le=100)
406: media_width_mm: float = Field(gt=0, le=108)
407: row_height_mm: float = Field(gt=0, le=100)
408: gap_mm: float = Field(default=0, ge=0, le=50)
409: x_positions: list[int] = Field(min_length=4, max_length=4)
410: offset_x_dots: int = Field(default=0, ge=-200, le=200)
411: offset_y_dots: int = Field(default=0, ge=-200, le=200)
412: speed: int = Field(default=1, ge=1, le=5)
413: density: int = Field(default=10, ge=0, le=15)
414:
415:
416: class LabelPreviewRequest(BaseModel):
417: profile_id: str
418: mode: str = "combined"
419: items: list[LabelPrintItemRequest] = Field(default_factory=list, min_length=1, max_length=200)
420:
421:
422: class LabelJobRequest(LabelPreviewRequest):
423: source_batch_id: str | None = Field(default=None, max_length=128)
424: idempotency_key: str | None = Field(default=None, max_length=128)
425: confirmed: bool = False
426:
427:
428: class PackingStickerRequest(BaseModel):
429: scope: str = Field(pattern="^(manual|processed_order|processed_all|ready_all)$")
430: canonical_name: str = Field(default="", max_length=255)
431: quantity: int = Field(default=1, ge=1, le=2000)
432: order_sn: str = Field(default="", max_length=128)
433: profile_id: str = Field(default="", max_length=128)
434: request_id: str = Field(default="", max_length=128)
435: confirmed: bool = False
436:
437:
438: class LabelPairingCodeRequest(BaseModel):
439: agent_name: str = Field(default="TSC Print Agent", min_length=1, max_length=120)
440:
441:
442: class LabelAgentPairRequest(BaseModel):
443: agent_id: str
444: pairing_code: str
445: machine_name: str = Field(default="", max_length=255)
446: printer_names: list[str] = Field(default_factory=list, max_length=100)
447:
--- 448 582 ---
448:
449: class LabelAgentAutoPairRequest(BaseModel):
450: pairing_code: str
451: machine_name: str = Field(default="", max_length=255)
452: printer_names: list[str] = Field(default_factory=list, max_length=100)
453:
454:
455: class LabelAgentHeartbeatRequest(BaseModel):
456: machine_name: str = Field(default="", max_length=255)
457: printer_names: list[str] = Field(default_factory=list, max_length=100)
458: agent_version: str = Field(default="", max_length=64)
459: startup_mode: str = Field(default="", max_length=64)
460: scheduled_task_ok: bool = False
461:
462:
463: class LabelAgentStatusRequest(BaseModel):
464: status: str
465: error: str | None = Field(default=None, max_length=1000)
466:
467:
468: class LabelConfirmRequest(BaseModel):
469: confirmed: bool = False
470:
471:
472: class ScannerResolveRequest(BaseModel):
473: raw_text: str = Field(min_length=1, max_length=8000)
474:
475:
476: class ScannerEnrollmentRequest(BaseModel):
477: device_id: str = Field(min_length=8, max_length=128)
478: device_name: str = Field(default="", max_length=128)
479: app_version: str = Field(default="", max_length=64)
480: enrollment_key: str = Field(min_length=8, max_length=256)
481:
482:
483: class OperationsEnrollmentRequest(BaseModel):
484: shop_id: int = Field(gt=0)
485: device_id: str = Field(min_length=8, max_length=128)
486: device_name: str = Field(default="Shopee Operations", max_length=128)
487: app_version: str = Field(default="", max_length=64)
488:
489:
490: class OperationsPrimaryRequest(BaseModel):
491: confirmed: bool = False
492:
493:
494: class OperationsAckRequest(BaseModel):
495: order_sn: str = Field(min_length=6, max_length=80)
496:
497:
498: class ScannerForecastRequest(BaseModel):
499: start_date: date
500: duration_days: int = Field(ge=1, le=90)
501: service_level: float = Field(default=0.99, ge=0.80, le=0.995)
502: allocation_percent: float | None = Field(default=None, ge=0, le=100)
503: concentrate_percent: float | None = Field(default=None, ge=0, le=100)
504:
505:
506: class ScannerForecastLeaveApplyRequest(ScannerForecastRequest):
507: request_id: str = Field(min_length=8, max_length=128)
508: preview_token: str = Field(default="", max_length=128)
509:
510:
511: class ScannerForecastLeaveRevertRequest(BaseModel):
512: request_id: str = Field(min_length=8, max_length=128)
513: session_id: str = Field(min_length=8, max_length=128)
514:
515:
516: class ScannerNameCreateRequest(BaseModel):
517: canonical_name: str = Field(min_length=1, max_length=255)
518: alias_name: str = Field(default="", max_length=512)
519: sku: str = Field(default="", max_length=64)
520: associate_code: str = Field(default="", max_length=64)
521: capacity_ml: float | None = Field(default=None, ge=0, le=100000)
522:
523:
524: class ScannerAliasUpdateRequest(BaseModel):
525: canonical_name: str = Field(min_length=1, max_length=255)
526: alias_name: str = Field(min_length=1, max_length=512)
527:
528:
529: class ScannerCanonicalRenameRequest(BaseModel):
530: old_canonical_name: str = Field(min_length=1, max_length=255)
531: canonical_name: str = Field(min_length=1, max_length=255)
532:
533:
534: class ScannerProductCodesRequest(BaseModel):
535: canonical_name: str = Field(min_length=1, max_length=255)
536: sku: str = Field(default="", max_length=64)
537: associate_code: str = Field(default="", max_length=64)
538:
539:
540: class ScannerStickerCodesRequest(BaseModel):
541: canonical_name: str = Field(min_length=1, max_length=255)
542: sticker_codes: list[str] = Field(default_factory=list, max_length=20)
543:
544:
545: class ScannerNameDeleteRequest(BaseModel):
546: confirmed: bool = False
547:
548:
549: class ScannerBatchCreateRequest(BaseModel):
550: batch_type: str = Field(default="stock_in", max_length=32)
551: note: str = Field(default="", max_length=500)
552: client_batch_id: str | None = Field(default=None, max_length=64)
553:
554:
555: class ScannerBatchLockRequest(BaseModel):
556: device_id: str = Field(min_length=1, max_length=128)
557: lease_token: str = Field(default="", max_length=128)
558: lease_seconds: int = Field(default=120, ge=30, le=3600)
559:
560:
561: class ScannerBatchSnapshotItem(BaseModel):
562: client_ref: str = Field(min_length=8, max_length=64)
563: source_mode: str = Field(default="ocr", max_length=32)
564: canonical_name: str = Field(min_length=1, max_length=255)
565: supplier: str = Field(default="", max_length=120)
566: product_code: str = Field(default="", max_length=20)
567: volume_ml: float | None = Field(default=None, ge=0, le=100000)
568: capacity_ml: float | None = Field(default=None, ge=0, le=100000)
569: location: str = Field(default="", max_length=180)
570: container_code: str = Field(default="", max_length=40)
571: raw_text: str = Field(default="", max_length=8000)
572: print_requested: bool = False
573: quantity: int = Field(default=1, ge=1, le=1000)
574: scan_sequence: int = Field(default=0, ge=0)
575:
576:
577: class ScannerBatchSnapshotRequest(BaseModel):
578: device_id: str = Field(min_length=1, max_length=128)
579: lease_token: str = Field(min_length=8, max_length=128)
580: local_revision: int = Field(default=0, ge=0)
581: content_hash: str = Field(default="", max_length=128)
582: items: list[ScannerBatchSnapshotItem] = Field(default_factory=list, max_length=5000)
--- 455 589 ---
583:
584:
585: class ScannerBatchItemCreateRequest(BaseModel):
586: source_mode: str = Field(default="barcode", max_length=32)
587: canonical_name: str = Field(min_length=1, max_length=255)
588: supplier: str = Field(default="", max_length=120)
589: product_code: str = Field(default="", max_length=20)
--- 459 593 ---
590: volume_ml: float | None = Field(default=None, ge=0, le=100000)
591: capacity_ml: float | None = Field(default=None, ge=0, le=100000)
592: location: str = Field(default="", max_length=180)
593: container_code: str = Field(default="", max_length=40)
--- 2055 2189 ---
2055: def _normalise_info_needed(value: Any) -> dict[str, set[str]]:
2056: result: dict[str, set[str]] = {}
2057: if isinstance(value, dict):
2058: for method, fields in value.items():
2059: if isinstance(fields, list):
2060: result[str(method).lower()] = {str(x).lower() for x in fields}
2061: elif isinstance(fields, str):
2062: result[str(method).lower()] = {fields.lower()}
2063: elif fields is True or fields == {}:
2064: result[str(method).lower()] = set()
2065: elif fields:
2066: result[str(method).lower()] = set()
2067: elif isinstance(value, list):
2068: for method in value:
2069: result[str(method).lower()] = set()
2070: elif isinstance(value, str) and value.strip():
2071: result[value.strip().lower()] = set()
2072: return result
2073:
2074:
2075: def _first_nested_id(container: Any, id_key: str) -> Any | None:
2076: if isinstance(container, dict):
2077: if container.get(id_key) not in (None, ""):
2078: return container.get(id_key)
2079: for key in ("branch_list", "address_list", "time_slot_list", "dropoff_branch_list", "pickup_time_list"):
2080: val = container.get(key)
2081: if isinstance(val, list) and val:
2082: found = _first_nested_id(val[0], id_key)
2083: if found not in (None, ""):
2084: return found
2085: if isinstance(container, list) and container:
2086: return _first_nested_id(container[0], id_key)
2087: return None
2088:
2089:
2090: def _build_auto_ship_body(order_sn: str, package_number: str | None, params_payload: dict[str, Any], requested_method: str) -> tuple[str, dict[str, Any]]:
2091: resp = _payload_response(params_payload)
2092: info = _normalise_info_needed(resp.get("info_needed"))
2093: method = (requested_method or "auto").lower().strip()
2094: supported = set(info.keys())
2095: for k in ("dropoff", "pickup", "non_integrated"):
2096: if isinstance(resp.get(k), (dict, list)):
2097: supported.add(k)
2098: if method == "auto":
2099: # In Shopee Seller Centre this matches the usual "Arrange Shipment" path:
2100: # if dropoff has no extra info_needed, ship_order may accept an empty dropoff object.
2101: if "dropoff" in supported or not supported:
2102: method = "dropoff"
2103: elif "pickup" in supported:
2104: method = "pickup"
2105: elif "non_integrated" in supported:
2106: method = "non_integrated"
2107: else:
2108: method = "dropoff"
2109: if method not in {"pickup", "dropoff", "non_integrated"}:
2110: raise ValueError(f"Kaedah shipment tidak sah: {method}")
2111: body: dict[str, Any] = {"order_sn": order_sn}
2112: if package_number:
2113: body["package_number"] = package_number
2114: needed = info.get(method, set())
2115: if method == "dropoff":
2116: dropoff: dict[str, Any] = {}
2117: branch_id = settings.shipment_default_branch_id or _first_nested_id(resp.get("dropoff"), "branch_id")
2118: if branch_id and ("branch_id" in needed or settings.shipment_default_branch_id):
2119: dropoff["branch_id"] = int(branch_id)
2120: if "sender_real_name" in needed:
2121: if not settings.shipment_sender_real_name:
2122: raise ValueError("Dropoff perlukan sender_real_name. Set SHIPMENT_SENDER_REAL_NAME dalam .env.")
2123: dropoff["sender_real_name"] = settings.shipment_sender_real_name
2124: body["dropoff"] = dropoff
2125: return method, body
2126: if method == "pickup":
2127: pickup: dict[str, Any] = {}
2128: address_id = settings.shipment_pickup_address_id or _first_nested_id(resp.get("pickup"), "address_id")
2129: pickup_time_id = settings.shipment_pickup_time_id or _first_nested_id(resp.get("pickup"), "pickup_time_id")
2130: if not address_id:
2131: raise ValueError("Pickup perlukan address_id tetapi tiada pickup address dijumpai.")
2132: pickup["address_id"] = int(address_id)
2133: if pickup_time_id:
2134: pickup["pickup_time_id"] = pickup_time_id
2135: elif "pickup_time_id" in needed:
2136: raise ValueError("Pickup perlukan pickup_time_id tetapi tiada slot dijumpai.")
2137: body["pickup"] = pickup
2138: return method, body
2139: raise ValueError("Non-integrated shipment perlukan tracking number; batch auto tidak disokong untuk kaedah ini.")
2140:
2141:
2142: def _tracking_number_from_payload(value: Any) -> str:
2143: if isinstance(value, dict):
2144: for key, item in value.items():
2145: if str(key).lower() == "tracking_number" and item not in (None, ""):
2146: return str(item).strip()
2147: found = _tracking_number_from_payload(item)
2148: if found:
2149: return found
2150: elif isinstance(value, list):
2151: for item in value:
2152: found = _tracking_number_from_payload(item)
2153: if found:
2154: return found
2155: return ""
2156:
2157:
2158: def _start_arranged_awb_prepare(shop_id: int, documents: list[dict[str, Any]], source: str) -> dict[str, Any] | None:
2159: unique: dict[tuple[str, str], dict[str, str]] = {}
2160: for item in documents:
2161: order_sn = str(item.get("order_sn") or "").strip()
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",
--- 2177 2311 ---
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,
--- 2289 2423 ---
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)
2387: return {
2388: "enabled": bool(row.get("enabled")) if row else False,
2389: "message": str(row.get("message") or ""),
2390: "has_image": bool(row.get("image_relpath")) and image_path.is_file(),
2391: "image_sha256": str(row.get("image_sha256") or ""),
2392: "original_filename": str(row.get("original_filename") or ""),
2393: "updated_at": row.get("updated_at"),
2394: }
2395:
2396:
2397: def _awb_print_format(value: str | None) -> str:
2398: selected = str(value or "official").strip().lower()
2399: if selected not in {"official", "custom"}:
2400: raise HTTPException(422, "Format AWB mesti official atau custom.")
2401: return selected
2402:
2403:
2404: def _remote_print_public_job(job: dict[str, Any]) -> dict[str, Any]:
2405: hidden = {"payload_relpath", "preview_relpath", "documents_json"}
2406: return {key: value for key, value in job.items() if key not in hidden}
2407:
2408:
2409: def _awb_remote_printer(shop_id: int, printer_name: str) -> tuple[dict[str, Any], str]:
2410: selected = str(printer_name or "").strip()
2411: for agent in db.list_label_print_agents(shop_id):
2412: printers = [str(value) for value in (agent.get("printer_names") or [])]
2413: if selected in printers and str(agent.get("status") or "") == "online":
2414: return agent, selected
2415: raise HTTPException(409, f"Printer {selected} tidak tersedia pada Print Agent yang online.")
2416:
2417:
2418: def _queue_awb_remote_print(
2419: *,
2420: shop_id: int,
2421: printer_name: str,
2422: pdf_bytes: bytes,
2423: documents: list[dict[str, str]],