feat(product): 补充香港安培公开库存采集脚本与聚水潭库存适配

public_sku_inventory_sheet 与 jushuitan_inventory 适配器、hk_anpei 采集配置,
支撑 product.hk_anpei_public_inventory.daily 工作流。
This commit is contained in:
wangyunlong committed 2026-10-09 13:45:56 +08:00
1 parent 36fe7b81f8
commit 97a486e97d
3 files changed
+1161

No files matched your search

@@ -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",
]
@@ -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"
}
]
}
@@ -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())