Files
silo/internal/partnum/generator.go
Forbes 1f7960db50 feat: implement date segment type for part number generation
Fixes #79

Implement the date segment type in the part number generator. Uses Go's
time.Format with the segment's Value field as the layout string.

- Default format: 20060102 (YYYYMMDD) when no Value is specified
- Custom formats via Value field: "0601" (YYMM), "2006" (YYYY), etc.
- Always uses UTC time
- Add 3 tests: default format, custom YYMM format, year-only format
2026-02-13 13:10:57 -06:00

185 lines
4.5 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)
}
// TODO: parse part number and validate each segment
_ = s
return nil
}