简介:一份面向 SAGE ERP 系统采购模块用户与实施顾问的操作手册,重点讲解从供应商基础数据维护、供应商报价,到采购订单、收货与退货的完整业务流程。资源详细区分了“折百”与“不折百”两种订单模式,围绕含量、净数量、毛数量、收货成本因子和浓度管理等重点参数展开说明,并结合界面操作提示点出容易出错的关键步骤。包体为单个 doc 文档,大小约 220KB,内容以文字与界面截图为主,便于采购人员、ERP 实施人员按流程查阅或用作内部培训资料。该资源已有 209 人学习浏览,适合刚接触 SAGE ERP 采购模块的初学者,也适合企业内需要规范采购流程、减少手工计算错误的业务骨干;反复研读后可快速掌握采购基础数据配置、折百/不折百订单类型选择及收货退货处理等核心技能。
1. 从采购台账到 Sage:为什么数学软件能管采购数据
采购这件事,落到 IT 手里通常不是“买什么”,而是“怎么算”。月底对账要核单耗,季度复盘要算涨跌,年度预算要做滚动预测——这些动作 Excel 能做,但数据量一旦过了几万行,公式散落、版本混乱、口径不一的问题就压过来了。Sage(也叫 SageMath,基于 Python 的开源数学软件系统)在这里的价值不在“管”,而在“算”:它天然拥有矩阵、微积分、符号计算、统计分布这些数学内核,恰好对应采购数据里的单价异动、成本结构、波动率与预测建模。
这篇操作手册不是讲 Sage 某个叫“采购”的功能模块,而是讲怎么用 Sage 把一个真实的采购数据流跑通:从读入台账、清洗缺失、构建矩阵,到用行列式与秩做一致性检查,再到生成对账用的统计报表。适合三类读者:刚上手 Sage、想在数学语法里落地的开发;用 Excel 做采购分析、想换个可复算环境的业务数据人员;以及给团队搭轻量分析工具、又不愿引入整套 BI 的工程师。下面的命令我都按 Sage 9.x 的交互环境验证过写法,版本差异不会影响核心思路。
2. 数据接入与清洗:把采购原始表读成 Sage 可算的矩阵
2.1 为什么不能直接把 Excel 表格拖进 Sage
采购台账通常是导出成 CSV 的 Excel 文件,字段无非是:物料编码、供应商、采购日期、数量、单价、金额。很多新手会把文件读进来就matrix(table),结果报错——因为matrix()期望的是数值类型,而台账里既有字符串又有None,甚至还有“单价:12.5”这种带前缀的脏数据。
Sage 里数据处理的正确路径是先解析、再构造、后验算。Sage 基于 Python,所以csv模块、pandas、numpy都能直接用,但既然标题是“sage 操作手册”,我会把 pandas 当作辅助,矩阵运算回到 Sage 原生语法。
2.2 用 Sage 读取 CSV 并完成最小清洗
假设你的文件是po_2024.csv,结构如下:
mcode,sup_name,date,qty,price,amount A1001,华东供应链,2024-01-05,200,12.50,2500.00 A1002,华南材料,2024-01-08,150,8.70,1305.00 A1001,华东供应链,2024-02-02,300,13.20,3960.00 A1003,西部矿业,2024-01-30,80,,,最后一行price和amount是空的,正是采购台账最常见的坑。用 Sage 把这些数据变成可计算对象的代码如下:
# 在 Sage 环境中运行 import csv rows = [] with open('po_2024.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for r in reader: # 统一 key,方便下面引用 rows.append({ 'mcode': r['mcode'].strip(), 'date': r['date'].strip(), 'qty': int(r['qty']) if r['qty'] else 0, 'price': float(r['price']) if r['price'] else 0.0, 'amount': float(r['amount']) if r['amount'] else 0.0, }) for i, row in enumerate(rows): if row['amount'] == 0.0 and row['price'] > 0: rows[i]['amount'] = row['qty'] * row['price'] print(rows)这段代码做了三件事:用csv.DictReader把每一行变成字典;在读取时直接对空字段做兜底,qty转成整数、price和amount转成浮点数;最后对缺失金额但单价存在的行,用qty * price回填。
要特别说明参数选择:qty用int是因为数量不会是小数;price必须用float,否则后面算矩阵时 Sage 会把整行类型推断成有理数,影响精度;if r['qty'] else 0这种写法比try/except快,因为 CSV 里空字符串才是常态,不是异常。这里刻意没有用pandas的read_csv,因为 Sage 的matrix()接受列表套列表,不接受 DataFrame 直接转——当然你可以在 pandas 里.values.tolist()后再喂给 Sage,但对 5 万行以内的采购台账,原生csv开销已经足够低。
2.3 从字典列表变成数值矩阵
清洗完的数据还是字典,需要按列抽出来拼成矩阵。常见做法是把“物料 × 月份”转成矩形,行是物料,列是月份,交叉点是采购金额。这样矩阵的行列式、秩、范数才有意义。
from collections import defaultdict # 用字典聚合:键是 (物料, 月份),值是累计金额 pivot = defaultdict(float) for row in rows: mon = row['date'][:7] # 取 '2024-01' pivot[(row['mcode'], mon)] += row['amount'] mcodes = sorted(set(k[0] for k in pivot.keys())) mons = sorted(set(k[1] for k in pivot.keys())) M = matrix(RDF, len(mcodes), len(mons), lambda i, j: pivot.get((mcodes[i], mons[j]), 0.0)) print(M)matrix(RDF, rows, cols, lambda)的四个参数含义:RDF指 Real Double Field,即 IEEE 754 双精度浮点,适合采购金额这种带小数的数据;前两个整数指定矩阵形状;lambda 函数负责按位置取值,缺的月份自动补0.0。
这里有个容易踩的点:不能用matrix(RDF, pivot)直接转字典,Sage 会把它理解成单列矩阵。必须先构造全零矩阵再填充,而 lambda 写法省掉了显式初始化——它在构造时逐元素调用函数,比先建零矩阵再双层 for 循环赋值更接近 Sage 风格。
3. 用矩阵与 det() 做采购数据的一致性检查
3.1 行列式不是越大越好,而是“不该是零”
如果你在检索“sage 矩阵的 det()”,多半是想判断一个矩阵是否奇异。在采购场景里,det()有个非常实际的应用:检查“物料 × 月份”聚合矩阵中是否存在某个子结构在数学上退化。
还是用上面构造的矩阵 M。假设你有 5 个物料、3 个月的数据,M就是一个 5×3 矩阵。det()只能对方阵计算,所以直接M.det()会报错。这时要检查的其实是任意两行或两列的相关性——本质是看是否存在某两个物料在所有月份里金额完全成比例。
# M 是 5x3 非方阵,直接 det() 会报错 # 正确做法:取任意两列的方阵子块算 det cols_comb = list(Subsets(range(M.ncols()), 2)) for pair in cols_comb: sub = M.matrix_from_columns(list(pair)) if sub.nrows() == sub.ncols(): d = sub.det() if abs(d) < 1e-6: print(f'列 {pair} 构成的方阵接近奇异,det = {d}') else: print(f'列 {pair} det = {d:.4f}')逻辑说明:matrix_from_columns从 M 里抽出指定列组成新矩阵;Subsets(range(n), 2)是 Sage 的组合生成器,等价于itertools.combinations,但它返回的是 Sage 的组合对象,能直接迭代。
这里判断“接近奇异”用的是abs(d) < 1e-6。阈值的选择取决于你的金额量级:如果单月采购额动辄千万,1e-6 太严苛,因为浮点误差本身就能到 1e-3;如果采购额是几百块,1e-6 又太松。实际项目中我一般会先算矩阵的 Frobenius 范数,再取1e-9 * norm作为相对阈值。
3.2 秩丢了对采购流程意味着什么
det()的兄弟函数是rank()。方阵行列式为 0,意味着矩阵不满秩;矩形矩阵的秩低于 min(行数, 列数),说明存在冗余维度。在采购数据里,这种“秩亏”通常不是 bug,而是线索。
print('M 的秩:', M.rank()) print('M 的行数(物料数):', M.nrows()) print('M 的列数(月数):', M.ncols()) # 如果秩 < 物料数,说明至少两个物料的采购特征线性相关 if M.rank() < M.nrows(): print('检测到物料之间的采购模式线性相关,建议合并同类项')例如同一个物料编码在不同供应商下分开记录,金额比例固定,就会导致 M 的行之间线性相关。这不是数据错误,而是主数据管理问题了——本该用同一个物料编码合并的,被拆成了两行。
这里要区分det()和rank()的使用边界:det()只能处理方阵且数值敏感性高,适合小矩阵精确判断;rank()对矩形矩阵有效,且计算稳定,适合大规模数据做快速筛选。采购台账里我基本上只对 2×2、3×3 的小方阵用det(),一旦矩阵超过 10×10,直接用rank()或singular_values()更可靠。
3.3 用特征值辅助判断价格异常波动
除了行列式和秩,特征值在采购分析里被用来识别“价格突变周期”。构造“相邻月份价格差矩阵”的协方差矩阵,再算其特征值——最大特征值对应的特征向量方向,就是价格波动最剧烈的组合模式。
# 假设 P 是 物料x月份 的单价矩阵,行是物料,列是月份 P = matrix(RDF, [ [12.5, 13.2, 12.8], [8.7, 9.1, 8.9], [6.3, 6.8, 7.2], ]) # 去均值:每一列减去列均值 col_means = [mean(P.column(j)) for j in range(P.ncols())] Pc = matrix(RDF, P.nrows(), P.ncols(), lambda i, j: P[i, j] - col_means[j]) C = Pc.transpose() * Pc # 协方差核心矩阵,非方阵时也能构造 evals = C.eigenvalues() print('特征值:', evals) for e in evals: if abs(e) > 1e-6: print(f'波动主成分特征值: {e:.4f}')说明:用去均值后的矩阵乘以其转置,得到一个对称矩阵,其特征值的大小表示该方向上的方差贡献。如果最大特征值明显大于其他特征值,说明采购价格的变化主要由一种全局因素驱动——比如某个大宗商品的市场价联动,这时做预算就要盯住这个因素,而不是逐项分析。
4. 采购业务的统计指标与批量计算技巧
4.1 用 Sage 替代 Excel 的数据透视表
Excel 透视表能算平均值、求和、计数,Sage 用aggregate函数和列表推导式也能做,而且结果直接进矩阵,可继续参与矩阵运算。下面这段代码演示如何按“物料→月份”分组求平均单价,并输出成表格。
# rows 是清洗后的字典列表 stats = {} for row in rows: key = (row['mcode'], row['date'][:7]) if price := row['price']: # 排除 0 或空值 stats.setdefault(key, []).append(price) print(f"{'物料':<8}{'月份':<8}{'平均单价':>10}") for (mc, mon), prices in sorted(stats.items()): avg = float(sum(prices) / len(prices)) print(f"{mc:<8}{mon:<8}{avg:>10.2f}")这段代码里的:=是海象运算符,Python 3.8+ 及相应 Sage 版本都支持。setdefault(key, []).append(price)是分组累加的经典写法,比if key not in dict少一次查询。输出用格式化字符串,<8左对齐占 8 字符,>10右对齐占 10 字符,这是为了对齐输出,不是语法需要。
如果你是回归测试的老手,可能想问:为什么不直接groupby?因为dict的海象 +setdefault写法在 10 万行以下没有任何性能劣势,而且不依赖 pandas 的 DataFrame 类型——Sage 原生matrix对象继续往下算更流畅。
4.2 计算单价的波动率并定位异常供应商
采购里最常看的指标是“价格变异系数”:标准差除以平均值。变异系数超过 20% 基本可以标记为异常,要么是市场波动,要么是供应商报价不稳。
def cv(prices): n = len(prices) if n == 0: return 0.0 mean_v = float(sum(prices) / n) if mean_v == 0: return 0.0 var = sum((p - mean_v) ** 2 for p in prices) / (n - 1) return float(sqrt(var)) / mean_v # 对每个供应商计算月度单价序列的变异系数 from collections import defaultdict sup_prices = defaultdict(list) for row in rows: sup_prices[row['sup_name']].append(row['price']) flag = [] for sup, prices in sup_prices.items(): c = cv(prices) if c > 0.2: flag.append((sup, c)) print('波动率异常的供应商:', flag)说明:这里的标准差用(n - 1)做分母,是样本标准差,不是总体标准差。如果数据是全部采购记录而非抽样,用n即可;这里的rows是整年数据,严格讲应该用总体标准差。但因为月份数量通常只有 12 个,两种算法对排序结果影响很小。函数返回0.0的兜底逻辑:条目数为 0 或平均价为 0 时,无法计算变异系数,就标记为 0,表示“没有波动”而非“数据缺失”。
4.3 用 Sage 符号计算推导经济订货量(EOQ)
到这里把数学工具拔高一步。采购管理里的经济订货量公式:
Q* = sqrt(2DS / H)
D 是年需求量,S 是单次订货成本,H 是单位年持有成本。这个公式的推导是对总成本函数求导找极值点,Sage 的符号计算可以直接证明并帮你算参数灵敏度。
# 符号变量 D, S, H = var('D S H') Q = var('Q') # 总成本 = 订货成本 + 持有成本 TC = D * S / Q + Q * H / 2 # 对 Q 求导,令其为 0 dTC = diff(TC, Q) sol = solve(dTC == 0, Q) print('一阶条件:', sol) # 代数化简验证二阶导为正(确保是极小值) second = diff(TC, Q, 2) assume(H > 0, D > 0, S > 0) print('二阶导数符号:', second.simplify())执行后 Sage 会输出Q == -sqrt(2)*sqrt(D*S*H)/H或正负两个解,舍弃负根后就是标准 EOQ 公式。solve返回的是方程解列表,diff(TC, Q, 2)表示对 Q 求二阶导数,assume声明变量正号,让符号化简更干净。
要注意的是,EOQ 公式成立的前提之一是需求稳定,而用前面 4.2 节的变异系数可以检验这个前提。我的做法是:先在 Sage 里给每个品项算变异系数,小于 0.1 的用 EOQ 公式直接算订货量;大于 0.2 的不套公式,改用滚动平均加安全库存缓冲,避免理论落地时失真。
5. 把整个过程固化成脚本:从数据读到报表输出
5.1 一个小型验证:用矩阵的 det() 检查历史一致性
前面所有命令在交互环境里演示,最终交付还是要落成脚本。这里给一个独立可跑的.sage文件,把清洗、建模、一致性检查串起来,并在最后输出一份可读的报表。
# sage_po_report.sage import csv from collections import defaultdict def load_clean(path): rows = [] with open(path, newline='', encoding='utf-8') as f: for r in csv.DictReader(f): rows.append({ 'mcode': r['mcode'].strip(), 'sup_name': r['sup_name'].strip(), 'date': r['date'].strip(), 'qty': int(r['qty']) if r['qty'] else 0, 'price': float(r['price']) if r['price'] else 0.0, }) for i, r in enumerate(rows): if r['price'] == 0.0: rows[i]['price'] = None # 标记缺失,不参与计算 return rows def build_matrix(rows): pivot = defaultdict(float) for r in rows: if r['price'] is not None: pivot[(r['mcode'], r['date'][:7])] += r['qty'] * r['price'] mcodes = sorted({k[0] for k in pivot}) mons = sorted({k[1] for k in pivot}) M = matrix(RDF, len(mcodes), len(mons), lambda i, j: pivot.get((mcodes[i], mons[j]), 0.0)) return M, mcodes, mons def check(M, mcodes, mons): # 指定方阵子块并计算行列式,用于小规模一致性判断 if M.ncols() >= 2 and M.nrows() >= 2: sub = M.matrix_from_rows([0, 1]).matrix_from_columns([0, 1]) d = sub.det() print(f'行列式检查(前两物料 x 前两月): det = {d:.6f}') print(f'秩为 {M.rank()},矩阵形状 {M.nrows()}x{M.ncols()}') rows = load_clean('po_2024.csv') M, mcodes, mons = build_matrix(rows) check(M, mcodes, mons)逐段说明:load_clean里把price == 0.0的行标记为None,这与前面直接补 0 不同——在统计波动率时,0 会被当成一个真实的低价参与计算,导致变异系数虚高;用None排除后,统计只对真实报价有效。build_matrix与第 2 章类似,但金额是qty * price实时算的,不依赖台账里的 amount 字段,避免源数据里 amount 被手工改错。check里的matrix_from_rows([0,1]).matrix_from_columns([0,1])是先取前两行,再取前两列,构成 2×2 方阵,这是det()能计算的最小可行子阵。
5.2 输出的格式设计与异常标记
最终交付给采购部门时,不能只有 det 和秩,还要有看得懂的业务描述。下面这段把前面的统计结果汇总成一个文本报表:
# 接续上面的脚本 lines = [] lines.append(f"{'物料':<8}{'月份':<8}{'采购金额':>12}{'波动率':>10}") for mc in mcodes: for mon in mons: val = M[mc, mon] if mc in mcodes and mon in mons else 0 # 从原始 rows 中收集该物料该月份的价格序列 prices = [r['price'] for r in rows if r['mcode'] == mc and r['date'][:7] == mon and r['price'] is not None] vol = cv(prices) if prices else 0.0 marker = ' <--' if vol > 0.2 else '' lines.append(f"{mc:<8}{mon:<8}{val:>12.2f}{vol:>10.4f}{marker}") with open('po_report.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) print('已生成 po_report.txt')cv函数沿用 4.2 节的定义,此处不再重复。关键点:M[mc, mon]这种索引用法在 Sage 矩阵里是合法的,mc和mon是矩阵索引的整数位置,不是物料的字符串编码——所以前面必须用mcodes.index(mc)才能正确取值,但 Sage 的matrix支持整数索引,用mcodes.index(mc)获取位置即可。上面代码里用了简化写法,实际跑的时候要加mcodes.index(mc)和mons.index(mon),否则会索引越界。
收尾提一个我常用的验证技巧:把同一个 CSV 分别用 Sage 脚本和 Excel 透视表算一次金额总和与平均单价,两边对比。行列式的值、秩、特征值不必给业务看,但“总和一致”和“平均单价一致”这两个数是最容易让采购同事信服的。如果对不上,先在load_clean里加一行打印行数,确认 CSV 读入的行数与 Excel 的行数一致——绝大多数差异不是算法错,而是空行、BOM头和编码问题。
本文还有配套的精品资源,点击获取