material_manage_system/internal/repository/material.go

420 lines
14 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 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{}
}
// Exists checks ownership without decoding optional legacy material fields.
func (r *MaterialRepo) Exists(organizationID, id int64) (bool, error) {
var exists bool
err := database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND id = ?)`, organizationID, id).Scan(&exists)
return exists, err
}
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
}
// IsActiveUnit 判断某个基本单位是否属于当前公司且处于启用状态。物料引用的单位
// 必须落在同一公司,否则 A 公司可以引用 B 公司的单位。
func (r *MaterialRepo) IsActiveUnit(unitID, organizationID int64) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM units WHERE id = ? AND organization_id = ? AND status = 'active')`
var exists bool
err := database.DB.QueryRow(query, unitID, 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.OrganizationID, m.ID, m)
}
// GetByID 读取本公司物料。organization_id 与 id 同时作为条件,跨公司记录读不到
// 会返回 sql.ErrNoRows,由上层映射成 404。
func (r *MaterialRepo) GetByID(organizationID, 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 organization_id = ? AND id = ?`
var materialImage sql.NullString
var manufacturerModel sql.NullString
err := database.DB.QueryRow(query, organizationID, 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(organizationID int64, 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 organization_id = ? AND material_code = ?`
var materialImage sql.NullString
var manufacturerModel sql.NullString
err := database.DB.QueryRow(query, organizationID, 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(organizationID int64, search, status string, limit, offset int) ([]model.Material, int64, error) {
countQuery := `SELECT COUNT(*) FROM materials WHERE organization_id = ?`
countArgs := []interface{}{organizationID}
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 organization_id = ?`
args := []interface{}{organizationID}
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(organizationID, 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
}
// organization_id 与 id 同时作为条件,A 不能改到 B 的物料。
query := fmt.Sprintf("UPDATE materials SET %s WHERE organization_id = ? AND id = ?", strings.Join(sets, ", "))
args = append(args, organizationID, id)
_, err := database.DB.Exec(query, args...)
return err
}
func (r *MaterialRepo) Delete(organizationID, 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 organization_id = ? AND id = ?`, organizationID, id)
return err
}
// obsoleteMaterialTx 在给定事务内作废物料:先确认没有非零库存,再带组织条件更新
// 状态并推进版本号。影响 0 行表示物料不存在或不属于该组织。
func obsoleteMaterialTx(tx *sql.Tx, organizationID, id int64) error {
var nonZero int
if err := tx.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 := tx.Exec(`UPDATE materials SET status = 'obsolete', version = version + 1 WHERE organization_id = ? AND id = ?`, organizationID, id)
if err != nil {
return err
}
// version always increments, so zero affected rows means the material does
// not exist in this organization; re-obsoleting an already-obsolete material
// still reports 1.
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("物料不存在")
}
return nil
}
// Obsolete 自带事务地作废物料,供直接调用;Service 需要把变更日志写进同一事务时
// 改用 ObsoleteTx。
func (r *MaterialRepo) Obsolete(organizationID, id int64) error {
tx, err := BeginTx()
if err != nil {
return err
}
if err := obsoleteMaterialTx(tx, organizationID, id); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// ObsoleteTx 在调用方事务内作废物料,便于和变更日志一起提交或回滚。
func (r *MaterialRepo) ObsoleteTx(tx *sql.Tx, organizationID, id int64) error {
return obsoleteMaterialTx(tx, organizationID, id)
}
// restoreMaterialTx 在给定事务内恢复物料,影响 0 行表示物料不存在或不属于该组织。
func restoreMaterialTx(tx *sql.Tx, organizationID, id int64) error {
result, err := tx.Exec(`UPDATE materials SET status = 'active', version = version + 1 WHERE organization_id = ? AND id = ?`, organizationID, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("物料不存在")
}
return nil
}
func (r *MaterialRepo) Restore(organizationID, id int64) error {
tx, err := BeginTx()
if err != nil {
return err
}
if err := restoreMaterialTx(tx, organizationID, id); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
// RestoreTx 在调用方事务内恢复物料,便于和变更日志一起提交或回滚。
func (r *MaterialRepo) RestoreTx(tx *sql.Tx, organizationID, id int64) error {
return restoreMaterialTx(tx, organizationID, id)
}