Project A / LANCANG

澜仓

库存与批次管理 · 水光

瓶装气进销存系统 · LPG Inventory & Distribution

预览版

案例整理于

进入系统

一台电脑上双击即开的瓶装气进销存:采购、销售、库存、配送、钢瓶、财务、报表全链路,结存由数据库触发器维护,报表口径与原 Access 库逐单回对。

问题与约束

这家瓶装气配送企业原来用 Access 记账:二十多个窗体、一个 .accdb 存了多年的进出场与送气流水。要替换的不是界面,而是那套已经长在这套字段名上的业务口径——金额怎么算、销量按什么汇总、月怎么结、钢瓶在谁手上,全都得和老库逐单对得上。

所以这个系统真正的难点不在功能数量,而在两件事:一是它必须像那台机器上的一个桌面程序那样存在——单机部署、双击脚本即开、不出外网;二是它必须比老 Access 更不容易记错账——每一笔结存都能回到来源流水,任何更正都留下痕迹。

  • 单机版:后端与网页在同一个进程里,数据落在本机 SQL Server Express,不依赖服务器和外网。
  • 口径零翻译:表名、列名、C# 属性、前端字段 key 大量沿用中文业务名,与原库之间不引入映射层。
  • 报表要和老库对得上:金额、销量、对账单的算法必须能逐单回查到来源单据。
  • 库存结存不允许由应用层随手改写,更正只能走反向流水。
  • 没有可用的打印机驱动环境,打印只做三联单与人民币大写,砍掉模板设计器与 PDF 导出。
瓶装气系统的新建送气单界面:表头是送气日期、客户、送气与跟车员工、计提方案、付款方式等字段,下方明细表按钢瓶规格列出实瓶数量、空瓶数量、销售单价与金额。
单据录入 · 由同一份配置渲染的表头与明细

我具体做了什么

一个人完成全部链路:领域建模与 60 支建库脚本、128 支存储过程与 26 个触发器、ASP.NET Core 后端(Api / Infrastructure / Domain 三层,40 个实体)、Vue 前端的配置引擎与页面、四支 .bat 部署脚本、一年演示数据的生成器与它的九项恒等式自检。

边界也说清楚:业务口径来自原 Access 库和实际送气流程,不是我发明的;我做的是把它翻译成一套不会漂移的结构——把口径写进触发器和存储过程,把重复的部分压进配置,把能自动核对的部分全部做成脚本跑。

前端

  • Vue 3.5
  • TypeScript
  • Vite 8
  • Element Plus 2.14
  • Pinia
  • ECharts 6

后端与数据

  • ASP.NET Core 9
  • EF Core 9(只映射不建表)
  • SQL Server 2022 Express
  • Cookie 认证 + 接口级 RBAC
权限矩阵页:按岗位列出功能资源、类型、对象名,以及查看、新增、修改、删除、打印五列勾选。
权限矩阵 · 岗位 × 资源 × 权限项

关键决策,以及放弃了什么

