构建可复用的野餐采购清单

PythonBeginner
立即练习

介绍

朋友们正在筹备野餐。两份食谱使用了部分相同食材,家里已有一些存货,而商店按整包出售。你的任务是完善一个可复用的 Python 程序,把这些资料转换成真正能用的采购清单。

你会先运行一个小型正常样例,再改进准备好的计算模块,生成完整 CSV。picnic.py 中预先提供的命令参数与 CSV 写入代码,以及合成食谱,均已准备。本实验要求你了解 Python 列表、字典、循环、函数、基本异常及基本终端输入;新的文件格式与选项会在这里讲解。预计时长包含阅读与常见错误处理,是作者估计,并非实测的初学者完成时间。

阅读一份小型野餐清单

本步骤将为一道沙拉生成三人份采购清单,并把每个 CSV 行与 JSON 输入对应起来。从 WebIDE 开始,打开其中的 Terminal 菜单,选择 New Terminal。本实验的所有命令都在该终端执行。准备好的文件不依赖之前的实验。

你需要了解 Python 列表、字典、循环、函数和基本异常。下文会解释 JSON 文件、CSV 写入以及这里使用的命令选项。这是整数数量练习:使用克(g)、毫升(ml)或个数(each),每个食材 ID 只使用一种单位。不进行单位换算或营养规划。

从正常的小样例开始

cd 切换当前目录。绝对路径以 / 开头;后续的相对路径(如 data/menu.json)相对于当前目录解析。如果切换目录后出现路径错误,用同一个 cd 命令回到这里。

cd /home/labex/project/picnic

cat 打印文本文件。阅读小菜单:

cat data/sample-menu.json

JSON 用 {} 表示对象(读取后是 Python 字典),用 [] 表示列表,并使用双引号包围字符串键。每道食谱都有一个 ingredients 对象:"chickpea": 70 表示每人 70 g,不是 70 包。chickpea 是稳定 ID,商品的 name 则是显示名称。两个商品可能同名,但并不可以互相替代。

cat data/products.json

商品表提供 name、unit 和正整数 pack_size。鹰嘴豆每包 200 g。小样例使用 data/empty-pantry.json,其中的空对象 {} 表示没有库存。其他已提供输入的数量都是非负整数。库存中缺少某个 ID 也表示零库存;商品缺失则是错误,因为其包装容量未知。

picnic.py 中预先提供的代码负责命令参数与 CSV 写入,使用 python3 运行。以 -- 开头的选项说明后一个值的含义:菜单、库存、商品表、正整数人数和输出 CSV 路径。--output 指定要创建或替换的文件,因此请使用这里给出的新文件名。源 JSON 文件保持不变。

python3 picnic.py --menu data/sample-menu.json --pantry data/empty-pantry.json --products data/products.json --people 3 --output sample.csv

预期消息:

Created 2 ingredient rows in sample.csv
cat sample.csv

表头说明各列名称。鹰嘴豆需求为 70 * 3 = 210 g。两包各 200 g,一共提供 400 g,剩余 190 g。番茄需求为 120 g,购买一包 250 g 后剩余 130 g。这个小样例能够正常运行,但共享菜单和现有库存会暴露初始程序的两个错误。

最终 CSV 每个本次需要的食材 ID 对应一行,包含这些列:ingredient_id,name,unit,required,stock,shortfall,packs,leftover。stock 是原始库存,不只是实际使用量。leftover 包含未用完的原库存。行列顺序不限,但每个字段与关系必须正确。保留 picnic.py、可复用的计算程序和 data/ 中的所有原始文件。可以使用等价的 Python 计算方式,但接口和输入输出格式保持一致。

按 ID 合并共享食材

本步骤先修复共享需求计算,再进行采购决策。继续使用同一个 WebIDE 终端。

cat data/menu.json

沙拉每人使用 70 g 鹰嘴豆,卷饼每人使用 40 g。五人份总量应为 (70 + 40) * 5 = 550 g。为每道食谱分别购买可能造成多买包装。先合并所有需求,再只抵扣一次库存,并只对剩余缺口向上取整一次。

