Files
bj_power/bj_power_wms/internal/db/db.go
T
SunYF 5f68e77d5f feat(cli): 添加命令行敏感操作门禁和数据库管理功能
- 添加 migrate、seed、reset-all 命令行子命令
- 实现管理员密码门禁机制保护敏感操作
- 添加数据库表结构迁移和种子数据初始化功能
- 实现清空并重建全部表的 reset-all 功能
- 重构种子数据只包含系统必需基础数据,移除演示数据
- 添加 RBAC 权限角色初始化的命令行支持
- 配置命令行密码验证和操作日志记录功能
2026-09-20 15:38:45 +08:00

307 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"bj_power_wms/ent"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
// rawDB 持有底层 *sql.DB,供迁移期清理孤儿表(如已下线的 package_boxes)使用
var rawDB *sql.DB
// MustNewDB 创建 Ent 客户端并验证连接
func MustNewDB(c DatabaseConf) *ent.Client {
ec, err := NewDB(c)
if err != nil {
panic(fmt.Sprintf("数据库连接失败: %v", err))
}
return ec
}
func NewDB(c DatabaseConf) (*ent.Client, error) {
dsn := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=disable",
c.User, c.Password, c.Host, c.Port, c.Dbname,
)
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
rawDB = db
drv := entsql.OpenDB(dialect.Postgres, db)
return ent.NewClient(ent.Driver(drv)), nil
}
// EnsureDB 若目标数据库不存在则自动创建(先连 postgres 维护库做检查/创建)
func EnsureDB(c DatabaseConf) error {
maintenanceDSN := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=disable",
c.User, c.Password, c.Host, c.Port, "postgres")
sqlDB, err := sql.Open("pgx", maintenanceDSN)
if err != nil {
return err
}
defer sqlDB.Close()
var exists bool
if err := sqlDB.QueryRow(
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, c.Dbname,
).Scan(&exists); err != nil {
return err
}
if exists {
return nil
}
if _, err := sqlDB.Exec(`CREATE DATABASE "` + c.Dbname + `"`); err != nil {
return err
}
slog.Info("已自动创建数据库 " + c.Dbname)
return nil
}
// AutoMigrate 启动时自动建表/迁移
func AutoMigrate(client *ent.Client) error {
ctx := contextBackdrop()
// 迁移前补列:必须先于 ent 的 Schema.Create 执行(详见 applyPreMigratePatches 说明)。
applyPreMigratePatches(ctx)
if err := client.Schema.Create(ctx); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
// ent 的 Schema.Create 只会"建新表",不会给已存在的表补列(本项目已知约束)。
// 新增字段统一在此以幂等 DDL 补齐(ADD COLUMN IF NOT EXISTS 可重复执行)。
applyColumnPatches(ctx)
// 清理历史独立装箱表:装箱已合并到统一的出库主表 OutboundOrder
// 原 package_boxes 表成为孤儿表,此处幂等删除(DROP TABLE IF EXISTS 可重复执行)。
if rawDB != nil {
if _, err := rawDB.ExecContext(ctx, "DROP TABLE IF EXISTS package_boxes"); err != nil {
slog.Warn("清理旧装箱表 package_boxes 失败(可忽略): " + err.Error())
} else {
slog.Info("已清理旧装箱表 package_boxes")
}
}
slog.Info("数据库迁移完成")
return nil
}
// applyPreMigratePatches 必须在 ent 的 Schema.Create 之前执行。
//
// 背景(2026-09-19 启动 panic 根因):ent(v0.14/Atlas) 给「已存在的表」新增「非空且无默认值」的列时,
// 只会先 ADD COLUMN(可空)再 ALTER COLUMN SET NOT NULL;存量行填不上值即报
// `column "xxx" of relation "yyy" contains null values (SQLSTATE 23502)`,直接 panic 打断启动。
// 因此这类列必须先用「带默认值」的 DDL 补齐(存量行被一次填满),Schema.Create 才能顺利收紧约束;
// 业务真实值随后由 applyColumnPatches 回填、并 DROP DEFAULT,使库内结构与 ent schema 完全一致。
//
// 登记口径:给「既有表」加非空且无默认值的列 → 必须登记在此;
// 只加可空列或带默认值的列 → 无需登记(ent 自己 ADD COLUMN 即可)。
func applyPreMigratePatches(ctx context.Context) {
if rawDB == nil {
return
}
stmts := []string{
// 区域库位父子层级重构:存量 zones 表补 level/codeent schema 中二者均非空且无默认值)。
// 这里的默认值仅用于让存量行通过 NOT NULL 收紧,真实层级值由 applyColumnPatches 回填。
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS level bigint NOT NULL DEFAULT 4`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS code character varying NOT NULL DEFAULT ''`,
}
for _, s := range stmts {
if _, err := rawDB.ExecContext(ctx, s); err != nil {
// 全新库此时表尚未建立,Schema.Create 会按 schema 全新建表,属正常情况
slog.Warn("迁移前补列跳过(新库属正常): " + err.Error())
}
}
}
// applyColumnPatches 幂等补齐已有表的新增列。
// 背景:ent 的 Schema.Create 对已存在表不会 ADD COLUMN,故新增字段必须在此登记,
// 否则新库正常、存量库缺列导致运行时 500。
func applyColumnPatches(ctx context.Context) {
if rawDB == nil {
return
}
stmts := []string{
// P0-2 不合格品处置(问题记录 L135-145)
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS disposal_type text NOT NULL DEFAULT 'NONE'`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS disposal_remark text`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS return_tracking_no text`,
// D5 区域库位:货架号 / 第几层 / 位置号(手填)
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS shelf_no text`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS layer_no text`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS position_no text`,
// D6 其他入库:归属(工单号/科技项目号/无)
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS ownership_type text NOT NULL DEFAULT 'none'`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS ownership_no text`,
// 缺货预警:物料安全库存
`ALTER TABLE materials ADD COLUMN IF NOT EXISTS safety_stock bigint NOT NULL DEFAULT 0`,
// 备料台账:目标工位(备料送到哪个装配工位)
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS target_station text`,
// 备料台账:完成时间(最后一笔出库时间,问题记录 六.10)
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS completed_at bigint`,
// 通用出库:出库类别/归属/目标工位(问题记录 二.2/二.3/二.4)
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS outbound_category text`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS ownership_type text`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS ownership_no text`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS target_station text`,
// 出库库位四级贯通(客户诉求:出库与入库同口径,2026-09-19):区域已有 zone_code,补货架/层/位置
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS shelf_no text`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS layer_no text`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS position_no text`,
// 库存明细:库位贯通 货架/层/位置(问题记录 一.4)
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS shelf_no text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS layer_no text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS position_no text`,
// 半成品/成品入库扩展(问题记录 L218/L222/L224/L230
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS ownership_type text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS ownership_no text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS product_status text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS related_standard text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS test_record_no text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS recorded_by text`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS recorded_at bigint`,
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS outbound_reason text`,
// 检验记录扩展(问题记录 L107/L149/L150/L494:归属/相关标准/生产厂家/名称冗余)
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS material_name text`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS ownership_type text`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS ownership_no text`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS related_standard text`,
`ALTER TABLE inspection_records ADD COLUMN IF NOT EXISTS manufacturer text`,
// 入库单质量状态(问题记录 L129:入库记录增加 合格/不合格 列)
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS quality_status text`,
// 入库单库位四级贯通(方案 E):区域已有 zone_code,补货架/层/位置三列
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS shelf_no text`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS layer_no text`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS position_no text`,
// 图号合并(问题记录 L18):drawing_no 已并入 code,存量库丢弃旧列
`ALTER TABLE materials DROP COLUMN IF EXISTS drawing_no`,
// 「简称」已按 2026-09-19 用户裁决整体删除(新增/编辑表单不再出现),存量库丢弃旧列
`ALTER TABLE materials DROP COLUMN IF EXISTS short_name`,
// 批次号/SN 唯一约束(P2-1/P2-2):并发导入防撞号。部分唯一索引只约束非空值——
// 空串/NULL(电气件无批次、结构件无SN)不参与唯一性;batch_no 仅约束结构件(manage_mode=1)。
// 注:存量若有重复数据会导致建索引失败(仅告警不阻断启动),上线前须先清理重复。
`CREATE UNIQUE INDEX IF NOT EXISTS ux_inventories_batch_no ON inventories (batch_no) WHERE manage_mode = 1 AND batch_no IS NOT NULL AND batch_no <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS ux_inventories_sn_code ON inventories (sn_code) WHERE sn_code IS NOT NULL AND sn_code <> ''`,
// 区域库位父子层级重构(统一主表 + 层级类型字段 level/code/name/parent_code):
// 在扁平 zones 表上加层级列,把"一行一货位"升级为 区域(level1)→货架(level2)→层(level3)→位置号(level4) 父子结构。
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS level integer NOT NULL DEFAULT 4`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS code text`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS name text`,
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS parent_code text`,
// 删 ent 旧生成的 zone_code 单列唯一索引与旧组合索引,改建层级节点唯一索引(防同层级同父下编码重复)
`DROP INDEX IF EXISTS zones_zone_code_key`,
`DROP INDEX IF EXISTS ux_zones_location`,
`CREATE UNIQUE INDEX IF NOT EXISTS ux_zones_node ON zones (level, zone_code, COALESCE(shelf_no, ''), COALESCE(layer_no, ''), code)`,
// 存量数据回填(幂等,可重复执行):扁平货位 → 父子层级
// 1) 纯区域行(无货架/层/位置)→ 区域节点(level1)
`UPDATE zones SET level = 1, code = zone_code, parent_code = '' WHERE (shelf_no IS NULL OR shelf_no = '') AND (layer_no IS NULL OR layer_no = '') AND (position_no IS NULL OR position_no = '') AND (code IS NULL OR code = '')`,
// 2) 货位行(有 position_no)→ 位置节点(level4)parent_code 取层号(默认位置 layer_no 为空)
`UPDATE zones SET level = 4, code = position_no, parent_code = COALESCE(layer_no, ''), name = COALESCE(name, '') WHERE position_no IS NOT NULL AND position_no <> '' AND (code IS NULL OR code = '')`,
// 3) 补货架节点(level2):从货位行去重得到 (zone_code, shelf_no)
`INSERT INTO zones (level, code, parent_code, zone_code, shelf_no, status, created_at, updated_at)
SELECT DISTINCT 2, z.shelf_no, z.zone_code, z.zone_code, z.shelf_no, '启用', EXTRACT(EPOCH FROM NOW())::bigint, EXTRACT(EPOCH FROM NOW())::bigint
FROM zones z WHERE z.shelf_no IS NOT NULL AND z.shelf_no <> ''
AND NOT EXISTS (SELECT 1 FROM zones s WHERE s.level = 2 AND s.zone_code = z.zone_code AND s.code = z.shelf_no)`,
// 4) 补层节点(level3):从货位行去重得到 (zone_code, shelf_no, layer_no)
`INSERT INTO zones (level, code, parent_code, zone_code, shelf_no, layer_no, status, created_at, updated_at)
SELECT DISTINCT 3, z.layer_no, z.shelf_no, z.zone_code, z.shelf_no, z.layer_no, '启用', EXTRACT(EPOCH FROM NOW())::bigint, EXTRACT(EPOCH FROM NOW())::bigint
FROM zones z WHERE z.layer_no IS NOT NULL AND z.layer_no <> ''
AND NOT EXISTS (SELECT 1 FROM zones l WHERE l.level = 3 AND l.zone_code = z.zone_code AND l.shelf_no = z.shelf_no AND l.code = z.layer_no)`,
// level/code 回填完毕,撤销 applyPreMigratePatches 补列时的临时默认值,
// 使库内结构与 ent schema 对齐(非空、无默认值)。
`ALTER TABLE zones ALTER COLUMN level DROP DEFAULT`,
`ALTER TABLE zones ALTER COLUMN code DROP DEFAULT`,
// U18 盘点四级贯通:盘点快照冻结货架/层/位置,盘点差异可精确定位到货位。
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS shelf_no text`,
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS layer_no text`,
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS position_no text`,
// 接驳台是独立主数据(不依附工位):产线/库房/其他三类;与工位 1:1 绑定。
// 部分唯一索引只约束产线接驳台(station_no > 0),库房/其他(station_no=0)不参与。
`CREATE UNIQUE INDEX IF NOT EXISTS ux_docks_station_no ON docks (station_no) WHERE station_no > 0`,
// 入库作废(2026-09-19):入库单可作废,记录原因/人/时间;库存回滚后单仍保留供追溯
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS voided boolean NOT NULL DEFAULT false`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS void_reason text`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS voided_by text`,
`ALTER TABLE inbound_orders ADD COLUMN IF NOT EXISTS voided_at bigint`,
// 附件:新存储方案(年/月/日/文件类型/uuid.ext)——补文件类型/UUID名/相对路径/逻辑删除/归档列
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS file_type text`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS file_ext text`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS file_md5 text`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS archived_at bigint`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted_at bigint`,
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted_by text`,
// 台账口径拆分(2026-09-19 出库与配送闭环):一个字段只有一个写者。
// returned_qty = 退库收货回写;consumed_qty = 报工反冲核销。均带默认值,存量行走 ent 亦可。
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS returned_qty bigint NOT NULL DEFAULT 0`,
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS consumed_qty bigint NOT NULL DEFAULT 0`,
// 出库单状态 + 是否叫AGV(配送进度只读展示用;「已接料」不落在本表)
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT '已出库'`,
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS need_agv boolean NOT NULL DEFAULT false`,
// 盘点15问(2026-09-20 最优方案):库存冻结标记 + 盘点单状态机/写回调整单/未盘项/取消原因
// inventories.stocktake_no 非空=该行正被盘点锁定,出库/入库/锁定入口据此拦截,保证盘点期间账实冻结。
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS stocktake_no text NOT NULL DEFAULT ''`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS diff_count bigint NOT NULL DEFAULT 0`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS adjusted boolean NOT NULL DEFAULT false`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS adjust_no text`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS written_at bigint`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS written_by text`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS uncounted_mode text`,
`ALTER TABLE stocktake_orders ADD COLUMN IF NOT EXISTS cancel_reason text`,
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS counted_by text`,
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS counted_at bigint`,
}
for _, s := range stmts {
if _, err := rawDB.ExecContext(ctx, s); err != nil {
slog.Warn("补齐列失败(可忽略): " + err.Error())
}
}
}
// DropAllTables 删除 public schema 下全部表(CASCADE 连带索引/序列/外键约束)。
// 仅供命令行 reset-all 使用:清空后由 AutoMigrate 重建结构、SeedIfEmpty + handler.SeedRBAC 重灌种子。
// 与 MES 的 db.DropAllTables 对齐(逐表 DROP 而非 DROP SCHEMA,保留 schema 权限归属)。
func DropAllTables(c DatabaseConf) error {
dsn := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s?sslmode=disable",
c.User, c.Password, c.Host, c.Port, c.Dbname)
sqlDB, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
defer sqlDB.Close()
rows, err := sqlDB.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
if err != nil {
return err
}
var names []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
rows.Close()
return err
}
names = append(names, t)
}
rows.Close()
for _, t := range names {
if _, err := sqlDB.Exec(`DROP TABLE IF EXISTS "` + t + `" CASCADE`); err != nil {
return err
}
}
slog.Info(fmt.Sprintf("已删除全部表 %d 张", len(names)))
return nil
}