每条决策都写清被放弃的替代方案和它当时的理由,并配一条能在仓库里查到的证据。

  1. 三层都用中文标识符,不建映射层

    选了什么 数据库表列名、C# 实体属性、前端字段 key 全部沿用中文业务名,ETL、SQL、报表、页面四处字面一致。

    放弃了什么 放弃第一版方案的「数据库保中文、C# 用英文 + [Column] 标注映射」。理由是几百个列要人工映射几百次,错一个就静默丢字段;而且回归核对老库时每次都要在脑子里翻译一遍。代价是代码在非中文工具链里要格外注意编码,这个代价用「源文件 UTF-8、执行前转 UTF-16LE + BOM」的构建脚本兜住了。

    证据 C# 与 EF Core 原生支持中文标识符,实体与表一一对应;这条决策是推翻同仓库早期方案得来的,两段都在实施说明里留着。

  2. 结存只能由流水推导,改它要先过触发器这一关

    选了什么 库存流水表只允许新增,更正一律写反向流水;结存是流水的镜像,由 AFTER INSERT 触发器按五维(仓库 × 商品 × 批次 × 状态 × 权属)聚合写入。

    放弃了什么 放弃「应用层算好余额再 UPDATE」这种更直观的写法,也放弃在 C# 里维护结存的任何诱惑。理由是并发与补录会让余额和流水分叉,而一旦分叉就没有可信的账可查。把规则推到数据库层之后,绕过流程的写入不是「容易出错」,而是「不可能发生」。

    证据 db/v3/31 里的 trg_库存流水_只增不改 对 UPDATE/DELETE 直接 THROW 53101「库存流水只允许新增;更正请用反向流水」,同文件的 trg_库存流水_结存 负责累加镜像;数据生成脚本的第 1 项恒等式就是「结存 <> Σ流水 的行数必须为 0」。

  3. 通用 CRUD 走元数据白名单,而不是每张表一个接口

    选了什么 一个数据服务加一份编译期生成的表元数据,20 多张结构相似的档案表共用同一套读写;表名列名只能来自白名单,值全部走 SqlParameter。

    放弃了什么 放弃「为每张档案表各写一个 Controller」的重复劳动,也放弃「把表名列名当参数拼进 SQL」的省事做法。白名单外或未登记的 kind 一律回 400 而不是放行后再判权限——把参数写错伪装成没权限,排查成本更高。

    证据 白名单由脚本从库里真实结构生成、不手改;密码哈希、密码盐、迭代次数三列被刻意排除在白名单之外,否则通用 CRUD 就能直接改别人的密码。

  4. 口令算法之外,再加一条数据库约束

    选了什么 PBKDF2-HMAC-SHA256、逐员工独立 16 字节盐、迭代次数落在员工行上;同时用 CHECK 约束要求哈希、盐、迭代次数三者同时为空或同时非空且迭代次数落在 10000~1000000。

    放弃了什么 放弃老库里未加盐的 32 位小写 MD5——彩虹表可反查。也放弃「只在代码里校验参数合法性」:代码会被绕过,约束不会。

    证据 db/v3/12 的 CK_员工_密码完整性 就是这条不变量的可执行版本,任何绕过应用直接写库的脚本也会被它拦下。

  5. 权限登记在启动时自检,不等运行时 403

    选了什么 启动时把三处登记(页面路由资源表、kind/表驱动的动态资源表、控制器上的 [资源] 标注)与库里的权限资源对象名逐一比对,缺失就告警。

    放弃了什么 放弃「等测试点到某个按钮发现整族 403 再回头查」。登记错一个对象名的表现是全部拒绝,而超管账号又永远看不出问题。

    证据 Program.cs 里这段自检在启动日志里打印比对结果;批次 20 之前鉴权是三条并行通路,读接口基本裸奔,收拢成一条链之后才有这份可核对的登记。

  6. 报表直读视图,不在后端再写一遍 JOIN

    选了什么 报表中心直读数据库里的视图,前端只登记「数据源视图 + 字段 + 参数」。

    放弃了什么 放弃「后端手写一遍查询更可控」的想法。同一套口径抄第二遍,迟早有一遍是错的;新增视图就自动多一张报表,而不是多一次前后端联调。

    证据 视图全部由建库脚本创建,报表不落 SQL,因此既能审计也不会变成任意 SQL 执行器。

  7. 过账这类破坏性动作,只在副本库上验

    选了什么 用 COPY_ONLY 备份还原出一个独立库,在副本上起另一个端口的服务跑过账端到端,跑完删库。

    放弃了什么 放弃「在真库上试一次,大不了再还原」——真库只要被写过一次,就不再有可信的基准。

    证据 交接文档记录:真库全程只被读过一次备份,一行未改;也正是这次副本验证暴露出「盘点账面数量是单批次不是整仓」的断言错误。

可度量的结果

下面每个数字都是我在仓库里用命令量出来的,不是从文档抄的;口径不同的地方分开写,避免把不同时点的数字混成一句话。