cat calculations.py

初始 total_requirements 每次都把 amount * people 赋给该 ID。当第二道食谱使用同一个 ID 时,就会覆盖第一道食谱的需求。totals.get(ingredient_id, 0) 则读取已累计的总量,ID 首次出现时返回零。将下一道食谱按人数放大的用量加到这个值上。

替换小型计算模块

下面的命令写入完整模块,保证缩进与函数边界清楚。cat > calculations.py 将文本重定向到该文件,替换原内容。<<'PYCODE' 开始一个 here-document:粘贴全部文本,直到最后单独一行的 PYCODE。为分隔符加引号可防止 shell 解释 Python 文本。此操作只替换 calculations.py,不会修改 JSON 和 CSV。若有拼写错误,可重新执行完整代码块。Python 缩进有意义,请保留每级四个空格。

cat > calculations.py <<'PYCODE'
"""The two calculation functions you will improve."""


def total_requirements(menu, people):
    totals = {}
    for recipe in menu["recipes"]:
        for ingredient_id, amount in recipe["ingredients"].items():
            totals[ingredient_id] = totals.get(ingredient_id, 0) + amount * people
    return totals


def shopping_rows(totals, pantry, products):
    rows = []
    for ingredient_id, required in totals.items():
        product = products[ingredient_id]
        size = product["pack_size"]
        packs = (required + size - 1) // size
        rows.append({"ingredient_id": ingredient_id,
                     "name": product["name"], "unit": product["unit"],
                     "required": required, "stock": 0,
                     "shortfall": required, "packs": packs,
                     "leftover": packs * size - required})
    return rows
PYCODE

目前只修正了需求汇总。生成主要的五人份清单:

python3 picnic.py --menu data/menu.json --pantry data/pantry.json --products data/products.json --people 5 --output five.csv
cat five.csv

现在鹰嘴豆的 required 必须为 550,番茄需求为 200,卷饼需求为 5。stock 列仍为零,因为第二个函数尚未处理库存。这份中间清单还不能用于采购。

先使用库存,再按整包购买

本步骤完成采购计算,读懂已经考虑家中存货的清单。继续在 WebIDE 终端操作。

cat data/pantry.json

五人份需要 550 g 鹰嘴豆,库存为 150 g,因此缺口是 400 g。两包各 200 g 正好补足。番茄需求为 200 g,但已有 250 g:缺口与购买包数都为零,并且还剩 50 g。卷饼需求为 5,库存为 2,缺口 3 需要购买一包 6 个,最终剩余 3 个。

使用 max(0, required - stock),避免库存富余产生负的购买量。对于非负整数缺口 s 和正包装容量 p,(s + p - 1) // p 将购买量向上取整为整包;// 是整数向下取整除法。每包 200 g 时,缺口 0 对应 0 包,200 对应 1 包,201 对应 2 包。这样无需浮点运算就能正确处理边界。

最终剩余量为 stock + packs * pack_size - required。如果只计算 packs * pack_size - shortfall,当库存超过需求时,就会漏掉未使用的库存。

用两个已完成的函数替换模块。here-document 的操作与上一步相同。

cat > calculations.py <<'PYCODE'
"""Calculate requirements before deciding what to buy."""


def total_requirements(menu, people):
    totals = {}
    for recipe in menu["recipes"]:
        for ingredient_id, amount in recipe["ingredients"].items():
            totals[ingredient_id] = totals.get(ingredient_id, 0) + amount * people
    return totals


def shopping_rows(totals, pantry, products):
    rows = []
    for ingredient_id, required in totals.items():
        product = products[ingredient_id]
        stock = pantry.get(ingredient_id, 0)
        shortfall = max(0, required - stock)
        size = product["pack_size"]
        packs = (shortfall + size - 1) // size
        rows.append({"ingredient_id": ingredient_id,
                     "name": product["name"], "unit": product["unit"],
                     "required": required, "stock": stock,
                     "shortfall": shortfall, "packs": packs,
                     "leftover": stock + packs * size - required})
    return rows
