Job 1788123266767

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]],