
一、背景退货单据没闭环账上永远对不平贸易和批发企业里退货是日常业务。麻烦的不是退货本身而是退货单和原始出货单之间没有闭环有人是超退了退回来的比当初卖出去的还多有人是无源退货找不到对应的出货单可能是手工开的。这两种情况在系统里单看退货单是看不出来的得拿退货单跟出货单去比对。财务一般怎么做导两张 Excel用 VLOOKUP 拉一遍。几千行就是半天而且每次口径还不一定一致。我们给客户写的方案是一个核对脚本把已审核的销售出库单和销售退货单都拉下来按「客户 物料」聚合算出两组差异——超退和无源退货直接打印出来给人去查。二、整体流程定时/手动触发│├─ 1. 取 access_token本地缓存├─ 2. 分页拉已审核销售出库单客户/物料/数量├─ 3. 分页拉已审核销售退货单同结构├─ 4. 按 (客户, 物料) 分别累计数量├─ 5. 比对退货 出货 → 超退出货为 0 → 无源退货└─ 6. 按超出金额/数量降序输出 Top N接口路径、FormId 和字段名会随版本与授权范围有差异本文代码里的值请以你所在版本的开放平台文档为准思路是通用的。三、代码可复制-- coding: utf-8 --“”“金蝶云星辰 OpenAPI销售退货闭环核对找出两类异常①超退退货数量 出货数量②无源退货找不到对应出货单依赖pip install requests“””import collectionsimport timeimport requestsBASE “https://你的开放平台网关” # 以所在版本文档为准APP_KEY “your_app_key”APP_SECRET “your_app_secret”OUT_FORM “SAL_OUTSTOCK” # 销售出库单以文档为准RTN_FORM “SAL_RETURNSTOCK” # 销售退货单以文档为准FIELDS (“FBillNo,FCustId.FName,FDate,”“FEntity_FMaterialId.FNumber,FEntity_FQty”)EPS 1e-6 # 浮点容差_tok {“v”: None, “ts”: 0}def token():“”“token 本地缓存避免每次分页都取”“”if _tok[“v”] and time.time() - _tok[“ts”] 3000:return _tok[“v”]r requests.post(BASE “/oauth2/token”, data{“grant_type”: “client_credentials”,“client_id”: APP_KEY,“client_secret”: APP_SECRET,}, timeout20)r.raise_for_status()_tok[“v”] r.json()[“access_token”]_tok[“ts”] time.time()return _tok[“v”]def fetch_all(form):“”“分页拉全量只取已审核的单据”“”out, start, limit [], 0, 500while True:body {“FormId”: form,“FieldKeys”: FIELDS,“FilterString”: “FDocumentStatus‘C’”, # C 已审核“OrderString”: “FDate ASC”,“StartRow”: start,“Limit”: limit,}r requests.post(BASE “/api/query”,headers{“Authorization”: Bearer token()},jsonbody, timeout60)r.raise_for_status()rows r.json()if not rows:breakout.extend(rows)if len(rows) limit: # 关键不足一页就结束breakstart limittime.sleep(0.3) # 别把接口打爆return outdef index_by_cust_mat(rows):“”“按 (客户, 物料) 累计数量。同一组合可能有多行必须累加”“”acc collections.defaultdict(float)for x in rows:cust (x[1] or “”).strip()mat (x[3] or “”).strip().upper()acc[(cust, mat)] float(x[4] or 0)return accdef check():o_acc index_by_cust_mat(fetch_all(OUT_FORM))r_acc index_by_cust_mat(fetch_all(RTN_FORM))over, nosrc [], [] for key, rq in r_acc.items(): oq o_acc.get(key, 0.0) if oq EPS: nosrc.append((key[0], key[1], rq)) elif rq - oq EPS: # 浮点比对留容差 over.append((key[0], key[1], oq, rq, rq - oq)) print(出货组合 %d 组退货组合 %d 组 % (len(o_acc), len(r_acc))) print(\n【超退】%d 条 % len(over)) for c, m, oq, rq, d in sorted(over, keylambda t: -t[4])[:20]: print( %s / %s出货 %.4f退货 %.4f超出 %.4f % (c, m, oq, rq, d)) print(\n【无源退货】%d 条 % len(nosrc)) for c, m, rq in sorted(nosrc, keylambda t: -t[2])[:20]: print( %s / %s退货 %.4f % (c, m, rq)) return over, nosrcifname “main”:check()四、字段表FieldKeys含义要点FBillNo单据编号定位单据异常时给出源单号FCustId.FName客户名称匹配键之一单头字段FDate单据日期排序、按期间筛选FEntity_FMaterialId.FNumber物料编码匹配键之一明细字段需带 FEntity_ 前缀FEntity_FQty明细数量累计用不能用单头数量代替FDocumentStatus单据状态过滤条件C 为已审核写 FieldKeys 时最容易踩的坑就是前缀单头字段直接写如 FCustId.FName单据体字段要带 FEntity_。混用会直接报字段无效。五、报错处理表现象常见原因处理401 / token invalid密钥错、token 过期检查 app_secrettoken 做缓存但别太久返回空数组FilterString 写错、状态值不对先去掉过滤条件跑一遍再逐个加回字段报无效明细字段漏了 FEntity_ 前缀用字段查询接口确认本版本可用字段数量总和与单头对不上用单头数量代替了明细累加必须按明细行累加最后一页数据丢失只判断了「返回为空」同时判断 len(rows) limit误报大量超退未过滤未审核单据FilterString 加 FDocumentStatus‘C’少量数量差异误报浮点直接比较用 EPS 容差不要用 429 / 请求限流分页循环没有间隔分页之间 sleep(0.3)六、三个踩坑一对多匹配必须累计不能取一条。 这是这类核对脚本的核心。同一个客户买同一个物料一个月可能出了 5 次货、退了 3 次货。如果按单据逐条比对永远对不上。正确做法是先按「客户 物料」把两边各自累加成总数再比总值。数据量大的时候用 defaultdict(float) 建索引比双层循环快好几个量级。明细字段的前缀不一样。 单头字段是 FCustId.FName 这种直接写的单据体字段必须带 FEntity_比如 FEntity_FQty。这两个混着写接口不会给你友好提示直接返回字段无效。第一次写的时候我在这个上面卡了挺久后来养成习惯——写 FieldKeys 前先去字段查询接口把本版本的字段名列一遍。数量比对不要用 。 数量和金额经过多次累加之后会有浮点误差0.1 0.2 ! 0.3 是常见现象。用 比会报出一堆根本没问题的「差异」。统一用一个容差比如 1e-6来判断误报会少很多。这一点在做金额核对时更明显。七、再往前一步这个核对逻辑跑稳之后通常还会往下延伸两个方向一是把结果推给企业微信让退货异常每天自动到人二是按业务员维度汇总看退货是不是集中在少数人身上。两个方向都不用重写拉数逻辑在现有结构上加一段就行。