diff --git a/src/gyxx_flow/adapters/jushuitan_inventory.py b/src/gyxx_flow/adapters/jushuitan_inventory.py new file mode 100644 index 0000000..e164967 --- /dev/null +++ b/src/gyxx_flow/adapters/jushuitan_inventory.py @@ -0,0 +1,338 @@ +"""Minimal, signed Jushuitan inventory-query client for workflow adapters.""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any +from urllib.parse import urlparse + +import requests + +_TOKEN_PATH = "/openWeb/auth/getInitToken" +_INVENTORY_PATH = "/open/inventory/query" +_RATE_LIMIT_CODES = {199, 200} +_ACCESS_TOKEN_INVALID_CODE = 100 +_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_MAX_PAGES = 100 +_REQUEST_INTERVAL_SECONDS = 1.1 + + +class JushuitanInventoryError(RuntimeError): + """Safe-to-log error raised for Jushuitan query or response failures.""" + + +@dataclass(frozen=True, slots=True) +class InventoryRecord: + sku: str + qty: int + order_lock: int + lock_qty: int + virtual_qty: int + + @property + def public_available_qty(self) -> int: + return self.qty - self.order_lock - self.lock_qty + self.virtual_qty + + +def _sign(app_secret: str, params: Mapping[str, object]) -> str: + source = app_secret + "".join( + f"{key}{value}" + for key, value in sorted(params.items()) + if key.lower() != "sign" and value is not None and str(value) != "" + ) + return hashlib.md5(source.encode("utf-8")).hexdigest() + + +def _response_code(value: object) -> int: + if isinstance(value, bool): + raise JushuitanInventoryError("Jushuitan response has an invalid code") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise JushuitanInventoryError( + "Jushuitan response has an invalid code" + ) from exc + + +def _quantity(value: object, *, field: str, sku: str) -> int: + if value is None: + return 0 + if isinstance(value, bool): + raise JushuitanInventoryError( + f"Jushuitan inventory has an invalid {field} for SKU {sku}" + ) + try: + parsed = Decimal(str(value).strip()) + except (InvalidOperation, ValueError) as exc: + raise JushuitanInventoryError( + f"Jushuitan inventory has an invalid {field} for SKU {sku}" + ) from exc + if not parsed.is_finite() or parsed != parsed.to_integral_value(): + raise JushuitanInventoryError( + f"Jushuitan inventory has a non-integer {field} for SKU {sku}" + ) + return int(parsed) + + +def _value(item: Mapping[str, Any], snake_name: str, camel_name: str) -> object: + if snake_name in item: + return item[snake_name] + return item.get(camel_name) + + +class JushuitanInventoryClient: + """Signed form client matching data-hub's Jushuitan API contract.""" + + def __init__( + self, + *, + app_key: str | None = None, + app_secret: str | None = None, + base_url: str | None = None, + timeout: tuple[float, float] = (10.0, 45.0), + ) -> None: + self.app_key = (app_key or os.environ.get("JUSHUITAN_APP_KEY", "")).strip() + self.app_secret = ( + app_secret or os.environ.get("JUSHUITAN_APP_SECRET", "") + ).strip() + configured_url = ( + base_url + or os.environ.get("JUSHUITAN_BASE_URL", "") + or "https://openapi.jushuitan.com" + ).strip() + parsed_url = urlparse(configured_url) + if ( + parsed_url.scheme.lower() != "https" + or not parsed_url.netloc + or parsed_url.username + or parsed_url.password + or parsed_url.query + or parsed_url.fragment + ): + raise JushuitanInventoryError( + "JUSHUITAN_BASE_URL must be an HTTPS base URL without credentials" + ) + if not self.app_key or not self.app_secret: + raise JushuitanInventoryError( + "JUSHUITAN_APP_KEY and JUSHUITAN_APP_SECRET must be injected " + "into the scheduler environment" + ) + self.base_url = configured_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + self._access_token = "" + self._last_request_at = 0.0 + + def query(self, skus: list[str]) -> dict[str, InventoryRecord]: + normalized_skus = list(dict.fromkeys(sku.strip() for sku in skus if sku.strip())) + if not normalized_skus: + raise JushuitanInventoryError("No SKU values were supplied") + if len(normalized_skus) > 100: + raise JushuitanInventoryError( + "A single inventory query may include at most 100 SKUs" + ) + + self._access_token = self._request_access_token() + page_index = 1 + page_count = 1 + records: dict[str, InventoryRecord] = {} + requested = set(normalized_skus) + while page_index <= page_count: + if page_index > _MAX_PAGES: + raise JushuitanInventoryError( + "Jushuitan inventory response exceeded the page safety limit" + ) + response = self._query_page(normalized_skus, page_index) + data = response.get("data") + if not isinstance(data, Mapping): + raise JushuitanInventoryError( + "Jushuitan inventory response is missing its data object" + ) + raw_rows = data.get("inventorys") + if raw_rows is None: + raw_rows = data.get("datas") + if raw_rows is None: + raw_rows = [] + if not isinstance(raw_rows, list): + raise JushuitanInventoryError( + "Jushuitan inventory response has an invalid inventory list" + ) + for index, raw_item in enumerate(raw_rows, start=1): + if raw_item is None: + continue + if not isinstance(raw_item, Mapping): + raise JushuitanInventoryError( + f"Jushuitan inventory row {index} is invalid" + ) + sku_value = _value(raw_item, "sku_id", "skuId") + sku = str(sku_value or "").strip() + if not sku: + raise JushuitanInventoryError( + f"Jushuitan inventory row {index} has no SKU" + ) + if sku not in requested: + raise JushuitanInventoryError( + "Jushuitan returned a SKU that was not requested" + ) + record = InventoryRecord( + sku=sku, + qty=_quantity( + _value(raw_item, "qty", "qty"), field="qty", sku=sku + ), + order_lock=_quantity( + _value(raw_item, "order_lock", "orderLock"), + field="order_lock", + sku=sku, + ), + lock_qty=_quantity( + _value(raw_item, "lock_qty", "lockQty"), + field="lock_qty", + sku=sku, + ), + virtual_qty=_quantity( + _value(raw_item, "virtual_qty", "virtualQty"), + field="virtual_qty", + sku=sku, + ), + ) + if sku in records: + raise JushuitanInventoryError( + f"Jushuitan returned duplicate inventory rows for SKU {sku}" + ) + records[sku] = record + + raw_page_count = data.get("page_count", data.get("pageCount", 1)) + try: + page_count = max(1, int(raw_page_count or 1)) + except (TypeError, ValueError) as exc: + raise JushuitanInventoryError( + "Jushuitan inventory response has an invalid page count" + ) from exc + if page_count > _MAX_PAGES: + raise JushuitanInventoryError( + "Jushuitan inventory response exceeded the page safety limit" + ) + page_index += 1 + return records + + def _request_access_token(self) -> str: + params: dict[str, object] = { + "app_key": self.app_key, + "charset": "utf-8", + "code": uuid.uuid4().hex[:6], + "grant_type": "authorization_code", + "timestamp": str(int(time.time())), + } + response = self._post_form(_TOKEN_PATH, params) + data = response.get("data") + if not isinstance(data, Mapping): + raise JushuitanInventoryError( + "Jushuitan token response is missing its data object" + ) + token = data.get("access_token") or data.get("accessToken") + if not isinstance(token, str) or not token.strip(): + raise JushuitanInventoryError( + "Jushuitan token response has no access token" + ) + return token.strip() + + def _query_page(self, skus: list[str], page_index: int) -> dict[str, Any]: + biz = { + "sku_ids": ",".join(skus), + "has_lock_qty": True, + "page_index": page_index, + "page_size": 100, + } + params: dict[str, object] = { + "access_token": self._access_token, + "app_key": self.app_key, + "charset": "utf-8", + "timestamp": str(int(time.time())), + "version": "2", + "biz": json.dumps(biz, ensure_ascii=False, separators=(",", ":")), + } + for token_attempt in range(2): + response = self._post_form( + _INVENTORY_PATH, + params, + allowed_api_codes=frozenset({_ACCESS_TOKEN_INVALID_CODE}), + ) + code = _response_code(response.get("code")) + if code == _ACCESS_TOKEN_INVALID_CODE and token_attempt == 0: + self._access_token = self._request_access_token() + params["access_token"] = self._access_token + params["timestamp"] = str(int(time.time())) + continue + return response + raise JushuitanInventoryError("Jushuitan rejected the access token") + + def _post_form( + self, + path: str, + params: dict[str, object], + *, + allowed_api_codes: frozenset[int] = frozenset(), + ) -> dict[str, Any]: + rate_limit_attempt = 0 + while True: + now = time.monotonic() + wait_seconds = _REQUEST_INTERVAL_SECONDS - (now - self._last_request_at) + if wait_seconds > 0: + time.sleep(wait_seconds) + self._last_request_at = time.monotonic() + signed_params = dict(params) + signed_params["sign"] = _sign(self.app_secret, signed_params) + try: + response = self.session.post( + f"{self.base_url}{path}", + data=signed_params, + timeout=self.timeout, + headers={"Accept": "application/json"}, + ) + except requests.RequestException as exc: + raise JushuitanInventoryError( + f"Jushuitan request failed for {path}" + ) from exc + if response.status_code < 200 or response.status_code >= 300: + raise JushuitanInventoryError( + f"Jushuitan endpoint {path} returned HTTP {response.status_code}" + ) + if len(response.content) > _MAX_RESPONSE_BYTES: + raise JushuitanInventoryError( + "Jushuitan response exceeded the 2 MiB safety limit" + ) + try: + payload = response.json() + except ValueError as exc: + raise JushuitanInventoryError( + f"Jushuitan endpoint {path} returned invalid JSON" + ) from exc + if not isinstance(payload, dict): + raise JushuitanInventoryError( + f"Jushuitan endpoint {path} returned an invalid response" + ) + code = _response_code(payload.get("code")) + if code in _RATE_LIMIT_CODES and rate_limit_attempt < 4: + time.sleep(0.5 * (2**rate_limit_attempt)) + rate_limit_attempt += 1 + params["timestamp"] = str(int(time.time())) + continue + if code != 0 and code not in allowed_api_codes: + raise JushuitanInventoryError( + f"Jushuitan endpoint {path} returned API code {code}" + ) + return payload + + +__all__ = [ + "InventoryRecord", + "JushuitanInventoryClient", + "JushuitanInventoryError", +] diff --git a/src/gyxx_flow/modules/product_commerce/config/hk_anpei_public_inventory.json b/src/gyxx_flow/modules/product_commerce/config/hk_anpei_public_inventory.json new file mode 100644 index 0000000..e6bb319 --- /dev/null +++ b/src/gyxx_flow/modules/product_commerce/config/hk_anpei_public_inventory.json @@ -0,0 +1,381 @@ +{ + "target": { + "spreadsheet_token": "LwbBswjfohdoEvtsu1Oc2rI3nac", + "sheet_id": "2HAOmi", + "sheet_name": "現有庫存", + "sku_column": "C", + "inventory_column": "D", + "product_code_column": "H", + "first_data_row": 2, + "last_data_row": 52, + "expected_sku_count": 51 + }, + "source_workbook": "光影行星香港安沛2026Q2計劃書(2026.09.28).xlsx", + "source_sheet": "現有庫存", + "source_as_of": "2026-09-28", + "schedule": { + "kind": "daily", + "at": "09:00", + "timezone": "Asia/Shanghai" + }, + "availability_formula": "qty - order_lock - lock_qty + virtual_qty", + "skus": [ + { + "sku": "GY-001BLK", + "product_name": "Apollo X1 Shoulder Bag", + "ean": "4897080019369", + "source_row": 2, + "jushuitan_sku_id": "10388030" + }, + { + "sku": "GY-001WHT", + "product_name": "Apollo X1 Shoulder Bag", + "ean": "4897080019376", + "source_row": 3, + "jushuitan_sku_id": "10388031" + }, + { + "sku": "GY-007BLK", + "product_name": "Liviana Shoulder Bag", + "ean": "4897080019468", + "source_row": 4, + "jushuitan_sku_id": "10390039" + }, + { + "sku": "GY-007WHT", + "product_name": "Liviana Shoulder Bag", + "ean": "4897080019475", + "source_row": 5, + "jushuitan_sku_id": "10390040" + }, + { + "sku": "GY-008WHT", + "product_name": "Starship Satchel", + "ean": "4897080019499", + "source_row": 6, + "jushuitan_sku_id": "10365042" + }, + { + "sku": "GY-009KAI", + "product_name": "Nebula Crossbody Bag", + "ean": "4897080010021", + "source_row": 7, + "jushuitan_sku_id": "10273005" + }, + { + "sku": "GY-011WHT", + "product_name": "Zeus Backpack", + "ean": "4897080019536", + "source_row": 8, + "jushuitan_sku_id": "10244056" + }, + { + "sku": "GY-012BLK", + "product_name": "Zeus II Travel Backpack (Expandable version)", + "ean": "4897080019543", + "source_row": 9, + "jushuitan_sku_id": "10434003" + }, + { + "sku": "GY-013BLK", + "product_name": "Startrail Crossbody Bag", + "ean": "4897080019550", + "source_row": 10, + "jushuitan_sku_id": "10394065" + }, + { + "sku": "GY-013GRN", + "product_name": "Startrail Crossbody Bag", + "ean": "4897080012308", + "source_row": 11, + "jushuitan_sku_id": "10394066" + }, + { + "sku": "GY-013WHT", + "product_name": "Startrail Crossbody Bag", + "ean": "4897080019567", + "source_row": 12, + "jushuitan_sku_id": "10394052" + }, + { + "sku": "GY-021BLK", + "product_name": "Boundary Backpack", + "ean": "4897080012483", + "source_row": 13, + "jushuitan_sku_id": "10447004" + }, + { + "sku": "GY-022BLK", + "product_name": "Apollo X2 Shoulder Bag", + "ean": "4897080012490", + "source_row": 14, + "jushuitan_sku_id": "10442009" + }, + { + "sku": "GY-022WHT", + "product_name": "Apollo X2 Shoulder Bag", + "ean": "4897080012506", + "source_row": 15, + "jushuitan_sku_id": "10442010" + }, + { + "sku": "GY-023BLK", + "product_name": "Apollo Air Shoulder Bag", + "ean": "4897080012513", + "source_row": 16, + "jushuitan_sku_id": "10456013" + }, + { + "sku": "GY-023WHT", + "product_name": "Apollo Air Shoulder Bag", + "ean": "4897080012995", + "source_row": 17, + "jushuitan_sku_id": "10456021" + }, + { + "sku": "GY-024BLK", + "product_name": "Skyreach Air Backpack", + "ean": "4897080012520", + "source_row": 18, + "jushuitan_sku_id": "10454007" + }, + { + "sku": "GY-024WHT", + "product_name": "Skyreach Air Backpack", + "ean": "4897080012537", + "source_row": 19, + "jushuitan_sku_id": "10454008" + }, + { + "sku": "GY-0260RG", + "product_name": "Polar Star Backpack", + "ean": "4897080010793", + "source_row": 20, + "jushuitan_sku_id": "10465009" + }, + { + "sku": "GY-026BLK", + "product_name": "Polar Star Backpack", + "ean": "4897080010588", + "source_row": 21, + "jushuitan_sku_id": "10465003" + }, + { + "sku": "GY-026GRN", + "product_name": "Polar Star Backpack", + "ean": "4897080010786", + "source_row": 22, + "jushuitan_sku_id": "10465020" + }, + { + "sku": "GY-026WHT", + "product_name": "Polar Star Backpack", + "ean": "4897080010595", + "source_row": 23, + "jushuitan_sku_id": "10465004" + }, + { + "sku": "GY-027BLK", + "product_name": "Polar Star tote bag", + "ean": "4897080010809", + "source_row": 24, + "jushuitan_sku_id": "10464008" + }, + { + "sku": "GY-027WHT", + "product_name": "Polar Star tote bag", + "ean": "4897080010571", + "source_row": 25, + "jushuitan_sku_id": "10464004" + }, + { + "sku": "GY-028BLK", + "product_name": "Poseidon tote bag (cordura)", + "ean": "4897080010779", + "source_row": 26, + "jushuitan_sku_id": "10472004" + }, + { + "sku": "GY-029BLK", + "product_name": "Nebula miniCrossbody Bag", + "ean": "4897080010762", + "source_row": 27, + "jushuitan_sku_id": "10475015" + }, + { + "sku": "GY-029KAI", + "product_name": "Nebula miniCrossbody Bag", + "ean": "4897080010748", + "source_row": 28, + "jushuitan_sku_id": "10475016" + }, + { + "sku": "GY-029WHT", + "product_name": "Nebula miniCrossbody Bag", + "ean": "4897080010755", + "source_row": 29, + "jushuitan_sku_id": "10475014" + }, + { + "sku": "GY-030BLK", + "product_name": "Morning Star miniCrossbody Bag", + "ean": "4897080013008", + "source_row": 30, + "jushuitan_sku_id": "10468007" + }, + { + "sku": "GY-030WHT", + "product_name": "Morning Star miniCrossbody Bag", + "ean": "4897080010731", + "source_row": 31, + "jushuitan_sku_id": "10468003" + }, + { + "sku": "GY-031BLK", + "product_name": "Star-by-Star EVOCrossbody Bag", + "ean": "4897080010724", + "source_row": 32, + "jushuitan_sku_id": "10474004" + }, + { + "sku": "GY-032BLK", + "product_name": "GlacierCrossbody Bag", + "ean": "4897080010717", + "source_row": 33, + "jushuitan_sku_id": "10453005" + }, + { + "sku": "GY-033BLK", + "product_name": "Morning Star 2 Crossbody Bag", + "ean": "4897080012896", + "source_row": 34, + "jushuitan_sku_id": "10469003" + }, + { + "sku": "GY-033WHT", + "product_name": "Morning Star 2 Crossbody Bag", + "ean": "4897080012902", + "source_row": 35, + "jushuitan_sku_id": "10469004" + }, + { + "sku": "GY-034BLK", + "product_name": "Startrail 2 Crossbody Bag", + "ean": "4897080012919", + "source_row": 36, + "jushuitan_sku_id": "10504027" + }, + { + "sku": "GY-034GRN", + "product_name": "Startrail 2 Crossbody Bag", + "ean": "4897080012926", + "source_row": 37, + "jushuitan_sku_id": "10504028" + }, + { + "sku": "GY-034WHT", + "product_name": "Startrail 2 Crossbody Bag", + "ean": "4897080012933", + "source_row": 38, + "jushuitan_sku_id": "10504029" + }, + { + "sku": "GY-035BLK", + "product_name": "Zeus 3 air Backpack", + "ean": "4897080012940", + "source_row": 39, + "jushuitan_sku_id": "10482004" + }, + { + "sku": "GY-035WHT", + "product_name": "Zeus 3 air Backpack", + "ean": "4897080012957", + "source_row": 40, + "jushuitan_sku_id": "10482003" + }, + { + "sku": "GY-036BLK", + "product_name": "Cirrus 2 Backpack", + "ean": "4897080012964", + "source_row": 41, + "jushuitan_sku_id": "10481004" + }, + { + "sku": "GY-036GRN", + "product_name": "Cirrus 2 Backpack", + "ean": "4897080012988", + "source_row": 42, + "jushuitan_sku_id": "10481006" + }, + { + "sku": "GY-036WHT", + "product_name": "Cirrus 2 Backpack", + "ean": "4897080012971", + "source_row": 43, + "jushuitan_sku_id": "10481005" + }, + { + "sku": "GY-037BLK", + "product_name": "Polar Star 2", + "ean": "4897080013046", + "source_row": 44, + "jushuitan_sku_id": "10520009" + }, + { + "sku": "GY-037GRY", + "product_name": "Polar Star 2", + "ean": "4897080013039", + "source_row": 45, + "jushuitan_sku_id": "10520014" + }, + { + "sku": "GY-038WHT", + "product_name": "Polar Star Shoulder Bag", + "ean": "4897080013060", + "source_row": 46, + "jushuitan_sku_id": "10519010" + }, + { + "sku": "GY-038BLK", + "product_name": "Polar Star Shoulder Bag", + "ean": "4897080013053", + "source_row": 47, + "jushuitan_sku_id": "10519009" + }, + { + "sku": "GY-039BLK", + "product_name": "Poseidon Laptop Bag", + "ean": "4897080013084", + "source_row": 48, + "jushuitan_sku_id": "10515020" + }, + { + "sku": "GY-039GRY", + "product_name": "Poseidon Laptop Bag", + "ean": "4897080013077", + "source_row": 49, + "jushuitan_sku_id": "10515019" + }, + { + "sku": "GY-040BLK", + "product_name": "Skyreach 3", + "ean": "4897080013091", + "source_row": 50, + "jushuitan_sku_id": "10510019" + }, + { + "sku": "GY-040WHT", + "product_name": "Skyreach 3", + "ean": "4897080013107", + "source_row": 51, + "jushuitan_sku_id": "10510020" + }, + { + "sku": "GY-041BLK", + "product_name": "Phantom", + "ean": "4897080013114", + "source_row": 52, + "jushuitan_sku_id": "10507015" + } + ] +} diff --git a/src/gyxx_flow/modules/product_commerce/public_sku_inventory_sheet.py b/src/gyxx_flow/modules/product_commerce/public_sku_inventory_sheet.py new file mode 100644 index 0000000..dd6627d --- /dev/null +++ b/src/gyxx_flow/modules/product_commerce/public_sku_inventory_sheet.py @@ -0,0 +1,442 @@ +"""Refresh Hong Kong Anpei public inventory in the Feishu spreadsheet.""" + +from __future__ import annotations + +import json +import sys +import time +from collections.abc import Mapping +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from gyxx_flow.adapters.jushuitan_inventory import ( + InventoryRecord, + JushuitanInventoryClient, +) +from gyxx_flow.modules.product_commerce.lark_cli_runtime import run_lark_cli + +_WORKFLOW_ID = "product.hk_anpei_public_inventory.daily" +_CONFIG_PATH = ( + Path(__file__).resolve().parent + / "config" + / "hk_anpei_public_inventory.json" +) +_REPOSITORY_ROOT = Path(__file__).resolve().parents[4] +_TIMEZONE = ZoneInfo("Asia/Shanghai") +_MAX_SHEET_ROWS = 200 +_WRITE_INTERVAL_SECONDS = 0.25 + + +class InventorySpreadsheetError(RuntimeError): + """Safe-to-log error for invalid spreadsheet configuration or state.""" + + +def _load_config() -> dict[str, Any]: + try: + config = json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise InventorySpreadsheetError( + "Unable to read the public inventory workflow configuration" + ) from exc + if not isinstance(config, dict): + raise InventorySpreadsheetError( + "Public inventory configuration must be an object" + ) + + target = config.get("target") + if not isinstance(target, dict): + raise InventorySpreadsheetError( + "Public inventory configuration has no spreadsheet target" + ) + for key in ( + "spreadsheet_token", + "sheet_id", + "sheet_name", + "sku_column", + "inventory_column", + "product_code_column", + ): + if not str(target.get(key) or "").strip(): + raise InventorySpreadsheetError( + f"Spreadsheet target is missing {key}" + ) + for key in ("first_data_row", "last_data_row", "expected_sku_count"): + value = target.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise InventorySpreadsheetError( + f"Spreadsheet target has an invalid {key}" + ) + if target["last_data_row"] - target["first_data_row"] + 1 != target[ + "expected_sku_count" + ]: + raise InventorySpreadsheetError( + "Spreadsheet target rows do not match the frozen SKU count" + ) + + sku_items = config.get("skus") + if not isinstance(sku_items, list) or not sku_items: + raise InventorySpreadsheetError( + "Public inventory configuration has no SKUs" + ) + if len(sku_items) != target["expected_sku_count"]: + raise InventorySpreadsheetError( + "Configured SKU count does not match the spreadsheet target" + ) + seen_skus: set[str] = set() + seen_product_codes: set[str] = set() + for item in sku_items: + if not isinstance(item, dict): + raise InventorySpreadsheetError( + "Public inventory SKU metadata is invalid" + ) + sku = str(item.get("sku") or "").strip() + product_code = str(item.get("jushuitan_sku_id") or "").strip() + if not sku or sku in seen_skus: + raise InventorySpreadsheetError( + "Public inventory configuration contains a blank or duplicate SKU" + ) + if not product_code or product_code in seen_product_codes: + raise InventorySpreadsheetError( + "Public inventory configuration contains a blank or duplicate " + "Jushuitan product code" + ) + seen_skus.add(sku) + seen_product_codes.add(product_code) + return config + + +def _sheet_args(config: Mapping[str, Any]) -> list[str]: + target = config["target"] + return [ + "--as", + "user", + "--spreadsheet-token", + str(target["spreadsheet_token"]), + "--sheet-id", + str(target["sheet_id"]), + "--format", + "json", + ] + + +def _run_sheets(args: list[str], *, operation: str) -> dict[str, Any]: + try: + return run_lark_cli( + args, + cwd=_REPOSITORY_ROOT, + timeout=180, + ) + except Exception as exc: + raise InventorySpreadsheetError( + f"Feishu spreadsheet {operation} failed" + ) from exc + + +def _cell_value(cell: object) -> object: + if not isinstance(cell, Mapping): + raise InventorySpreadsheetError( + "Feishu spreadsheet returned an invalid cell" + ) + return cell.get("value") + + +def _read_sheet_rows( + config: Mapping[str, Any], + sku_items: list[Mapping[str, Any]], +) -> dict[str, dict[str, Any]]: + target = config["target"] + result = _run_sheets( + [ + "sheets", + "+cells-get", + *_sheet_args(config), + "--range", + f"{target['sku_column']}1:{target['product_code_column']}{_MAX_SHEET_ROWS}", + "--include", + "value", + ], + operation="cells-get", + ) + data = result.get("data") + ranges = data.get("ranges") if isinstance(data, Mapping) else None + if ( + not isinstance(data, Mapping) + or data.get("has_more") is not False + or not isinstance(ranges, list) + or len(ranges) != 1 + ): + raise InventorySpreadsheetError( + "Feishu spreadsheet response is incomplete or invalid" + ) + + sheet_range = ranges[0] + if not isinstance(sheet_range, Mapping): + raise InventorySpreadsheetError( + "Feishu spreadsheet range response is invalid" + ) + cells = sheet_range.get("cells") + row_indices = sheet_range.get("row_indices") + col_indices = sheet_range.get("col_indices") + required_columns = { + str(target["sku_column"]), + str(target["inventory_column"]), + str(target["product_code_column"]), + } + if ( + not isinstance(cells, list) + or not isinstance(row_indices, list) + or not isinstance(col_indices, list) + or len(cells) != len(row_indices) + or not required_columns.issubset({str(col) for col in col_indices}) + ): + raise InventorySpreadsheetError( + "Feishu spreadsheet response does not cover the configured columns" + ) + column_positions = {str(col): index for index, col in enumerate(col_indices)} + row_values: dict[int, list[object]] = {} + for row_number, values in zip(row_indices, cells, strict=True): + if ( + isinstance(row_number, bool) + or not isinstance(row_number, int) + or not isinstance(values, list) + or len(values) != len(col_indices) + ): + raise InventorySpreadsheetError( + "Feishu spreadsheet returned malformed row coordinates" + ) + row_values[row_number] = values + + first_row = int(target["first_data_row"]) + last_row = int(target["last_data_row"]) + header = row_values.get(1) + if header is None: + raise InventorySpreadsheetError( + "Feishu spreadsheet is missing its header row" + ) + expected_headers = { + str(target["sku_column"]): "sku", + str(target["inventory_column"]): "仓库现有库存", + str(target["product_code_column"]): "商品编码(光影)", + } + for column, expected_header in expected_headers.items(): + actual_header = str( + _cell_value(header[column_positions[column]]) or "" + ).strip() + if actual_header != expected_header: + raise InventorySpreadsheetError( + f"Feishu spreadsheet header in column {column} does not match" + ) + + rows: dict[str, dict[str, Any]] = {} + for row_number in range(first_row, last_row + 1): + values = row_values.get(row_number) + if values is None: + raise InventorySpreadsheetError( + "Feishu spreadsheet is missing an expected inventory row" + ) + sku = str( + _cell_value(values[column_positions[str(target["sku_column"])]]) or "" + ).strip() + product_code = str( + _cell_value( + values[column_positions[str(target["product_code_column"])] ] + ) + or "" + ).strip() + if not sku or sku in rows: + raise InventorySpreadsheetError( + "Feishu spreadsheet contains a blank or duplicate SKU" + ) + rows[sku] = { + "row": row_number, + "product_code": product_code, + } + + expected_by_sku = { + str(item["sku"]).strip(): str(item["jushuitan_sku_id"]).strip() + for item in sku_items + } + if set(rows) != set(expected_by_sku): + raise InventorySpreadsheetError( + "Feishu spreadsheet SKU rows differ from the frozen workbook list" + ) + for sku, row in rows.items(): + if row["product_code"] != expected_by_sku[sku]: + raise InventorySpreadsheetError( + f"Feishu spreadsheet product code does not match SKU {sku} " + f"at row {row['row']}" + ) + return rows + + +def _read_written_inventory( + config: Mapping[str, Any], + expected: list[object], +) -> None: + target = config["target"] + column = str(target["inventory_column"]) + first_row = int(target["first_data_row"]) + last_row = int(target["last_data_row"]) + result = _run_sheets( + [ + "sheets", + "+cells-get", + *_sheet_args(config), + "--range", + f"{column}{first_row}:{column}{last_row}", + "--include", + "value", + ], + operation="inventory readback", + ) + data = result.get("data") + ranges = data.get("ranges") if isinstance(data, Mapping) else None + if ( + not isinstance(data, Mapping) + or data.get("has_more") is not False + or not isinstance(ranges, list) + or len(ranges) != 1 + ): + raise InventorySpreadsheetError( + "Feishu inventory readback is incomplete or invalid" + ) + sheet_range = ranges[0] + if not isinstance(sheet_range, Mapping): + raise InventorySpreadsheetError( + "Feishu inventory readback range is invalid" + ) + cells = sheet_range.get("cells") + row_indices = sheet_range.get("row_indices") + if ( + not isinstance(cells, list) + or not isinstance(row_indices, list) + or row_indices != list(range(first_row, last_row + 1)) + or len(cells) != len(expected) + ): + raise InventorySpreadsheetError( + "Feishu inventory readback does not cover every target row" + ) + actual: list[object] = [] + for row in cells: + if not isinstance(row, list) or len(row) != 1: + raise InventorySpreadsheetError( + "Feishu inventory readback has an invalid cell shape" + ) + actual.append(_cell_value(row[0])) + for offset, (actual_value, expected_value) in enumerate( + zip(actual, expected, strict=True) + ): + if expected_value == "": + matched = actual_value in (None, "") + else: + try: + matched = int(str(actual_value).strip()) == int(expected_value) + except (TypeError, ValueError): + matched = False + if not matched: + raise InventorySpreadsheetError( + "Feishu inventory readback did not match the submitted value " + f"at row {first_row + offset}" + ) + + +def _write_inventory( + config: Mapping[str, Any], + sku_items: list[Mapping[str, Any]], + inventories: Mapping[str, InventoryRecord], + rows: Mapping[str, Mapping[str, Any]], +) -> tuple[int, bool, int]: + target = config["target"] + first_row = int(target["first_data_row"]) + last_row = int(target["last_data_row"]) + by_row = sorted(rows.items(), key=lambda entry: int(entry[1]["row"])) + if [int(row["row"]) for _, row in by_row] != list( + range(first_row, last_row + 1) + ): + raise InventorySpreadsheetError( + "Feishu spreadsheet SKU rows are not a contiguous target range" + ) + sku_config = {str(item["sku"]): item for item in sku_items} + expected_values: list[object] = [] + missing_count = 0 + for sku, _ in by_row: + product_code = str(sku_config[sku]["jushuitan_sku_id"]) + inventory = inventories.get(product_code) + if inventory is None: + # An omitted Jushuitan row is unknown inventory, so clear stale data. + expected_values.append("") + missing_count += 1 + else: + expected_values.append(inventory.public_available_qty) + + cells = [[{"value": value}] for value in expected_values] + result = _run_sheets( + [ + "sheets", + "+cells-set", + *_sheet_args(config), + "--range", + f"{target['inventory_column']}{first_row}:" + f"{target['inventory_column']}{last_row}", + "--cells", + json.dumps(cells, ensure_ascii=False, separators=(",", ":")), + ], + operation="inventory update", + ) + if result.get("acceptance_skipped") is True: + return 0, True, missing_count + _read_written_inventory(config, expected_values) + return len(expected_values), False, missing_count + + +def run() -> dict[str, object]: + config = _load_config() + sku_items = config["skus"] + jushuitan_sku_ids = [str(item["jushuitan_sku_id"]) for item in sku_items] + + # Validate the complete target and ERP response before modifying Feishu. + rows = _read_sheet_rows(config, sku_items) + inventories = JushuitanInventoryClient().query(jushuitan_sku_ids) + if not inventories: + raise InventorySpreadsheetError( + "Jushuitan returned no inventory rows for the frozen product-code list; " + "refusing to clear the Feishu inventory column" + ) + written, acceptance_skipped, inventory_missing = _write_inventory( + config, + sku_items, + inventories, + rows, + ) + return { + "workflow_id": _WORKFLOW_ID, + "target_sheet": str(config["target"]["sheet_name"]), + "sku_count": len(sku_items), + "inventory_found": len(inventories), + "inventory_missing": inventory_missing, + "cells_written": written, + "acceptance_skipped": acceptance_skipped, + "queried_at": datetime.now(_TIMEZONE).strftime("%Y-%m-%d %H:%M:%S"), + } + + +def main() -> int: + try: + print(json.dumps(run(), ensure_ascii=False, separators=(",", ":"))) + return 0 + except Exception as exc: + print( + json.dumps( + {"workflow_id": _WORKFLOW_ID, "error": str(exc)}, + ensure_ascii=False, + separators=(",", ":"), + ), + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())