装修进行到瓦工阶段,你会发现一个反直觉的事实:最贵的不是瓷砖,是被切坏和找不到的那部分。
一块 800×800 的砖 60 块一平,一个卫生间墙面要切 30 多刀。切下来的边角料,师傅顺手码在阳台角落;三个月后补一块 300×600 的踢脚线,没人记得哪块能对上花色,于是再开一箱新砖。等装修结束,阳台上堆着几十块”以后可能有用”的砖,最后一起被清运车拉走。
这篇文章记录我如何把这件事做成一个能用的手机 App:按尺寸匹配、按真实切割扣减、把余料(包括 L 形异形)继续入库,并且每一片砖都能追回原砖。
项目地址(GPL-3.0):https://github.com/z7ui/tile-remnant-inventory
一、先把问题定义清楚
和几位师傅聊完,需求收敛成四句话:
- 录入要快:手上是灰、戴着线手套,输尺寸不能超过三步。
- 匹配要准:说 800×600,就得告诉我哪块砖能切、怎么切、切完剩什么。
- 余料要留:切下来的不是垃圾,是下一块砖;但异形余料也得能存。
- 账要能查:三个月后要知道”这块 400×600 的砖是哪天从哪块砖上切下来的”。
这四条决定了技术选型:移动端 Flutter(一套代码出 APK,工地不需要应用商店),后端 FastAPI + SQLAlchemy,数据库默认 SQLite(挂在用户自己的博客云主机上,零额外成本),需要时切 PostgreSQL。
二、尺寸这件事,比想象中容易错
第一个坑是单位。师傅会说”八十”,意思是 80 厘米;图纸写 800;意大利砖的包装上印 80×80。如果 App 里让用户选单位再输数字,现场一定会有人输成 800 厘米。
做法是:输入层宽容,存储层严格。
- 输入
800、80cm、800 mm、80.5cm都能解析; - 项目有默认单位,纯数字按默认单位解释;
- 解析后一律转成整数毫米存库;
- 库存和需求都规范成
(短边, 长边),用户不需要先想清楚哪条是长边。
# app/units.py
mm = int((value * UNIT_FACTOR[unit]).to_integral_value(rounding="ROUND_HALF_UP"))
用 Decimal 而不是 float,因为 80.1 * 10 在二进制浮点里是 800.9999999999999,四舍五入后变成 800,少 1 毫米——切砖时这 1 毫米就是缝。
三、匹配:不是”找一块更大的”,而是”找一块切完最不浪费的”
匹配的第一条规则很朴素:需求短边 ≤ 库存短边、需求长边 ≤ 库存长边,允许旋转 90°。第二条才是关键:需求必须被完全覆盖,不允许有缝。900×800 的砖能满足 800×900,但 801×901 直接拒绝,不给”差不多”的候选。
排序也不是简单按面积:
浪费面积升序
→ 余料可再利用价值降序
→ 单块可切片数降序
→ 录入时间 / 批次号升序
为什么第二项重要?假设有两块砖都能切出 600×600:
- A:900×800,切完剩一条 300×800(还能切踢脚线)
- B:700×650,切完剩一条 100×650(基本是废料)
只按”浪费面积”排,B 可能更优;但师傅真正在意的是”剩下的还能不能用”。所以余料价值 = 余料面积 × 形状系数(异形 ×0.75,低于最小可用阈值 ×0.4)。
四、切割:两种切法,四种结果
现场只有两种切法,必须让用户选,而不是替他们决定:
- 长边切到底:需求长边与原砖长边平行,刀路沿长边贯穿;
- 短边切到底:旋转 90°,刀路沿短边方向贯穿。
选定之后,结果只有四种:
| 情况 | 余料 |
|---|---|
| 需求 = 整砖 | 无余料,跳过余料处置 |
| 需求宽 = 库存宽 | 矩形条 |
| 需求高 = 库存高 | 矩形条 |
| 都不相等 | L 形(一个角被切掉) |
这里有个容易忽略的产品点:余料处置必须让用户选。直接入库会凭空多出一块可能永远用不上的砖,丢弃又可能浪费。所以确认页是两个图示卡片:”直接入库”(余料高亮)/”丢弃”(余料灰化 + 垃圾桶)。选丢弃也写流水——账要留住,库存不留。
L 形的建模没有用多边形布尔运算,而是用矩形并集:
{
"type": "l_shape",
"outer": {"w": 800, "h": 900},
"notch": {"x": 600, "y": 600, "w": 200, "h": 300},
"notch_corner": "top_right",
"rects": [{"x":0,"y":0,"w":800,"h":600}, {"x":0,"y":600,"w":600,"h":300}]
}
好处很直接:
- 匹配时只需判断需求能否放进某个矩形子区域;
- 每次切割就是
region - rect,最多产生 4 个子矩形,不需要通用几何库; - 面积守恒可以一行断言:
sum(rect.area) == 原面积 - 需求面积; - 客户端遍历
rects就能画,不用解路径。
多次切割后余料可能不再是一个”标准 L”(缺口不在角上),这时仍标记为 l_shape 但 composite: true,几何信息完整保留,图示依然正确——不为了好看而丢信息。
五、图示:让师傅一眼看懂切哪儿
工地上的文字说明没人看,图才有用。移动端用 Flutter 的 CustomPainter 画俯视图:
- 外框 = 原砖;
- 实心块 = 本次需求;
- 剩余 = 余料色;
- 缺口 = 先用斜线铺满外接矩形,再用区域矩形覆盖,没被覆盖的地方自然呈现为斜线缺口(L 形和复合形都适用,不需要缺口坐标);
- 虚线 = 刀路,从需求边一直延伸到外框;
- 尺寸标注 = 外框底边与左边,用等宽数字,方便对比。
TileShapePreview 这个组件在匹配卡片、确认页、库存卡片、溯源页复用了四次。图形永远按真实长宽比缩放,不拉伸——否则 300×600 的砖会被画成正方形,师傅会以为你算错了。
六、账本:每片砖都要能倒推
数据模型里最关键的是两张表:
inventory_batches:一块砖的”当前状态”(形状、尺寸、片数、父批次、根批次);inventory_movements:只追加的流水(变动前后尺寸/片数、使用尺寸、切法、余料处置、设备)。
确认使用时,事务里做五件事:
- 读批次(PostgreSQL 用
SELECT … FOR UPDATE); - 用同一套几何函数算出切割方案与余料;
UPDATE … WHERE id=? AND version=? AND quantity>=?,rowcount != 1即冲突;- 写流水(每片一条
usage)+ 生成余料批次(或写remnant_discard); - 提交。
重复提交用幂等键挡住:usage_records 上 (project_id, idempotency_key) 唯一约束,命中就回放原结果。
并发测试是真的开 10 个线程打同一个接口:库存 5 片,只允许 5 次成功,最终库存必须是 0,不能是 -5。这条用例在 backend/tests/test_concurrency.py 里。
七、没有账号,怎么保证”只有自己人能看到”
这是个容易被做复杂的点。需求是:不登录、扫码加入、下次打开不用再扫。
方案是两层凭证:
- 首次启动注册设备,服务端返回
device_id+device_secret(bcrypt 哈希存储,密钥只返回一次); - 加入项目后签发长期
project_token(sha256 哈希存储,可撤销)。
所有项目接口都必须带 X-Device-Id + X-Project-Token,服务端每次校验:令牌属于这台设备、设备是该项目成员、项目未删除。只凭 project_id 拿不到任何数据,越权一律 403。
代价也要说清楚:没有项目恢复码。换手机、卸载重装、清缓存,本机身份就没了,必须重新扫码加入。项目数据在服务器上不受影响。这条写进了 README 和 App 的设置页——比事后解释”为什么我的项目没了”要好。
八、部署在用户自己的博客主机上
目标用户是”有个 WordPress 博客、有台云主机”的人,所以部署必须简单:
cp .env.example .env # 改域名、密钥
docker compose up -d --build
- API 只监听容器内 8000,不映射到宿主机;
- Nginx 负责 80→443 跳转、TLS1.2/1.3、HSTS、限流;
- 数据库不暴露端口;
- 启动时自动
alembic upgrade head; - 备份用
scripts/backup.sh(SQLite 用.backup,WAL 下也一致)。
一台 1C1G 的机器,十几个项目、几十台设备,内存占用 130MB 左右。
九、几个做对了的决定
- 主动录入只允许矩形。异形只可能来自切割,这个约束让录入页永远只有两个数字框,师傅不会在”形状”下拉框前发呆。
- 切割方式必须用户选。系统可以推荐,但不能替用户决定——同一块砖,切长边还是短边,剩下的余料形状完全不同。
- 丢弃也写流水。库存是”现在有什么”,流水是”发生过什么”,两者不能混。
- 余料最小边阈值只提示不阻止。50mm 以下基本没用,但总有人要留着垫东西;给提示,把决定权留给现场。
- 图示复用同一个组件。四处图形一处代码,改一次全对。
十、还没做完的
- APK 还没在真实设备上跑过全流程(开发环境没有 Flutter/Android SDK,构建脚本已就绪);
- iOS 未适配(需求只要 Android);
- 离线队列没做,断网时明确提示”操作未提交”,不做假成功;
- 图片附件(拍一张余料照片)是下一步最想要的功能。
十一、如果你也想用
代码是 GPL-3.0,自己部署、自己改都行:
git clone https://github.com/z7ui/tile-remnant-inventory.git /opt/tile/app
python3 -m venv /opt/tile/venv
/opt/tile/venv/bin/pip install -r /opt/tile/app/backend/requirements.txt
cp /opt/tile/app/.env.example /opt/tile/app/.env # 改 PUBLIC_BASE_URL 和 DATABASE_URL
cd /opt/tile/app/backend && /opt/tile/venv/bin/alembic upgrade head
# 然后用 systemd 起服务(只监听 127.0.0.1:8000),nginx 加一个 location 反代进来
完整步骤(建用户、unit、nginx、证书、升级回滚)见仓库的 docs/deployment.md,Docker Compose 作为可选方案保留。
后端有 104 个测试,覆盖单位解析、旋转、覆盖校验、L 形面积守恒、异形入库/丢弃、并发不超扣、越权 403、邀请码撤销。
十二、上线之后:把服务器地址写死进安装包
设计里我原本留了个「贴心」功能:App 里可以改服务器地址。理由是工地网络环境千奇百怪,
万一 HTTPS 不通,让用户自己换一个总比装不上强。
上线那天把这个功能删了。
不是因为它用不上,是因为它给了别人一条路。这个 App 没有账号体系,设备身份
(设备 ID + 一对密钥)是发给「服务器」的。地址如果能随便填,就等于允许把密钥发到
任意一个地址上去——而密钥是这台设备在服务端的唯一凭证。
改成什么了:
- 地址在编译时写进安装包(
--dart-define=API_BASE_URL=...),release 构建里
AppConfig.serverLocked = kReleaseMode 锁死
- 三处入口全部隐藏:项目列表的设置图标、加入项目页的「修改」、启动失败页的「改地址」
Session.setServer()里再抛一次异常,入口漏判时的双保险- 首次启动会清掉本地存的旧地址;如果它和编译值不同,连设备身份和项目列表一起清
(那些是旧服务器签发的,带过来只会 401)
代价是换服务器只能换安装包。对「自部署」这个定位来说可以接受——自己的服务不太可能天天搬。
部署:最后没用 Docker
原本文档写的是 docker compose up -d,nginx、api、可选 PostgreSQL 三件套。
真到自己机器上第一件事就卡住:这台机器已经有一个 nginx 在跑博客,占着 80/443。
compose 里那个 nginx 服务也映射 80:80 和 443:443,up 下去博客当场 502。
而这个项目本身是什么?一个 uvicorn 进程,一个 SQLite 文件。为它再套一层容器,
换来的是多一份镜像、多一层网络、多 100–200MB 内存——这台机器总共 1.7G。
所以变成:
systemd tile-api.service (User=tile, 127.0.0.1:8000)
└── 宿主机 nginx: location /tile-api/ → proxy_pass http://127.0.0.1:8000/
Compose 保留在仓库里,降级为「全新机器想整套容器化」的可选项,并把自带的 nginx
挂到 standalone profile 下——默认不启动,不会再抢端口。
线上现在是这样:GET /tile-api/healthz → 200 {"status":"ok","version":"0.1.1"},
未认证访问 /tile-api/api/projects → 401。
一个具体的数字
APK 36.5 MB,arm64,minSdk 24。
第一次 flutter build apk 跑了 1209 秒——不是编译慢,是它在自动补 NDK、SDK Platform 36、
CMake,本机一个都没有。增量重编 66 秒。
中间踩的坑比代码本身多:Flutter SDK 缺 .git、dart-sdk 只剩一个空目录(完整的那份被
改名成 dart-sdk.old1)、JDK 25 和 Gradle 8.7 打架、services.gradle.org 连不上、
AGP 版本低于 Flutter 的下限。全写进了 docs/DELIVERY-STATUS.md——不是留档,是下次别再花一晚上。
装修这件事,业主在意的是效果,师傅在意的是效率。一个能把”半块砖”管起来的工具,省下的不只是材料费,还有每次翻找时的那十分钟。
本文由项目作者撰写,代码与文档均以 GPL-3.0 发布。
发表回复