feat(product): 补充香港安培公开库存采集脚本与聚水潭库存适配
public_sku_inventory_sheet 与 jushuitan_inventory 适配器、hk_anpei 采集配置, 支撑 product.hk_anpei_public_inventory.daily 工作流。
This commit is contained in:
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())
|
||||||
Reference in new issue
Block a user