Author SHA1 Message Date
yeuimu 5f4e1ffa1f feat: 快递100上门取件集成(SDS运单自动生成+面单回传)
- api/kuaidi100: 上门取件API客户端(bOrder/price/cancel/detail,MD5签名封装)
- api/sds: 变体克重(children接口,24h缓存)/面单PDF上传/挂包裹/carriageNoStatus筛选
- flows/sds/waybill_autogen: 每10分钟遍历运单待生成订单(factoryIds+carriageNoStatus+orderStatusCode)
  自动下极兔(returnType=20+tempid出面单),面单回调转PDF回传SDS;
  幂等跳过已有运单;面单超时不自动取消(记人工跟进);schedule 默认 active:false
- scripts: 手动E2E测试脚本 + 回调接收服务(验签MD5(param+salt)+JSONL落盘,配frpc 18090穿透)
- 依赖 +pillow(面单PNG转PDF)
2026-11-03 08:02:39 +00:00
inkreach d60a99ab4e Merge pull request 'feat: RIIN 商品拉取改用 goods/list 批量接口(200/批串行)替换逐单 order/goods' (#14) from fix/riin-goods-batch-list into main
Reviewed-on: #14
2026-10-31 10:23:48 +00:00
yeuimu 4a7c44a354 feat: RIIN 商品拉取改用 goods/list 批量接口(200/批串行)替换逐单 order/goods
CI / test (pull_request) Successful in 1m11s
- api/riin/client.py: 新增 get_order_goods_batch(distribution/order/goods/list)
- flows/shared/clients.py: fetch_goods_concurrent → fetch_goods_for_orders,
  批量 200/批、串行、批间 2s,批级重试;移除逐单全局限速(340~500ms)机制
- flows/riin/_pipeline.py: 适配新批量拉取
- flows/shared/db.py: 存在性预查 unnest cast timestamp[] → timestamptz[]
  (入参为带时区偏移的 tz-aware datetime,timestamptz 才能稳定匹配)

背景:旧逐单接口高频被软限流(成功返回空 data),10-28 起日均缺口 13~33%;
批量接口调用量从日均 ~1 万次降到 ~50 次,已实测回补 7689 单/9717 条商品全量成功。
2026-10-31 10:01:50 +00:00
inkreach 40288c2cd6 Merge pull request 'fix: EVRI 面单 OCR 单号入库前去掉 '-'' (#13) from develop into main
Reviewed-on: #13
2026-10-30 01:53:01 +00:00
yeuimu 740bd803a9 fix: EVRI 面单 OCR 单号入库前去掉 '-'
CI / test (pull_request) Successful in 36s
2026-10-29 09:41:53 +00:00
inkreach a7fd330622 Merge pull request 'fix: order/goods 限速收紧至峰值<3次/秒(340ms+0~160ms随机间隔)' (#12) from inkreach/order-goods-throttle into main
Reviewed-on: #12
2026-10-23 11:14:11 +00:00
yeuimu f87662ba4b fix: order/goods 限速收紧至峰值<3次/秒(340ms+0~160ms随机间隔)
CI / test (pull_request) Successful in 1m8s
2026-10-23 11:06:49 +00:00
yeuimu eb97ee1647 fix: RIIN order/goods 全局限速(并发20→5+150ms间隔,峰值≤6.7QPS);暂停 recent-sync/verify 调度(分销侧反馈调用量过大) 2026-10-23 10:57:46 +00:00
rom 0baac665da Merge pull request 'Develop' (#11) from develop into main
Reviewed-on: #11
2026-10-21 09:50:24 +00:00
rom 1a13075926 fix(ci): 锁定 Python 3.12,避免 uv 选 3.15 触发全量源码编译
CI / test (pull_request) Successful in 3m40s
2026-10-21 17:45:56 +08:00
rom 019cbadd1e feat: 新增 shop-dim-sync flow 并注册每 5 分钟调度
CI / test (pull_request) Canceled after 5m33s
2026-10-21 16:55:31 +08:00
inkreach e6fdda0b3f Merge pull request 'feat: OCR 新增支持波兰(PL,版式 pables_dpd/pables_inpost)' (#10) from inkreach/ocr-pl into main
Reviewed-on: #10
2026-10-15 07:00:30 +00:00
inkreach e4885803df Merge pull request 'fix: 写库重试 3→5 次、退避拉长至 1/3/7/15/30s' (#9) from inkreach/harden-db-write-retry into main
Reviewed-on: #9
2026-10-15 07:00:22 +00:00
yeuimu 2321e8ae9f Merge branch 'inkreach/ocr-pl': OCR 新增支持波兰(PL) 2026-10-15 03:14:40 +00:00
yeuimu fdcf107993 feat: OCR 新增支持波兰(PL,版式 pables_dpd/pables_inpost)
CI / test (pull_request) Successful in 2m49s
2026-10-15 03:12:21 +00:00
yeuimu e5175a0504 fix: 写库重试 3→5 次、退避拉长至 1/3/7/15/30s
CI / test (pull_request) Successful in 4m30s
sceptical-finch(riin-recent-sync 10-10 16:04)失败复盘:17track 物流回写
(宿主机本地进程,不走 advisory 锁)与 sync 写库在 panel 触发器链
(panel.orders/dims/dims_daily)上交叉持锁,冲突窗口持续约 3 分钟,
原 3 次重试(1/3/7s)全部耗尽。加深重试使其可跨过分钟级外部冲突窗口。
2026-10-10 09:08:01 +00:00
inkreach 489dacca49 Merge pull request 'Develop' (#8) from inkreach/develop into main
Reviewed-on: #8
2026-09-28 07:29:10 +00:00
yeuimu 5eacfefb6c fix: RIIN 商品状态补 CLOSE=已取消(重拉残留时告警发现的新枚举;UNKNOWN 保留 NULL)
CI / test (pull_request) Successful in 2m8s
2026-09-28 03:19:29 +00:00
inkreach 63d30d8f25 Merge pull request 'fix: RIIN 商品状态补 COMPLETE/PAUSE 映射,修复 Goods.status 大面积 NULL' (#7) from inkreach/fix/riin-goods-status-map into develop
CI / test (pull_request) Successful in 48s
Reviewed-on: #7
2026-09-28 03:04:39 +00:00
yeuimu c030eca178 fix: RIIN 商品状态补 COMPLETE/PAUSE 映射,修复 Goods.status 大面积 NULL
CI / test (pull_request) Successful in 1m35s
问题:RIIN 商品级 goodsStatus 复用平台状态枚举,COMPLETE/PAUSE 无映射,
.get() 返回 None 落库为 NULL,累计 206 万行(占 RIIN 商品 33.75%),
终态订单不再被 refresh_pending 刷新而长期滞留。该缺口继承自
inkreach-bi-auth-mng TS 版,且 TS 的 warnUnknown 告警在移植时被省略,
导致从未暴露。

修复:
- RIIN_GOODS_STATUS_MAP 补 COMPLETE=已完成、PAUSE=暂停
- 对齐 TS 版 warnUnknown:四个状态映射函数对未知值告警(同值只报一次)
- docs/sql:Goods.status 约束新增允许"暂停"(原约束拒绝,已在库执行)
- scripts/backfill_riin_goods_status.py:存量 NULL 按"已发货"订单终态
  近似回填(幂等、分批);"已关闭"与非终态订单走 by-orders 重拉真值
- tests/test_mappers.py:9 个单测全过;实单 GSU1S555400MFLL e2e 验证
  4 行 NULL → 已完成/暂停
2026-09-28 03:02:05 +00:00
inkreach fff5b707ac Merge pull request 'feat: OCR 新增支持西班牙/意大利/加拿大(ES/IT/CA)' (#6) from inkreach/ocr-es-it-ca into main
Reviewed-on: #6
2026-09-24 03:56:05 +00:00
yeuimu c6be5bacf6 ci: 检出兼容 AGit PR 的 refs/pull/N/head(clone --branch 只认分支名,改为 fetch 后 checkout)
CI / test (pull_request) Successful in 1m6s
2026-09-24 03:53:34 +00:00
yeuimu 2259bce7c4 feat: OCR 新增支持西班牙/意大利/加拿大(ES/IT/CA)
CI / test (pull_request) Failing after 1s
- api/ocr/models.py: SUPPORTED_COUNTRIES/_COUNTRY_MAP 补 CA/ES/IT 的 ISO 码与中文国名
- flows/label_ocr.py: label-ocr 默认国家列表扩为 8 国,docstring 同步
2026-09-24 03:49:46 +00:00
rom d07c67327e Merge pull request 'Develop' (#5) from develop into main
Reviewed-on: #5
2026-09-23 03:46:14 +00:00
rom 10d3c5f278 feat: 新增 platform-order-upper-sync flow 并注册部署
CI / test (pull_request) Successful in 1m2s
2026-09-23 11:41:10 +08:00
rom 6007b2c34b 增加商户号
CI / test (pull_request) Successful in 1m7s
2026-09-21 10:52:45 +08:00
rom d8e4adb0b5 增加商户号 2026-09-21 10:43:49 +08:00
rom 9c76772969 Merge pull request 'Develop' (#4) from develop into main
Reviewed-on: #4
2026-09-20 09:04:49 +00:00
rom f37ef24cf0 增加商户号
CI / test (pull_request) Successful in 45s
2026-09-20 16:53:49 +08:00
yeuimu 1393b2a86b fix: platform_order 本地直跑改为直接执行,不再 serve 同名 deployment(会覆盖正式部署导致 Not Ready) 2026-09-19 02:57:01 +00:00
yeuimu 5aaddbbf7c fix: worker 加 init 回收僵尸进程;label-ocr 批量请求重试且失败不落 ERROR
- docker-compose: prefect-worker 加 init: true。pull 步骤的 git 子进程被杀后
  无人 wait 变僵尸,9/17 宿主机重启后爆发 3.7 万僵尸占满容器 cgroup PID 配额,
  导致所有 flow run "Failed to start flow run process"(全量 CRASH 约 40h)。
- label_ocr: OCR 编排服务(lm-server-canary)偶发秒级重启,原逻辑对批量请求
  失败零重试并整批标永久 ERROR,9/1、9/5、9/9 三波共 ~2500 单误标。
  现改为请求级重试 3 次,仍失败则保持未处理,留给下个小时 run 自动重试。
2026-09-18 10:09:24 +00:00
29 changed files with 1797 additions and 65 deletions
+4 -1
View File
@@ -12,7 +12,10 @@ jobs:
- name: 检出代码
run: |
REF="${{ github.event.pull_request.head.ref || github.ref_name }}"
git clone --depth 1 --branch "$REF" "${{ github.server_url }}/${{ github.repository }}.git" .
git clone --depth 1 "${{ github.server_url }}/${{ github.repository }}.git" .
# PR head 可能是完整 ref(AGit 建的 PR 为 refs/pull/N/head),clone --branch 只认分支名,改 fetch 后 checkout
git fetch --depth 1 origin "$REF"
git checkout --detach FETCH_HEAD
- name: 安装 uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: 安装依赖
+1
View File
@@ -0,0 +1 @@
3.12
+8
View File
@@ -0,0 +1,8 @@
"""快递100 上门取件API(线上支付) 客户端。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
"""
from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client
__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"]
+160
View File
@@ -0,0 +1,160 @@
"""快递100「上门取件API(线上支付)」REST 客户端(httpx + pydantic 思路,纯客户端层,禁止 import prefect)。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
鉴权与签名:
- 公共 body 参数 method/key/sign/t/param(Content-Type: application/x-www-form-urlencoded);
- sign = MD5(param + t + key + secret) 转 32 位大写,secret 在企业管理后台获取;
- 凭据(key/secret/customer/tempid)由调用方(flow 层)从 Secret Block 读取后注入,本层不落盘。
实测注意(勿踩):
- taskId/orderId 回传取消接口时必须原样保持类型(orderId 是字符串数字);
- cancelMsg 含"测试"字样会被后端判"订单信息错误",用固定文案"暂时不寄件了";
- returnType=20 出面单图片短链,经 callBackUrl 回调推送(status=200),tempid 必填。
"""
from __future__ import annotations
import hashlib
import json
import time
from typing import Any
import httpx
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
CANCEL_MSG = "暂时不寄件了"
class Kuaidi100ApiError(RuntimeError):
"""快递100 业务失败(result != true 或 returnCode != 200)。"""
class Kuaidi100Client:
"""寄件下单(bOrder)/查价(price)/取消(cancel)/订单详情(detail)。"""
def __init__(
self,
key: str,
secret: str,
*,
base_url: str = PROD_BASE_URL,
customer: str | None = None,
timeout: float = 30.0,
) -> None:
self._key = key
self._secret = secret
self._customer = customer
self._base_url = base_url
self._http = httpx.Client(timeout=timeout)
# ----- 公共调用 -----
@staticmethod
def make_sign(param: str, t: str, key: str, secret: str) -> str:
return hashlib.md5(f"{param}{t}{key}{secret}".encode("utf-8")).hexdigest().upper()
def call(self, method: str, param: dict[str, Any]) -> dict[str, Any]:
"""POST 公共表单,业务失败抛 Kuaidi100ApiError,成功返回整个响应 JSON。"""
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
t = str(int(time.time() * 1000))
body = {
"method": method,
"key": self._key,
"sign": self.make_sign(param_str, t, self._key, self._secret),
"t": t,
"param": param_str,
}
resp = self._http.post(self._base_url, data=body)
resp.raise_for_status()
data = resp.json()
if data.get("result") is not True or str(data.get("returnCode")) != "200":
raise Kuaidi100ApiError(f"[{method}] {json.dumps(data, ensure_ascii=False)[:500]}")
return data
# ----- 业务接口 -----
def price(
self,
kuaidi_com: str,
send_addr: str,
rec_addr: str,
weight: str = "1",
service_type: str | None = None,
) -> dict[str, Any]:
"""查价(只读)。返回 data:defPrice/defFirstPrice/price/overPricePerKg/serviceType 等。"""
param: dict[str, Any] = {
"kuaidiCom": kuaidi_com,
"sendManPrintAddr": send_addr,
"recManPrintAddr": rec_addr,
"weight": weight,
}
if service_type:
param["serviceType"] = service_type
return self.call("price", param).get("data") or {}
def border(
self,
*,
kuaidicom: str,
rec_man_name: str,
rec_man_mobile: str,
rec_man_print_addr: str,
send_man_name: str,
send_man_mobile: str,
send_man_print_addr: str,
callback_url: str,
cargo: str | None = None,
weight: str | None = None,
payment: str = "SHIPPER",
remark: str | None = None,
salt: str | None = None,
return_type: str | None = None,
tempid: str | None = None,
print_callback_url: str | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""寄件下单。returnType=20 + tempid 时面单短链经回调(status=200)推送。
返回 data:taskId/orderId/kuaidinum/pollToken/appointGotStartTime 等,
taskId/orderId 后续回传必须保持原类型。
"""
param: dict[str, Any] = {
"kuaidicom": kuaidicom,
"recManName": rec_man_name,
"recManMobile": rec_man_mobile,
"recManPrintAddr": rec_man_print_addr,
"sendManName": send_man_name,
"sendManMobile": send_man_mobile,
"sendManPrintAddr": send_man_print_addr,
"callBackUrl": callback_url,
"payment": payment,
}
for key, value in (
("cargo", cargo), ("weight", weight), ("remark", remark), ("salt", salt),
("returnType", return_type), ("tempid", tempid), ("printCallBackUrl", print_callback_url),
):
if value is not None:
param[key] = value
if extra:
param.update(extra)
return self.call("bOrder", param).get("data") or {}
def cancel(self, task_id: Any, order_id: Any, cancel_msg: str = CANCEL_MSG) -> None:
"""取消订单(取件前)。taskId/orderId 原样回传下单返回值。"""
self.call("cancel", {"taskId": task_id, "orderId": order_id, "cancelMsg": cancel_msg})
def detail(self, task_id: Any) -> dict[str, Any]:
"""订单详情(只读):status/kuaidiNum/payStatus/preWeight 等。"""
return self.call("detail", {"taskId": task_id}).get("data") or {}
def close(self) -> None:
self._http.close()
def __enter__(self) -> "Kuaidi100Client":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
+3 -1
View File
@@ -37,14 +37,16 @@ class OcrResult(BaseModel):
# OCR 服务已有分类器模型的国家(版面见 api.md §4)
SUPPORTED_COUNTRIES = {"US", "UK", "DE", "MX", "SA"}
SUPPORTED_COUNTRIES = {"US", "UK", "DE", "MX", "SA", "CA", "ES", "IT", "PL"}
# ISO 国家代码 / 中文国名 → OCR 国家代码
_COUNTRY_MAP: dict[str, str] = {
# 代码
"GB": "UK", "UK": "UK", "US": "US", "DE": "DE", "MX": "MX", "SA": "SA",
"CA": "CA", "ES": "ES", "IT": "IT", "PL": "PL",
# 中文名
"英国": "UK", "美国": "US", "德国": "DE", "墨西哥": "MX", "沙特": "SA",
"加拿大": "CA", "西班牙": "ES", "意大利": "IT", "波兰": "PL",
}
+8
View File
@@ -246,6 +246,14 @@ class RiinDisClient(TokenHttpClient):
data = self.post("distribution/order/goods", body={"orderId": order_id})
return OrderGoodsResponse.model_validate(data)
def get_order_goods_batch(self, ids: list[str]) -> OrderGoodsResponse:
"""批量获取订单商品(distribution/order/goods/list)。
上游要求:单批 ≤200 个 id 且串行调用;高频/并发会被软限流(成功但空 data)。
"""
data = self.post("distribution/order/goods/list", body={"ids": ids})
return OrderGoodsResponse.model_validate(data)
def get_order_count(
self, params: OrderPageRequest | None = None
) -> OrderListCountResponse:
+97
View File
@@ -23,6 +23,7 @@ from api.sds.models import (
OrderQueryParams,
OrderStatistics,
OrderStatus,
ProductChildrenResponse,
ProductListRequest,
ProductListResponse,
)
@@ -30,6 +31,7 @@ from api.sds.models import (
BASE_URL = "https://pod-api.sdspod.com"
COMPANY_NO = "印美达"
KEYWORDS_BATCH_SIZE = 200
PRODUCT_CACHE_TTL_SECONDS = 24 * 3600 # 产品变体(克重等)是主数据,长缓存
FILTERS_FIELDS = (
"factoryList,merchantList,logisticsList,earlyWarningList,"
"issuingBayAreaList,logisticsSourceList,extendStatusList"
@@ -64,6 +66,7 @@ class SdsMngClient(TokenHttpClient):
self._username = username
self._password = password
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
self._product_cache: TTLCache[Any] = TTLCache(PRODUCT_CACHE_TTL_SECONDS)
self._concurrency = concurrency
# ----- 认证 -----
@@ -129,6 +132,8 @@ class SdsMngClient(TokenHttpClient):
query["earlyWarningType"] = params.early_warning_type
if params.item_num_type is not None:
query["itemNumType"] = params.item_num_type
if params.carriage_no_status is not None:
query["carriageNoStatus"] = params.carriage_no_status
return query
@staticmethod
@@ -297,6 +302,98 @@ class SdsMngClient(TokenHttpClient):
merged.extend(r.records)
return ProductListResponse(total_count=first.total_count, records=merged)
# ----- 产品变体/克重(主数据,24h 缓存) -----
def get_product_children(self, product_id: int) -> ProductChildrenResponse:
"""产品变体列表(含每尺码克重 weight 克/件、包装盒尺寸)。
GET /pod/product/{id}/children;变体克重是主数据,缓存 PRODUCT_CACHE_TTL_SECONDS。
"""
key = f"children:{product_id}"
cached = self._product_cache.get(key)
if cached is not None:
return cached
data = self.get(f"/pod/product/{product_id}/children", params={"t": int(time.time() * 1000)})
result = ProductChildrenResponse.model_validate(data)
self._product_cache.set(key, result)
return result
def get_variant_weight_map(self, product_id: int) -> dict[int, float]:
"""{变体 id: 克重}。订单行的 product_id 即变体 id,可直接查。"""
return {
c.id: c.weight
for c in self.get_product_children(product_id).records
if c.weight is not None
}
def resolve_product_id(self, code: str) -> int | None:
"""产品编码(父 sku,如 RB9327005) -> 产品 id(keyword 精确搜索,长缓存)。"""
key = f"pid:{code}"
cached = self._product_cache.get(key)
if cached is not None:
return cached or None
resp = self.get_products(ProductListRequest(keyword=code, size=1))
pid = resp.records[0].id if resp.records else 0
self._product_cache.set(key, pid)
return pid or None
def get_order_weight_grams(self, order: Any) -> tuple[float | None, list[str]]:
"""按变体克重汇总订单克重。返回 (总克重, 未匹配行描述);None 时调用方需回退。"""
total, missed, maps = 0.0, [], {}
for it in order.items or []:
supply = getattr(it, "productSupply", None) or {}
parent_id = supply.get("productParentId")
if parent_id is None or it.product_id is None:
missed.append(f"行{it.no or '?'} 缺 productParentId/product_id")
continue
if parent_id not in maps:
maps[parent_id] = self.get_variant_weight_map(parent_id)
weight = maps[parent_id].get(it.product_id)
if weight is None:
missed.append(f"行{it.no or it.product_id} 变体无克重字段")
continue
total += weight * (it.num or 1)
return (total or None), missed
# ----- 面单文件/包裹(运单回传) -----
def _send_with_token(
self,
method: str,
url: str,
*,
files: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
"""基类 _send 未覆盖的请求形态(multipart 上传 / PUT),带 token + 401 重登一次。"""
for attempt in (1, 2):
headers = {self._token_header: self._ensure_token()}
resp = self._http.request(method, url, files=files, json=json_body, params=params, headers=headers)
if resp.status_code == 401 and attempt == 1:
self._clear_token()
continue
resp.raise_for_status()
return resp.json() if resp.content else {}
return {}
def upload_waybill_pdf(self, filename: str, pdf_bytes: bytes) -> dict[str, Any]:
"""上传面单 PDF,返回含 CDN url 的文件信息。POST /files/pdf/logisticsWaybill。"""
files = {"file": (filename, pdf_bytes, "application/pdf")}
return self._send_with_token("POST", "/files/pdf/logisticsWaybill", files=files)
def attach_parcel_label(self, order_id: Any, parcel_id: Any, order_no: str, label_pdf_url: str) -> None:
"""把面单挂到订单包裹(覆盖语义)。PUT /orders/{orderId}/logistic/parcel/{parcelId}。
orderNo 传该包裹运单号(原样保留或回写新单号);labelPdf 为 upload_waybill_pdf 的 url。
"""
self._send_with_token(
"PUT",
f"/orders/{order_id}/logistic/parcel/{parcel_id}",
json_body={"orderNo": order_no, "labelPdf": label_pdf_url},
params={"t": int(time.time() * 1000)},
)
# ----- 物流 -----
def get_logistics_areas(self) -> IssuingBayAreaResponse:
+21
View File
@@ -219,6 +219,26 @@ class ProductListResponse(_SdsModel):
records: list[Product] = Field(default=[], alias="list")
class ProductVariant(_SdsModel):
"""产品变体(/pod/product/{id}/children):颜色/尺码/克重/包装盒尺寸。"""
id: int
parent_id: int | None = Field(default=None, alias="parentId")
sku: str | None = None
size: str | None = None
color_name: str | None = Field(default=None, alias="colorName")
color_code: str | None = Field(default=None, alias="colorCode")
status: int | None = None
weight: float | None = None # 克/件
box_length: float | None = Field(default=None, alias="boxLength")
box_width: float | None = Field(default=None, alias="boxWidth")
box_height: float | None = Field(default=None, alias="boxHeight")
class ProductChildrenResponse(_SdsModel):
records: list[ProductVariant] = Field(default=[], alias="list")
# ----- 物流域(混用 snake_case,显式 alias)-----
@@ -318,6 +338,7 @@ class OrderPageRequest(BaseModel):
extend_status: str | None = None
early_warning_type: str | None = None
item_num_type: str | None = None
carriage_no_status: str | None = None # 运单号状态筛选,如 generate=运单待生成
limit: int | None = None
all: bool | None = None
+3
View File
@@ -15,6 +15,9 @@ services:
prefect-worker:
build: .
container_name: prefect-worker
# init 进程(tini)回收孤儿/僵尸:pull 步骤的 git 子进程被杀后没人 wait 会变僵尸,
# 堆满 cgroup PID 配额后容器内无法再 fork(2026-09-17 事故根因)
init: true
environment:
PREFECT_API_URL: http://prefect-server:4200/api
TZ: Asia/Shanghai
@@ -0,0 +1,13 @@
-- 2026-09-28 Goods.status 约束新增允许 '暂停'
-- 背景:RIIN 商品级 goodsStatus 复用平台状态枚举,存在 PAUSE(暂停),
-- 原约束未包含该值,导致映射层只能落 NULL(chk_goods_status 拒绝其余新值)。
-- 配套:mappers.py 的 RIIN_GOODS_STATUS_MAP 补 COMPLETE/PAUSE。
-- 执行方式(两步,避免阻塞 ETL 写入;VALIDATE 只锁 SHARE UPDATE EXCLUSIVE):
ALTER TABLE "Goods" DROP CONSTRAINT chk_goods_status;
ALTER TABLE "Goods" ADD CONSTRAINT chk_goods_status CHECK (
status IS NULL OR status = ANY (ARRAY[
'备货中', '已删除', '已取消', '已完成', '待付款', '待确认',
'搁置中', '待发货', '已发货', '退款中', '暂停'
])
) NOT VALID;
ALTER TABLE "Goods" VALIDATE CONSTRAINT chk_goods_status;
+33 -9
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import os
import time
import httpx
from prefect import flow, task
@@ -16,6 +17,8 @@ from api.ocr import OcrClient, country_to_ocr_code
from flows.shared import SyncDB, build_stats_rows, emit_sync_summary
BATCH_SIZE = 200
BATCH_RETRIES = 3 # 批量请求级重试(OCR 服务偶发秒级重启)
BATCH_RETRY_BACKOFF = [5, 15] # 重试间隔(秒)
_OCR_FIELD_MAP = [
("layout", "ocrLayout"), ("courier", "ocrCourier"), ("tracking", "ocrTracking"),
@@ -26,13 +29,20 @@ _OCR_FIELD_MAP = [
]
# 单号不需要 "-" 的承运商(Evri 单号两段式带 "-",下游查询用无 "-" 形式)
_DASH_STRIP_COURIERS = {"EVRI"}
def _result_to_fields(r) -> dict[str, str | None]:
if not r.ok:
err = (r.error or "").lower()
if "reject" in err or "not a recognized layout" in err:
return {"ocrLayout": "UNSUPPORTED"}
return {"ocrLayout": "ERROR"}
return {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP}
fields = {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP}
if (r.courier or "").strip().upper() in _DASH_STRIP_COURIERS:
fields["ocrTracking"] = fields["ocrTracking"].replace("-", "")
return fields
def _has_running_peer() -> bool:
@@ -63,7 +73,7 @@ def _has_running_peer() -> bool:
def process_all(pending: list) -> dict[str, int]:
db = SyncDB()
ocr = OcrClient()
stats = {"success": 0, "error": 0, "unsupported": 0, "batches": 0}
stats = {"success": 0, "error": 0, "unsupported": 0, "batches": 0, "deferred": 0}
try:
# 1. 按国家分组;不支持的国家直接标 UNSUPPORTED
by_country: dict[str, list[tuple[str, object, str]]] = {}
@@ -89,12 +99,24 @@ def process_all(pending: list) -> dict[str, int]:
urls = [{"url": url, "filename": no} for no, _, url in batch]
batch_no = i // BATCH_SIZE + 1
total_batches = -(-total // BATCH_SIZE)
results = None
for attempt in range(BATCH_RETRIES):
try:
results = ocr.recognize_batch(urls, country_code)
break
except Exception as err: # noqa: BLE001
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} 请求失败: {str(err)[:100]}")
db.update_ocr_batch([(no, created_map[no], {"ocrLayout": "ERROR"}) for no in created_map])
stats["error"] += len(batch)
if attempt < BATCH_RETRIES - 1:
wait = BATCH_RETRY_BACKOFF[min(attempt, len(BATCH_RETRY_BACKOFF) - 1)]
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} 请求失败"
f"({str(err)[:80]}),{wait}s 后重试({attempt + 2}/{BATCH_RETRIES})")
time.sleep(wait)
else:
# OCR 服务端短暂不可用(容器秒级重启类故障):订单保持未处理,
# 留给下个小时的 run 自动重试,不落 ERROR(那会是永久终态)。
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} "
f"重试 {BATCH_RETRIES} 次仍失败,留待下次 run: {str(err)[:100]}")
if results is None:
stats["deferred"] += len(batch)
continue
updates = []
@@ -122,12 +144,12 @@ def label_ocr(
courier_source: str | None = None,
days: int = 7,
) -> dict:
"""面单 OCR enrichment。默认处理英国+美国,近 7 天下单的订单。
"""面单 OCR enrichment。默认处理全部已支持国家(见 api.ocr.models.SUPPORTED_COUNTRIES),近 7 天下单的订单。
手动跑其他国家: label_ocr(country_regions=['德国'])
手动跑指定国家: label_ocr(country_regions=['德国'])
"""
if country_regions is None:
country_regions = ['英国', '美国', '墨西哥', '德国', '沙特']
country_regions = ['英国', '美国', '墨西哥', '德国', '沙特', '加拿大', '西班牙', '意大利', '波兰']
if _has_running_peer():
print("已有 label-ocr 在运行,跳过本次")
return {"skipped": True}
@@ -152,11 +174,13 @@ def label_ocr(
after = db.count_ocr_status()
total_processed = stats["success"] + stats["error"] + stats["unsupported"]
print(f"完成{scope}: 处理 {total_processed} 单 / {stats['batches']} 批 | "
f"成功 {stats['success']} 失败 {stats['error']} 不支持 {stats['unsupported']}")
f"成功 {stats['success']} 失败 {stats['error']} 不支持 {stats['unsupported']} "
f"遗留待重试 {stats['deferred']}")
print(f"累计: {after}")
emit_sync_summary(f"面单 OCR{scope}", build_stats_rows("ALL", {}, [
("本批处理", total_processed), ("成功", stats["success"]),
("失败", stats["error"]), ("不支持", stats["unsupported"]),
("遗留待重试", stats["deferred"]),
("累计已完成", after.get("done", 0)), ("累计待处理", after.get("pending", 0)),
]))
return {"processed": total_processed, **stats, **after}
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""平台订单 platform 字段大写同步:把 platform_order_data.platform 统一转为大写。
优化自 platform_order_upper_sync.py,对应 platform-order-upper-sync。
"""
import time
import psycopg2
from prefect import flow
# 数据库连接参数
conn_params = {
"host": "192.168.124.92",
"port": 5432,
"user": "postgres",
"password": "Inkreach123456.",
"database": "smt",
}
def log_msg(msg, level="info"):
ts = time.strftime('%H:%M:%S')
tag = {"success": "[OK]", "error": "[ERR]"}.get(level, "")
print(f"[{ts}] {tag} {msg}")
def update_platform_upper() -> int:
conn = None
cur = None
try:
conn = psycopg2.connect(**conn_params)
cur = conn.cursor()
# 仅更新需要变更的行,避免每次都全表写入
update_sql = """
UPDATE platform_order_data
SET platform = UPPER(platform)
WHERE platform IS NOT NULL
AND platform <> UPPER(platform);
"""
cur.execute(update_sql)
affected_rows = cur.rowcount
conn.commit()
log_msg(f"platform 大写转换完成,共 {affected_rows} 行受影响", "success")
return affected_rows
except Exception as e:
log_msg(f"platform 大写转换失败:{e}", "error")
if conn:
conn.rollback()
raise
finally:
if cur:
cur.close()
if conn:
conn.close()
@flow(name="platform-order-upper-sync", log_prints=True)
def platform_order_upper_sync() -> int:
return update_platform_upper()
if __name__ == "__main__":
platform_order_upper_sync.serve(name="platform-order-upper-sync", cron="*/2 * * * *")
+3 -2
View File
@@ -22,7 +22,7 @@ RIIN_LOGIN_URL = "http://tshirt.riin.com/auth/api/auth/login"
RIIN_ORDER_URL = "https://tshirt.riin.com/distribution/order/page"
USERNAME_RIIN = "预上网"
PASSWORD_RIIN = "Aa123456."
SUB_ID_LIST = ["10368", "10283", "16905", "11502", "13615", "11531"]
SUB_ID_LIST = ["10368", "10283", "16905", "11502", "13615", "11531","12279"]
MAX_RETRY = 3; REQUEST_TIMEOUT = 30; PAGE_SIZE_RIIN = 200
LOOKBACK_DAYS = 6 # 回查14天避免漏单
@@ -208,4 +208,5 @@ async def riin_platform_sync():
log_msg("===== 完成 =====")
if __name__ == "__main__":
riin_platform_sync.serve(name="platform-order-riin", cron="15 * * * *")
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
riin_platform_sync()
+2 -1
View File
@@ -273,4 +273,5 @@ def rouhui_platform_sync():
log_msg("===== 提取完成 =====")
if __name__ == "__main__":
rouhui_platform_sync.serve(name="platform-order-rouhui", cron="*/15 * * * *")
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
rouhui_platform_sync()
+11 -2
View File
@@ -359,10 +359,18 @@ def sd_format_item(item):
create_time = item.get("gmtCreated", "")
parcel_list = item.get("parcelList", [])
if parcel_list and isinstance(parcel_list, list):
qty = sum(p.get("totalQty", 0) for p in parcel_list)
def safe_int(val):
try:
return int(val)
except (ValueError, TypeError):
return 0
qty = sum(safe_int(p.get("totalQty", 0)) for p in parcel_list)
else:
qty = 0
return [
to_chinese_country(country),
to_chinese_country(country),
@@ -471,4 +479,5 @@ async def sdspod_platform_sync():
if __name__ == "__main__":
sdspod_platform_sync.serve(name="platform-order-sdspod", cron="*/15 * * * *")
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
sdspod_platform_sync()
+144
View File
@@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
"""shop_dim 同步 flow:teable(源) -> 本地 smt 库(目标)。
最小包装自 sync_shop_dim.py,对应 shop-dim-sync,每 5 分钟调度一次。
源: PostgreSQL @ table.inkreach.cc:10432 db=teable schema=bsehWCxpH6HoHUdkjUd
物理表 tblUF7OrBxeRKUyWBc2 (teable 里名为 "shop_dim" 的表)
目标: PostgreSQL @ 192.168.124.92:5432 db=smt public.shop_dim
本地直接跑: python flows/platform_order/sync_shop_dim.py
"""
import time
from typing import Any
import psycopg2
from prefect import flow
SRC = dict(host="table.inkreach.cc", port=10432, dbname="teable",
user="read_only_role_bsehWCxpH6HoHUdkjUd",
password="ygBuh8Au5J-PjuNQASos0", connect_timeout=25)
SRC_TABLE = '"bsehWCxpH6HoHUdkjUd"."tblUF7OrBxeRKUyWBc2"'
DST = dict(host="192.168.124.92", port=5432, dbname="smt",
user="postgres", password="Inkreach123456.", connect_timeout=15)
COL_MAP = [
("Guo_Jia", "country"),
("Dian_Pu_Ming_Cheng", "shop_name"),
("DBDian_Pu_Ming", "db_store_name"),
("Ping_Tai", "platform"),
("Lei_Xing", "shop_type"),
("Deng_Ji", "tier"),
("Fu_Ze_Ren", "owner"),
("Pin_Lei", "category"),
("Xia_Dian_Shi_Jian", "opened_at"),
("Mu_Biao_Jian_Shu", "sep_target_qty"),
]
def log_msg(msg):
print(msg)
def connect(cfg, tries=5):
last = None
for i in range(tries):
try:
return psycopg2.connect(**cfg)
except Exception as e:
last = e
log_msg(f" 连接重试 {i+1}/{tries}: {e}")
time.sleep(3)
raise ConnectionError(f"连接失败: {last}")
def norm(v, col):
if v is None:
return None
if col == "opened_at":
return v.date() if hasattr(v, "date") else v
if col == "sep_target_qty":
try:
return int(round(float(v)))
except Exception:
return None
s = str(v).strip()
return s if s != "" else None
@flow(name="shop-dim-sync", log_prints=True)
def shop_dim_sync(dry_run: bool = False, prune: bool = True) -> dict[str, Any]:
log_msg(f"[1/4] 读取源表 {SRC_TABLE} ...")
sc = connect(SRC)
scur = sc.cursor()
sel = ", ".join('"%s"' % c for c, _ in COL_MAP)
scur.execute("SELECT %s FROM %s" % (sel, SRC_TABLE))
raw = scur.fetchall()
scur.close(); sc.close()
src_rows = [
dict(zip([d for _, d in COL_MAP], [norm(v, c) for v, (_, c) in zip(row, COL_MAP)]))
for row in raw
]
log_msg(f" 源记录: {len(src_rows)} 行")
log_msg("[2/4] 连接目标 smt.shop_dim ...")
dc = connect(DST)
dcur = dc.cursor()
dcur.execute("SELECT country, db_store_name FROM shop_dim WHERE db_store_name IS NOT NULL")
existing = {(r[0], r[1]) for r in dcur.fetchall()}
log_msg(f" 目标现有: {len(existing)} 个 (country, db_store_name)")
src_keys = {(r["country"], r["db_store_name"]) for r in src_rows}
to_update = src_keys & existing
to_insert = src_keys - existing
to_delete = existing - src_keys
log_msg(f"[3/4] 计划: 更新 {len(to_update)} | 新增 {len(to_insert)} | 待删 {len(to_delete)}")
if dry_run:
log_msg("[dry-run] 不执行写入。")
if to_delete:
log_msg(" 待删示例: " + str(list(to_delete)[:10]))
dcur.close(); dc.close()
return {"sourceRows": len(src_rows), "dryRun": True}
cols = [d for _, d in COL_MAP]
upd = 0
ins = 0
for r in src_rows:
vals = [r[c] for c in cols]
if (r["country"], r["db_store_name"]) in existing:
set_clause = ", ".join(f"{c}=%s" for c in cols)
dcur.execute(
f"UPDATE shop_dim SET {set_clause} WHERE country=%s AND db_store_name=%s",
vals + [r["country"], r["db_store_name"]])
upd += dcur.rowcount
else:
ph = ", ".join(["%s"] * len(cols))
dcur.execute(f"INSERT INTO shop_dim ({', '.join(cols)}) VALUES ({ph})", vals)
ins += dcur.rowcount
dele = 0
if prune and to_delete:
for country, dbn in to_delete:
dcur.execute("DELETE FROM shop_dim WHERE country=%s AND db_store_name=%s", (country, dbn))
dele += dcur.rowcount
dc.commit()
log_msg(f"[4/4] 完成: 更新 {upd} | 新增 {ins} | 删除 {dele}")
dcur.execute("SELECT COUNT(*) FROM shop_dim WHERE db_store_name IS NOT NULL")
total = dcur.fetchone()[0]
log_msg(f" 目标现有总数: {total}")
dcur.close(); dc.close()
return {
"sourceRows": len(src_rows),
"updated": upd,
"inserted": ins,
"deleted": dele,
"total": total,
}
if __name__ == "__main__":
shop_dim_sync.serve(name="shop-dim-sync", cron="*/5 * * * *")
+3 -3
View File
@@ -1,10 +1,10 @@
"""RIIN 同步公共管道:映射订单 → 并发拉商品 → upsert 到 PG。"""
"""RIIN 同步公共管道:映射订单 → 批量拉商品 → upsert 到 PG。"""
from __future__ import annotations
from typing import Any
from flows.shared import fetch_goods_concurrent
from flows.shared import fetch_goods_for_orders
from flows.shared.db import SyncDB
from flows.shared.mappers import map_riin_goods, map_riin_order
from api import RiinDisClient
@@ -31,7 +31,7 @@ def sync_riin_records(raw_orders: list[Any], client: RiinDisClient, db: SyncDB)
goods_inputs: list[Any] = []
if order_meta:
goods_map = fetch_goods_concurrent(client, list(order_meta.keys()))
goods_map = fetch_goods_for_orders(client, list(order_meta.keys()))
for oid, (order_no, created_at) in order_meta.items():
items = goods_map.get(oid, [])
if not items:
+241
View File
@@ -0,0 +1,241 @@
"""SDS 国内工厂运单自动生成:遍历「运单待生成」订单 → 自动下极兔 → 面单回传 SDS。
筛选参数: carriageNoStatus=generate(运单待生成) + factoryIds=1161(印美达国内工厂)
+ orderStatusCode=2(订单状态)。
链路: SDS 订单 → 变体克重(children 接口,客户端 24h 缓存)
→ 快递100 bOrder(极兔,returnType=20+tempid 出面单,回调走 frp 公网地址)
→ 回调服务 /list 轮询取面单短链(status=200)
→ PNG 转 PDF → SDS /files/pdf/logisticsWaybill 上传 → PUT 挂包裹(orderNo=新运单号)。
幂等与安全:
- 已有运单号的包裹跳过(10 分钟轮询天然去重);
- 面单等待超时只记录待人工跟进,不自动取消(快递员已接单时取消会扣其绩效);
- max_orders 限制单轮下单量(首轮积压保护);
- 改动必须先在测试环境/本地模拟验证,再真实下单(运营约定);
- ⚠️ 真实下单 flow:prefect.yaml 中本 deployment 的 schedule 默认 active:false,
上线前人工核对后改 true 并 `uv run prefect deploy --all` 才会开始调度。
对应 sds-waybill-autogen。
"""
from __future__ import annotations
import io
import json
import time
from typing import Any
import httpx
from PIL import Image
from prefect import flow
from prefect.blocks.system import Secret
from api.kuaidi100 import Kuaidi100Client
from api.sds.models import OrderPageRequest
from flows.shared import create_sds_client
# 寄件人固定为广州发货仓联系人
SEND_MAN_NAME = "张文风"
SEND_MAN_MOBILE = "18320690872"
SEND_MAN_ADDR = "广东省广州市番禺区南村镇新基村新盈科技园3栋2楼"
CALLBACK_BASE = "http://8.138.172.239:18090/kuaidi100" # 回调服务(frp 公网): /order 收推送, /list 查历史
CALLBACK_SALT = "inkreach-test" # 须与回调服务 kuaidi100-callback.service 的 --salt 一致
LABEL_POLL_INTERVAL_SECONDS = 5
CARGO_DEFAULT = "日用品"
def _load_secret(name: str) -> dict[str, str]:
value = Secret.load(name).get()
return json.loads(value) if isinstance(value, str) else value
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
detail = detail or ""
prefix = ""
for part in (province or "", city or ""):
if part and part not in detail and part not in prefix:
prefix += part
return prefix + detail
def _parcel_info(order: Any) -> tuple[Any, str]:
"""取首个包裹 (parcel_id, 当前运单号,可能为空)。多包裹订单只处理包裹1并提示。"""
parcels = order.parcel_list or []
if len(parcels) > 1:
print(f"[autogen] 订单 {order.no} 有 {len(parcels)} 个包裹,仅处理包裹1")
if not parcels:
return None, ""
parcel = parcels[0] or {}
carriage = (parcel.get("logistics") or {}).get("carriage") or {}
return parcel.get("id"), str(carriage.get("no") or "")
def _wait_label_url(order_id: Any, callback_base: str, timeout_seconds: int) -> str | None:
"""轮询回调服务 /list,取该订单 status=200 回调里的面单短链。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
items = httpx.get(f"{callback_base}/list", params={"n": 50}, timeout=10).json().get("items") or []
for item in items:
param = item.get("param")
data = (param.get("data") or {}) if isinstance(param, dict) else {}
if str(data.get("orderId")) == str(order_id) and data.get("status") == 200 and data.get("label"):
return str(data["label"])
except Exception as err: # noqa: BLE001
print(f"[autogen] 回调服务查询失败(继续等待): {err}")
time.sleep(LABEL_POLL_INTERVAL_SECONDS)
return None
def _label_to_pdf(label_url: str) -> bytes:
resp = httpx.get(label_url, timeout=60, follow_redirects=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, "PDF", resolution=150.0)
return buf.getvalue()
def _process_one(
sds: Any,
kd: Kuaidi100Client,
order: Any,
*,
courier: str,
tempid: str | None,
callback_base: str,
label_wait_seconds: int,
) -> dict[str, Any]:
"""单订单全链路:下单 → 等面单 → 回传。失败尽力取消快递单,返回结果记录。"""
order_no = order.no or order.id
record: dict[str, Any] = {"orderNo": order_no, "result": "fail"}
parcel_id, current_no = _parcel_info(order)
if not parcel_id:
record["error"] = "订单无包裹"
return record
if current_no:
record.update(result="skip", waybill=current_no, reason="已有运单号")
return record
addr = order.address
if not addr or not addr.consignee or not addr.mobile_phone:
record["error"] = "订单缺收件人信息"
return record
grams, missed = sds.get_order_weight_grams(order)
weight = f"{grams / 1000:.2f}" if grams else "1"
if missed:
print(f"[autogen] 订单 {order_no} 克重未完全匹配({missed}),申报重量 {weight}kg")
data = kd.border(
kuaidicom=courier,
rec_man_name=addr.consignee,
rec_man_mobile=addr.mobile_phone,
rec_man_print_addr=_join_addr(addr.province, addr.city, addr.detail),
send_man_name=SEND_MAN_NAME,
send_man_mobile=SEND_MAN_MOBILE,
send_man_print_addr=SEND_MAN_ADDR,
callback_url=f"{callback_base}/order",
print_callback_url=f"{callback_base}/order",
cargo=(order.items[0].product_name if order.items else None) or CARGO_DEFAULT,
weight=weight,
remark=f"SDS-{order_no} autogen",
salt=CALLBACK_SALT,
return_type="20",
tempid=tempid,
extra={"op": 0, "resultv2": "0"},
)
waybill = str(data.get("kuaidinum") or "")
record.update(kuaidiOrderId=data.get("orderId"), waybill=waybill)
print(f"[autogen] 订单 {order_no} 下单成功 {waybill},等待面单回调…")
label_url = _wait_label_url(data.get("orderId"), callback_base, label_wait_seconds)
if not label_url:
# 不自动取消:快递员可能已接单,取消会扣其绩效;记录待人工跟进(后台补打面单或人工取消)。
record["error"] = f"面单回调等待超时({label_wait_seconds}s),需人工跟进"
record["manualFollowUp"] = True
print(f"[autogen] 订单 {order_no} 面单超时,保留快递单 {waybill} 待人工处理(不自动取消)")
return record
pdf_bytes = _label_to_pdf(label_url)
upload = sds.upload_waybill_pdf(f"{order_no}.pdf", pdf_bytes)
sds.attach_parcel_label(order.id, parcel_id, waybill, upload["url"])
record.update(result="ok", labelPdf=upload["url"], labelImage=label_url, weightKg=weight)
print(f"[autogen] 订单 {order_no} 完成: {waybill} → {upload['url']}")
return record
@flow(name="sds-waybill-autogen", log_prints=True)
def waybill_autogen(
enabled: bool = True,
courier: str = "jtexpress",
factory_ids: list[int] | None = None,
order_status_code: str = "2",
max_orders: int = 20,
label_wait_seconds: int = 120,
kuaidi100_block: str = "kuaidi100-prod",
callback_base: str = CALLBACK_BASE,
) -> dict[str, Any]:
"""运单待生成订单 → 极兔下单 + 面单回传。prefect.yaml 默认 schedule active:false。"""
factory_ids = factory_ids or [1161]
if not enabled:
print("[autogen] enabled=false,本轮跳过(不查询不下单)")
return {"enabled": False, "processed": 0}
cred = _load_secret(kuaidi100_block)
if not cred.get("tempid"):
raise ValueError(f"Secret Block {kuaidi100_block} 缺 tempid(面单模板编码),无法出面单")
sds = create_sds_client()
kd = Kuaidi100Client(cred["key"], cred["secret"], customer=cred.get("customer"))
try:
resp = sds.get_orders(
OrderPageRequest(
factory_ids=factory_ids,
carriage_no_status="generate",
order_status_code=order_status_code,
size=min(max_orders, 200),
page=1,
)
)
print(
f"[autogen] 待处理订单 {resp.total_count} 条"
f"(本轮上限 {max_orders},快递={courier},orderStatusCode={order_status_code})"
)
records = []
for order in resp.records[:max_orders]:
try:
records.append(
_process_one(
sds, kd, order,
courier=courier, tempid=cred["tempid"],
callback_base=callback_base, label_wait_seconds=label_wait_seconds,
)
)
except Exception as err: # noqa: BLE001
print(f"[autogen] 订单 {order.no} 处理异常: {err}")
records.append({"orderNo": order.no, "result": "fail", "error": str(err)[:300]})
finally:
kd.close()
sds.close()
summary = {
"enabled": True,
"pendingTotal": resp.total_count,
"processed": len(records),
"ok": sum(1 for r in records if r["result"] == "ok"),
"skip": sum(1 for r in records if r["result"] == "skip"),
"fail": sum(1 for r in records if r["result"] == "fail"),
"records": records,
}
print(
f"[autogen] 本轮完成: 成功 {summary['ok']} / 跳过 {summary['skip']} / 失败 {summary['fail']}"
f"(待生成共 {resp.total_count})"
)
return summary
if __name__ == "__main__":
# 本地直接运行默认不下单;要真实执行请带 enabled=True 显式运行或部署后由调度触发。
waybill_autogen(enabled=False)
+2 -2
View File
@@ -5,7 +5,7 @@ from flows.shared.artifacts import build_stats_rows, emit_sync_summary, emit_ver
from flows.shared.clients import (
create_riin_client,
create_sds_client,
fetch_goods_concurrent,
fetch_goods_for_orders,
fetch_riin_all_pages,
fetch_sds_all_pages,
floor_to_hour,
@@ -23,7 +23,7 @@ __all__ = [
"clients", "db", "mappers", "SyncDB",
"OrderUpsertInput", "GoodsUpsertInput",
"create_riin_client", "create_sds_client",
"fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_concurrent",
"fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_for_orders",
"get_channel_type_map", "refresh_channel_type_map", "resolve_courier_source",
"format_date", "start_of_day", "floor_to_hour",
"build_stats_rows", "emit_sync_summary", "emit_verify_report",
+42 -24
View File
@@ -21,7 +21,6 @@ from flows.shared.mappers import map_sds_channel_type
T = TypeVar("T")
GOODS_CONCURRENCY = 20
PAGE_DELAY_MS = 500
MAX_RETRIES = 3
RETRY_DELAY_MS = 3000
@@ -144,39 +143,58 @@ def fetch_sds_all_pages(client: SdsMngClient, params: dict[str, Any]) -> list[An
# ---------- RIIN 商品 ----------
def fetch_goods_for_order(client: RiinDisClient, order_id: str) -> list[Any]:
try:
resp = client.get_order_goods(order_id)
return list(resp.data)
except Exception as err: # noqa: BLE001
print(f"[RIIN sync] 订单 {order_id} 商品拉取失败: {err}")
return []
# 批量接口 distribution/order/goods/list:单批 ≤200 个 id,且要求串行调用(分销侧约定)。
# 相比旧逐单接口(软限流:高频返回成功但空 data),调用量从日均 ~1 万次降到 ~50 次。
GOODS_BATCH_SIZE = 200
GOODS_BATCH_INTERVAL_MS = 2000 # 相邻两批的串行间隔
def fetch_goods_concurrent(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]:
"""以 GOODS_CONCURRENCY 并发拉取多订单商品,返回 {order_id: [goods...]}。"""
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
def fetch_goods_for_orders(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]:
"""按 goods/list 批量接口串行拉取多订单商品,返回 {order_id: [goods...]}。
results: dict[str, list[Any]] = {}
单批失败(网络/业务错误)整批重试最多 MAX_RETRIES 次,仍失败则该批订单记空列表,
由调用方按"无商品订单"计数(与旧逐单路径的容错语义一致)。
"""
results: dict[str, list[Any]] = {oid: [] for oid in order_ids}
if not order_ids:
return results
concurrency = min(GOODS_CONCURRENCY, len(order_ids))
print(f"[RIIN sync] 商品拉取开始: {len(order_ids)} 个订单 (并发={concurrency})")
def make(oid: str) -> Callable[[], None]:
def run() -> None:
results[oid] = fetch_goods_for_order(client, oid)
return run
with ThreadPoolExecutor(max_workers=concurrency) as pool:
list(pool.map(lambda fn: fn(), [make(oid) for oid in order_ids]))
total_batches = -(-len(order_ids) // GOODS_BATCH_SIZE)
print(f"[RIIN sync] 商品批量拉取开始: {len(order_ids)} 个订单 / {total_batches} 批"
f" (goods/list 串行, 批间 {GOODS_BATCH_INTERVAL_MS}ms)")
for bi in range(total_batches):
chunk = order_ids[bi * GOODS_BATCH_SIZE : (bi + 1) * GOODS_BATCH_SIZE]
items = _fetch_goods_batch_with_retry(client, chunk, bi + 1)
for g in items:
if g.order_id in results:
results[g.order_id].append(g)
if bi < total_batches - 1:
time.sleep(GOODS_BATCH_INTERVAL_MS / 1000)
total_goods = sum(len(v) for v in results.values())
empty_orders = sum(1 for v in results.values() if not v)
print(f"[RIIN sync] 商品拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品")
print(f"[RIIN sync] 商品批量拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品")
return results
def _fetch_goods_batch_with_retry(client: RiinDisClient, chunk: list[str], batch_no: int) -> list[Any]:
last_err: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = fetch_with_retry(
lambda: client.get_order_goods_batch(chunk), f"RIIN goods 批次 {batch_no}"
)
if resp.successful is False:
raise RuntimeError(f"goods/list 业务失败: {resp.message}")
return list(resp.data)
except Exception as err: # noqa: BLE001
last_err = err
print(f"[RIIN sync] 商品批次 {batch_no} 第 {attempt}/{MAX_RETRIES} 次失败: {str(err)[:120]}")
if attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY_MS * attempt / 1000)
assert last_err is not None
print(f"[RIIN sync] 商品批次 {batch_no} 放弃, {len(chunk)} 个订单本轮无商品")
return []
# ---------- SDS 物流渠道字典 ----------
_channel_cache: dict[str, Any] = {}
+7 -4
View File
@@ -25,8 +25,9 @@ LOOKUP_BATCH = 10000
# 单锁无环,写事务之间从结构上不可能死锁。1768851058 = "inkr" 的 ASCII 整数表示。
DB_WRITE_LOCK_KEY = 1768851058
LOCK_TIMEOUT = "15s" # 等锁超时 → LockNotAvailable → 交给重试兜底
WRITE_RETRIES = 3 # 死锁/等锁超时的整事务重试次数
WRITE_RETRY_BACKOFF = [1, 3, 7] # 重试退避(秒)
WRITE_RETRIES = 5 # 死锁/等锁超时的整事务重试次数(外部写者如 17track
# 物流回写不走 advisory 锁,冲突窗口可达分钟级,重试需够深)
WRITE_RETRY_BACKOFF = [1, 3, 7, 15, 30] # 重试退避(秒;末位预留,最后一次失败直接抛出)
WRITE_RETRYABLE_ERRORS = (pg_errors.DeadlockDetected, pg_errors.LockNotAvailable)
@@ -459,9 +460,11 @@ class SyncDB:
stats = {"ordersCreated": 0, "ordersUpdated": 0, "goodsCreated": 0, "goodsUpdated": 0, "goodsRemoved": 0}
with conn.cursor() as cur:
# 统计已存在 → created/updated
# 注:必须 cast 成 timestamptz——入参是带时区偏移的 tz-aware datetime,
# cast 成 timestamp 会丢弃偏移,在会话时区非 +08 时匹配不到既有行,导致 created/updated 统计全错。
pairs = [(o.orderNo, o.createdAt) for o in orders]
cur.execute(
"SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamp[]) AS t(order_no,created_at) "
"SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamptz[]) AS t(order_no,created_at) "
"JOIN \"Orders\" o ON o.\"orderNo\"=t.order_no AND o.\"createdAt\"=t.created_at",
_unnest_pairs(pairs),
)
@@ -622,6 +625,6 @@ class SyncDB:
def _unnest_pairs(pairs: list[tuple]) -> tuple[list, list]:
"""构造 psycopg2 unnest 双数组参数:返回 (col_a_list, col_b_list)。
配合 SQL:unnest(%s::text[], %s::timestamp[]) 或 unnest(%s::text[], %s::text[])。
配合 SQL:unnest(%s::text[], %s::timestamptz[]) 或 unnest(%s::text[], %s::text[])。
"""
return [p[0] for p in pairs], [p[1] for p in pairs]
+51 -8
View File
@@ -33,6 +33,10 @@ RIIN_EXPRESS_SOURCE_MAP: dict[int, str] = {
RIIN_GOODS_STATUS_MAP: dict[str, str] = {
"NOT_SHIPPED": "待发货", "SHIPPED": "已发货", "CANCEL": "已取消",
"CANCELLED": "已取消", "REFUNDING": "退款中",
# 商品级状态复用平台状态枚举,COMPLETE/PAUSE 缺失曾导致 1/3 商品行 status 为 NULL
"COMPLETE": "已完成", "PAUSE": "暂停",
# CLOSE 同为商品终态(Goods 约束无"已关闭",取"已取消");UNKNOWN 保留 NULL 并由告警暴露
"CLOSE": "已取消",
}
RIIN_PLATFORM_STATUS_MAP: dict[str, str] = {
@@ -55,6 +59,17 @@ SDS_CHANNEL_TYPE_LABEL_MAP: dict[str, str] = {
# ---------- 日期解析 ----------
# 未知枚举值告警:同值只报一次,避免批量同步刷屏(对齐 TS 版 warnUnknown)
_warned_values: set[str] = set()
def _warn_unknown(map_name: str, value: object) -> None:
key = f"{map_name}:{value!r}"
if key not in _warned_values:
_warned_values.add(key)
print(f"[mapper] unknown {map_name} value: {value!r}")
def safe_date(value: Any, context: str = "") -> datetime:
"""解析日期字符串(无时区视为 +08:00),返回 tz-aware datetime。"""
if not value or not isinstance(value, str):
@@ -188,15 +203,20 @@ class GoodsUpsertInput:
# ---------- RIIN 映射 ----------
def map_riin_system_status(order_status: int) -> str | None:
return RIIN_SYSTEM_STATUS_MAP.get(order_status)
def map_riin_system_status(order_status: int | None) -> str | None:
if order_status is None:
return None
v = RIIN_SYSTEM_STATUS_MAP.get(order_status)
if v is None:
_warn_unknown("RIIN systemStatus", order_status)
return v
def map_riin_order(raw: Any) -> OrderUpsertInput:
platform_oid = raw.platform_oid
ordered_at = _parse(raw.push_time, f"RIIN order {platform_oid} pushTime")
confirmed_at = _parse(raw.distributor_push_time, f"RIIN order {platform_oid} distributorPushTime")
system_status = map_riin_system_status(raw.order_status) if raw.order_status is not None else None
system_status = map_riin_system_status(raw.order_status)
is_terminal = system_status in ("已发货", "已关闭", "已取消")
completed_at: datetime | None = None
if is_terminal:
@@ -236,6 +256,15 @@ def map_riin_order(raw: Any) -> OrderUpsertInput:
)
def map_riin_goods_status(goods_status: str | None) -> str | None:
if not goods_status:
return None
v = RIIN_GOODS_STATUS_MAP.get(goods_status)
if v is None:
_warn_unknown("RIIN goodsStatus", goods_status)
return v
def map_riin_goods(raw: Any, parent_order_no: str, parent_created_at: datetime) -> GoodsUpsertInput:
product_code = raw.product_code
item_name = product_code.split("-")[0] if product_code else None
@@ -250,7 +279,7 @@ def map_riin_goods(raw: Any, parent_order_no: str, parent_created_at: datetime)
itemSize=raw.size_code,
itemStyleNo=style_no,
quantity=raw.num if raw.num is not None else 1,
status=RIIN_GOODS_STATUS_MAP.get(raw.goods_status) if raw.goods_status else None,
status=map_riin_goods_status(raw.goods_status),
)
@@ -262,13 +291,27 @@ def map_sds_channel_type(channel_type: Any) -> str | None:
return SDS_CHANNEL_TYPE_LABEL_MAP.get(channel_type)
def map_sds_system_status(status: int) -> str | None:
return SDS_SYSTEM_STATUS_MAP.get(status)
def map_sds_system_status(status: int | None) -> str | None:
if status is None:
return None
v = SDS_SYSTEM_STATUS_MAP.get(status)
if v is None:
_warn_unknown("SDS systemStatus", status)
return v
def map_sds_goods_status(status: int | None) -> str | None:
if status is None:
return None
v = SDS_GOODS_STATUS_MAP.get(status)
if v is None:
_warn_unknown("SDS goodsStatus", status)
return v
def map_sds_order(raw: Any, channel_type_map: dict[int, str | None], pre_courier: str | None = None) -> OrderUpsertInput:
ordered_at = _parse(raw.gmt_pay, f"SDS order {raw.no} gmtPay")
system_status = map_sds_system_status(raw.status) if raw.status is not None else None
system_status = map_sds_system_status(raw.status)
is_terminal = system_status in ("已完成", "已取消")
completed_at: datetime | None = None
if is_terminal and raw.finish_time and raw.finish_time > 0:
@@ -369,7 +412,7 @@ def map_sds_goods(raw: Any, parent_order_no: str, parent_created_at: datetime) -
itemSize=raw.product_size,
itemStyleNo=_parse_sds_style_no(product_name),
quantity=raw.num if raw.num is not None else 1,
status=SDS_GOODS_STATUS_MAP.get(raw.status) if raw.status is not None else None,
status=map_sds_goods_status(raw.status),
)
+25 -1
View File
@@ -21,6 +21,7 @@ deployments:
schedules:
- cron: 30 * * * *
timezone: Asia/Shanghai
active: false # 暂停:每小时重拉近7天全部订单商品,对 RIIN distribution/order/goods 调用量过大(增量/刷新未完结/日报核验已覆盖)
work_pool:
name: inkreach-pool
- name: riin-refresh-pending
@@ -39,6 +40,7 @@ deployments:
schedules:
- cron: 0 6 * * *
timezone: Asia/Shanghai
active: false # 暂停:与 daily-report 18:30 的核验子流程重复,保留日报一处即可
work_pool:
name: inkreach-pool
- name: sds-order-sync
@@ -80,6 +82,14 @@ deployments:
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
- name: sds-waybill-autogen
entrypoint: flows/sds/waybill_autogen.py:waybill_autogen
schedules:
- cron: '*/10 * * * *'
timezone: Asia/Shanghai
active: false # 暂停:真实下单 flow(极兔+面单回传)。链路人工核对后改 true 并 `uv run prefect deploy --all` 生效
work_pool:
name: inkreach-pool
- name: platform-order-riin
entrypoint: flows/platform_order/riin.py:riin_platform_sync
schedules:
@@ -97,7 +107,21 @@ deployments:
- name: platform-order-sdspod
entrypoint: flows/platform_order/sdspod.py:sdspod_platform_sync
schedules:
- cron: '*/30 * * * *'
- cron: 0 * * * *
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
- name: platform-order-upper-sync
entrypoint: flows/platform_order/platform_order_upper_sync.py:platform_order_upper_sync
schedules:
- cron: '*/2 * * * *'
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
- name: shop-dim-sync
entrypoint: flows/platform_order/sync_shop_dim.py:shop_dim_sync
schedules:
- cron: '*/5 * * * *'
timezone: Asia/Shanghai
work_pool:
name: inkreach-pool
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"psutil>=7.2.2",
"requests>=2.34.2",
"aiohttp>=3.14.3",
"pillow>=13.0.0",
]
[build-system]
+72
View File
@@ -0,0 +1,72 @@
"""一次性回填:RIIN Goods.status 为 NULL 的历史行(终态"已发货"订单)。
背景:RIIN 商品级 goods_status 复用平台状态枚举,COMPLETE/PAUSE 此前无映射,
约 1/3 商品行 status 被写为 NULL(mappers.py 已补映射)。本脚本按订单终态
将近似值回填;"已关闭"与非终态订单不在此列,走 riin-by-orders 重拉取真值。
幂等:只更新 status IS NULL 的行。分批提交,避免长事务。
运行:uv run python scripts/backfill_riin_goods_status.py [--dry-run] [--batch N]
"""
from __future__ import annotations
import argparse
import os
import psycopg2
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
DEFAULT_BATCH = 50_000
SQL_COUNT = """
SELECT count(*) FROM "Goods" g
JOIN "Orders" o ON o."orderNo" = g."orderNo" AND o."createdAt" = g."createdAt"
WHERE o."systemName" = 'RIIN' AND o."systemStatus" = '已发货' AND g."status" IS NULL
"""
# ctid 分批:PostgreSQL 的 UPDATE 不支持 LIMIT,用子查询圈定每批行
SQL_BACKFILL_BATCH = """
UPDATE "Goods" g SET "status" = '已发货'
WHERE g.ctid IN (
SELECT g2.ctid FROM "Goods" g2
JOIN "Orders" o ON o."orderNo" = g2."orderNo" AND o."createdAt" = g2."createdAt"
WHERE o."systemName" = 'RIIN' AND o."systemStatus" = '已发货' AND g2."status" IS NULL
LIMIT %s
)
"""
def main() -> None:
parser = argparse.ArgumentParser(description="回填 RIIN Goods.status NULL(已发货订单)")
parser.add_argument("--dry-run", action="store_true", help="只统计待回填行数,不写入")
parser.add_argument("--batch", type=int, default=DEFAULT_BATCH, help="每事务回填行数")
args = parser.parse_args()
conn = psycopg2.connect(os.environ["DB_DSN"])
try:
with conn.cursor() as cur:
cur.execute(SQL_COUNT)
(pending,) = cur.fetchone()
print(f"[backfill] 待回填(status IS NULL 且订单已发货): {pending} 行")
if args.dry_run or pending == 0:
return
total = 0
while True:
with conn.cursor() as cur:
cur.execute(SQL_BACKFILL_BATCH, (args.batch,))
updated = cur.rowcount
conn.commit()
total += updated
print(f"[backfill] 本批 {updated} 行,累计 {total}")
if updated < args.batch:
break
print(f"[backfill] 完成,共回填 {total} 行")
finally:
conn.close()
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
"""快递100 上门取件API 回调接收服务(本机常驻,经 frpc 暴露公网)。
接收两类推送并验签落盘(MD5(param + salt)):
- POST /kuaidi100/order 下单状态回调(callBackUrl)与面单短链/打印回调(printCallBackUrl)
- POST /kuaidi100/poll 轨迹推送(pollCallBackUrl)
应答格式必须是 {"result":true,"returnCode":"200","message":"成功"},
否则快递100会以 30 分钟间隔重推 3 次。
查看:
- GET / 状态与最近回调摘要
- GET /list?n=20 最近 n 条回调明细(JSON)
落盘: <repo>/data/kuaidi100/callbacks.jsonl,每行 {ts,path,verified,taskId,param}
用法: uv run python scripts/kuaidi100_callback_server.py [--port 9802] [--salt inkreach-test]
salt 需与下单 param.salt 一致;也可用环境变量 KUAIDI100_CALLBACK_SALT 覆盖。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
REPO_ROOT = Path(__file__).resolve().parent.parent
LOG_FILE = REPO_ROOT / "data" / "kuaidi100" / "callbacks.jsonl"
MAX_BODY = 1 << 20 # 1MB 上限,防异常大包
_state = {"started_at": time.time(), "received": 0, "verified_ok": 0}
def _append(record: dict) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _recent(n: int = 20) -> list[dict]:
if not LOG_FILE.exists():
return []
try:
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
except OSError:
return []
out = []
for line in lines[-n:]:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
class Handler(BaseHTTPRequestHandler):
salt: str = os.environ.get("KUAIDI100_CALLBACK_SALT", "inkreach-test")
def log_message(self, fmt: str, *args) -> None: # 静默默认访问日志,落盘代替
pass
# ----- POST 回调 -----
def do_POST(self) -> None:
path = urlparse(self.path).path
if not path.startswith("/kuaidi100/"):
self._json(404, {"result": False, "returnCode": "404", "message": "not found"})
return
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
body = self.rfile.read(length).decode("utf-8", errors="replace")
form = parse_qs(body, keep_blank_values=True)
param_raw = (form.get("param") or [""])[0]
sign = (form.get("sign") or [""])[0].upper()
task_id = (form.get("taskId") or [""])[0]
expect = hashlib.md5(f"{param_raw}{self.salt}".encode("utf-8")).hexdigest().upper()
verified = bool(param_raw) and sign == expect
parsed: dict | None = None
if param_raw:
try:
parsed = json.loads(param_raw)
except json.JSONDecodeError:
parsed = None
_state["received"] += 1
_state["verified_ok"] += int(verified)
_append(
{
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"path": path,
"verified": verified,
"sign": sign,
"taskId": task_id or None,
"param": parsed if parsed is not None else param_raw[:2000],
"client": self.client_address[0],
}
)
# 验签失败也先按成功应答(已落盘标记 verified=false),避免测试环境反复重推干扰;
# 生产可改为拒绝: {"result": false, ...}
self._json(200, {"result": True, "returnCode": "200", "message": "成功"})
# ----- GET 查看 -----
def do_GET(self) -> None:
url = urlparse(self.path)
# 兼容两种路径:直连 /list 与经 frp/反代的 /kuaidi100/list(与 POST 路由前缀对齐)
if url.path in ("/list", "/kuaidi100/list"):
qs = parse_qs(url.query)
n = min(int((qs.get("n") or ["20"])[0]), 200)
self._json(200, {"count": n, "items": list(reversed(_recent(n)))})
return
recent = _recent(5)
self._json(
200,
{
"service": "kuaidi100-callback",
"listen": "127.0.0.1 (via frpc)",
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_state["started_at"])),
"uptime_s": int(time.time() - _state["started_at"]),
"received": _state["received"],
"verified_ok": _state["verified_ok"],
"log_file": str(LOG_FILE),
"recent": [
{"ts": r.get("ts"), "path": r.get("path"), "verified": r.get("verified"),
"status": (r.get("param") or {}).get("data", {}).get("status")
if isinstance(r.get("param"), dict) else None}
for r in recent
],
},
)
def _json(self, code: int, payload: dict) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def main() -> None:
parser = argparse.ArgumentParser(description="快递100回调接收服务")
parser.add_argument("--port", type=int, default=9802)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--salt", default=Handler.salt, help="验签 salt,须与下单 param.salt 一致")
args = parser.parse_args()
Handler.salt = args.salt
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"[kuaidi100-callback] listening on http://{args.host}:{args.port} (salt={args.salt})")
print(f"[kuaidi100-callback] log -> {LOG_FILE}")
server.serve_forever()
if __name__ == "__main__":
main()
+407
View File
@@ -0,0 +1,407 @@
"""快递100「上门取件API(线上支付)」端到端测试脚本。
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
流程:
1. 从 SDS 取一单国内工厂订单(factoryIds=1161),提取收/寄件人地址(收件人=订单 address,寄件人=发货仓 issuing_bay)
2. 逐家查价(method=price),选最便宜的快递公司
3. 下单(method=bOrder)拿 taskId/orderId/kuaidinum
4. 取消订单(method=cancel)收尾,避免遗留测试单
鉴权: 公共参数 method/key/sign/t,param 为 JSON 字符串;
sign = MD5(param + t + key + secret) 转 32 位大写。
测试环境: http://e-test.kuaidilab.com/api/order/borderapi.do(测试 key/secret 需向快递100技术支持申请)
用法:
uv run python scripts/kuaidi100_pickup_test.py --key KEY --secret SECRET [--customer CUSTOMER]
# 或把 JSON {"key":"...","secret":"...","customer":"..."} 存进 Prefect Secret Block kuaidi100-test 后:
uv run python scripts/kuaidi100_pickup_test.py --from-secret
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
from pathlib import Path
from typing import Any
import httpx
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
# 文档列出的本 API 支持的快递公司编码(小写)
SUPPORTED_COURIERS = [
"jd",
"debangkuaidi",
"shunfeng",
"jtexpress",
"yuantong",
"shentong",
"zhongtong",
"zhongtongkuaiyun",
"yunda",
"kuayue",
"shunfengkuaiyun",
"ems",
"yimidida",
"kuaidi100zhixuan",
]
COURIER_NAMES = {
"jd": "京东",
"debangkuaidi": "德邦快递",
"shunfeng": "顺丰速运",
"jtexpress": "极兔速递",
"yuantong": "圆通速递",
"shentong": "申通快递",
"zhongtong": "中通快递",
"zhongtongkuaiyun": "中通快运",
"yunda": "韵达快递",
"kuayue": "跨越速运",
"shunfengkuaiyun": "顺丰快运",
"ems": "EMS",
"yimidida": "壹米滴答",
"kuaidi100zhixuan": "快递100智选",
}
# ---------- 快递100 公共调用 ----------
def make_sign(param: str, t: str, key: str, secret: str) -> str:
"""文档签名算法: MD5(param + t + key + secret) 转 32 位大写。"""
raw = f"{param}{t}{key}{secret}"
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def call_api(
method: str,
param: dict[str, Any],
auth: dict[str, str],
base_url: str = TEST_BASE_URL,
timeout: float = 30.0,
) -> dict[str, Any]:
"""POST application/x-www-form-urlencoded,公共参数 method/key/sign/t/param。"""
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
t = str(int(time.time() * 1000))
body = {
"method": method,
"key": auth["key"],
"sign": make_sign(param_str, t, auth["key"], auth["secret"]),
"t": t,
"param": param_str,
}
resp = httpx.post(base_url, data=body, timeout=timeout)
resp.raise_for_status()
return resp.json()
def check(resp: dict[str, Any], action: str) -> dict[str, Any]:
if resp.get("result") is not True or str(resp.get("returnCode")) != "200":
raise RuntimeError(f"[{action}] 失败: {json.dumps(resp, ensure_ascii=False)[:500]}")
print(f"[{action}] 成功: {resp.get('message')}")
return resp
# ---------- SDS 取单 ----------
def calc_order_weight_grams(client: Any, order: Any) -> tuple[float | None, list[str]]:
"""委托 SdsMngClient.get_order_weight_grams(children 变体克重,24h 缓存)。"""
return client.get_order_weight_grams(order)
def fetch_sds_domestic_order(client: Any | None = None) -> dict[str, str]:
"""取一单国内工厂(factoryIds=1161)订单,返回寄件/收件人字段+结构化克重。"""
if client is None:
from api import SdsMngClient
from prefect.blocks.system import Secret
value = Secret.load("sds-credentials").get()
cred = json.loads(value) if isinstance(value, str) else value
client = SdsMngClient(cred["username"], cred["password"])
from api.sds.models import OrderPageRequest
resp = client.get_orders(OrderPageRequest(factory_ids=[1161], size=1, page=1))
if not resp.records:
raise RuntimeError("SDS 未找到 factoryIds=1161 的订单")
order = resp.records[0]
addr = order.address
bay = order.issuing_bay
if not addr or not bay:
raise RuntimeError("订单缺少收件地址或发货仓信息")
send_print_addr = _join_addr(bay.province, bay.city, bay.detail)
rec_print_addr = _join_addr(addr.province, addr.city, addr.detail)
cargo = order.items[0].product_name or "日用品" if order.items else "日用品"
info = {
"order_no": order.no or order.id,
"send_name": bay.name or "印美达",
"send_addr": send_print_addr,
"rec_name": addr.consignee or "收件人",
"rec_mobile": addr.mobile_phone or "",
"rec_addr": rec_print_addr,
"cargo": cargo,
}
grams, missed = calc_order_weight_grams(client, order)
if grams:
info["weight_kg"] = f"{grams / 1000:.2f}"
print(f"[weight] 结构化克重合计 {round(grams)}g = {info['weight_kg']}kg")
else:
print(f"[weight] 未能取到结构化克重({'; '.join(missed) or '无商品行'}),下单重量回退为默认 1kg")
print(
f"[SDS] 选中订单 {info['order_no']}\n"
f" 寄件: {info['send_name']} / {info['send_addr']}\n"
f" 收件: {info['rec_name']} {info['rec_mobile']} / {info['rec_addr']}\n"
f" 物品: {info['cargo']}"
)
return info
# ---------- 业务步骤 ----------
def compare_prices(auth: dict[str, str], order_info: dict[str, str], base_url: str, weight: str) -> tuple[str, list[dict[str, Any]]]:
"""逐家查价,按 defPrice(默认总价)升序,返回 (最便宜编码, 全部结果)。"""
results: list[dict[str, Any]] = []
for com in SUPPORTED_COURIERS:
param = {
"kuaidiCom": com,
"sendManPrintAddr": order_info["send_addr"],
"recManPrintAddr": order_info["rec_addr"],
"weight": weight,
}
try:
resp = call_api("price", param, auth, base_url)
if resp.get("result") is True:
data = resp.get("data", {})
results.append({"com": com, **data})
print(
f"[price] {COURIER_NAMES.get(com, com):8s} 首重={data.get('defFirstPrice')} "
f"续重={data.get('defOverPrice')} 总价={data.get('defPrice')}"
)
else:
print(f"[price] {COURIER_NAMES.get(com, com):8s} 不支持: {resp.get('message')}")
except Exception as err: # noqa: BLE001
print(f"[price] {COURIER_NAMES.get(com, com):8s} 查询异常: {str(err)[:120]}")
time.sleep(0.3)
priced = [r for r in results if str(r.get("defPrice", "") not in ("", None)) and _to_float(r.get("defPrice")) is not None]
if not priced:
raise RuntimeError("没有任何快递公司返回有效价格")
priced.sort(key=lambda r: _to_float(r["defPrice"]))
best = priced[0]
print(
f"[price] 共 {len(priced)} 家报价,最便宜: {COURIER_NAMES.get(best['com'], best['com'])} "
f"({best['com']}) 总价 {best['defPrice']} 元"
)
return best["com"], results
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
"""拼省+市+详细地址;detail 已含省/市名时跳过对应前缀,避免重复。"""
detail = detail or ""
prefix = ""
for part in (province or "", city or ""):
if part and part not in detail and part not in prefix:
prefix += part
return prefix + detail
def _to_float(v: Any) -> float | None:
try:
return float(v)
except (TypeError, ValueError):
return None
def place_order(
auth: dict[str, str], order_info: dict[str, str], base_url: str, kuaidicom: str, weight: str,
sender_mobile: str, callback_url: str, dry_run: bool = False,
) -> dict[str, Any]:
param = {
"kuaidicom": kuaidicom,
"recManName": order_info["rec_name"],
"recManMobile": order_info["rec_mobile"],
"recManPrintAddr": order_info["rec_addr"],
"sendManName": order_info["send_name"],
"sendManMobile": sender_mobile,
"sendManPrintAddr": order_info["send_addr"],
"callBackUrl": callback_url,
"cargo": order_info["cargo"],
"weight": weight,
"payment": "SHIPPER",
"remark": f"kuaidi100-e2e-test {order_info['order_no']}",
"salt": "inkreach-test",
"op": 0,
"resultv2": "0",
}
if kuaidicom in ("shunfeng", "shunfengkuaiyun"): # 文档要求顺丰必填预约取件时间窗(间隔≥1小时)
now = time.localtime()
start_h = (now.tm_hour + 1) % 24
param.update(
dayType="1",
pickupStartTime=f"{start_h:02d}:00",
pickupEndTime=f"{(start_h + 1) % 24:02d}:00",
)
if dry_run:
print("[bOrder dry-run] 以下为将提交的完整 param(未发送请求):")
print(json.dumps(param, ensure_ascii=False, indent=2))
return {}
resp = check(call_api("bOrder", param, auth, base_url), "bOrder 下单")
data = resp.get("data") or {}
print(
f"[bOrder] taskId={data.get('taskId')} orderId={data.get('orderId')} "
f"kuaidinum={data.get('kuaidinum')} pollToken={str(data.get('pollToken'))[:20]}"
)
return data
def query_detail(auth: dict[str, str], base_url: str, task_id: Any) -> dict[str, Any] | None:
"""订单详情查询(method=detail,只传 taskId),校验订单已正确落库。"""
try:
resp = check(call_api("detail", {"taskId": task_id}, auth, base_url), "detail 订单详情")
data = resp.get("data") or {}
print(
f"[detail] status={data.get('status')} kuaidinum={data.get('kuaidiNum')} "
f"serviceType={data.get('serviceType')} payStatus={data.get('payStatus')}"
)
return data
except Exception as err: # noqa: BLE001
print(f"[detail] 查询失败(不中断测试): {str(err)[:160]}")
return None
def cancel_order(auth: dict[str, str], base_url: str, task_id: Any, order_id: Any) -> None:
"""取消订单。taskId/orderId 原样回传;cancelMsg 用文档示例文案(实测含"测试"字样会被判订单信息错误)。"""
param = {"taskId": task_id, "orderId": order_id, "cancelMsg": "暂时不寄件了"}
check(call_api("cancel", param, auth, base_url), "cancel 取消")
def track_query(auth: dict[str, str], com: str, num: str, phone: str = "") -> dict[str, Any] | None:
"""实时轨迹查询(poll/query.do): sign=MD5(param+key+customer),需 customer 参数。"""
if not auth.get("customer"):
print("[track] 未提供 customer,跳过轨迹查询")
return None
url = base_query_url(auth)
param = json.dumps({"com": com, "num": num, "phone": phone}, ensure_ascii=False, separators=(",", ":"))
customer = auth["customer"]
sign = hashlib.md5(f"{param}{auth['key']}{customer}".encode()).hexdigest().upper()
resp = httpx.post(
url,
data={"customer": customer, "sign": sign, "param": param},
timeout=30,
).json()
print(f"[track] {json.dumps(resp, ensure_ascii=False)[:300]}")
return resp
def base_query_url(auth: dict[str, str]) -> str:
base = TEST_BASE_URL.replace("/order/borderapi.do", "")
return f"{base}/poll/query.do"
# ---------- 入口 ----------
def load_auth(args: argparse.Namespace) -> dict[str, str]:
if args.from_secret:
from prefect.blocks.system import Secret
value = Secret.load(args.secret_block).get()
auth = json.loads(value) if isinstance(value, str) else value
else:
auth = {"key": args.key or "", "secret": args.secret or "", "customer": args.customer or ""}
missing = [k for k in ("key", "secret") if not auth.get(k)]
if missing:
raise SystemExit(f"缺少快递100鉴权参数: {missing}。用 --key/--secret 或 --from-secret 提供。")
return auth
def main() -> None:
parser = argparse.ArgumentParser(description="快递100上门取件API端到端测试")
parser.add_argument("--key", help="企业版授权码 key")
parser.add_argument("--secret", help="签名密钥 secret")
parser.add_argument("--customer", default="", help="轨迹查询用 customer(可选)")
parser.add_argument("--from-secret", action="store_true", help="从 Prefect Secret Block 读鉴权")
parser.add_argument("--secret-block", default="kuaidi100-test", help="Secret Block 名(默认 kuaidi100-test)")
parser.add_argument("--base-url", default=TEST_BASE_URL, help=f"API 地址(默认测试环境 {TEST_BASE_URL})")
parser.add_argument("--courier", default="yuantong", help="快递编码(默认固定圆通 yuantong)")
parser.add_argument("--auto-cheapest", action="store_true", help="按比价结果自动选最便宜(默认用 --courier 固定值)")
parser.add_argument("--skip-price", action="store_true", help="跳过比价查询")
parser.add_argument("--weight", default=None, help="重量 KG;默认用 SDS 变体克重自动汇总,取不到回退 1")
parser.add_argument(
"--sender-name", default="张文风",
help="寄件人姓名(固定,覆盖 SDS 发货仓名)",
)
parser.add_argument(
"--sender-mobile", default="18320690872",
help="寄件人手机号(固定)",
)
parser.add_argument(
"--sender-addr", default="广东省广州市番禺区南村镇新基村新盈科技园3栋2楼",
help="寄件地址(固定,覆盖 SDS 发货仓地址)",
)
parser.add_argument("--dry-run", action="store_true", help="只构造并打印下单参数,不实际下单")
parser.add_argument(
"--callback-url",
default="http://8.138.172.239:18090/kuaidi100/order",
help="订单回调地址(默认走专属 frpc 实例 kuaidi100 穿透到本机回调服务)",
)
parser.add_argument("--keep-order", action="store_true", help="下单后不取消")
args = parser.parse_args()
auth = load_auth(args)
order_info = fetch_sds_domestic_order()
# 寄件人固定为广州仓联系人(命令行可覆盖),收件人保持 SDS 订单动态获取
order_info["send_name"] = args.sender_name
order_info["send_addr"] = args.sender_addr
print(f"[sender] 寄件人固定: {order_info['send_name']} {args.sender_mobile} / {order_info['send_addr']}")
weight = args.weight or order_info.get("weight_kg") or "1"
print(f"[weight] 下单申报重量: {weight}kg")
kuaidicom = args.courier
if not args.skip_price:
cheapest, _ = compare_prices(auth, order_info, args.base_url, weight) # 比价仅作参考
if args.auto_cheapest:
kuaidicom = cheapest
print(f"[price] --auto-cheapest 生效,切到最便宜: {COURIER_NAMES.get(cheapest, cheapest)} ({cheapest})")
print(f"[courier] 固定使用: {COURIER_NAMES.get(kuaidicom, kuaidicom)} ({kuaidicom})")
data = place_order(
auth, order_info, args.base_url, kuaidicom, weight,
args.sender_mobile, args.callback_url, dry_run=args.dry_run,
)
if args.dry_run:
print("[done] dry-run 结束,未下单 ✓")
return
task_id = data.get("taskId") # 保持原始类型回传,cancel 接口对类型敏感
order_id = data.get("orderId")
kuaidinum = str(data.get("kuaidinum") or "")
if kuaidinum:
track_query(auth, kuaidicom, kuaidinum, order_info["rec_mobile"])
if task_id:
query_detail(auth, args.base_url, task_id)
if not args.keep_order and task_id and order_id:
cancel_order(auth, args.base_url, task_id, order_id)
else:
print(f"[done] 保留测试单: taskId={task_id} orderId={order_id}")
print("[done] 端到端测试完成 ✓")
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
"""mappers 状态映射单测。
运行:uv run python -m unittest discover -s tests -v
"""
from __future__ import annotations
import io
import unittest
from contextlib import redirect_stdout
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from flows.shared import mappers
from flows.shared.mappers import (
map_riin_goods,
map_riin_goods_status,
map_riin_system_status,
map_sds_goods_status,
map_sds_system_status,
)
TZ_CN = timezone(timedelta(hours=8))
class WarnUnknownTest(unittest.TestCase):
def setUp(self):
mappers._warned_values.clear()
def _capture(self):
return io.StringIO()
def test_未知值告警且同值只报一次(self):
buf = self._capture()
with redirect_stdout(buf):
self.assertIsNone(map_riin_goods_status("SOMETHING_NEW"))
self.assertIsNone(map_riin_goods_status("SOMETHING_NEW")) # 第二次不再报
lines = [ln for ln in buf.getvalue().splitlines() if "SOMETHING_NEW" in ln]
self.assertEqual(len(lines), 1)
self.assertIn("[mapper] unknown RIIN goodsStatus", lines[0])
class RiinGoodsStatusTest(unittest.TestCase):
def setUp(self):
mappers._warned_values.clear()
def test_全部已知枚举可映射(self):
cases = {
"NOT_SHIPPED": "待发货",
"SHIPPED": "已发货",
"CANCEL": "已取消",
"CANCELLED": "已取消",
"REFUNDING": "退款中",
"COMPLETE": "已完成", # 本次补齐(曾导致 NULL)
"PAUSE": "暂停", # 本次补齐(曾导致 NULL)
"CLOSE": "已取消", # 回填重拉时告警发现的新枚举
}
for raw, want in cases.items():
with self.subTest(raw=raw):
self.assertEqual(map_riin_goods_status(raw), want)
def test_空值返回None(self):
self.assertIsNone(map_riin_goods_status(None))
self.assertIsNone(map_riin_goods_status(""))
def test_map_riin_goods_接线_PAUSE与COMPLETE不再落NULL(self):
# 复现线上订单 GSU1S555400MFLL:4 行商品 goods_status=PAUSE/COMPLETE
created = datetime(2026, 9, 14, 9, 39, 24, tzinfo=TZ_CN)
base = dict(
platform_oid="GSU1S555400MFLL", product_code="DG001-BK",
color_code="BK", size_code="L", style_code=None, num=1,
)
g1 = map_riin_goods(SimpleNamespace(id="P1", goods_status="PAUSE", **base),
"GSU1S555400MFLL", created)
g2 = map_riin_goods(SimpleNamespace(id="P2", goods_status="COMPLETE", **base),
"GSU1S555400MFLL", created)
self.assertEqual(g1.status, "暂停")
self.assertEqual(g2.status, "已完成")
self.assertEqual(g1.productionNo, "P1")
self.assertEqual(g1.quantity, 1)
self.assertEqual(g1.itemName, "DG001")
class RiinSystemStatusTest(unittest.TestCase):
def setUp(self):
mappers._warned_values.clear()
def test_已知与空值(self):
self.assertEqual(map_riin_system_status(12), "已发货")
self.assertEqual(map_riin_system_status(-4), "待推送")
self.assertIsNone(map_riin_system_status(None))
def test_未知值返回None并告警(self):
buf = io.StringIO()
with redirect_stdout(buf):
self.assertIsNone(map_riin_system_status(999))
self.assertIn("unknown RIIN systemStatus", buf.getvalue())
class SdsStatusTest(unittest.TestCase):
def setUp(self):
mappers._warned_values.clear()
def test_商品状态含已删除(self):
self.assertEqual(map_sds_goods_status(99), "已删除")
self.assertEqual(map_sds_goods_status(98), "已取消")
self.assertEqual(map_sds_goods_status(4), "已完成")
self.assertIsNone(map_sds_goods_status(None))
def test_未知商品状态返回None并告警(self):
buf = io.StringIO()
with redirect_stdout(buf):
self.assertIsNone(map_sds_goods_status(6))
self.assertIn("unknown SDS goodsStatus", buf.getvalue())
def test_订单状态与未知告警(self):
self.assertEqual(map_sds_system_status(3), "备货中")
self.assertIsNone(map_sds_system_status(None))
buf = io.StringIO()
with redirect_stdout(buf):
self.assertIsNone(map_sds_system_status(42))
self.assertIn("unknown SDS systemStatus", buf.getvalue())
if __name__ == "__main__":
unittest.main()
Generated
+82 -4
View File
@@ -952,6 +952,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "pillow" },
{ name = "prefect" },
{ name = "prefect-sqlalchemy" },
{ name = "psutil" },
@@ -965,6 +966,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.3" },
{ name = "httpx" },
{ name = "pandas" },
{ name = "pillow", specifier = ">=13.0.0" },
{ name = "prefect", specifier = ">=3.4" },
{ name = "prefect-sqlalchemy" },
{ name = "psutil", specifier = ">=7.2.2" },
@@ -1483,8 +1485,8 @@ name = "pendulum"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "python_full_version < '3.13'" },
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" }
wheels = [
@@ -1521,6 +1523,82 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" },
]
[[package]]
name = "pillow"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
{ url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
{ url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
{ url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
{ url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" },
{ url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" },
{ url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" },
{ url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" },
{ url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" },
{ url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" },
{ url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" },
{ url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" },
{ url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" },
{ url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" },
{ url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" },
{ url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" },
{ url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" },
{ url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" },
{ url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" },
{ url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" },
{ url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" },
{ url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" },
{ url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" },
{ url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" },
{ url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" },
{ url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" },
{ url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" },
{ url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" },
{ url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" },
{ url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" },
{ url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" },
{ url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" },
{ url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
{ url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
{ url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2691,8 +2769,8 @@ name = "whenever"
version = "0.10.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "python_full_version >= '3.13' and sys_platform == 'win32'" },
{ name = "tzlocal", marker = "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzlocal", marker = "sys_platform != 'darwin' and sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/40/94856fe2439c4b8162d1a23b4cf3a48bb00bed0ed7b8d10add40988fa555/whenever-0.10.3.tar.gz", hash = "sha256:af8958c870bd178742f0089c3552d5702241cab1f4b6bdfd3b03111db4bc2150", size = 435946, upload-time = "2026-07-17T07:35:25.734Z" }
wheels = [