表 / 存储过程 / 视图 / 触发器
188 / 128 / 123 / 26
统计 db/v3 与 db/stage7 全部 .sql 里的 CREATE 语句;仓库文档口径为 186 表 / 121 视图
外键 / CHECK 约束
508 / 428
统计 db/v3 里的 FOREIGN KEY 与 CONSTRAINT CK_ 声明
一条命令重建整套结构
60 支脚本 + 六套自检
db/v3 脚本数与 db/tools/build.py;文档记录满数据下 293 项自检、219 项冒烟全绿
界面规模
41 个页面 / 47 条路由 / 11 个菜单组
统计 frontend/src/views 下 .vue 文件数、router 里的 path 声明、modules.ts 菜单顶层键
配置驱动覆盖面
1002 行配置 → 11 类单据 + 14 类档案
config/modules.ts 行数与顶层键数;渲染它们的是列表/录入/打印/档案四个通用页面
台账恒等式自检
9 项,全部为 0 才算过
db/tools/seed_stage7.py 的校验函数:结存=Σ流水、凭证借贷平、试算平、成本结转、已结账无草稿、过账必挂凭证、应收核销平、提成、薪资
演示数据可复现
固定种子 20261019 · 12 个月
seed_stage7.py 的随机种子;看板取库内最新送货日期作为「数据截止」,不是系统当天
后端实体
40 个实体 / 40 个 DbSet
统计 Domain/Entities 下 .cs 与 DbContext 里的 DbSet 声明;文档口径 38 个是早期数字

结构类数字(表、过程、外键)来自脚本静态统计,重建一次就变;自检与冒烟的项数来自仓库记录,我没有在本地重跑数据库。

踩过的坑

  1. 后端停在旧模型,库已经改完:登录 500

    库结构升到 v3 之后,登录接口开始报「列名 '密码' 无效」。根因不是哪一行写错,而是后端实体还停在 v2 的表上,而这件事当时不在任何一批计划里——计划书写着「每完成一个域就做后端」,但这条没落到任何阶段。

    漂移不是「以后再说」的技术债,是要排进批次的任务。后来它被固化成一个独立批次(后端升 v3 六步),并且给权限登记加上了启动自检。

    瓶装气进销存系统 · _代码索引/00 与 db/00_分批交接计划书.md 的批次 6 复盘

  2. 按钮永远不显示,因为只有超管在测

    前端拿业务动作名(submit / approve / reverse / delete)当权限代码去问「有没有权限」,而库里的权限项只有 view/add/edit/del/audit/post/print/export 八种。结果非超管岗位这些按钮永远不显示;测试用的是负责人账号,超管走短路直接放行,所以一直没人发现。

    用超管测权限等于没测。后来把「非超管岗位逐接口打 403」做成了探针项。

    瓶装气进销存系统 · _代码索引/01 权限段

  3. 拿「重建结存」当基准,是一条会自己骗自己的断言

    库存作业的探针要在跑完之后把库还原到基准值再比下一次。第一版用存储过程重建结存当「回到基准」,看起来全绿——但重建过程会顺带以流水为准重算成本单价,和触发器逐步累出来的值不保证逐行相同,于是「回到基准」这件事从来没被真正验证过。

    探针的还原动作必须和被测动作无关。改成备份/还原副本库之后,同类问题在副本上暴露,真库不受影响。

    瓶装气进销存系统 · db/04 批次 19 的坑记录

  4. 接口 200、界面白屏

    首页看板永远显示「看板数据加载失败」,但接口实测是 200。查下去发现是一行取色代码写成 getComputedStyle(...).classList——getComputedStyle 返回的对象根本没有 classList,于是画图每次抛错,又被上层的 catch 吞成一条失败提示。

    catch 里的静默降级会把「界面坏了」伪装成「数据没到」。同批还抓到一条同类:把 kind 当资源名去问权限,恒为 false。

    瓶装气进销存系统 · db/00_分批交接计划书.md 批次 13

  5. 复制命令返回 0,但什么都没拷过去

    部署脚本用 xcopy 把前端产物拷进发布目录,目标路径写成以反斜杠结尾的带引号形式时,尾部反斜杠会吞掉引号导致路径无效,而 errorlevel 仍然是 0。结果是发布包里长期留着旧页面,且脚本一路「成功」。

    Windows 脚本里每个「返回码 0」都要用别的方式复核一次,现在改成 robocopy 之后再用 fc /b 比字节。

    瓶装气进销存系统 · _代码索引/06 部署与运行

进入真实系统 / Access

线上预览已就绪

预览地址已配置,可直接在新窗口打开。

演示账号

  • 任意工号 / 任意密码

    演示超管(Mock 固定身份)

    登录、菜单、列表与单据详情都走接口快照,不校验账号、也不写库

这一套系统的各个端

这一版演示的是界面与业务流程:数据是接口快照,随便点都不会存盘。要真的录单、查库存、算结存,下载单机版(完整系统需要 .NET 9 运行时与 SQL Server Express;包里另有一条免依赖的演示路线)。