第三方检测报价单要按客户批量汇总比对,手工一天只够报三家 客户发来一张询价明细几十个样品、几十个项目。报价员照着价目表逐项查单价、按客户汇总成单再翻上次的成交价比一遍——一天走不了几家。慢还不是最要命的。客户写「铅Pb含量测定」价目表里叫「铅含量」换个方法就是另一个价没写方法时不知道报哪个表里还压着下个月才生效的调价和去年的旧价一走神就填错。报错一条客户拿着这个价来砍你。为了解决这个问题Python 提供了 openpyxl 与标准库 csv前者读价目表这种长表并填模板后者读询价明细——编号 0000713 的前导零不会被吃成 713。本文按四步走配路径与归一化 → 读数据归堆 → 匹配算价 → 出单与比对台账。一、难点不在算乘法单价乘数量再乘折扣谁都会。难的是前一步这一条询价行该用价目表里的哪一行。它是个长表一个项目占好几行项目检测方法单价生效期铅含量ICP-OES2202026 全年铅含量XRF 荧光光谱1302026 全年铅含量ICP-OES2602026-10-01 起pH值pH计602026 全年pH值pH计502025 全年取「第一条」可能取到去年那档取「最后一条」可能取到还没生效的调价。人眼逐项对一下午就没了。二、第一步配好路径把项目名压成可比的键配置区只放该改的东西。报价基准日写成常量不用date.today()——换个天再跑算出来的价就变了台账对不上。出处run.py顶部的路径与字段常量区上半段PRICE_XLSX RAW_DIR / 价目表.xlsx INQUIRY_CSV RAW_DIR / 询价明细.csv HISTORY_CSV RAW_DIR / 历史成交价.csv TPL_XLSX TPL_DIR / 报价单模板.xlsx COMPARE_XLSX OUT_DIR / 报价汇总比对.xlsx QUOTE_DIR OUT_DIR / 报价单 # 报价基准日价目表按这一天挑生效价。**不许用 date.today()**—— # 换了天再跑同一批询价算出来的价就变了台账跟报价单对不上说不清是谁的错。 QUOTE_DAY date(2026, 9, 24) QUOTE_PREFIX BJ VALID_DAYS 30 # 报价有效期 # 阶梯折扣按**这一家客户当次能报出来的项数**定档从大到小第一个命中。 # 「能报出来」不等于「客户问了几项」——挂起项不算否则折扣按 10 项算、单上只报 9 项。 TIERS [(10, 0.90), (5, 0.95), (0, 1.00)] # 跟历史成交价比变动超过这个幅度就标出来等于不算超。 RAISE_LIMIT 0.10 MAX_DETAIL 12 # 报价单模板明细区行数RAW_DIR、OUT_DIR在同区往上几行date来自顶部 import。归一化最要紧客户在自家系统挑的项目名价目表是自家编的写法天然不一样。出处run.py的归一化常量区与norm_item()顶部往下第一段# 项目名归一化全角转半角 → 去括号注释 → 去空白与分隔符 → 去尾缀 → 转小写。 FULL2HALF str.maketrans(【】〔〕、, ()[]{}:,,/.-) BRACKET_RE re.compile(r[(][^)]*[)]) # 「Pb」「(Pb)」这类注解 SEP_RE re.compile(r[\s/、,;.\-]) # 尾缀按长的排前面先去「含量测定」再去「含量」不然「铅含量测定」只掉一个尾巴。 TAIL_WORDS (含量测定, 含量检测, 含量, 测定, 检测, 测试) ALIAS_SPLIT re.compile(r[、,;/]) def norm_item(text): 把项目名压成可比的键「铅Pb含量测定」「铅含量」「铅」压完都是「铅」。 为什么必须归一化客户是在自家系统里挑的项目名价目表是自家编的 两边写法天然不一样——靠人工对着看看一天也看不出「重金属铅」和「铅」是一回事。 s clean(text).translate(FULL2HALF) s BRACKET_RE.sub(, s) s SEP_RE.sub(, s) for word in TAIL_WORDS: if s.endswith(word) and len(s) len(word): s s[: -len(word)] break return s.lower()压完「铅Pb含量测定」「铅含量」「铅」是同一个键。尾缀顺序不能反先掉「含量测定」再掉「含量」。三、第二步读数据——价目表是长表不是一一对应输入是三张表询价明细一行一个 样品 × 项目、价目表长表、历史成交价要带方法——同一项目不同方法不是同一个价。索引键用项目规范名加方法规范名不然几套方法会互相串价。出处run.py的load_price_list()读数据那一段def load_price_list(): 读价目表。**价目表是长表不是一一对应**一个项目有几行很常见—— 不同检测方法不同价、同一年里调过价、去年的旧价还留在表里。 所以索引键不能只用项目名要用项目规范名 方法规范名 另外把「别名」列也编进索引客户写「重金属铅」也能落到「铅」上。 返回 (索引, 别名表, 认识的项目名集合)。 index, alias, names {}, {}, set() for row in sheet_rows(PRICE_XLSX): name norm_item(row.get(项目名称)) method norm_method(row.get(检测方法)) start parse_date(row.get(生效日期)) price to_amount(row.get(单价)) if not name or not method or not start or price is None: continue # 资料不全的价目行不进索引别让它带错价 rec {编号: row.get(项目编号), 名称: row.get(项目名称), 规范: name, 方法: row.get(检测方法), 单位: row.get(计价单位), 单价: price, 生效: start, 失效: parse_date(row.get(失效日期))} # 空 至今有效不是无效 index.setdefault((name, method), []).append(rec) names.add(name) for one in ALIAS_SPLIT.split(row.get(别名) or ): if clean(one): alias[norm_item(one)] name names.add(norm_item(one)) # 别名的 key 也要算认识的项目名不然客户写别名会被判成匹配不上 return index, alias, names失效日期为空表示至今有效不是无效「别名」那列也要编进索引不然客户写「重金属铅」落不到「铅」上。取价时按基准日过滤出处run.py的active_prices()紧接match_line()之前def active_prices(recs, day): 挑在报价基准日生效的价目行。 两个边界都在这里生效日期晚于基准日的**还没生效的调价**必须排除取最后一条 就会把下个月的涨价提前报出去失效日期为空表示一直有效不是无效。 return [r for r in recs if r[生效] day and (r[失效] is None or day r[失效])]四、第三步匹配与算价匹配分三步归一化、查别名表、查价目表。不做模糊匹配——看着像就选一个报错价了还不报错比挂起危险得多。出处run.py的match_line()文件中部def match_line(item, method, index, alias, names, day): 给一条询价行找价目行。三步归一化 → 查别名表 → 查价目表三步都落空才算匹配不上。 这里**不做模糊匹配**。difflib 那类看着像就选一个的做法报错价了还不报错 比挂起危险得多挂起最多晚一天报出去报错价是直接在客户面前丢分。 key norm_item(item) if not key: return None, 检测项目缺失, 询价明细里这一行的检测项目是空的 if key not in names: return None, 项目名称匹配不上, \ f「{clean(item)}」在价目表里找不到对应项目不许按相近字猜一个价 std alias.get(key, key) mkey norm_method(method) same_item [r for (name, _m), recs in index.items() if name std for r in recs] if mkey: hit active_prices(index.get((std, mkey), []), day) if hit: return hit[0], , others active_prices(same_item, day) if others: return None, 价目表里没有这个方法, \ f「{std}」在价目表里只有 {、.join(sorted({r[方法] for r in others}))} \ f客户写的是「{clean(method)}」先确认用哪一种 return None, 项目名称匹配不上, f「{clean(item)}」在价目表里没有可用价格 # 客户没写方法同一个项目只有一种方法才敢直接用有两种以上就是替客户挑价。 hit active_prices(same_item, day) if not hit: return None, 项目名称匹配不上, f「{clean(item)}」在价目表里没有可用价格 methods sorted({r[方法] for r in hit}) if len(methods) 1: return None, 方法待确认, \ f「{std}」有 {len(methods)} 种检测方法{、.join(methods)}、单价不同 \ f客户没写用哪一种 return hit[0], , 动作为什么这么写定位价目行要三个键项目 方法 生效期一个项目占好几行少一个维度就串价客户没写方法、项目又有两种方法以上 → 挂起默认取第一条就是替客户挑了个价查不到项目名不猜能加别名就加别名报错价代价远大于晚一天报出处run.py的tier_rate()与price_customer()文件中部def tier_rate(items): 按项数定折扣档。从大到小第一个命中的生效——**等于门槛算命中** 「满 5 项打 95 折」里的 5 项就是 5 项。这条口子要跟商务定死 不然天天为刚好多一项算哪档扯皮。 for floor, rate in TIERS: if items floor: return rate return 1.0 def price_customer(rows): 一家客户的单价与折扣先按**能报出来的项数**定档再逐行乘数量、乘折扣。 金额一律「逐行 round 后再合计」不许「先加总再乘折扣」—— 后者遇到带小数的单价会跟报价单差一分钱客户对账时第一个抓的就是这个。 ok [r for r in rows if r.get(rec)] rate tier_rate(len(ok)) for row in ok: row[折扣] rate row[金额] round(row[单价] * row[数量] * rate, 2) return rate, round(sum(r[金额] for r in ok), 2)折扣按这一家当次能报出来的项数定档挂起项不算。还有一组判定顺序不能换客户名、重复行、数量排最前。出处run.py的price_all()开头紧跟tier_rate()之后seen set() for row in rows: if not cust_key(row[客户名称]): row.update(状态待确认, 问题客户名缺失, 说明询价明细里客户名称是空的不知道这份报价单该报给谁) continue # 同一家、同一客户样品编号、同一个项目出现两行只留先出现的那行后一行挂起。 # 直接删掉后一行是错的——客户可能真送了两个平行样得问清楚哪一行作准。 dup (cust_key(row[客户名称]), clean(row[客户样品编号]), norm_item(row[检测项目])) if dup in seen: row.update(状态待确认, 问题重复询价行, 说明同一客户、同一客户样品编号、同一项目出现两行先跟客户确认哪一行作准) continue seen.add(dup) qty parse_qty(row[数量原文]) if qty is None: row.update(状态待确认, 问题数量不可用, 说明f客户原件把数量写成「{clean(row[数量原文])}」整数数量才能计价 f不替客户改成数字) continue跟历史价比记录要带方法——只按项目名比会把「XRF 报 130、ICP-OES 报 220」判成一条降幅超阈值。出处run.py的compare_history()紧接price_all()之后def compare_history(row, history): 跟这家客户上次的成交价比一遍。 变了要标出来客户问「怎么比上次贵」的时候报价员手上得有数。 降了也要标——多半是价目表调过价没通知商务报出去才发现自己降了一半。 查不到历史价是「无历史价」不是异常历史价记的是文字如「待定」的 不能当 0 去算涨幅那是一除就爆的东西。 rec row[rec] last history.get((cust_key(row[客户名称]), rec[规范], norm_method(rec[方法]))) if last is None: return 无历史价, , , last_price, last_day last if last_price is None: return 历史价待核, , , f历史台账里这一项记的不是数字{last_day}核完再定要不要调 gap (rec[单价] - last_price) / last_price text f{gap:.1%} if abs(gap) RAISE_LIMIT: return 价格变动, f{last_price:g}, text, \ f上次 {last_price:g} 元、这次 {rec[单价]:g} 元{text}报出去前先把原因说清楚 return 已核对, f{last_price:g}, text, f跟 {last_day} 那次一致或微调五、第四步填模板再汇总成台账模板是质量部备案的脚本只能填、不能改结构。坐标不写死按标签名反查格子——模板改一版就多一行少一行写死 B2 会漂。出处run.py的fill_quote()写明细那一段文件后部for i, row in enumerate(ok_rows, start1): r first i - 1 ws.cell(rowr, column1, valuei) ws.cell(rowr, column2, valuerow[rec][名称]) ws.cell(rowr, column3, valuerow[rec][方法]) code ws.cell(rowr, column4, valuerow[客户样品编号]) code.number_format # 不设文本0000713 会掉前导零 ws.cell(rowr, column5, valuerow[数量]) ws.cell(rowr, column6, valuerow[单价]).number_format 0.00 ws.cell(rowr, column7, valuerow[金额]).number_format 0.00first是明细区第一行由detail_span()按标签名加边框认出来。客户样品编号要设成文本格式不设 0000713 出来是 713。填完再出一张汇总比对台账一页逐行列全挂起也在一页单列要回去问客户的。台账行数必须等于询价明细行数挂起也算。六、跑一遍看看示例包 27 行询价明细故意混了边界值没生效的调价、已失效的旧价、项目名带括号注解、客户写别名、漏写方法、数量写成「2 件」、客户名空着、客户名粘了全角空格、同一编号重复两行。跑完4 份报价单、20 行可报、7 行挂起合计 8162.50 元回读校验 32 项全绿。挂起那 7 行全在「待确认」页上说明里带着原件原文。本轮踩到的真坑读询价明细时我先按「客户名称」判空跳过客户名空着的那 2 行整行消失——恰恰是必须挂起报出来的那两行。判空一律挑一定会有值的那一列。折扣档也有个坑一开始按「客户问了几项」定档挂了 1 项的那家被按更高档位算了折扣。总结难点是匹配不是算乘法定位价目行要三个键项目 方法 生效期少一个维度就串价。匹配不上、方法没写、数量不是整数一律挂起不猜不兜也不替客户改口径。挂起最多晚一天报错价丢客户。折扣按当次能报出来的项数定档金额逐行 round 后再合计跟历史价比是顺手的事救的是客户问为什么涨价那一刻。完整源码本文配套的可运行示例已开源含三张原始表和可复跑的脚本克隆下来直接跑huang_jianhua0101/examples - Gitee.com关于我在实验室一线待了 13 年9 年制药 4 年第三方检测做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导招标到 3Q 验证到验收全流程也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验其中一段经 Citrix 远程接入。现在专做实验室里那些重复劳动报表自动生成、仪器数据对接、合规文档批量处理。本科物理化学、硕士计算机化学既听得懂 QA 说的变更控制也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词不用你解释。现在主要做这几类- 检验报告与台账批量生成模板不动数据自动填格式一步不错- 仪器数据对接色谱、光谱、酶标仪导出的原始文件解析、清洗、入库、转成报表- 合规文档自动化SOP、验证方案、批记录这类重复文档的批量生成与核对- 数据完整性核查按 ALCOA 逐条核对原始数据与记录是否对得上手里有这类活儿卡着或者只是想问问能不能自动化都欢迎评论区聊先把问题说清楚再谈怎么做。