package repository import ( "database/sql" "errors" "fmt" "strings" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) type MaterialLogRepo struct{} var ErrMaterialLogOperatorNotFound = errors.New("material log operator does not exist") func NewMaterialLogRepo() *MaterialLogRepo { return &MaterialLogRepo{} } // normalizeEndDate ensures endDate has a time component for proper date range filtering func normalizeEndDate(endDate string) string { if endDate == "" { return "" } // If already contains a time component (has : in time part like 23:59:59), return as-is if strings.Contains(endDate, " ") && strings.Contains(strings.Split(endDate, " ")[1], ":") { return endDate } // Append end of day time return endDate + " 23:59:59" } func (r *MaterialLogRepo) insertLog(execer sqlExecer, log *model.MaterialLog) error { var result sql.Result var err error if log.OperatorID == nil { query := `INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) VALUES (?, ?, NULL, ?, ?, ?, ?)` result, err = execer.Exec(query, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description) } else { // Take both identity fields from one users row so an old token username or // caller-provided name can never be stored beside a different user ID. query := `INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) SELECT ?, ?, u.id, ?, ?, u.username, ? FROM users u WHERE u.id = ?` result, err = execer.Exec(query, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Description, *log.OperatorID) } if err != nil { return err } if log.OperatorID != nil { rowsAffected, rowsErr := result.RowsAffected() if rowsErr != nil { return rowsErr } if rowsAffected != 1 { return ErrMaterialLogOperatorNotFound } } id, err := result.LastInsertId() if err != nil { return err } log.ID = id return nil } func (r *MaterialLogRepo) Create(log *model.MaterialLog) error { return r.insertLog(database.DB, log) } // CreateTx 在调用方事务内写入日志,便于和物料主记录的修改一起提交或回滚。 func (r *MaterialLogRepo) CreateTx(tx *sql.Tx, log *model.MaterialLog) error { return r.insertLog(tx, log) } // List 只返回当前公司物料的日志:material_logs 本身不带组织列,经 materials // 关联过滤,避免 A 看到 B 的物料变更记录。 func (r *MaterialLogRepo) List(organizationID int64, operationType, materialId string, startDate, endDate string, limit, offset int) ([]model.MaterialLog, int64, error) { countQuery := `SELECT COUNT(*) FROM material_logs ml JOIN materials m ON m.id = ml.material_id WHERE m.organization_id = ?` countArgs := []interface{}{organizationID} if operationType != "" { countQuery += ` AND ml.operation_type = ?` countArgs = append(countArgs, operationType) } if materialId != "" { countQuery += ` AND ml.material_id = ?` countArgs = append(countArgs, materialId) } if startDate != "" { countQuery += ` AND ml.created_at >= ?` countArgs = append(countArgs, startDate) } if endDate != "" { countQuery += ` AND ml.created_at <= ?` countArgs = append(countArgs, normalizeEndDate(endDate)) } var total int64 err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.operator_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at FROM material_logs ml JOIN materials m ON m.id = ml.material_id WHERE m.organization_id = ?` args := []interface{}{organizationID} if operationType != "" { query += ` AND ml.operation_type = ?` args = append(args, operationType) } if materialId != "" { query += ` AND ml.material_id = ?` args = append(args, materialId) } if startDate != "" { query += ` AND ml.created_at >= ?` args = append(args, startDate) } if endDate != "" { query += ` AND ml.created_at <= ?` args = append(args, normalizeEndDate(endDate)) } query += ` ORDER BY ml.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 logs []model.MaterialLog for rows.Next() { var log model.MaterialLog var operatorID sql.NullInt64 var oldValue, newValue []byte err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &operatorID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt) if err != nil { return nil, 0, err } if oldValue != nil { log.OldValue = string(oldValue) } if operatorID.Valid { log.OperatorID = &operatorID.Int64 } if newValue != nil { log.NewValue = string(newValue) } logs = append(logs, log) } return logs, total, nil } // CreateBatchTx 在同一事务里批量写审计日志。导入场景一条物料一条 CREATE 日志, // 逐条往返太慢,这里折叠成多值 INSERT。 func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error { if len(logs) == 0 { return nil } var builder strings.Builder builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) `) args := make([]interface{}, 0, len(logs)*7) withUserID := false for _, log := range logs { if log.OperatorID != nil { withUserID = true break } } if !withUserID { builder.WriteString(`VALUES `) } for i, log := range logs { if i > 0 { if withUserID { builder.WriteString(" UNION ALL ") } else { builder.WriteString(", ") } } if withUserID && log.OperatorID != nil { builder.WriteString(`SELECT ?, ?, u.id, ?, ?, u.username, ? FROM users u WHERE u.id = ?`) args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Description, *log.OperatorID) } else if withUserID { builder.WriteString(`SELECT ?, ?, NULL, ?, ?, ?, ?`) args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description) } else { builder.WriteString("(?, ?, NULL, ?, ?, ?, ?)") args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description) } } result, err := tx.Exec(builder.String(), args...) if err != nil { return err } if withUserID { rowsAffected, rowsErr := result.RowsAffected() if rowsErr != nil { return rowsErr } if rowsAffected != int64(len(logs)) { return fmt.Errorf("%w: expected %d rows, inserted %d", ErrMaterialLogOperatorNotFound, len(logs), rowsAffected) } } return nil } // GetByMaterialID 只返回属于当前公司的该物料日志;物料不属于本公司时结果为空。 func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]model.MaterialLog, error) { query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.operator_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at FROM material_logs ml JOIN materials m ON m.id = ml.material_id WHERE m.organization_id = ? AND ml.material_id = ? ORDER BY ml.id DESC` rows, err := database.DB.Query(query, organizationID, materialID) if err != nil { return nil, err } defer rows.Close() var logs []model.MaterialLog for rows.Next() { var log model.MaterialLog var operatorID sql.NullInt64 var oldValue, newValue []byte err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &operatorID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt) if err != nil { return nil, err } if oldValue != nil { log.OldValue = string(oldValue) } if operatorID.Valid { log.OperatorID = &operatorID.Int64 } if newValue != nil { log.NewValue = string(newValue) } logs = append(logs, log) } return logs, nil }