Implement Generator.Validate() to check part numbers against schemas: - Split by separator, verify segment count - Constant: must equal expected value - Enum: must be in allowed values map - String: length, case, pattern constraints - Serial: length + numeric-only check - Date: length matches expected format output Add belt-and-suspenders call in HandleCreateItem after Generate(). Add 9 validation tests (all pass alongside 10 existing tests). Closes #80
262 lines
6.7 KiB
Go
262 lines
6.7 KiB
Go
// Package partnum handles part number generation from schemas.
|
|
package partnum
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kindredsystems/silo/internal/schema"
|
|
)
|
|
|
|
// SequenceStore provides atomic sequence number generation.
|
|
type SequenceStore interface {
|
|
NextValue(ctx context.Context, schemaID string, scope string) (int, error)
|
|
}
|
|
|
|
// Generator creates part numbers from schemas.
|
|
type Generator struct {
|
|
schemas map[string]*schema.Schema
|
|
seqStore SequenceStore
|
|
}
|
|
|
|
// NewGenerator creates a new part number generator.
|
|
func NewGenerator(schemas map[string]*schema.Schema, seqStore SequenceStore) *Generator {
|
|
return &Generator{
|
|
schemas: schemas,
|
|
seqStore: seqStore,
|
|
}
|
|
}
|
|
|
|
// Input provides values for part number generation.
|
|
type Input struct {
|
|
SchemaName string
|
|
Values map[string]string // segment name -> value
|
|
}
|
|
|
|
// Generate creates a new part number.
|
|
func (g *Generator) Generate(ctx context.Context, input Input) (string, error) {
|
|
s, ok := g.schemas[input.SchemaName]
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown schema: %s", input.SchemaName)
|
|
}
|
|
|
|
segments := make([]string, len(s.Segments))
|
|
resolvedValues := make(map[string]string)
|
|
|
|
for i, seg := range s.Segments {
|
|
val, err := g.resolveSegment(ctx, s, &seg, input.Values, resolvedValues)
|
|
if err != nil {
|
|
return "", fmt.Errorf("segment %s: %w", seg.Name, err)
|
|
}
|
|
segments[i] = val
|
|
resolvedValues[seg.Name] = val
|
|
}
|
|
|
|
// Use format template if provided, otherwise join with separator
|
|
if s.Format != "" {
|
|
return g.applyFormat(s.Format, resolvedValues), nil
|
|
}
|
|
|
|
return strings.Join(segments, s.Separator), nil
|
|
}
|
|
|
|
func (g *Generator) resolveSegment(
|
|
ctx context.Context,
|
|
s *schema.Schema,
|
|
seg *schema.Segment,
|
|
input map[string]string,
|
|
resolved map[string]string,
|
|
) (string, error) {
|
|
switch seg.Type {
|
|
case "constant":
|
|
return seg.Value, nil
|
|
|
|
case "string":
|
|
val, ok := input[seg.Name]
|
|
if !ok && seg.Required {
|
|
return "", fmt.Errorf("required value not provided")
|
|
}
|
|
return g.formatString(seg, val)
|
|
|
|
case "enum":
|
|
val, ok := input[seg.Name]
|
|
if !ok && seg.Required {
|
|
return "", fmt.Errorf("required value not provided")
|
|
}
|
|
if _, valid := seg.Values[val]; !valid {
|
|
return "", fmt.Errorf("invalid enum value: %s", val)
|
|
}
|
|
return val, nil
|
|
|
|
case "serial":
|
|
scope := g.resolveScope(seg.Scope, resolved)
|
|
next, err := g.seqStore.NextValue(ctx, s.Name, scope)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting sequence: %w", err)
|
|
}
|
|
return g.formatSerial(seg, next), nil
|
|
|
|
case "date":
|
|
layout := seg.Value
|
|
if layout == "" {
|
|
layout = "20060102"
|
|
}
|
|
return time.Now().UTC().Format(layout), nil
|
|
|
|
default:
|
|
return "", fmt.Errorf("unknown segment type: %s", seg.Type)
|
|
}
|
|
}
|
|
|
|
func (g *Generator) formatString(seg *schema.Segment, val string) (string, error) {
|
|
// Apply case transformation
|
|
switch seg.Case {
|
|
case "upper":
|
|
val = strings.ToUpper(val)
|
|
case "lower":
|
|
val = strings.ToLower(val)
|
|
}
|
|
|
|
// Validate length
|
|
if seg.Length > 0 && len(val) != seg.Length {
|
|
return "", fmt.Errorf("value must be exactly %d characters", seg.Length)
|
|
}
|
|
if seg.MinLength > 0 && len(val) < seg.MinLength {
|
|
return "", fmt.Errorf("value must be at least %d characters", seg.MinLength)
|
|
}
|
|
if seg.MaxLength > 0 && len(val) > seg.MaxLength {
|
|
return "", fmt.Errorf("value must be at most %d characters", seg.MaxLength)
|
|
}
|
|
|
|
// Validate pattern
|
|
if seg.Validation.Pattern != "" {
|
|
re := regexp.MustCompile(seg.Validation.Pattern)
|
|
if !re.MatchString(val) {
|
|
msg := seg.Validation.Message
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern)
|
|
}
|
|
return "", fmt.Errorf("%s", msg)
|
|
}
|
|
}
|
|
|
|
return val, nil
|
|
}
|
|
|
|
func (g *Generator) formatSerial(seg *schema.Segment, val int) string {
|
|
format := fmt.Sprintf("%%0%dd", seg.Length)
|
|
return fmt.Sprintf(format, val)
|
|
}
|
|
|
|
func (g *Generator) resolveScope(scopeTemplate string, resolved map[string]string) string {
|
|
if scopeTemplate == "" {
|
|
return "_global_"
|
|
}
|
|
|
|
result := scopeTemplate
|
|
for name, val := range resolved {
|
|
result = strings.ReplaceAll(result, "{"+name+"}", val)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (g *Generator) applyFormat(format string, resolved map[string]string) string {
|
|
result := format
|
|
for name, val := range resolved {
|
|
result = strings.ReplaceAll(result, "{"+name+"}", val)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Validate checks if a part number matches a schema.
|
|
func (g *Generator) Validate(partNumber string, schemaName string) error {
|
|
s, ok := g.schemas[schemaName]
|
|
if !ok {
|
|
return fmt.Errorf("unknown schema: %s", schemaName)
|
|
}
|
|
|
|
parts := strings.Split(partNumber, s.Separator)
|
|
if len(parts) != len(s.Segments) {
|
|
return fmt.Errorf("expected %d segments, got %d", len(s.Segments), len(parts))
|
|
}
|
|
|
|
for i, seg := range s.Segments {
|
|
val := parts[i]
|
|
if err := g.validateSegment(&seg, val); err != nil {
|
|
return fmt.Errorf("segment %s: %w", seg.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateSegment checks that a single segment value is valid.
|
|
func (g *Generator) validateSegment(seg *schema.Segment, val string) error {
|
|
switch seg.Type {
|
|
case "constant":
|
|
if val != seg.Value {
|
|
return fmt.Errorf("expected %q, got %q", seg.Value, val)
|
|
}
|
|
|
|
case "enum":
|
|
if _, ok := seg.Values[val]; !ok {
|
|
return fmt.Errorf("invalid enum value: %s", val)
|
|
}
|
|
|
|
case "string":
|
|
if seg.Length > 0 && len(val) != seg.Length {
|
|
return fmt.Errorf("value must be exactly %d characters", seg.Length)
|
|
}
|
|
if seg.MinLength > 0 && len(val) < seg.MinLength {
|
|
return fmt.Errorf("value must be at least %d characters", seg.MinLength)
|
|
}
|
|
if seg.MaxLength > 0 && len(val) > seg.MaxLength {
|
|
return fmt.Errorf("value must be at most %d characters", seg.MaxLength)
|
|
}
|
|
if seg.Case == "upper" && val != strings.ToUpper(val) {
|
|
return fmt.Errorf("value must be uppercase")
|
|
}
|
|
if seg.Case == "lower" && val != strings.ToLower(val) {
|
|
return fmt.Errorf("value must be lowercase")
|
|
}
|
|
if seg.Validation.Pattern != "" {
|
|
re := regexp.MustCompile(seg.Validation.Pattern)
|
|
if !re.MatchString(val) {
|
|
msg := seg.Validation.Message
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern)
|
|
}
|
|
return fmt.Errorf("%s", msg)
|
|
}
|
|
}
|
|
|
|
case "serial":
|
|
if seg.Length > 0 && len(val) != seg.Length {
|
|
return fmt.Errorf("value must be exactly %d characters", seg.Length)
|
|
}
|
|
for _, ch := range val {
|
|
if ch < '0' || ch > '9' {
|
|
return fmt.Errorf("serial must be numeric")
|
|
}
|
|
}
|
|
|
|
case "date":
|
|
layout := seg.Value
|
|
if layout == "" {
|
|
layout = "20060102"
|
|
}
|
|
expected := time.Now().UTC().Format(layout)
|
|
if len(val) != len(expected) {
|
|
return fmt.Errorf("date segment length mismatch: expected %d, got %d", len(expected), len(val))
|
|
}
|
|
|
|
default:
|
|
return fmt.Errorf("unknown segment type: %s", seg.Type)
|
|
}
|
|
|
|
return nil
|
|
}
|