Python实现小店进销存:用移动加权平均法自动生成利润表 小店进销存系统做到后面真正考验设计能力的往往不是库存数量显示得准不准而是利润表能不能自动算对。很多小店老板到现在还在月底手工算账这个月卖了多少钱很好统计难的是每一笔货的成本。上个月进货 3 元一件这个月进货 3.5 元一件那已经卖掉的那一批到底按哪个价格算成本如果你也在做类似的小店管理系统或者正准备给家里的小生意写一套管理工具这篇文章会从数据模型、成本算法、Python 实现到利润表生成完整走一遍。这篇文章会实现一个最小可用的进销存系统商品管理、采购入库、销售出库、库存维护、利润表生成。代码不依赖第三方库只用 Python 标准库自带的 sqlite3 就能跑通。内容适合刚接触业务系统的开发者也适合想把手动 Excel 记账改成系统的店主参考。核心结论先放在这里想让利润表不再手算关键不是写一条复杂的报表 SQL而是先在销售明细里把“这批货卖出去时的成本”固定下来。1. 先理解手工利润表为什么难算核心问题是销售成本1.1 收入很好统计成本才是难点小店每天的流水可以这样描述卖了多少件、单价多少、收了多少钱。收入是明确的因为每一笔销售都有成交价和数量。真正模糊的是成本。同一个商品不同批次进货价很可能不一样。今天进的苹果 3 元一斤下个星期变成 3.5 元一斤那么现在卖掉的一斤苹果成本到底是 3 元还是 3.5 元如果店里有三种商品、每个月有几十张采购单和几百条销售记录手工要按“每一笔卖出时实际对应的进价”去算基本不现实。实际经营中手工记账常见的做法有几种手工做法利润结果问题用最后一次进货价算所有成本利润可能虚高之前低价进的库存被忽略用第一次进货价算所有成本利润可能虚低后续涨价成本没体现月底倒推“本月收入减本月采购支出”利润严重失真采购不等于销售库存变化没考虑凭感觉估一个成本每次结果都不一样无法复核也无法对账所以进销存系统要解决的第一个问题不是“能不能显示利润”而是“用什么口径计算销售成本”。口径不定义清楚报表做得再好看也是错的数字。1.2 移动加权平均法利润计算的推荐口径小店场景下最合适的成本口径通常是“移动加权平均法”。它的规则很简单每次采购入库后重新计算一次该商品的平均成本。计算公式新的平均成本 (当前库存数量 × 当前平均成本 本次采购数量 × 本次采购单价) / (当前库存数量 本次采购数量)销售出库时按当前的平均成本结转销售成本。平均成本只在采购入库时变化销售出库只减少数量、不改变平均成本。举个例子。苹果第一次进货 100 斤单价 3 元平均成本就是 3 元。卖出 30 斤销售成本按 3 元算库存剩 70 斤平均成本仍是 3 元。此时再进货 50 斤单价 3.5 元新的平均成本是(70 × 3 50 × 3.5) / (70 50) 385 / 120 ≈ 3.2083 元之后再卖出成本就按 3.2083 元结转。移动加权平均的优点是小店容易理解、计算稳定、不会因为库存批次太少而无法处理缺点是价格波动大时成本会比最新进价滞后。它有保质期的商品可以考虑先进先出法FIFO但作为一套通用小店系统移动加权平均足够覆盖大多数场景。1.3 自动化的目标从“记流水”升级成“出报表”手工记账的本质是流水记录今天进了什么、今天卖了什么。自动化的目标是让系统在流水之上直接产出三个结果实时库存余额、按商品汇总的毛利、按时间段汇总的利润表。要做到这一点系统必须完成三条数据链路采购单录入时更新库存数量同时重算移动平均成本。销售单录入时把“当前平均成本”写入销售明细作为这一笔的历史成本快照。报表查询时用销售明细中的售价和成本快照汇总算出收入、成本和毛利。人工只需要录入采购单和销售单剩下的加权平均计算、库存扣减、利润汇总全部交给程序。这就是“利润表不用自己算了”的技术本质。2. 数据模型设计把进销存变成可以计算的表结构2.1 六张核心表和它们的分工实现这套逻辑只需要六张表。它们分别承担商品主数据、采购、销售、库存余额四种职责。表名职责关键字段products商品主数据sku、name、category、unit、sale_pricepurchase_orders采购单头order_no、supplier、order_datepurchase_items采购明细order_id、product_id、quantity、unit_costsales_orders销售单头order_no、customer、order_datesales_items销售明细order_id、product_id、quantity、price、cost_priceinventory实时库存余额product_id、quantity、avg_cost表之间的关系如下purchase_orders 与 purchase_items 是一对多sales_orders 与 sales_items 是一对多products 与 inventory 是一对一inventory 只保存每个商品当前的库存数量和当前平均成本。这里有一个容易忽略的设计点purchase_items 只保存采购数量和采购单价不承担实时成本计算sales_items 保存售价的同时还要保存 cost_price 字段记录卖出那一刻的移动平均成本。2.2 关键设计在销售明细里快照成本价这是整套系统最核心的设计决定。如果只把平均成本存在 inventory 表里销售时实时读取那么一旦后续有新的采购入库平均成本就会变化。这时再去查历史销售会发现历史利润也跟着变了。进了一批价格不同的货上个月的利润数字却改变了这在财务上完全不可接受。解决办法就是“快照”销售出库发生的那一刻把当时的 avg_cost 复制到 sales_items.cost_price 字段。之后无论采购价格怎么变历史销售的成本都保持不变。利润表只依赖 sales_items 表里的 cost_price不再回头读 inventory 表的实时平均成本。这个原则在系统后续扩展时也要守住历史单据不允许重算成本新单据只按新的平均成本记账。2.3 SQLite 建表脚本下面的脚本可以直接保存为 db.py。SQLite 是 Python 自带的标准库不需要额外安装数据库服务。import sqlite3 DB_PATH shop.db def get_conn(db_pathDB_PATH): conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row # SQLite 默认不启用外键约束这里必须手动打开 conn.execute(PRAGMA foreign_keys ON) return conn def init_db(conn): conn.executescript( CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku TEXT UNIQUE NOT NULL, name TEXT NOT NULL, category TEXT DEFAULT , unit TEXT DEFAULT 件, sale_price REAL NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS purchase_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, supplier TEXT DEFAULT , order_date TEXT NOT NULL, remark TEXT DEFAULT ); CREATE TABLE IF NOT EXISTS purchase_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity REAL NOT NULL, unit_cost REAL NOT NULL, FOREIGN KEY (order_id) REFERENCES purchase_orders(id), FOREIGN KEY (product_id) REFERENCES products(id) ); CREATE TABLE IF NOT EXISTS sales_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, customer TEXT DEFAULT , order_date TEXT NOT NULL, remark TEXT DEFAULT ); CREATE TABLE IF NOT EXISTS sales_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity REAL NOT NULL, price REAL NOT NULL, cost_price REAL NOT NULL, FOREIGN KEY (order_id) REFERENCES sales_orders(id), FOREIGN KEY (product_id) REFERENCES products(id) ); CREATE TABLE IF NOT EXISTS inventory ( product_id INTEGER PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_cost REAL NOT NULL DEFAULT 0, FOREIGN KEY (product_id) REFERENCES products(id) ); ) conn.commit()建表脚本里有三个细节要注意。第一order_no 设置了 UNIQUE这是防止重复单号的最底层防线。第二inventory 表用 product_id 做主键保证一个商品只对应一条库存余额记录。第三开了 PRAGMA foreign_keys让 SQLite 真正检查外键约束避免出现“销售明细指向不存在的商品”这类脏数据。关于数量字段称重类商品数量可能是小数所以这里统一使用 REAL。按件销售的纯整数商品也可以使用 INTEGER但为了简化类型处理示例统一用 REAL。3. Python 实现核心逻辑入库、出库和库存维护3.1 环境准备和项目结构运行环境只需要 Python 3.8 及以上版本sqlite3 是标准库不需要 pip 安装任何第三方包。建议按下面的目录组织文件shop-psi/ ├── db.py # 连接、建表 ├── core.py # 商品、采购、销售、库存核心逻辑 ├── report.py # 利润表查询 ├── demo.py # 造数并运行验证 └── shop.db # 运行后自动生成下面开始实现 core.py。所有函数都接收 conn 作为第一个参数这样方便在调用方统一控制事务也方便以后替换数据库连接方式。3.2 采购入库更新库存并重算移动平均成本采购入库要完成两件事写入采购单和采购明细更新 inventory 表的库存数量和平均成本。如果商品还没有库存记录直接以本次采购单价作为平均成本。如果已经有库存记录按移动加权平均公式重算。# core.py def add_product(conn, sku, name, category, unit件, sale_price0): conn.execute( INSERT INTO products (sku, name, category, unit, sale_price) VALUES (?, ?, ?, ?, ?) , (sku, name, category, unit, sale_price), ) conn.commit() def purchase_in(conn, order_no, supplier, order_date, items): cur conn.cursor() cur.execute( INSERT INTO purchase_orders (order_no, supplier, order_date) VALUES (?, ?, ?) , (order_no, supplier, order_date), ) order_id cur.lastrowid for item in items: product_id item[product_id] quantity item[quantity] unit_cost item[unit_cost] cur.execute( INSERT INTO purchase_items (order_id, product_id, quantity, unit_cost) VALUES (?, ?, ?, ?) , (order_id, product_id, quantity, unit_cost), ) inv cur.execute( SELECT quantity, avg_cost FROM inventory WHERE product_id ?, (product_id,), ).fetchone() if inv is None: # 首次入库平均成本就是本次采购单价 cur.execute( INSERT INTO inventory (product_id, quantity, avg_cost) VALUES (?, ?, ?) , (product_id, quantity, unit_cost), ) else: old_qty inv[quantity] old_avg_cost inv[avg_cost] new_qty old_qty quantity new_avg_cost (old_qty * old_avg_cost quantity * unit_cost) / new_qty # 平均成本保留 4 位小数减少金额累加误差 cur.execute( UPDATE inventory SET quantity ?, avg_cost ? WHERE product_id ? , (new_qty, round(new_avg_cost, 4), product_id), ) conn.commit()这里的关键行为是 new_avg_cost 的计算。它不是在原来平均成本上简单取平均值而是用“库存金额”作为权重原来的库存金额加上新采购金额除以总数量。这样先进的价格和新的价格都会按数量占比影响最终成本符合业务直觉。3.3 销售出库扣减库存并写入本次成本销售出库的流程比采购多一个校验必须先确认当前库存足够否则直接回滚不允许出现负库存。扣减库存时要把当前的 avg_cost 写入销售明细的 cost_price。这个动作就是前面说的“成本快照”。# core.py def sale_out(conn, order_no, customer, order_date, items): cur conn.cursor() cur.execute( INSERT INTO sales_orders (order_no, customer, order_date) VALUES (?, ?, ?) , (order_no, customer, order_date), ) order_id cur.lastrowid for item in items: product_id item[product_id] quantity item[quantity] price item[price] inv cur.execute( SELECT quantity, avg_cost FROM inventory WHERE product_id ?, (product_id,), ).fetchone() if inv is None or inv[quantity] quantity: conn.rollback() current_qty 0 if inv is None else inv[quantity] raise ValueError( f库存不足product_id{product_id}需要{quantity}现有{current_qty} ) # 关键把当前平均成本快照到销售明细 cur.execute( INSERT INTO sales_items (order_id, product_id, quantity, price, cost_price) VALUES (?, ?, ?, ?, ?) , (order_id, product_id, quantity, price, inv[avg_cost]), ) cur.execute( UPDATE inventory SET quantity quantity - ? WHERE product_id ? , (quantity, product_id), ) conn.commit()注意 sale_out 里调用了 conn.rollback() 再抛出异常。这是因为一张销售单可能包含多个商品如果后面的商品库存不足前面的写入也必须全部撤销。不这样做就会留下只有半张单的脏数据。3.4 为什么必须用事务保护出入库采购和销售本质上都是“一张单据 多条明细 一次库存变动”的组合操作。任何一步失败单据和库存就可能不一致。Python 的 sqlite3 默认在一个连接上开启隐式事务commit 之前的所有写操作都属于同一个事务。上面代码里出现异常时执行 rollback能把订单头、订单明细和库存变更全部回滚。这个设计在正式数据库里同样成立只是底层事务管理换成了框架能力。注意不要为了省事把一条销售拆分成多次独立的 commit。比如先插入销售单再单独更新库存一旦更新库存时程序崩溃就会出现“已收款但没扣库存”的情况。4. 利润表生成从销售明细汇总到商品维度和时间维度4.1 用一条 SQL 按商品汇总利润有了销售明细里的 price 和 cost_price利润汇总就变成纯粹的 SQL 聚合。单笔销售的毛利公式毛利 (售价 - 成本价) × 销售数量按商品汇总时对每个商品累加销售收入、销售成本和毛利。# report.py def profit_statement(conn, start_date, end_date): rows conn.execute( SELECT p.id, p.sku, p.name, SUM(si.quantity) AS sale_quantity, ROUND(SUM(si.price * si.quantity), 2) AS sale_amount, ROUND(SUM(si.cost_price * si.quantity), 2) AS sale_cost, ROUND(SUM((si.price - si.cost_price) * si.quantity), 2) AS gross_profit FROM sales_items si JOIN sales_orders so ON si.order_id so.id JOIN products p ON si.product_id p.id WHERE so.order_date BETWEEN ? AND ? GROUP BY p.id, p.sku, p.name ORDER BY gross_profit DESC , (start_date, end_date), ).fetchall() return rows这段 SQL 的关键是只从 sales_items 取成本完全不碰 inventory 表。因为成本已经在销售时被快照报表查询是确定性的同一条销售记录任何时候查收入和成本都一样。4.2 按月份汇总满足“月底看利润”场景如果要做月度利润表可以改成按 strftime 格式化订单日期后分组。日期范围的起始值建议统一包含边界例如 2025-01-01 到 2025-01-31。SELECT strftime(%Y-%m, so.order_date) AS month, COUNT(DISTINCT so.id) AS order_count, ROUND(SUM(si.quantity), 2) AS sale_quantity, ROUND(SUM(si.price * si.quantity), 2) AS sale_amount, ROUND(SUM(si.cost_price * si.quantity), 2) AS sale_cost, ROUND(SUM((si.price - si.cost_price) * si.quantity), 2) AS gross_profit FROM sales_items si JOIN sales_orders so ON si.order_id so.id GROUP BY month ORDER BY month;这条 SQL 适合在后台管理页面按月展示也适合后续导出成 Excel 给店主对账。注意 month 字段的边界如果存在跨天补录的单据要统一以 order_date 的业务日期为准而不是录入时间。4.3 输出格式与调用方式profit_statement 返回的是 sqlite3.Row 列表。每个元素可以通过字段名访问例如 r[sku]、r[gross_profit]。这样在 Web 项目里可以直接转成 JSON在脚本项目里可以直接打印成文本表格。5. 造一组模拟数据验证利润表5.1 模拟数据场景为了让验证有说服力造一组能覆盖“多次进货、价格变化、跨日期销售”的数据商品苹果A001、牛奶A002苹果分两次进货第一次 3 元第二次 3.5 元中间有销售结果应该体现移动加权平均牛奶只有一次进货、一次销售结果应该简单可验算# demo.py from db import get_conn, init_db from core import add_product, purchase_in, sale_out from report import profit_statement def main(): conn get_conn() init_db(conn) # 商品 add_product(conn, A001, 苹果, 水果, 斤, 5.0) add_product(conn, A002, 牛奶, 乳品, 盒, 3.5) # 采购 purchase_in( conn, PO20250105001, 果农合作社, 2025-01-05, [{product_id: 1, quantity: 100, unit_cost: 3.00}], ) purchase_in( conn, PO20250106001, 乳品批发, 2025-01-06, [{product_id: 2, quantity: 60, unit_cost: 2.00}], ) purchase_in( conn, PO20250111001, 果农合作社, 2025-01-11, [{product_id: 1, quantity: 50, unit_cost: 3.50}], ) # 销售 sale_out( conn, SO20250108001, 散客, 2025-01-08, [{product_id: 1, quantity: 30, price: 5.00}], ) sale_out( conn, SO20250109001, 便利店, 2025-01-09, [{product_id: 2, quantity: 20, price: 3.50}], ) sale_out( conn, SO20250112001, 散客, 2025-01-12, [{product_id: 1, quantity: 40, price: 5.50}], ) # 利润表 rows profit_statement(conn, 2025-01-01, 2025-01-31) print(SKU 商品 数量 销售收入 销售成本 毛利) for r in rows: print( f{r[sku]:7}{r[name]:5} f{r[sale_quantity]:5.0f}{r[sale_amount]:10.2f} f{r[sale_cost]:10.2f}{r[gross_profit]:10.2f} ) if __name__ __main__: main()运行命令python demo.py5.2 运行结果正常情况下终端输出的利润表应该是SKU 商品 数量 销售收入 销售成本 毛利 A001 苹果 70 370.00 218.33 151.67 A002 牛奶 20 70.00 40.00 30.00合计销售收入 440.00 元销售成本 258.33 元毛利 181.67 元。5.3 手工验算一遍按移动加权平均逐步推导苹果的数据时间动作数量单价库存数量平均成本01-05采购1003.001003.000001-08销售-305.00703.000001-11采购503.501203.208301-12销售-405.50803.2083苹果的销售收入30 × 5.00 40 × 5.50 370.00 元。苹果的销售成本30 × 3.00 40 × 3.2083 90 128.33 218.33 元。苹果的毛利370.00 - 218.33 151.67 元。牛奶的验算60 盒进价 2 元卖出 20 盒成本 40 元收入 70 元毛利 30 元。结果与程序输出一致。注意这里 3.2083 是平均成本保留 4 位小数后的值。40 × 3.2083 等于 128.332展示时四舍五入为 128.33。这个舍入位置要和业务约定一致避免对账时出现 0.01 元的差异。5.4 库存余额验证利润表正确还不够还要确认库存余额没有丢数据。执行下面的 SQL 查看实时库存def inventory_snapshot(conn): rows conn.execute( SELECT p.sku, p.name, i.quantity, i.avg_cost, ROUND(i.quantity * i.avg_cost, 2) AS stock_amount FROM inventory i JOIN products p ON i.product_id p.id ORDER BY p.sku ).fetchall() return rows预期结果SKU商品库存数量平均成本库存金额A001苹果803.2083256.66A002牛奶402.0080.00如果这一步数字也对说明采购入库、销售出库、库存扣减三条链路都正确。6. 利润数字对不上时的排查路径6.1 从报表倒推收入、成本、库存三层核对利润表出错时不要直接改 SQL 或改数据。按下面的顺序逐层核对先核对收入。把报表里的 sale_amount 与微信/支付宝流水、收款记录比对确认销售明细没有漏单、重单。再核对成本。把 sale_cost 与销售数量、成本价相乘的结果比对重点看是不是某个商品的 cost_price 异常。最后核对库存。把 inventory 的数量与盘点表比对如果库存多了或少了利润一定有问题。这三个数字是联动的库存出错成本必出错成本出错毛利必出错。定位到某一层后再用 SQL 缩小范围。6.2 排查日常用 SQL 清单把下面这几条 SQL 存成固定的排查脚本遇到问题先跑一遍。-- 1. 查找负库存这是最常见的成本异常源头 SELECT p.sku, p.name, i.quantity FROM inventory i JOIN products p ON i.product_id p.id WHERE i.quantity 0;-- 2. 查找成本价为 0 的销售明细 SELECT so.order_no, so.order_date, p.sku, p.name, si.quantity, si.price, si.cost_price FROM sales_items si JOIN sales_orders so ON si.order_id so.id JOIN products p ON si.product_id p.id WHERE si.cost_price 0;-- 3. 查找重复单号 SELECT order_no, COUNT(*) AS cnt FROM sales_orders GROUP BY order_no HAVING cnt 1;-- 4. 对比采购总数量和销售总数量 SELECT p.sku, p.name, (SELECT COALESCE(SUM(quantity), 0) FROM purchase_items WHERE product_id p.id) AS purchase_qty, (SELECT COALESCE(SUM(quantity), 0) FROM sales_items WHERE product_id p.id) AS sale_qty FROM products p;6.3 常见问题与处理方案问题现象常见原因检查方式处理建议利润明显偏高库存不足时先卖后补导致成本按 0 或按售价计查负库存、查 cost_price0出库前强校验库存禁止负库存出库修改历史采购价后历史利润跟着变没有做成本快照重复读实时 avg_cost比对销售明细的 cost_price 与出单当日成本销售时写死 cost_price历史单据不重算退货处理错误导致库存和利润同时乱直接把原销售记录删除或改数量查 sales_items 是否存在负数或整体缺失单独建销售退货表用负数明细冲销月底对账总差几分钱浮点数累加误差逐商品手工验算舍入位置金额用整数分存储只在展示层四舍五入某些商品没有出现在利润表该商品有采购但从未销售查销售明细是否漏录增加 0 销量商品的默认汇总便于发现问题第五条“某些商品没有出现在利润表”在实际运作中很常见。报表只聚合 sales_items有采购但没卖出的商品自然不会出现。如果目的是看整体经营情况这是对的如果目的是盘点“哪些商品动销、哪些不动销”需要另外写基于 products 的左连接查询。7. 从演示系统到真正可用的进销存系统7.1 学习环境与生产环境的差异本文的示例可以快速跑通但它只适合学习和小规模自用。真实小店系统至少要在下面几方面升级维度学习环境生产环境数据库SQLite 单文件MySQL / PostgreSQL支持并发和权限金额类型REAL 浮点DECIMAL(12,2) 精确小数操作入口命令行函数Web 界面或桌面客户端并发控制单连接隐式事务悲观锁、乐观锁或事务隔离级别数据安全无备份自动备份、恢复演练、操作审计对账手工核对每日自动对账异常告警如果在生产环境继续使用 REAL 存金额累加几十万条记录后出现误差几乎是必然的。推荐的稳妥做法是数据库字段用 DECIMAL(12,2)应用层使用 Decimal 类型只在 JSON 输出或表格展示时转成 float 或格式化字符串。7.2 退货、调价和盘点三个绕不开的扩展小店经营中退货是常态但退货不能直接删原销售记录否则会破坏收入、成本和库存三条链路的对账基础。建议单独建一张 sales_return_items 表用负数量正向记录冲销。调价要区分两种情况已经在售但未卖完的库存调价只影响未来售价采购价变动走采购入库的移动加权平均自动处理。不要直接去改 inventory.avg_cost 字段那样会破坏已卖出记录的成本快照。盘点差异也要单独记录。盘点时发现库存多了或少了不能直接改 inventory 表应该生成盘点单标明盈亏数量和原因由程序生成库存调整记录并在利润表里单独展示“盘盈盘亏”行。7.3 扩展方向FIFO、批次、多门店如果商品有保质期移动加权平均就不够用了。先进先出FIFO需要额外引入批次表每一步销售都要先匹配最早批次的剩余数量并记录本次销售消耗了哪个批次。数据结构上需要给采购明细增加批次号、入库日期、有效期字段销售明细增加批次关联字段。多门店场景则要引入仓库表和调拨表。库存余额表从 product_id 做主键改成 product_id warehouse_id 联合主键采购、销售、调拨都基于仓库维度记账。利润表汇总时可以选择按门店过滤或按门店分组。7.4 上线前检查清单不管系统规模多大上线前建议过一遍下面的清单商品主数据完整每个商品有唯一 SKU、默认售价、单位停用商品只下架不删除。成本口径明确移动加权平均还是 FIFO在代码注释和操作手册里都要写明。历史成本不可变销售明细的 cost_price 保存快照任何新采购不得影响历史利润。库存校验生效销售出库和采购入库在同一个事务内完成失败整体回滚。对账流程落地每天核对销售收入、销售成本、采购支出、库存盘点四个数字。备份可恢复SQLite 每天复制文件正式数据库做自动备份并演练恢复。操作有审计记录谁在什么时间改了哪张单据避免无法追溯的改数。金额精度可控生产环境不使用浮点类型统一为精确小数类型。回到最开始的问题利润表为什么不用自己算了核心不是写一条 SQL而是先定义清楚成本口径再把成本快照保存到每一笔销售明细里最后用确定性的查询汇总出不会变来变去的利润数字。把这一点想明白小店进销存系统的主干就稳了。下一步可以继续做退货单、盘点单、批次保质期和多门店但无论怎么扩展销售成本快照这个原则都不要丢掉。