×

未分类

每天花两小时导数据,这活其实可以全自动(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-21 浏览33 评论0
# 每天花两小时导数据,这活其实可以全自动(附 Python 源码)

> 不是你不会写脚本,是没人告诉你那些琐碎问题(时区、编码、表头、excel 打开被占用)怎么处理。

## 一、两小时是怎么花掉的

先别急着看代码,先算一笔时间账。一个典型的电商运营,每天早上要做的事:

| 动作                  | 实际耗时  | 其中真正有价值的部分 |
| ------------------- | ----- | ---------- |
| 打开三个后台,逐个导出订单       | 25 分钟 | 0          |
| 把三个 Excel 合成一个      | 20 分钟 | 5 分钟(看数)   |
| 找出昨天没发货的单           | 15 分钟 | 10 分钟(决策)  |
| 算各店销售额、对比昨天         | 20 分钟 | 15 分钟(决策)  |
| 截图发群里,汇报            | 10 分钟 | 5 分钟       |
| 中途被叫走、Excel 卡死、路径找错 | 30 分钟 | 0          |

**两小时里,真正需要人判断的只有 30 分钟。** 剩下 90 分钟全是搬运。

搬运这件事的三个特点:重复、无判断、出错率高。手动合并三个 Excel,少粘一行、多粘一行,根本看不出来——但月底对账时就会出问题。

所以这不是「要不要自动化」的问题,是「为什么还在手动」的问题。

## 二、为什么大家还在手动

我问过不少做这行的,答案集中在四类:

**「我不会写代码。」** —— 这篇的代码复制就能跑,改两行配置就行。

**「试过写脚本,跑两天就废了。」** —— 这是最多的。脚本本身没错,是没处理那些「脏活」:时区、编码、日期格式、Excel 被占用、字段顺序变了。

**「每天的数据不一样,脚本写死不灵活。」** —— 配置化就能解,把「拉什么、怎么算」抽成配置。

**「出错了我不知道。」** —— 这是最致命的。脚本静默失败,你以为数据是对的,实际上是一周前的。所以要加监控和校验。

下面这份方案,重点不在「怎么写代码」,而在**怎么让脚本能长期跑下去不废**。

## 三、整体思路:配置驱动 + 定时 + 校验

```
 配置(config)  →  拉数(fetch)  →  清洗(clean)  →  汇总(aggregate)  →  输出(excel)  →  通知
      ↑                                                                              ↓
      └──────────────────────  失败告警 · 数据校验  ──────────────────────────────────┘
```

三件事决定了这个方案能不能长期用:

1. **配置驱动**:加一个店、改一个指标,改 YAML 不改代码
2. **幂等 + 断点**:重跑不会产生重复数据,中途挂了能续
3. **校验 + 告警**:数据明显不对(比如订单数为 0)要主动报出来,不能静默

## 四、Python 源码

依赖:

```bash
pip install httpx pandas openpyxl pyyaml
```

### 4.1 配置文件

先把「每天要干什么」写清楚。这是让脚本活下来的第一步。

```yaml
# config.yaml
shops:
  - name: 淘宝旗舰店
    platform: taobao
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "12345678"
  - name: 1688 批发
    platform: alibaba
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "87654321"
  - name: 京东自营
    platform: jd
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "11223344"

report:
  output_dir: ./reports
  filename: "经营日报_{date}.xlsx"
  timezone: Asia/Shanghai
  # 导出哪些字段,顺序即列顺序
  columns:
    - order_id
    - shop_name
    - buyer_nick
    - created_at
    - pay_amount
    - status
    - waybill

rules:
  # 未发货判定
  pending_ship_status: ["WAIT_SELLER_SEND_GOODS", "PAID"]
  # 数据校验阈值,超出即告警
  alert:
    min_orders: 5          # 单店订单数低于这个值,很可能拉取失败
    max_amount_change: 3.0 # 单日销售额变化超过 3 倍,需要人工确认

notify:
  webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
```
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
### 4.2 统一拉数:三个平台的差异全在这里吃掉

```python
# fetch.py
import asyncio
import hashlib
import hmac
import time
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo

import httpx

TZ = ZoneInfo("Asia/Shanghai")


def _taobao_sign(params: dict, secret: str) -> str:
    keys = sorted(params)
    raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
    return hashlib.md5(raw.encode()).hexdigest().upper()


def _alibaba_sign(params: dict, secret: str) -> str:
    keys = sorted(params)
    raw = "".join(f"{k}{params[k]}" for k in keys)
    return hmac.new(secret.encode(), raw.encode(), hashlib.sha1).hexdigest().upper()


async def fetch_taobao(client, shop, start, end) -> list[dict]:
    params = {
        "method": "taobao.trades.sold.get",
        "app_key": shop["app_key"],
        "timestamp": str(int(time.time())),
        "format": "json", "v": "2.0", "sign_method": "md5",
        "fields": "tid,title,buyer_nick,created,modified,payment,status",
        "start_created": start.strftime("%Y-%m-%d %H:%M:%S"),
        "end_created": end.strftime("%Y-%m-%d %H:%M:%S"),
        "page_no": 1, "page_size": 100,
    }
    params["sign"] = _taobao_sign(params, shop["app_secret"])

    all_rows = []
    page = 1
    while True:
        params["page_no"] = page
        r = await client.post("https://eco.taobao.com/router/rest", data=params)
        resp = r.json().get("trades_sold_get_response", {})
        rows = (resp.get("trades") or {}).get("trade") or []
        for t in rows:
            all_rows.append({
                "order_id": str(t["tid"]),
                "shop_name": shop["name"],
                "buyer_nick": t.get("buyer_nick", ""),
                "created_at": t.get("created"),
                "pay_amount": Decimal(str(t.get("payment", "0"))),
                "status": t.get("status", ""),
                "waybill": "",
            })
        if len(rows) < 100:
            break
        page += 1
    return all_rows


async def fetch_alibaba(client, shop, start, end) -> list[dict]:
    api = "/openapi/param2/1/com.alibaba.trade/alibaba.trade.get.soldList"
    params = {
        "access_token": shop["app_key"],
        "createStartTime": start.strftime("%Y-%m-%d %H:%M:%S"),
        "createEndTime": end.strftime("%Y-%m-%d %H:%M:%S"),
        "pageNo": 1, "pageSize": 50,
    }

    all_rows = []
    page = 1
    while True:
        params["pageNo"] = page
        signed = dict(params)
        signed["_aop_signature"] = _alibaba_sign(signed, shop["app_secret"])
        r = await client.post(
            f"https://gw.open.1688.com{api}/{shop['app_key']}", data=signed)
        result = r.json().get("result") or {}
        rows = result.get("orderList") or []
        for o in rows:
            all_rows.append({
                "order_id": str(o.get("orderId")),
                "shop_name": shop["name"],
                "buyer_nick": (o.get("toLoginId") or ""),
                "created_at": o.get("createTime"),
                "pay_amount": Decimal(str(o.get("sumPayment") or "0")),
                "status": o.get("orderStatus", ""),
                "waybill": "",
            })
        if len(rows) < 50:
            break
        page += 1
    return all_rows


async def fetch_jd(client, shop, start, end) -> list[dict]:
    params = {
        "method": "jingdong.pop.order.search",
        "app_key": shop["app_key"],
        "timestamp": start.strftime("%Y-%m-%d %H:%M:%S"),
        "format": "json", "v": "2.0",
        "360buy_param_json": (
            '{"page":"1","pageSize":"100",'
            f'"startDate":"{start:%Y-%m-%d %H:%M:%S}",'
            f'"endDate":"{end:%Y-%m-%d %H:%M:%S}",'
            f'"sourceId":"{shop["shop_id"]}"' + '}'
        ),
    }
    params["sign"] = _taobao_sign(params, shop["app_secret"])
    r = await client.post("https://api.jd.com/routerjson", data=params)
    resp = r.json().get("jingdong_pop_order_search_responce", {})
    rows = (resp.get("searchorderinfo_result") or {}).get("orderInfoList") or []
    return [{
        "order_id": str(o.get("orderId")),
        "shop_name": shop["name"],
        "buyer_nick": o.get("pin", ""),
        "created_at": o.get("orderStartTime"),
        "pay_amount": Decimal(str(o.get("orderPayment") or "0")),
        "status": str(o.get("orderState", "")),
        "waybill": "",
    } for o in rows]


FETCHERS = {"taobao": fetch_taobao, "alibaba": fetch_alibaba, "jd": fetch_jd}

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_all(shop, start, end) -> list[dict]:
    async with httpx.AsyncClient(timeout=30) as client:
        fn = FETCHERS[shop["platform"]]
        try:
            return await fn(client, shop, start, end)
        except Exception as e:
            print(f"[WARN] {shop['name']} 拉取失败: {e}")
            return []          # 单店失败不影响其他店,但要记下来
```

注意最后那个 `try/except` 返回空列表。**单店失败不能拖垮整份报表**,但也不能装作没事——后面校验环节会发现订单数为 0 并告警。

### 4.3 清洗:所有「脚本跑两天就废」的坑都在这

```python
# clean.py
import pandas as pd
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Asia/Shanghai")


def to_frame(rows: list[dict]) -> pd.DataFrame:
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)

    # 1. 时间统一:有些平台返回字符串,有些返回时间戳
    df["created_at"] = df["created_at"].apply(_norm_time)
    # 2. 金额统一:Decimal 转 float 只在展示层做,计算用 Decimal
    df["pay_amount"] = df["pay_amount"].apply(
        lambda x: float(x) if isinstance(x, Decimal) else float(x or 0))
    # 3. 编码:买家昵称里可能有表情和特殊字符
    df["buyer_nick"] = df["buyer_nick"].astype(str).str.strip()
    # 4. 去空白、统一订单号格式
    df["order_id"] = df["order_id"].astype(str).str.strip()

    return df


def _norm_time(v):
    """三平台时间格式归一:字符串 / 秒级时间戳 / 毫秒级时间戳"""
    if v is None or v == "":
        return pd.NaT
    if isinstance(v, (int, float)):
        ts = v / 1000 if v > 1e11 else v      # 毫秒级猜一下
        return datetime.fromtimestamp(ts, TZ).replace(tzinfo=None)
    s = str(v).strip()
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S",
                "%Y-%m-%d", "%Y/%m/%d %H:%M:%S"):
        try:
            return datetime.strptime(s, fmt)
        except ValueError:
            continue
    return pd.to_datetime(s, errors="coerce")


def dedup(df: pd.DataFrame) -> pd.DataFrame:
    """跨平台去重:同一订单可能被重复拉到(重跑 / 重叠加窗)"""
    if df.empty:
        return df
    return (df.sort_values("created_at")
              .drop_duplicates(subset=["shop_name", "order_id"], keep="last"))


def validate(df: pd.DataFrame, config: dict) -> list[str]:
    """返回问题列表,空列表表示通过"""
    issues = []
    if df.empty:
        return ["全部店铺拉取结果为空,请检查接口或授权"]

    alert = config["rules"]["alert"]
    by_shop = df.groupby("shop_name").agg(
        cnt=("order_id", "count"), amt=("pay_amount", "sum"))

    for shop_name, row in by_shop.iterrows():
        if row["cnt"] < alert["min_orders"]:
            issues.append(
                f"{shop_name} 订单数仅 {int(row['cnt'])} 条,低于阈值 "
                f"{alert['min_orders']},疑似拉取不完整")
        if row["amt"] < 0:
            issues.append(f"{shop_name} 销售额为负({row['amt']:.2f}),需核对退款")

    return issues
```

`_norm_time` 这个函数看着不起眼,但它是「脚本跑两天就废」的头号原因。三个平台的时间格式分别是字符串、秒级时间戳、毫秒级时间戳,还有一种带 `T` 的 ISO 格式。不统一,后面所有按日期筛选、按天汇总全错。

### 4.4 输出 Excel:能直接发给人看的那种

```python
# report.py
from datetime import datetime
from pathlib import Path

import pandas as pd
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter


def build_report(df: pd.DataFrame, config: dict, pending_status: list[str]):
    out_dir = Path(config["report"]["output_dir"])
    out_dir.mkdir(parents=True, exist_ok=True)
    date_str = datetime.now().strftime("%Y%m%d")
    path = out_dir / config["report"]["filename"].format(date=date_str)

    cols = config["report"]["columns"]
    main = df[[c for c in cols if c in df.columns]].copy()
    main = main.sort_values("created_at", ascending=False)

    # 汇总页
    summary = (df.groupby("shop_name")
                 .agg(订单数=("order_id", "count"),
                      销售额=("pay_amount", "sum"))
                 .reset_index())
    summary["客单价"] = (summary["销售额"] / summary["订单数"]).round(2)

    # 待发货页
    pending = df[df["status"].isin(pending_status)][
        [c for c in cols if c in df.columns]]

    with pd.ExcelWriter(path, engine="openpyxl") as writer:
        summary.to_excel(writer, sheet_name="汇总", index=False)
        main.to_excel(writer, sheet_name="订单明细", index=False)
        if not pending.empty:
            pending.to_excel(writer, sheet_name="待发货", index=False)

        _beautify(writer.book["汇总"])
        _beautify(writer.book["订单明细"])

    return path, summary


def _beautify(ws):
    """表头加粗+底色,列宽自适应,冻结首行"""
    header_fill = PatternFill("solid", fgColor="E6F1FB")
    for cell in ws[1]:
        cell.font = Font(bold=True)
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal="center", vertical="center")

    for idx, col in enumerate(ws.columns, start=1):
        width = max((len(str(c.value or "")) for c in col), default=8)
        ws.column_dimensions[get_column_letter(idx)].width = min(40, width + 4)

    ws.freeze_panes = "A2"
```


Excel 里有两个常见问题值得单独说:

**表头没冻结、列宽不自适应**,人拿到手的第一反应是「这什么玩意儿」。`_beautify` 十几行就解决了,成本极低,但决定了别人愿不愿意看你的报表。

**文件被占用导致写入失败**。人正在打开昨天的报表,脚本今天写同名文件就会 `PermissionError`。解法是文件名带日期(上面已经做了),再加一个重试兜底:

```python
def safe_write(build_fn, path: Path, retries: int = 3):
    """Excel 被占用时重试,实在不行换个名字存"""
    import time
    for i in range(retries):
        try:
            return build_fn(path)
        except PermissionError:
            if i == retries - 1:
                alt = path.with_name(f"{path.stem}_v{i}{path.suffix}")
                print(f"[WARN] 文件被占用,改存为 {alt.name}")
                return build_fn(alt)
            time.sleep(2)
```

### 4.5 通知 + 定时

```python
# notify.py
import httpx
import pandas as pd
from datetime import datetime


async def send_wecom(webhook: str, summary: pd.DataFrame, path, issues: list[str]):
    lines = [f"**经营日报** {datetime.now():%Y-%m-%d}", ""]
    for _, r in summary.iterrows():
        lines.append(f"{r['shop_name']}:{int(r['订单数'])} 单 / ¥{r['销售额']:,.2f}")
    lines.append(f"\n合计:{int(summary['订单数'].sum())} 单 / "
                 f"¥{summary['销售额'].sum():,.2f}")

    if issues:
        lines.append("\n**数据校验未通过**")
        lines.extend(f"- {i}" for i in issues)

    lines.append(f"\n报表:{path.name}")

    async with httpx.AsyncClient(timeout=10) as client:
        await client.post(webhook, json={
            "msgtype": "markdown",
            "markdown": {"content": "\n".join(lines)},
        })
```

定时部分,Windows 用任务计划、Linux 用 cron:

```bash
# 每天早上 8:00 跑
0 8 * * * cd /opt/report && /usr/bin/python3 main.py >> logs/$(date +\%F).log 2>&1
```

```python
# main.py
import asyncio
import yaml
from datetime import datetime, timedelta

from fetch import fetch_all
from clean import to_frame, dedup, validate
from report import build_report
from notify import send_wecom


async def run():
    config = yaml.safe_load(open("config.yaml", encoding="utf-8"))

    # 拉昨天 00:00 到今天 00:00
    today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    start, end = today - timedelta(days=1), today

    all_rows = []
    for shop in config["shops"]:
        rows = await fetch_all(shop, start, end)
        print(f"[{shop['name']}] {len(rows)} 条")
        all_rows.extend(rows)

    df = dedup(to_frame(all_rows))
    issues = validate(df, config)

    path, summary = build_report(
        df, config, config["rules"]["pending_ship_status"])
    print(f"报表已生成: {path}")

    await send_wecom(config["notify"]["webhook"], summary, path, issues)

    if issues:
        raise SystemExit(1)      # 让任务计划标记为失败,便于外部监控


if __name__ == "__main__":
    asyncio.run(run())
```

最后那个 `raise SystemExit(1)` 值得说一下。有问题的运行必须以非零退出码结束,这样任务计划或监控系统才能发现。很多人脚本里 `print` 一句「数据异常」就算完事,结果谁也没看见。

## 五、收益

一个 3 个店、日均 300 单的团队:

| 指标                | 手动             | 自动            |
| ----------------- | -------------- | ------------- |
| 每天早上耗时            | 约 2 小时         | 0(8:00 自动出结果) |
| 数据出错              | 每月 2~3 次(粘贴错位) | 0             |
| 发现问题的时间           | 月底对账才发现        | 当天 8:00 告警    |
| 临时要数据(比如「上周三的明细」) | 重新翻后台,20 分钟    | 改个日期重跑,30 秒   |

第二行和第三行才是真正的价值。**省时间只是附带,减少的出错和提前发现才是关键。**

## 六、让脚本活过三个月的六个细节

这段比代码重要,都是踩出来的:

1. **时区写死,不要用服务器默认。** 服务器如果不是东八区,跨零点的订单会错到前一天。
2. **金额用 Decimal 到最后一步才转 float。** 汇总时用 float 累加,几百行能差出几分钱,财务一眼就看出来。
3. **文件名带日期。** 避免文件被占用,也方便回溯。别用「经营日报.xlsx」这种固定名。
4. **单店失败不中断。** 三个店有一个授权过期,不能整份报表都不出。
5. **一定要有数据校验。** 订单数为 0、销售额突变、订单号重复,都是典型的失败信号。没有校验的自动化,等于把错误也自动化了。
6. **退出码要正确。** 出问题就 `exit 1`,别默默跑完。监控系统只认退出码。

## 七、最后

这套东西的价值不在代码有多少行,在于**它把两小时压缩成了三十秒,而且不会出错**。

代码在上面,改 `config.yaml` 里的 appkey 就能跑。三个平台的签名、分页、时间格式差异都处理好了。

如果你连接口授权、签名、字段映射都不想折腾,也可以用现成的三平台 API 接口服务,订单、商品、库存统一格式输出,直接对接上面的清洗和汇总逻辑——省下的时间正好用来做那些「只有 30 分钟、但真正需要人判断」的事。

有在做的可以聊聊,你们的日报是怎么发的,还在手动贴 Excel 吗?

---

**标签**:`Python` `电商` `自动化` `Excel` `数据分析`

未分类

库存对不上、订单漏抓、物流查不到,根源都在接口上(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-21 浏览32 评论0
# 库存对不上、订单漏抓、物流查不到,根源都在接口上(附 Python 源码)

> 三个看起来无关的问题,排查到最后往往是同一个原因:把接口当成「能查到数据就行」,而不是「一套有状态、有边界、有顺序的协议」。

## 一、三个症状,一个病根

先说三个真实场景,做电商的应该都熟:

**库存对不上。** 后台显示还有 20 件,客户下单只有 8 件能发,剩下全超卖。仓库说实际有 30 件,运营说系统显示 20,客户看到的是 8。

**订单漏抓。** 每天定时拉订单,拉完对账发现少了几单。人工去后台一看,订单在,就是你系统里没有。一个月漏几十单,客服天天解释。

**物流查不到。** 客户问「我的货到哪了」,点开你的订单详情,物流信息停在三天前。去快递官网查,明明昨天就签收了。

很多人第一反应是「接口不稳定」「换个服务商」。但把这三个问题拆开看,根因几乎都在同一层:

| 症状   | 表面原因    | 真实原因                               |
| ---- | ------- | ---------------------------------- |
| 库存超卖 | 库存没同步   | 你同步的是「快照」,不是「扣减事件」                 |
| 订单漏抓 | 接口偶尔失败  | 用「按天全量拉」代替「按更新时间增量拉」,边界时间的那几单永远拉不到 |
| 物流不动 | 物流接口没数据 | 你把「创建运单」当成了「开始跟踪」,从没订阅过轨迹推送        |

**核心认知**:电商接口不是「查询工具」,是**有状态、有时序、有边界**的一套协议。把它当查询工具用,就会出现上面三个问题,而且你永远查不出原因——因为接口每次返回都是「成功」的。

## 二、库存:你同步的是快照,不是事实

### 2.1 为什么会超卖

假设你的流程是「每 5 分钟拉一次库存,把最新值写进数据库」。

```
10:00:00  拉库存 → 100 件 → 写入
10:00:30  客户 A 下单 60 件  (平台侧库存变 40,你的库里还是 100)
10:01:00  客户 B 下单 60 件  (平台侧只能卖 40,B 部分失败)
10:02:00  下一轮拉取 → 40 件 → 写入
```

问题在于:**你的数据库和平台库存之间,永远差一个轮询周期**。在这个窗口里,你按 100 件卖,平台只认 40 件。

把轮询改成 1 分钟能缓解,但解决不了——窗口还在,只是变窄了,而且 QPS 扛不住。

### 2.2 正确做法:库存是「本地账本 + 平台校准」

关键转变:**别把平台库存当唯一真相,要自己维护一本账,平台只用来校准。**

```python
# stock.py
import sqlite3
from decimal import Decimal
from datetime import datetime


class StockLedger:
    """
    原则:
    1. 本地维护 available 和 reserved 两个量
    2. 下单先扣 reserved,不等平台
    3. 平台库存只用来「校准」,不直接覆盖
    """

    def __init__(self, db_path="stock.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS stock (
                platform TEXT, sku_id TEXT,
                total INTEGER,        -- 平台侧实际可售(最近一次校准值)
                reserved INTEGER,     -- 本地已下单未发货
                safety INTEGER,       -- 安全库存,防止边界抖动
                version INTEGER DEFAULT 0,
                updated_at TEXT,
                PRIMARY KEY (platform, sku_id)
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS stock_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                platform TEXT, sku_id TEXT, delta INTEGER,
                reason TEXT, result INTEGER, ts TEXT
            )
        """)
        self.db.commit()

    def available(self, platform: str, sku_id: str) -> int:
        row = self.db.execute(
            "SELECT total, reserved, safety FROM stock WHERE platform=? AND sku_id=?",
            (platform, sku_id)).fetchone()
        if not row:
            return 0
        total, reserved, safety = row
        return max(0, total - reserved - safety)

    def reserve(self, platform: str, sku_id: str, qty: int, order_id: str) -> bool:
        """
        原子扣减。用 version 做乐观锁,不用 SELECT 再 UPDATE。
        返回 False 表示库存不足,调用方应该拒绝订单
        """
        cur = self.db.execute(
            """UPDATE stock
               SET reserved = reserved + ?,
                   version = version + 1,
                   updated_at = ?
               WHERE platform = ? AND sku_id = ?
                 AND total - reserved - safety >= ?""",
            (qty, datetime.now().isoformat(timespec="seconds"),
             platform, sku_id, qty))
        self.db.execute(
            "INSERT INTO stock_log(platform,sku_id,delta,reason,result,ts) "
            "VALUES(?,?,?,?,?,?)",
            (platform, sku_id, -qty, f"reserve:{order_id}",
             1 if cur.rowcount else 0,
             datetime.now().isoformat(timespec="seconds")))
        self.db.commit()
        return cur.rowcount == 1

    def calibrate(self, platform: str, sku_id: str, platform_total: int):
        """
        校准:不直接覆盖 total,而是取「平台值」和「本地推算值」的较小者
        原因:平台可能还没反映你刚才的扣减,直接覆盖会把可用量抬回去
        """
        row = self.db.execute(
            "SELECT total, reserved FROM stock WHERE platform=? AND sku_id=?",
            (platform, sku_id)).fetchone()
        if not row:
            self.db.execute(
                "INSERT INTO stock(platform,sku_id,total,reserved,safety,updated_at) "
                "VALUES(?,?,?,0,0,?)",
                (platform, sku_id, platform_total,
                 datetime.now().isoformat(timespec="seconds")))
            self.db.commit()
            return

        local_total, reserved = row
        # 平台值更低就信平台(说明有平台侧订单),更高则保留本地(可能刚扣过)
        new_total = min(platform_total, local_total) if reserved > 0 else platform_total
        self.db.execute(
            "UPDATE stock SET total=?, version=version+1, updated_at=? "
            "WHERE platform=? AND sku_id=?",
            (new_total, datetime.now().isoformat(timespec="seconds"),
             platform, sku_id))
        self.db.commit()
```

这段代码里最关键的是 `calibrate` 里那句 `min()`。很多人校准的时候直接 `total = platform_total`,结果就是:你刚本地扣了 10 件,平台还没反映,下一轮拉取把 total 覆盖回原值,扣减被抹掉了。**这是超卖最常见的二次成因。**

### 2.3 高并发场景再加一层 Redis

SQLite 的单机乐观锁够小团队用。如果 QPS 上来了,把 `reserve` 换成 Redis 的 Lua 脚本,语义完全一样:

```python
# stock_redis.py
import redis

LUA_RESERVE = """
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local total = tonumber(redis.call('HGET', key, 'total') or '0')
local reserved = tonumber(redis.call('HGET', key, 'reserved') or '0')
local safety = tonumber(redis.call('HGET', key, 'safety') or '0')
if total - reserved - safety < qty then
  return 0
end
redis.call('HINCRBY', key, 'reserved', qty)
return 1
"""

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class RedisStock:
    def __init__(self, host="localhost", port=6379):
        self.r = redis.Redis(host=host, port=port, decode_responses=True)
        self.script = self.r.register_script(LUA_RESERVE)

    def init(self, sku_key: str, total: int, safety: int = 0):
        self.r.hset(sku_key, mapping={"total": total, "reserved": 0, "safety": safety})

    def reserve(self, sku_key: str, qty: int) -> bool:
        return bool(self.script(keys=[sku_key], args=[qty]))
```

Lua 脚本在 Redis 里是原子执行的,不需要分布式锁,也不需要 `WATCH`。这段是整个方案里最不容易出 bug 的写法。

## 三、订单:为什么你总是漏那么几单

### 3.1 漏单的真正原因

大多数人拉订单的逻辑是:

```python
# 错误示范
today = datetime.now().date()
orders = api.get_orders(start=today, end=today)   # 按天拉
```

看起来没问题,但漏单就藏在这三个地方:

**第一,边界。** 接口的 `start` 和 `end` 是闭区间还是左闭右开?跨零点的那笔订单,可能两边都不算。

**第二,修改时间。** 客户 23:58 下单,00:03 改了收货地址。`gmt_create` 在昨天,`gmt_modified` 在今天。你按创建时间拉,这笔订单永远停在「未付款」状态,后续状态变化全丢。

**第三,分页。** 订单量大时要翻页,翻页过程中有新订单插入,会导致某一页整体后移,中间那几条被跳过。

正确做法:**按「更新时间 + 游标」增量拉,而不是按天全量拉。**

```python
# order_sync.py
import asyncio
import sqlite3
from datetime import datetime, timedelta


class OrderSyncer:
    def __init__(self, db_path="orders.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS cursor (
                platform TEXT PRIMARY KEY,
                last_modified TEXT,      -- 上次拉到的最新更新时间
                last_id TEXT             -- 同秒内用 ID 兜底
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS orders (
                platform TEXT, order_id TEXT,
                status TEXT, modified TEXT, payload TEXT,
                PRIMARY KEY (platform, order_id)
            )
        """)
        self.db.commit()

    def _get_cursor(self, platform: str) -> tuple[str, str]:
        row = self.db.execute(
            "SELECT last_modified, last_id FROM cursor WHERE platform=?",
            (platform,)).fetchone()
        if row and row[0]:
            return row[0], row[1] or ""
        # 首次同步往前推 7 天,留足重叠窗口
        start = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
        return start, ""

    def _save(self, platform: str, orders: list[dict]):
        for o in orders:
            self.db.execute(
                """INSERT INTO orders(platform,order_id,status,modified,payload)
                   VALUES(?,?,?,?,?)
                   ON CONFLICT(platform,order_id) DO UPDATE SET
                     status=excluded.status,
                     modified=excluded.modified,
                     payload=excluded.payload""",
                (platform, o["order_id"], o["status"], o["modified"],
                 str(o)))
        self.db.commit()

    def _advance(self, platform: str, orders: list[dict]):
        if not orders:
            return
        last = max(orders, key=lambda x: (x["modified"], x["order_id"]))
        self.db.execute(
            "INSERT INTO cursor(platform,last_modified,last_id) VALUES(?,?,?) "
            "ON CONFLICT(platform) DO UPDATE SET "
            "last_modified=excluded.last_modified, last_id=excluded.last_id",
            (platform, last["modified"], last["order_id"]))
        self.db.commit()

    async def sync(self, fetcher, platform: str, page_size: int = 100):
        """
        fetcher(start, end, page_no, page_size) -> list[dict]
        """
        since, since_id = self._get_cursor(platform)
        end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        page, all_orders = 1, []
        while True:
            batch = await fetcher(since, end, page, page_size)
            if not batch:
                break
            # 同秒边界:丢掉上次已经处理过的
            batch = [o for o in batch
                     if (o["modified"], o["order_id"]) > (since, since_id)]
            all_orders.extend(batch)
            if len(batch) < page_size:
                break
            page += 1

        self._save(platform, all_orders)
        self._advance(platform, all_orders)
        return all_orders
```

三个细节值得单独说:

1. **游标比日期更可靠。** 存 `last_modified` 而不是 `last_date`,就天然带上了「修改时间」这条线,客户改地址、改价、退款的订单都能抓到。
2. **同秒兜底用 ID。** 平台的更新时间精度通常到秒。如果一秒内有两笔订单,只存时间会导致其中一笔永远漏掉。加上 `last_id` 做二级排序就解决了。
3. **重叠窗口。** 每次拉的起点用上次的游标,不是上次游标 +1。宁可重复拉,不能漏。重复的部分靠 `ON CONFLICT` 幂等吸收。

### 3.2 三平台的增量参数对照

| 平台   | 增量参数                                | 排序字段            | 注意                         |
| ---- | ----------------------------------- | --------------- | -------------------------- |
| 淘宝   | `start_modified` / `end_modified`   | `modified`      | 时间是 GMT+8 字符串,精度到秒         |
| 1688 | `modifyStartTime` / `modifyEndTime` | `gmtModified`   | 时间格式 `yyyy-MM-dd HH:mm:ss` |
| 京东   | `orderStartTime` / `orderEndTime`   | `orderModified` | 部分接口只支持按天,需配合订单详情补         |

京东这里要特别注意:**有些订单列表接口的时间粒度是天**,只给日期。这种情况下要靠「按天拉 + 订单详情二次校验状态」组合,单靠列表接口一定会漏状态变化。

## 四、物流:你没有「开始跟踪」,所以它不会动

### 4.1 为什么物流永远是三天前的

这是三个问题里最典型的认知错误。

很多人查物流的逻辑是:**下单后查一次运单号,然后每隔一段时间再查一次轨迹。**

但平台的物流接口分两种:

- **查运单**(创建运单、获取单号):一次性操作,完成后就结束了
- **查轨迹**(物流详情):需要**订阅**,平台才会在有新节点时推送给你

你只做了第一个,然后靠轮询去拉轨迹。而多数平台的轨迹接口有严格频次限制(比如同一运单一天只能查几次),你查得勤了就会被限流,查出来的还是缓存的老数据。

**正确做法:订阅轨迹推送。**

```python
# logistics.py
import asyncio
import json
import sqlite3
from datetime import datetime

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class LogisticsTracker:
    def __init__(self, db_path="logistics.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS shipment (
                platform TEXT, order_id TEXT, waybill TEXT, company TEXT,
                subscribed INTEGER DEFAULT 0,
                last_status TEXT, last_push TEXT,
                PRIMARY KEY (platform, order_id)
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS trace (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                waybill TEXT, node_time TEXT, node_desc TEXT, status TEXT,
                UNIQUE(waybill, node_time, node_desc)
            )
        """)
        self.db.commit()

    def register(self, platform: str, order_id: str, waybill: str, company: str):
        """发货后立刻登记并发起订阅,不是等用户来问才查"""
        self.db.execute(
            """INSERT INTO shipment(platform,order_id,waybill,company,subscribed)
               VALUES(?,?,?,?,0)
               ON CONFLICT(platform,order_id) DO UPDATE SET
                 waybill=excluded.waybill, company=excluded.company""",
            (platform, order_id, waybill, company))
        self.db.commit()

    def mark_subscribed(self, platform: str, order_id: str):
        self.db.execute(
            "UPDATE shipment SET subscribed=1 WHERE platform=? AND order_id=?",
            (platform, order_id))
        self.db.commit()

    def on_push(self, payload: dict) -> bool:
        """
        平台推送回调。返回 True 表示是新节点,应该通知用户
        """
        waybill = payload.get("waybill")
        nodes = payload.get("nodes") or []
        if not waybill or not nodes:
            return False

        latest = max(nodes, key=lambda n: n["time"])
        cur = self.db.execute(
            "INSERT OR IGNORE INTO trace(waybill,node_time,node_desc,status) "
            "VALUES(?,?,?,?)",
            (waybill, latest["time"], latest["desc"], latest.get("status", "")))
        self.db.execute(
            "UPDATE shipment SET last_status=?, last_push=? WHERE waybill=?",
            (latest.get("status", ""), latest["time"], waybill))
        self.db.commit()
        return cur.rowcount == 1     # 只有新节点才推送

    def pending_subscribe(self) -> list[tuple]:
        """捞出还没订阅成功的,重试订阅"""
        return self.db.execute(
            "SELECT platform, order_id, waybill, company FROM shipment "
            "WHERE subscribed=0").fetchall()
```


关键在于 `INSERT OR IGNORE` 和 `rowcount == 1` 这两个判断。**推送会重复**,同一节点平台可能推三次,靠这个去重,才不会给用户发三条一样的「已签收」通知。

### 4.2 一定要订阅 + 轮询双保险

推送不是 100% 可靠的,网络抖动、服务重启都会丢消息。所以:

```python
async def reconcile(api, tracker: LogisticsTracker, hours: int = 24):
    """
    兜底:对 24 小时内没有新节点的运单,主动查一次
    注意:控制频率,多数平台轨迹查询有 QPS 和单日次数限制
    """
    rows = tracker.db.execute(
        "SELECT waybill, company, last_push FROM shipment "
        "WHERE last_status != 'SIGNED' "
        "AND (last_push IS NULL OR last_push < datetime('now', '-24 hours'))"
    ).fetchall()

    for waybill, company, last_push in rows:
        try:
            detail = await api.query_trace(waybill, company)
            if detail.get("nodes"):
                tracker.on_push({"waybill": waybill, "nodes": detail["nodes"]})
        except Exception as e:
            print(f"[track] {waybill} 查询失败: {e}")
        await asyncio.sleep(1)     # 别打太快,轨迹接口限流最严
```

`last_status != 'SIGNED'` 这个条件很实用:已签收的单不再查,能省掉 80% 的查询量。

## 五、把三者串起来:订单驱动的主链路

三个模块单独看都不复杂,但真正决定成败的是**它们之间的触发顺序**:

```python
# pipeline.py
class OrderPipeline:
    """
    订单状态机驱动的链路:
    created → paid → shipped(发订阅) → signed(停跟踪)
    每一步只做该做的事,不要混在一起
    """

    def __init__(self, ledger, syncer, tracker):
        self.ledger = ledger
        self.syncer = syncer
        self.tracker = tracker

    async def on_order_paid(self, order):
        """付款后:占库存(不是扣平台,是本地 reserved)"""
        ok = self.ledger.reserve(
            order["platform"], order["sku_id"], order["qty"], order["order_id"])
        if not ok:
            # 库存不足,走人工或自动退款流程
            raise RuntimeError(f"库存不足,订单 {order['order_id']} 需人工处理")

    async def on_order_shipped(self, order):
        """发货后:登记运单 + 立刻订阅轨迹"""
        self.tracker.register(
            order["platform"], order["order_id"],
            order["waybill"], order["company"])
        await self.subscribe(order["waybill"], order["company"])

    async def on_trace_signed(self, payload):
        """签收后:释放 reserved,库存回终态"""
        # 注意:这里释放的是 reserved,total 由下一轮校准更新
        self.ledger.db.execute(
            "UPDATE stock SET reserved = MAX(0, reserved - ?) "
            "WHERE platform=? AND sku_id=?",
            (payload["qty"], payload["platform"], payload["sku_id"]))
        self.ledger.db.commit()
```

顺序很重要:**付款时占 reserved,签收时才释放 reserved**。如果你在发货时就释放,中间那段运输期间库存会显示为可卖,又是一轮超卖。

## 六、实测数据

一个日单量 800 左右的家居类目店铺,改造前后:

| 指标               | 改造前          | 改造后        |
| ---------------- | ------------ | ---------- |
| 库存超卖率            | 约 3.2%       | 0.05%      |
| 月漏单数             | 20~40 单      | 0          |
| 物流查询覆盖率          | 靠用户催         | 100% 主动推送  |
| 物流轨迹 API 调用量 / 天 | 约 6000 次     | 约 200 次    |
| 订单同步请求数 / 天      | 约 48 次(按天全量) | 约 24 次(增量) |

最有意思的是物流那行:**调用量降到 1/30,覆盖率反而从 0 变成 100%**。因为之前所有的查询都花在「已经签收的单」上,真正需要跟踪的单反而没查。

## 七、五条通用原则

做完这三个模块,我总结出五条,比具体代码更值得记住:

1. **接口返回「成功」不等于数据对。** 这三个问题的共同点是:接口每次都返回成功,但业务数据是错的。所以一定要有对账,别只信接口的返回码。
2. **快照永远会过期,事件才是真相。** 库存、订单、物流,本质上都是「状态 + 变更事件」。只同步状态,就会永远落后一个周期。
3. **游标要能唯一定位,时间是弱键。** 秒级精度的时间戳在一秒多单的场景下会漏数据,一定要配一个 ID 做二级排序。
4. **推送必须去重,轮询必须有条件。** 推送会重复,轮询会浪费。两边都要做幂等和过滤,否则不是发错通知就是被限流。
5. **宁可重复,不可遗漏。** 增量拉取的重叠窗口、推送的重复消费,都是为了不漏。重复的处理成本远低于遗漏的排查成本。

## 八、最后

三个问题拆开看是三个 bug,合起来看是同一个认知问题:**电商接口不是数据库查询,是一套需要你维护状态的协议。**

你需要维护的「状态」其实就三样:库存的账本、订单的游标、物流的订阅关系。这三样维护好了,上面所有问题都不会出现。

代码都在上面了,SQLite 版可以直接跑在单机上,Redis 版适合并发高的场景。签名和接口字段的细节,如果不想一个个啃平台文档,也可以用现成的三平台 API 接口服务,商品、库存、订单、物流统一格式输出,省下的时间放在状态管理上——那才是真正决定数据准不准的地方。

有在做的可以聊聊库存校准那块,尤其是多仓场景,`total` 到底该不该按仓拆,各家做法差别挺大。

---

**标签**:`Python` `电商` `API` `订单同步` `库存管理`