package repository import ( "database/sql" "errors" "fmt" "strings" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) // ErrDuplicateMaterialCode 表示同组织下物料编码已被占用。 var ErrDuplicateMaterialCode = errors.New("物料编码已存在") // BeginTx starts a new database transaction func BeginTx() (*sql.Tx, error) { return database.DB.Begin() } // probeLimit 限制自动编码撞号后向后探测的次数:号段被大面积占用时宁可报错, // 也不要让一次创建长时间空转。 const probeLimit = 1000 type MaterialRepo struct{} func NewMaterialRepo() *MaterialRepo { return &MaterialRepo{} } func (r *MaterialRepo) IsActiveSubcategory(categoryID, organizationID int64) (bool, error) { query := `SELECT EXISTS( SELECT 1 FROM material_categories WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active' )` var exists bool err := database.DB.QueryRow(query, categoryID, organizationID).Scan(&exists) return exists, err } // sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。 type sqlExecer interface { Exec(query string, args ...interface{}) (sql.Result, error) } // insertMaterial 写入物料并回填自增主键,唯一键冲突统一翻译成 // ErrDuplicateMaterialCode。 func insertMaterial(execer sqlExecer, m *model.Material) error { query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` result, err := execer.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version) if err != nil { if strings.Contains(err.Error(), "Duplicate entry") { return ErrDuplicateMaterialCode } return err } id, err := result.LastInsertId() if err != nil { return err } m.ID = id return nil } // lockCodeRule 锁住小类当前启用的编码规则行,返回 nil 表示该小类没有规则。 // FOR UPDATE 让同一个小类的并发创建串行化,避免两条记录抢到同一个流水号。 func lockCodeRule(tx *sql.Tx, organizationID, categoryID int64) (*model.CodeRule, error) { query := `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE` rule := &model.CodeRule{} err := tx.QueryRow(query, organizationID, categoryID).Scan(&rule.ID, &rule.OrganizationID, &rule.CategoryID, &rule.Prefix, &rule.SequenceLength, &rule.NextSequence) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return rule, nil } // isMaterialCodeTaken 判断同组织下某个物料编码是否已被占用。 func isMaterialCodeTaken(tx *sql.Tx, organizationID int64, code string) (bool, error) { query := `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)` var exists bool err := tx.QueryRow(query, organizationID, code).Scan(&exists) return exists, err } // CreateWithCodeRule 在事务内插入物料,并根据提交的编码决定是否推进号段。 // // 提交的编码是不是「规则当前的产物」决定了走哪条分支:相等说明这个编码来自自动 // 生成、用户没有改过,于是沿着号段向后探测出第一个没被占用的流水号,插入成功后 // 把 next_sequence 推进到它之后;不相等说明用户手改了编码、或者该小类根本没有 // 规则,此时原样插入、号段保持不变,撞唯一键就报编码已存在。 func (r *MaterialRepo) CreateWithCodeRule(m *model.Material, submittedCode string) error { tx, err := BeginTx() if err != nil { return err } defer func() { _ = tx.Rollback() }() var categoryID int64 if m.CategoryID != nil { categoryID = *m.CategoryID } rule, err := lockCodeRule(tx, m.OrganizationID, categoryID) if err != nil { return err } code := submittedCode var nextSequence int64 if rule != nil && code == rule.FormatCode(rule.NextSequence) { sequence := rule.NextSequence for probed := 0; probed < probeLimit; probed++ { code = rule.FormatCode(sequence) taken, err := isMaterialCodeTaken(tx, m.OrganizationID, code) if err != nil { return err } if !taken { nextSequence = sequence + 1 break } sequence++ } if nextSequence == 0 { return fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix) } } m.MaterialCode = code if err := insertMaterial(tx, m); err != nil { return err } if nextSequence > 0 { if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, nextSequence, rule.ID); err != nil { return err } } if err := tx.Commit(); err != nil { return err } return r.GetByID(m.ID, m) } func (r *MaterialRepo) GetByID(id int64, m *model.Material) error { query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version, created_at, updated_at FROM materials WHERE id = ?` var materialImage sql.NullString var manufacturerModel sql.NullString err := database.DB.QueryRow(query, id).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID, &m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt) if err != nil { return err } if materialImage.Valid { m.MaterialImage = &materialImage.String } if manufacturerModel.Valid { m.ManufacturerModel = &manufacturerModel.String } return nil } func (r *MaterialRepo) GetByMaterialCode(code string) (*model.Material, error) { m := &model.Material{} query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version, created_at, updated_at FROM materials WHERE material_code = ?` var materialImage sql.NullString var manufacturerModel sql.NullString err := database.DB.QueryRow(query, code).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID, &m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt) if err != nil { return nil, err } if materialImage.Valid { m.MaterialImage = &materialImage.String } if manufacturerModel.Valid { m.ManufacturerModel = &manufacturerModel.String } return m, nil } func (r *MaterialRepo) List(search, status string, limit, offset int) ([]model.Material, int64, error) { countQuery := `SELECT COUNT(*) FROM materials WHERE 1=1` var countArgs []interface{} if status != "all" { countQuery += ` AND status = ?` countArgs = append(countArgs, status) } if search != "" { countQuery += ` AND (material_code LIKE ? OR name LIKE ?)` searchPattern := "%" + search + "%" countArgs = append(countArgs, searchPattern, searchPattern) } var total int64 err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version, created_at, updated_at FROM materials WHERE 1=1` var args []interface{} if status != "all" { query += ` AND status = ?` args = append(args, status) } if search != "" { query += ` AND (material_code LIKE ? OR name LIKE ?)` searchPattern := "%" + search + "%" args = append(args, searchPattern, searchPattern) } query += ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) rows, err := database.DB.Query(query, args...) if err != nil { return nil, 0, err } defer rows.Close() var materials []model.Material for rows.Next() { var m model.Material var materialImage sql.NullString var manufacturerModel sql.NullString err := rows.Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID, &m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt) if err != nil { return nil, 0, err } if materialImage.Valid { m.MaterialImage = &materialImage.String } if manufacturerModel.Valid { m.ManufacturerModel = &manufacturerModel.String } materials = append(materials, m) } return materials, total, nil } func (r *MaterialRepo) Update(id int64, req *model.UpdateMaterialRequest) error { var sets []string var args []interface{} if req.Name != "" { sets = append(sets, "name = ?") args = append(args, req.Name) } if req.MaterialImage != nil && *req.MaterialImage != "" { sets = append(sets, "material_image = ?") args = append(args, *req.MaterialImage) } if req.Spec != "" { sets = append(sets, "spec = ?") args = append(args, req.Spec) } if req.ManufacturerModel != nil && *req.ManufacturerModel != "" { sets = append(sets, "manufacturer_model = ?") args = append(args, *req.ManufacturerModel) } if req.CategoryID != nil { sets = append(sets, "category_id = ?") args = append(args, req.CategoryID) } if req.Unit != "" { sets = append(sets, "unit = ?") args = append(args, req.Unit) } if req.BaseUnitID != nil { sets = append(sets, "base_unit_id = ?") args = append(args, req.BaseUnitID) } if req.Status != "" { sets = append(sets, "status = ?") args = append(args, req.Status) } sets = append(sets, "version = version + 1") if len(sets) == 0 { return nil } query := fmt.Sprintf("UPDATE materials SET %s WHERE id = ?", strings.Join(sets, ", ")) args = append(args, id) _, err := database.DB.Exec(query, args...) return err } func (r *MaterialRepo) Delete(id int64) error { // Check if material has related records var count int // Check inbound_items database.DB.QueryRow(`SELECT COUNT(*) FROM inbound_items WHERE material_id = ?`, id).Scan(&count) if count > 0 { return fmt.Errorf("该物料有入库记录,无法删除") } // Check outbound_items database.DB.QueryRow(`SELECT COUNT(*) FROM outbound_items WHERE material_id = ?`, id).Scan(&count) if count > 0 { return fmt.Errorf("该物料有出库记录,无法删除") } // Check stock_logs database.DB.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE material_id = ?`, id).Scan(&count) if count > 0 { return fmt.Errorf("该物料有库存流水记录,无法删除") } _, err := database.DB.Exec(`DELETE FROM materials WHERE id = ?`, id) return err } func (r *MaterialRepo) Obsolete(id int64) error { var nonZero int if err := database.DB.QueryRow(`SELECT COUNT(*) FROM inventory_balances WHERE material_id = ? AND (on_hand <> 0 OR reserved <> 0 OR locked <> 0 OR in_transit <> 0)`, id).Scan(&nonZero); err != nil { return err } if nonZero > 0 { return fmt.Errorf("该物料仍有库存,无法作废") } result, err := database.DB.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE id = ?`, id) if err != nil { return err } // version always increments, so zero affected rows means the material does // not exist; re-obsoleting an already-obsolete material still reports 1. affected, _ := result.RowsAffected() if affected == 0 { return fmt.Errorf("物料不存在") } return nil } func (r *MaterialRepo) Restore(id int64) error { result, err := database.DB.Exec(`UPDATE materials SET status = 'active', version = version + 1 WHERE id = ?`, id) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return fmt.Errorf("物料不存在") } return nil }