工地上的半块砖,终于有地方放了:做一个瓷砖余料库存 App

作者:

在

装修进行到瓦工阶段,你会发现一个反直觉的事实:最贵的不是瓷砖,是被切坏和找不到的那部分。

一块 800×800 的砖 60 块一平,一个卫生间墙面要切 30 多刀。切下来的边角料,师傅顺手码在阳台角落;三个月后补一块 300×600 的踢脚线,没人记得哪块能对上花色,于是再开一箱新砖。等装修结束,阳台上堆着几十块”以后可能有用”的砖,最后一起被清运车拉走。

这篇文章记录我如何把这件事做成一个能用的手机 App:按尺寸匹配、按真实切割扣减、把余料(包括 L 形异形)继续入库,并且每一片砖都能追回原砖。

项目地址(GPL-3.0):https://github.com/z7ui/tile-remnant-inventory


一、先把问题定义清楚

和几位师傅聊完,需求收敛成四句话:

  1. 录入要快:手上是灰、戴着线手套,输尺寸不能超过三步。
  2. 匹配要准:说 800×600,就得告诉我哪块砖能切、怎么切、切完剩什么。
  3. 余料要留:切下来的不是垃圾,是下一块砖;但异形余料也得能存。
  4. 账要能查:三个月后要知道”这块 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}]
}

好处很直接:

  1. 匹配时只需判断需求能否放进某个矩形子区域;
  2. 每次切割就是 region - rect,最多产生 4 个子矩形,不需要通用几何库;
  3. 面积守恒可以一行断言:sum(rect.area) == 原面积 - 需求面积;
  4. 客户端遍历 rects 就能画,不用解路径。

多次切割后余料可能不再是一个”标准 L”(缺口不在角上),这时仍标记为 l_shape 但 composite: true,几何信息完整保留,图示依然正确——不为了好看而丢信息。

五、图示:让师傅一眼看懂切哪儿

工地上的文字说明没人看,图才有用。移动端用 Flutter 的 CustomPainter 画俯视图:

  • 外框 = 原砖;
  • 实心块 = 本次需求;
  • 剩余 = 余料色;
  • 缺口 = 先用斜线铺满外接矩形,再用区域矩形覆盖,没被覆盖的地方自然呈现为斜线缺口(L 形和复合形都适用,不需要缺口坐标);
  • 虚线 = 刀路,从需求边一直延伸到外框;
  • 尺寸标注 = 外框底边与左边,用等宽数字,方便对比。

TileShapePreview 这个组件在匹配卡片、确认页、库存卡片、溯源页复用了四次。图形永远按真实长宽比缩放,不拉伸——否则 300×600 的砖会被画成正方形,师傅会以为你算错了。

六、账本:每片砖都要能倒推

数据模型里最关键的是两张表:

  • inventory_batches:一块砖的”当前状态”(形状、尺寸、片数、父批次、根批次);
  • inventory_movements:只追加的流水(变动前后尺寸/片数、使用尺寸、切法、余料处置、设备)。

确认使用时,事务里做五件事:

  1. 读批次(PostgreSQL 用 SELECT … FOR UPDATE);
  2. 用同一套几何函数算出切割方案与余料;
  3. UPDATE … WHERE id=? AND version=? AND quantity>=?,rowcount != 1 即冲突;
  4. 写流水(每片一条 usage)+ 生成余料批次(或写 remnant_discard);
  5. 提交。

重复提交用幂等键挡住: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 左右。

九、几个做对了的决定

  1. 主动录入只允许矩形。异形只可能来自切割,这个约束让录入页永远只有两个数字框,师傅不会在”形状”下拉框前发呆。
  2. 切割方式必须用户选。系统可以推荐,但不能替用户决定——同一块砖,切长边还是短边,剩下的余料形状完全不同。
  3. 丢弃也写流水。库存是”现在有什么”,流水是”发生过什么”,两者不能混。
  4. 余料最小边阈值只提示不阻止。50mm 以下基本没用,但总有人要留着垫东西;给提示,把决定权留给现场。
  5. 图示复用同一个组件。四处图形一处代码,改一次全对。

十、还没做完的

  • 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 发布。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注