PYCODE
python3 picnic.py --menu data/menu.json --pantry data/pantry.json --products data/products.json --people 5 --output five.csv
cat five.csv

预期内容如下(等价实现的顺序可以不同):

ingredient_id,name,unit,required,stock,shortfall,packs,leftover
chickpea,Chickpeas,g,550,150,400,2,0
tomato,Tomatoes,g,200,250,0,0,50
wrap,Wraps,each,5,2,3,1,3

这是一份可用的采购清单:买两包鹰嘴豆、不买番茄、买一包卷饼。保留零采购行,让读者能区分“库存已足够”和“意外漏掉食材”。只有本菜单需要的 ID 才需要行;无关的库存项目无需输出。

复用程序并从输入错误中恢复

本步骤将帮助你理解 picnic.py 中预先提供的文件写入代码,将计算用于另一份菜单,并从普通错误中恢复。继续在 WebIDE 终端操作。不需要变量或之前的 shell 会话,每个命令都显式传入输入路径。

cat picnic.py

argparse 读取具名选项并提供 --help;type=int 拒绝非整数人数,接下来的检查拒绝零与负数。load_json 使用 open(..., encoding="utf-8") 和 json.load 读取完整结构化对象。with 代码块结束时会关闭文件。计算模块返回由行字典组成的列表。

程序先处理全部输入并完成计算,再打开输出文件。缺失商品检查把需求 ID 与商品 ID 比较;缺少包装资料时引发 ValueError。csv.DictWriter 按列名写入表头与各行。newline="" 让 CSV 模块处理换行,UTF-8 保留名称。不要用逗号拼接替代 CSV 写入:未来商品名中包含逗号时,仍应保留为一个字段。

picnic.py 中预先提供的代码捕获文件、JSON/值和缺键错误,向标准错误输出解释性消息,并以非零状态退出。它不保证磁盘写入中断时的保护,但人数非法或缺少商品资料必须保持已有输出不变。

阅读预期失败并恢复

下面两个命令会有意失败。出现错误是预期结果,终端仍可继续使用。第一个命令指定零人,应在替换 five.csv 前报告 people must be a positive integer。

python3 picnic.py --menu data/menu.json --pantry data/pantry.json --products data/products.json --people 0 --output five.csv

第二个命令把菜单文件名拼错。预期得到 Cannot make list: 和缺少文件的消息,而不是一份成功的空清单。检查 data/ 中的名称,再用正确路径重跑,即可恢复。

python3 picnic.py --menu data/menus.json --pantry data/pantry.json --products data/products.json --people 5 --output five.csv
ls data

missing-products.json 是刻意准备的不完整商品表,缺少菜单需要的商品。下个命令应报告 missing product:,并保留已有 five.csv。未知包装资料不等于零库存或零采购。

python3 picnic.py --menu data/menu.json --pantry data/pantry.json --products data/missing-products.json --people 5 --output five.csv
cat five.csv

成功的五人份清单仍应与上一步一致。选择完整的商品文件来恢复,不要更改原始输入数据。

制作另一份采购清单

cat data/other-menu.json

这份菜单只有番茄和卷饼。四人份需要 200 g 番茄和 8 个卷饼。同一个程序应使用原库存,不应保留上次运行中的其他食材。

python3 picnic.py --menu data/other-menu.json --pantry data/pantry.json --products data/products.json --people 4 --output other.csv
cat other.csv

番茄无需购买,剩余 50 g。卷饼缺口为 6,正好买一包,且无剩余。不应出现鹰嘴豆行。你的 five.csv、sample.csv、程序和原始 JSON 文件仍然保留。换一份菜单并使用新输出名,可以同时保留两份采购计划。检查会读取真实输出,并在临时输入案例上运行原样程序,不会生成或修复你的现场清单。

总结

你按食材 ID 合并了每人份食谱需求,只抵扣一次库存,并且只对剩余缺口按整包取整。你生成了真实 CSV,保留了有意义的零采购行,用同一程序处理另一份菜单,并从非法输入中恢复而没有替换正确结果。