diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3aa6389b..53f28b3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,24 +6,45 @@ deploy-dev: image: alpine:3.20 variables: DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" before_script: - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl + - apk update && apk add --no-cache openssh git curl bash + + # Setup SSH di runner - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - - eval $(ssh-agent -s) + - eval "$(ssh-agent -s)" - ssh-add ~/.ssh/id_rsa + + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts script: - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" + - > if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " - cd /home/devops/docker/deployment/development/lti-api && - git fetch origin development && - git reset --hard origin/development && + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + docker compose restart dev-api-lti || docker compose up -d dev-api-lti "; then STATUS='success'; diff --git a/go.mod b/go.mod index 517bcdc1..6d37a691 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,16 @@ go 1.23 require ( github.com/MicahParks/keyfunc/v2 v2.1.0 + github.com/aws/aws-sdk-go-v2 v1.40.0 + github.com/aws/aws-sdk-go-v2/config v1.32.2 + github.com/aws/aws-sdk-go-v2/credentials v1.19.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 github.com/bytedance/sonic v1.12.1 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgconn v1.14.1 github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 @@ -20,6 +25,21 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect @@ -30,7 +50,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect diff --git a/go.sum b/go.sum index c07e37e3..71f9378c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,44 @@ github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+G github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= +github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/internal/common/repository/common.document.repository.go b/internal/common/repository/common.document.repository.go new file mode 100644 index 00000000..79e8a04d --- /dev/null +++ b/internal/common/repository/common.document.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type DocumentRepository interface { + BaseRepository[entity.Document] + ListByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) ([]entity.Document, error) + DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) error +} + +type documentRepositoryImpl struct { + *BaseRepositoryImpl[entity.Document] +} + +func NewDocumentRepository(db *gorm.DB) DocumentRepository { + return &documentRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.Document](db), + } +} + +func (r *documentRepositoryImpl) ListByTarget( + ctx context.Context, + documentableType string, + documentableID uint64, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Document, error) { + var documents []entity.Document + + q := r.DB().WithContext(ctx). + Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("created_at ASC").Find(&documents).Error; err != nil { + return nil, err + } + + return documents, nil +} + +func (r *documentRepositoryImpl) DeleteByTarget( + ctx context.Context, + documentableType string, + documentableID uint64, + modifier func(*gorm.DB) *gorm.DB, +) error { + q := r.DB().WithContext(ctx). + Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID) + + if modifier != nil { + q = modifier(q) + } + + return q.Delete(&entity.Document{}).Error +} diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go new file mode 100644 index 00000000..38b1a93b --- /dev/null +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockAllocationRepository interface { + BaseRepository[entity.StockAllocation] + FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error) + ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error +} + +type StockAllocationRepositoryImpl struct { + *BaseRepositoryImpl[entity.StockAllocation] +} + +func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository { + return &StockAllocationRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db), + } +} + +func (r *StockAllocationRepositoryImpl) FindActiveByUsable( + ctx context.Context, + usableType string, + usableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.StockAllocation, error) { + var allocations []entity.StockAllocation + + q := r.DB().WithContext(ctx). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil { + return nil, err + } + + return allocations, nil +} + +func (r *StockAllocationRepositoryImpl) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + now := time.Now() + + updates := map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + } + if note != nil { + updates["note"] = *note + } + + q := r.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + return q.Updates(updates).Error +} diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go new file mode 100644 index 00000000..fe2a41cc --- /dev/null +++ b/internal/common/service/common.document.service.go @@ -0,0 +1,411 @@ +package service + +import ( + "context" + "errors" + "fmt" + "mime" + "mime/multipart" + "path/filepath" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/google/uuid" +) + +const ( + defaultDocumentPathLimit = 50 + defaultDocumentKeyPrefix = "docs" + maxDocumentNameLength = 50 +) + +type DocumentService interface { + UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) + ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) + DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error + DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error + PublicURL(document entity.Document) string +} + +type DocumentUploadRequest struct { + DocumentableType string + DocumentableID uint64 + CreatedBy *uint + Files []DocumentFile +} + +type DocumentFile struct { + File *multipart.FileHeader + Type string + Index *int +} + +type DocumentUploadResult struct { + Document entity.Document + URL string + Index *int +} + +type DocumentServiceOption func(*documentService) + +type documentService struct { + repo commonRepo.DocumentRepository + storage DocumentStorage + keyPrefix string + maxPathLength int +} + +func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService { + svc := &documentService{ + repo: repo, + storage: storage, + keyPrefix: defaultDocumentKeyPrefix, + maxPathLength: defaultDocumentPathLimit, + } + + for _, opt := range opts { + opt(svc) + } + + return svc +} + +func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) { + if repo == nil { + return nil, errors.New("document repository is required") + } + if strings.TrimSpace(config.S3Bucket) == "" { + return nil, errors.New("S3_BUCKET is not configured") + } + + storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{ + Region: config.S3Region, + Bucket: config.S3Bucket, + AccessKey: config.S3AccessKey, + SecretKey: config.S3SecretKey, + Endpoint: config.S3Endpoint, + BaseURL: config.S3PublicBaseURL, + ForcePathStyle: config.S3ForcePathStyle, + }) + if err != nil { + return nil, err + } + + prefix := config.S3DocumentKeyPrefix + if prefix == "" { + prefix = defaultDocumentKeyPrefix + } + + return NewDocumentService( + repo, + storage, + WithDocumentKeyPrefix(prefix), + WithDocumentPathLimit(defaultDocumentPathLimit), + ), nil +} + +func WithDocumentKeyPrefix(prefix string) DocumentServiceOption { + return func(svc *documentService) { + prefix = strings.Trim(prefix, "/") + if prefix == "" { + prefix = defaultDocumentKeyPrefix + } + svc.keyPrefix = prefix + } +} + +func WithDocumentPathLimit(limit int) DocumentServiceOption { + return func(svc *documentService) { + if limit > 0 { + svc.maxPathLength = limit + } + } +} + +func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) { + if s.repo == nil { + return nil, errors.New("document repository not configured") + } + if s.storage == nil { + return nil, errors.New("document storage not configured") + } + + documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType)) + if documentableType == "" { + return nil, errors.New("documentable type is required") + } + if req.DocumentableID == 0 { + return nil, errors.New("documentable id is required") + } + if len(req.Files) == 0 { + return nil, errors.New("no files to upload") + } + + var createdBy *uint + if req.CreatedBy != nil && *req.CreatedBy != 0 { + idCopy := *req.CreatedBy + createdBy = &idCopy + } + + results := make([]DocumentUploadResult, 0, len(req.Files)) + createdDocs := make([]entity.Document, 0, len(req.Files)) + + for _, file := range req.Files { + if file.File == nil { + return nil, errors.New("file header is required") + } + + originalName := sanitizeDocumentName(file.File.Filename) + contentType := detectContentType(file.File, originalName) + ext := detectExtension(file.File.Filename, contentType) + key, err := s.generateObjectKey(ext) + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + reader, err := file.File.Open() + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType) + _ = reader.Close() + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + docType := resolveDocumentType(file.Type, documentableType) + doc := entity.Document{ + DocumentableType: documentableType, + DocumentableId: req.DocumentableID, + Type: docType, + Path: uploadRes.Key, + Name: originalName, + Ext: strings.TrimPrefix(ext, "."), + Size: float64(file.File.Size), + CreatedBy: createdBy, + } + + if err := s.repo.CreateOne(ctx, &doc, nil); err != nil { + _ = s.storage.Delete(ctx, uploadRes.Key) + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + createdDocs = append(createdDocs, doc) + results = append(results, DocumentUploadResult{ + Document: doc, + URL: uploadRes.URL, + Index: cloneIndex(file.Index), + }) + } + + return results, nil +} + +func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) { + if s.repo == nil { + return nil, errors.New("document repository not configured") + } + + documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) + if documentableType == "" { + return nil, errors.New("documentable type is required") + } + if documentableID == 0 { + return nil, errors.New("documentable id is required") + } + + return s.repo.ListByTarget(ctx, documentableType, documentableID, nil) +} + +func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error { + if s.repo == nil { + return errors.New("document repository not configured") + } + if len(ids) == 0 { + return nil + } + + docs, err := s.repo.GetByIDs(ctx, ids, nil) + if err != nil { + return err + } + + for _, doc := range docs { + if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { + return err + } + if removeFromStorage && s.storage != nil { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) + } + } + } + + return nil +} + +func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error { + if s.repo == nil { + return errors.New("document repository not configured") + } + + documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) + if documentableType == "" || documentableID == 0 { + return errors.New("documentable type and id are required") + } + + var docs []entity.Document + if removeFromStorage && s.storage != nil { + var err error + docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil) + if err != nil { + return err + } + } + + if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil { + return err + } + + if removeFromStorage && len(docs) > 0 { + for _, doc := range docs { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) + } + } + } + + return nil +} + +func (s *documentService) PublicURL(document entity.Document) string { + if s.storage == nil || strings.TrimSpace(document.Path) == "" { + return "" + } + return s.storage.URL(document.Path) +} + +func (s *documentService) generateObjectKey(ext string) (string, error) { + normalizedExt := strings.TrimSpace(ext) + if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { + normalizedExt = "." + normalizedExt + } + + u := uuid.New().String() + key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt) + if s.keyPrefix == "" { + key = fmt.Sprintf("%s%s", u, normalizedExt) + } + + if len(key) > s.maxPathLength { + key = fmt.Sprintf("%s%s", u, normalizedExt) + } + + if len(key) > s.maxPathLength { + return "", fmt.Errorf("object key exceeds maximum length (%d)", s.maxPathLength) + } + + return key, nil +} + +func (s *documentService) rollbackDocuments(ctx context.Context, docs []entity.Document) { + if len(docs) == 0 { + return + } + + for i := len(docs) - 1; i >= 0; i-- { + doc := docs[i] + if s.repo != nil && doc.Id != 0 { + if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { + utils.Log.WithError(err).Warnf("failed to rollback document #%d", doc.Id) + } + } + if s.storage != nil && strings.TrimSpace(doc.Path) != "" { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to rollback document object %s", doc.Path) + } + } + } +} + +func sanitizeDocumentName(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + if name == "." || name == "" { + name = "document" + } + name = strings.Map(func(r rune) rune { + if r < 32 { + return -1 + } + switch r { + case '\\', '/', ':', '*', '?', '"', '<', '>', '|': + return '-' + default: + return r + } + }, name) + + if len(name) > maxDocumentNameLength { + runes := []rune(name) + if len(runes) > maxDocumentNameLength { + name = string(runes[:maxDocumentNameLength]) + } + } + return name +} + +func detectExtension(filename, contentType string) string { + ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) + if ext == "" && contentType != "" { + if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 { + ext = exts[0] + } + } + if ext == "" { + return ".bin" + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} + +func detectContentType(file *multipart.FileHeader, filename string) string { + if file == nil { + return "application/octet-stream" + } + contentType := strings.TrimSpace(file.Header.Get("Content-Type")) + if contentType != "" { + return contentType + } + if ext := filepath.Ext(filename); ext != "" { + if guess := mime.TypeByExtension(ext); guess != "" { + return guess + } + } + return "application/octet-stream" +} + +func resolveDocumentType(fileType, fallback string) string { + value := strings.ToUpper(strings.TrimSpace(fileType)) + if value == "" { + return fallback + } + return value +} + +func cloneIndex(index *int) *int { + if index == nil { + return nil + } + value := *index + return &value +} diff --git a/internal/common/service/common.document.service_test.go b/internal/common/service/common.document.service_test.go new file mode 100644 index 00000000..8b7d248d --- /dev/null +++ b/internal/common/service/common.document.service_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "bytes" + "context" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +func TestDocumentServiceUpload(t *testing.T) { + if strings.TrimSpace(config.S3Bucket) == "" { + t.Fatal("S3 bucket is not configured; set S3_* env vars to run this test") + } + + ctx := context.Background() + db := setupDocumentTestDB(t) + repo := commonRepo.NewDocumentRepository(db) + + svc, err := NewDocumentServiceFromConfig(ctx, repo) + if err != nil { + t.Fatalf("failed to create document service from config: %v", err) + } + + file := newTestFileHeader(t, "integration-proof.txt", "text/plain", []byte("document integration test")) + userID := uint(100) + + results, err := svc.UploadDocuments(ctx, DocumentUploadRequest{ + DocumentableType: "INVENTORY_TRANSFER", + DocumentableID: 99, + CreatedBy: &userID, + Files: []DocumentFile{ + {File: file, Type: "integration"}, + }, + }) + if err != nil { + t.Fatalf("upload to S3 failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 uploaded document, got %d", len(results)) + } + + doc := results[0].Document + if doc.Path == "" { + t.Fatalf("expected non-empty storage path") + } + if results[0].URL == "" { + t.Fatalf("expected public URL for uploaded document") + } + + t.Logf("uploaded document #%d to %s (path=%s)", doc.Id, results[0].URL, doc.Path) +} + +func setupDocumentTestDB(t *testing.T) *gorm.DB { + t.Helper() + if strings.TrimSpace(config.DBHost) == "" || strings.TrimSpace(config.DBName) == "" { + t.Fatal("database configuration missing; ensure DB_HOST and DB_NAME are set") + } + db := database.Connect(config.DBHost, config.DBName) + if db == nil { + t.Fatal("failed to create database connection") + } + if err := db.AutoMigrate(&entity.Document{}); err != nil { + t.Fatalf("failed to migrate document table: %v", err) + } + return db +} + +func newTestFileHeader(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("documents", filename) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("failed to write file data: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close writer: %v", err) + } + + req := httptest.NewRequest("POST", "http://example.com/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + _, fileHeader, err := req.FormFile("documents") + if err != nil { + t.Fatalf("failed to parse form file: %v", err) + } + fileHeader.Header.Set("Content-Type", contentType) + return fileHeader +} diff --git a/internal/common/service/common.document.storage.go b/internal/common/service/common.document.storage.go new file mode 100644 index 00000000..24e6fade --- /dev/null +++ b/internal/common/service/common.document.storage.go @@ -0,0 +1,160 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type DocumentStorage interface { + Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) + Delete(ctx context.Context, key string) error + URL(key string) string +} + +type DocumentStorageUploadResult struct { + Key string + URL string + ETag string +} + +type S3DocumentStorageConfig struct { + Region string + Bucket string + AccessKey string + SecretKey string + Endpoint string + BaseURL string + ForcePathStyle bool +} + +type s3DocumentStorage struct { + client *s3.Client + bucket string + base string +} + +func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) { + bucket := strings.TrimSpace(cfg.Bucket) + if bucket == "" { + return nil, errors.New("s3 bucket is required") + } + region := strings.TrimSpace(cfg.Region) + if region == "" { + region = "us-east-1" + } + + options := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(region), + } + + endpoint := strings.TrimSpace(cfg.Endpoint) + if endpoint != "" { + resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) { + if service == s3.ServiceID { + return aws.Endpoint{ + URL: endpoint, + SigningRegion: region, + HostnameImmutable: true, + }, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }) + options = append(options, awsconfig.WithEndpointResolverWithOptions(resolver)) + } + + accessKey := strings.TrimSpace(cfg.AccessKey) + secretKey := strings.TrimSpace(cfg.SecretKey) + if accessKey != "" && secretKey != "" { + options = append(options, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + )) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, options...) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.ForcePathStyle + }) + + baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + if endpoint != "" { + baseURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), bucket) + } else { + baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region) + } + } + + return &s3DocumentStorage{ + client: client, + bucket: bucket, + base: baseURL, + }, nil +} + +func (s *s3DocumentStorage) Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) { + if strings.TrimSpace(key) == "" { + return DocumentStorageUploadResult{}, errors.New("storage key is required") + } + if size < 0 { + size = 0 + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: body, + } + input.ContentLength = aws.Int64(size) + if ct := strings.TrimSpace(contentType); ct != "" { + input.ContentType = aws.String(ct) + } + + out, err := s.client.PutObject(ctx, input) + if err != nil { + return DocumentStorageUploadResult{}, err + } + + var etag string + if out.ETag != nil { + etag = strings.Trim(*out.ETag, "\"") + } + + return DocumentStorageUploadResult{ + Key: key, + URL: s.URL(key), + ETag: etag, + }, nil +} + +func (s *s3DocumentStorage) Delete(ctx context.Context, key string) error { + if strings.TrimSpace(key) == "" { + return nil + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + return err +} + +func (s *s3DocumentStorage) URL(key string) string { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" { + return s.base + } + if s.base == "" { + return key + } + return fmt.Sprintf("%s/%s", s.base, key) +} diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go new file mode 100644 index 00000000..e3b80268 --- /dev/null +++ b/internal/common/service/common.fifo.service.go @@ -0,0 +1,820 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/sirupsen/logrus" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FifoService interface { + RegisterStockable(cfg fifo.StockableConfig) error + RegisterUsable(cfg fifo.UsableConfig) error + + Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) + Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) + ReleaseUsage(ctx context.Context, req StockReleaseRequest) error +} + +type fifoService struct { + db *gorm.DB + logger *logrus.Logger + allocations commonRepo.StockAllocationRepository + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + defaultOrderBy []string + pendingBatchPerUsable int + maxLotsPerStockable int + defaultAllocationNotes string +} + +func NewFifoService( + db *gorm.DB, + allocations commonRepo.StockAllocationRepository, + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, + logger *logrus.Logger, +) FifoService { + if logger == nil { + logger = logrus.StandardLogger() + } + return &fifoService{ + db: db, + logger: logger, + allocations: allocations, + productWarehouseRepo: productWarehouseRepo, + defaultOrderBy: []string{"created_at ASC", "id ASC"}, + pendingBatchPerUsable: 25, + maxLotsPerStockable: 50, + } +} + +func (s *fifoService) withTransaction( + ctx context.Context, + tx *gorm.DB, + fn func(*gorm.DB) error, +) error { + if tx != nil { + return fn(tx.WithContext(ctx)) + } + return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return fn(inner) + }) +} + +func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return db +} + +func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error { + return fifo.RegisterStockable(cfg) +} + +func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error { + return fifo.RegisterUsable(cfg) +} + +type StockReplenishRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + +type PendingResolution struct { + UsableKey fifo.UsableKey + UsableID uint + Quantity float64 +} + +type StockReplenishResult struct { + AddedQuantity float64 + PendingResolved []PendingResolution + RemainingPending float64 +} + +type StockConsumeRequest struct { + UsableKey fifo.UsableKey + UsableID uint + ProductWarehouseID uint + Quantity float64 + AllowPending bool + Note *string + Tx *gorm.DB +} + +type AllocationDetail struct { + StockableKey fifo.StockableKey + StockableID uint + Quantity float64 +} + +type StockConsumeResult struct { + RequestedQuantity float64 + UsageQuantity float64 + PendingQuantity float64 + AddedAllocations []AllocationDetail + ReleasedQuantity float64 +} + +type StockReleaseRequest struct { + UsableKey fifo.UsableKey + UsableID uint + Reason *string + Tx *gorm.DB +} + +func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return nil, errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return nil, errors.New("product warehouse id is required") + } + if req.Quantity <= 0 { + return nil, errors.New("quantity must be greater than zero") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + result := &StockReplenishResult{ + AddedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + + resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID) + if err != nil { + return err + } + result.PendingResolved = resolved + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return nil, errors.New("usable key and id are required") + } + if req.Quantity < 0 { + return nil, errors.New("quantity must be zero or greater") + } + + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return nil, fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + result := &StockConsumeResult{ + RequestedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + + productWarehouseID := ctxRow.ProductWarehouseID + if productWarehouseID == 0 { + return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID) + } + if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID { + return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID) + } + + currentUsage := ctxRow.UsageQty + currentPending := ctxRow.PendingQty + currentTotal := currentUsage + currentPending + delta := req.Quantity - currentTotal + + var ( + usageDelta float64 + pendingDelta float64 + addedAlloc []AllocationDetail + releasedAmount float64 + ) + + switch { + case delta > 0: + allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta) + if err != nil { + return err + } + if allocationRes.pending > 0 && !req.AllowPending { + return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated) + } + + usageDelta += allocationRes.allocated + pendingDelta += allocationRes.pending + addedAlloc = allocationRes.allocations + + if allocationRes.allocated > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -allocationRes.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + } + case delta < 0: + reductionTarget := -delta + + if currentPending > 0 { + pendingReduction := math.Min(currentPending, reductionTarget) + if pendingReduction > 0 { + pendingDelta -= pendingReduction + reductionTarget -= pendingReduction + } + } + + if reductionTarget > 0 { + released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) + if err != nil { + return err + } + if released+1e-6 < reductionTarget { + return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released) + } + usageDelta -= released + releasedAmount = released + } + default: + // no change + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + result.AddedAllocations = addedAlloc + result.ReleasedQuantity = releasedAmount + result.UsageQuantity = currentUsage + usageDelta + result.PendingQuantity = currentPending + pendingDelta + + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return errors.New("usable key and id are required") + } + + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + + var usageDelta, pendingDelta float64 + if ctxRow.UsageQty > 0 { + if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { + return err + } + usageDelta -= ctxRow.UsageQty + } + if ctxRow.PendingQty > 0 { + pendingDelta -= ctxRow.PendingQty + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} + +// --- helpers --- + +type usableContextRow struct { + ProductWarehouseID uint + UsageQty float64 + PendingQty float64 +} + +func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) { + var row usableContextRow + + query := tx.Table(cfg.Table). + Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id). + Clauses(clause.Locking{Strength: "UPDATE"}) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + if err := query.Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("usable record %d not found", id) + } + return nil, err + } + + return &row, nil +} + +func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + column := cfg.Columns.TotalQuantity + + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + updates := map[string]any{ + column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty), + } + if cfg.Columns.TotalUsedQuantity != "" { + updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity)) + } + + return query.Updates(updates).Error +} + +func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + if qty == 0 { + return nil + } + column := cfg.Columns.TotalUsedQuantity + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error +} + +type allocationOutcome struct { + allocated float64 + pending float64 + allocations []AllocationDetail +} + +type stockLot struct { + StockableKey fifo.StockableKey + RecordID uint + AvailableQty float64 + CreatedAt time.Time +} + +func (s *fifoService) allocateFromStock( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + usableKey fifo.UsableKey, + usableID uint, + requestQty float64, +) (*allocationOutcome, error) { + lots, err := s.fetchStockLots(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(lots) == 0 { + return &allocationOutcome{pending: requestQty}, nil + } + + var ( + remaining = requestQty + applied float64 + allocations []*entities.StockAllocation + allocationSummaries []AllocationDetail + usageAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + for _, lot := range lots { + if remaining <= 0 { + break + } + if lot.AvailableQty <= 0 { + continue + } + + portion := lot.AvailableQty + if portion > remaining { + portion = remaining + } + + applied += portion + remaining -= portion + + allocationSummaries = append(allocationSummaries, AllocationDetail{ + StockableKey: lot.StockableKey, + StockableID: lot.RecordID, + Quantity: portion, + }) + + allocations = append(allocations, &entities.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: lot.StockableKey.String(), + StockableId: lot.RecordID, + UsableType: usableKey.String(), + UsableId: usableID, + Qty: portion, + Status: entities.StockAllocationStatusActive, + }) + + if _, ok := usageAdjustments[lot.StockableKey]; !ok { + usageAdjustments[lot.StockableKey] = make(map[uint]float64) + } + usageAdjustments[lot.StockableKey][lot.RecordID] += portion + } + + if len(allocations) > 0 { + if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + for key, deltas := range usageAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil { + return nil, err + } + } + } + } + + return &allocationOutcome{ + allocated: applied, + pending: remaining, + allocations: allocationSummaries, + }, nil +} + +func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) { + configs := fifo.Stockables() + if len(configs) == 0 { + return nil, nil + } + + var lots []stockLot + for key, cfg := range configs { + selectStmt := fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + + var rows []struct { + ID uint + AvailableQty float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity)) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + query = query.Limit(s.maxLotsPerStockable) + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.AvailableQty <= 0 { + continue + } + lots = append(lots, stockLot{ + StockableKey: key, + RecordID: row.ID, + AvailableQty: row.AvailableQty, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(lots) == 0 { + return nil, nil + } + + sort.SliceStable(lots, func(i, j int) bool { + if lots[i].CreatedAt.Equal(lots[j].CreatedAt) { + return lots[i].RecordID < lots[j].RecordID + } + return lots[i].CreatedAt.Before(lots[j].CreatedAt) + }) + + return lots, nil +} + +func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error { + if usageDelta == 0 && pendingDelta == 0 { + return nil + } + + updates := map[string]any{} + if usageDelta != 0 { + updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta) + } + if pendingDelta != 0 { + updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta) + } + + query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Updates(updates).Error +} + +type pendingCandidate struct { + UsableKey fifo.UsableKey + Config fifo.UsableConfig + UsableID uint + Pending float64 + CreatedAt time.Time +} + +func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) { + candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return nil, nil + } + + var resolutions []PendingResolution + + for _, candidate := range candidates { + if candidate.Pending <= 0 { + continue + } + + outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending) + if err != nil { + return nil, err + } + if outcome.allocated <= 0 { + break + } + + if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil { + return nil, err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -outcome.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + resolutions = append(resolutions, PendingResolution{ + UsableKey: candidate.UsableKey, + UsableID: candidate.UsableID, + Quantity: outcome.allocated, + }) + + if outcome.pending > 0 { + // No more stock available for this warehouse at the moment. + break + } + } + + return resolutions, nil +} + +func (s *fifoService) releaseUsagePortion( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, + target float64, +) (float64, error) { + if target <= 0 { + return 0, nil + } + + allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB { + target := s.txOrDB(tx, db) + return target.Clauses(clause.Locking{Strength: "UPDATE"}) + }) + if err != nil { + return 0, err + } + if len(allocations) == 0 { + return 0, nil + } + + var ( + remaining = target + totalReleased float64 + warehouseAdjustments = make(map[uint]float64) + stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + now := time.Now() + + for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- { + allocation := allocations[i] + releaseAmt := allocation.Qty + if releaseAmt > remaining { + releaseAmt = remaining + } + + remaining -= releaseAmt + totalReleased += releaseAmt + warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt + + key := fifo.StockableKey(allocation.StockableType) + if _, ok := stockableAdjustments[key]; !ok { + stockableAdjustments[key] = make(map[uint]float64) + } + stockableAdjustments[key][allocation.StockableId] += releaseAmt + + if releaseAmt == allocation.Qty { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "status": entities.StockAllocationStatusReleased, + "released_at": now, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } else { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "quantity": allocation.Qty - releaseAmt, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } + } + + if totalReleased == 0 { + return 0, nil + } + + for key, deltas := range stockableAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil { + return 0, err + } + } + } + + if len(warehouseAdjustments) > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + + for warehouseID := range warehouseAdjustments { + if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil { + return 0, err + } + } + } + + return totalReleased, nil +} + +func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) { + configs := fifo.Usables() + if len(configs) == 0 { + return nil, nil + } + + var candidates []pendingCandidate + + for key, cfg := range configs { + selectStmt := fmt.Sprintf( + "%s AS id, %s AS pending_qty, %s AS created_at", + cfg.Columns.ID, + cfg.Columns.PendingQuantity, + cfg.Columns.CreatedAt, + ) + + var rows []struct { + ID uint + Pending float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). + Limit(s.pendingBatchPerUsable) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(candidates) == 0 { + return nil, nil + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) { + return candidates[i].UsableID < candidates[j].UsableID + } + return candidates[i].CreatedAt.Before(candidates[j].CreatedAt) + }) + + return candidates, nil +} + +func (s *fifoService) orderClauses(custom []string) []string { + if len(custom) > 0 { + return custom + } + return s.defaultOrderBy +} diff --git a/internal/config/config.go b/internal/config/config.go index 2554bf57..5f76a9e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,14 @@ var ( SSOUserSyncDrift time.Duration SSOUserSyncNonceTTL time.Duration SSOUserSyncMaxBodyBytes int + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3ForcePathStyle bool + S3PublicBaseURL string + S3DocumentKeyPrefix string ) func init() { @@ -106,6 +114,16 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + // Object storage + S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) + S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) + S3Bucket = strings.TrimSpace(viper.GetString("S3_BUCKET")) + S3AccessKey = strings.TrimSpace(viper.GetString("S3_ACCESS_KEY")) + S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY")) + S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE") + S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/") + S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs") + // SSO integration SSOIssuer = viper.GetString("SSO_ISSUER") SSOJWKSURL = viper.GetString("SSO_JWKS_URL") diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql new file mode 100644 index 00000000..955610e9 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS stock_allocations_released_at_idx; +DROP INDEX IF EXISTS stock_allocations_status_idx; +DROP INDEX IF EXISTS stock_allocations_usage_lookup; +DROP INDEX IF EXISTS stock_allocations_lookup; +DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx; + +DROP TABLE IF EXISTS stock_allocations; diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql new file mode 100644 index 00000000..b2a8b053 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS stock_allocations ( + id BIGSERIAL PRIMARY KEY, + product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id), + stockable_type VARCHAR(100) NOT NULL, + stockable_id BIGINT NOT NULL, + usable_type VARCHAR(100) NOT NULL, + usable_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + released_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx + ON stock_allocations (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_lookup + ON stock_allocations (stockable_type, stockable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup + ON stock_allocations (usable_type, usable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_status_idx + ON stock_allocations (status); + +CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx + ON stock_allocations (released_at); diff --git a/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql b/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql new file mode 100644 index 00000000..64964c85 --- /dev/null +++ b/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE products +DROP COLUMN IF EXISTS is_visible; diff --git a/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql b/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql new file mode 100644 index 00000000..965e4f39 --- /dev/null +++ b/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE products +ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/internal/database/migrations/20251202103838_create_document_table.down.sql b/internal/database/migrations/20251202103838_create_document_table.down.sql new file mode 100644 index 00000000..68c3a98a --- /dev/null +++ b/internal/database/migrations/20251202103838_create_document_table.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS documents_documentable_polymorphic; +DROP TABLE IF EXISTS documents; diff --git a/internal/database/migrations/20251202103838_create_document_table.up.sql b/internal/database/migrations/20251202103838_create_document_table.up.sql new file mode 100644 index 00000000..cec686a4 --- /dev/null +++ b/internal/database/migrations/20251202103838_create_document_table.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE documents ( + id BIGSERIAL PRIMARY KEY, + documentable_type VARCHAR(50) NOT NULL, + documentable_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + path VARCHAR(50) NOT NULL, + name VARCHAR(50) NOT NULL, + ext VARCHAR(50) NOT NULL, + size NUMERIC(15, 3) NOT NULL, + created_by BIGINT REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX documents_documentable_polymorphic ON documents (documentable_type, documentable_id); diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql new file mode 100644 index 00000000..38b661a4 --- /dev/null +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql @@ -0,0 +1,35 @@ +BEGIN; + +-- Drop new indexes and FK +DROP INDEX IF EXISTS idx_product_warehouses_project_flock_kandang_id; +DROP INDEX IF EXISTS idx_product_warehouses_unique; + +ALTER TABLE product_warehouses + DROP CONSTRAINT IF EXISTS fk_product_warehouses_project_flock_kandang_id, + ALTER COLUMN project_flock_kandang_id DROP NOT NULL, + DROP COLUMN IF EXISTS project_flock_kandang_id; + +-- Revert qty to integer quantity +ALTER TABLE product_warehouses + RENAME COLUMN qty TO quantity; + +ALTER TABLE product_warehouses + ALTER COLUMN quantity TYPE INTEGER USING quantity::integer, + ALTER COLUMN quantity SET DEFAULT 0, + ALTER COLUMN quantity SET NOT NULL; + +-- Restore audit/soft-delete columns +ALTER TABLE product_warehouses + ADD COLUMN IF NOT EXISTS created_by BIGINT NOT NULL REFERENCES users (id), + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Recreate prior indexes +CREATE INDEX IF NOT EXISTS idx_product_warehouses_deleted_at ON product_warehouses (deleted_at); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique + ON product_warehouses (product_id, warehouse_id) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql new file mode 100644 index 00000000..cb1e16bc --- /dev/null +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql @@ -0,0 +1,41 @@ +BEGIN; + +-- Drop indexes that depend on deleted_at or old uniqueness +DROP INDEX IF EXISTS idx_product_warehouses_deleted_at; +DROP INDEX IF EXISTS idx_product_warehouses_unique; + +-- Add new relation and adjust quantity column +ALTER TABLE product_warehouses + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; + +ALTER TABLE product_warehouses + RENAME COLUMN quantity TO qty; + +-- Enforce numeric quantity with precision and default +ALTER TABLE product_warehouses + ALTER COLUMN qty TYPE NUMERIC(15, 3) USING qty::numeric(15, 3), + ALTER COLUMN qty SET DEFAULT 0, + ALTER COLUMN qty SET NOT NULL; + +-- Remove audit/soft-delete columns no longer used +ALTER TABLE product_warehouses + DROP COLUMN IF EXISTS created_by, + DROP COLUMN IF EXISTS created_at, + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS deleted_at; + +-- Enforce FK and not-null for project_flock_kandang_id +ALTER TABLE product_warehouses + ADD CONSTRAINT fk_product_warehouses_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- New indexes +CREATE INDEX IF NOT EXISTS idx_product_warehouses_project_flock_kandang_id + ON product_warehouses (project_flock_kandang_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique + ON product_warehouses (product_id, warehouse_id, project_flock_kandang_id); + +COMMIT; diff --git a/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql b/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql new file mode 100644 index 00000000..9f9b7aa4 --- /dev/null +++ b/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql @@ -0,0 +1,44 @@ +BEGIN; + +-- Drop new indexes +DROP INDEX IF EXISTS stock_logs_loggable_type_loggable_id_idx; +DROP INDEX IF EXISTS stock_logs_product_warehouse_id_idx; +DROP INDEX IF EXISTS stock_logs_created_by_idx; +DROP INDEX IF EXISTS stock_logs_created_at_idx; + +-- Restore obsolete columns +ALTER TABLE stock_logs + ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20) DEFAULT '' NOT NULL, + ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Rename columns back +ALTER TABLE stock_logs + RENAME COLUMN loggable_type TO log_type; + +ALTER TABLE stock_logs + RENAME COLUMN loggable_id TO log_id; + +ALTER TABLE stock_logs + RENAME COLUMN notes TO note; + +-- Drop new columns +ALTER TABLE stock_logs + DROP COLUMN IF EXISTS increase, + DROP COLUMN IF EXISTS decrease; + +-- Restore indexes for old structure +CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_logs_log_type_log_id_idx ON stock_logs (log_type, log_id); + +CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by); + +CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at); + +CREATE INDEX IF NOT EXISTS stock_logs_deleted_at_idx ON stock_logs (deleted_at); + +COMMIT; diff --git a/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql b/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql new file mode 100644 index 00000000..0501140f --- /dev/null +++ b/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql @@ -0,0 +1,50 @@ +BEGIN; + +-- Drop old indexes tied to removed columns +DROP INDEX IF EXISTS stock_logs_log_type_log_id_idx; +DROP INDEX IF EXISTS stock_logs_deleted_at_idx; + +-- Rename columns to new naming +ALTER TABLE stock_logs + RENAME COLUMN log_type TO loggable_type; + +ALTER TABLE stock_logs + RENAME COLUMN log_id TO loggable_id; + +ALTER TABLE stock_logs + RENAME COLUMN note TO notes; + +-- Add new increase/decrease columns +ALTER TABLE stock_logs + ADD COLUMN IF NOT EXISTS increase NUMERIC(15, 3) DEFAULT 0, + ADD COLUMN IF NOT EXISTS decrease NUMERIC(15, 3) DEFAULT 0; + +-- Adjust column definitions +ALTER TABLE stock_logs + ALTER COLUMN loggable_type TYPE VARCHAR(50), + ALTER COLUMN loggable_type SET NOT NULL, + ALTER COLUMN loggable_id SET NOT NULL, + ALTER COLUMN increase SET DEFAULT 0, + ALTER COLUMN increase SET NOT NULL, + ALTER COLUMN decrease SET DEFAULT 0, + ALTER COLUMN decrease SET NOT NULL; + +-- Remove obsolete columns +ALTER TABLE stock_logs + DROP COLUMN IF EXISTS transaction_type, + DROP COLUMN IF EXISTS quantity, + DROP COLUMN IF EXISTS before_quantity, + DROP COLUMN IF EXISTS after_quantity, + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS deleted_at; + +-- Recreate indexes for new structure +CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_logs_loggable_type_loggable_id_idx ON stock_logs (loggable_type, loggable_id); + +CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by); + +CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at); + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index d848711e..8da408ca 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -910,7 +910,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { ProductId: product.Id, WarehouseId: warehouse.Id, Quantity: seed.Quantity, - CreatedBy: createdBy, + // CreatedBy: createdBy, } if err := tx.Create(&productWarehouse).Error; err != nil { return err diff --git a/internal/entities/document.go b/internal/entities/document.go new file mode 100644 index 00000000..54974a02 --- /dev/null +++ b/internal/entities/document.go @@ -0,0 +1,18 @@ +package entities + +import "time" + +type Document struct { + Id uint `gorm:"primaryKey"` + DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"` + DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"` + Type string `gorm:"size:50;not null"` + Path string `gorm:"size:50;not null"` + Name string `gorm:"size:50;not null"` + Ext string `gorm:"size:50;not null"` + Size float64 `gorm:"type:numeric(15,3);not null"` + CreatedBy *uint `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/product.go b/internal/entities/product.go index 8f025fff..d8ce59fc 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -21,10 +21,12 @@ type Product struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + IsVisible bool `gorm:"column:is_visible;default:true"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Uom Uom `gorm:"foreignKey:UomId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - ProductSuppliers []ProductSupplier `gorm:"foreignKey:ProductId;references:Id"` - Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:products"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Uom Uom `gorm:"foreignKey:UomId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + ProductSuppliers []ProductSupplier `gorm:"foreignKey:ProductId;references:Id"` + Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:products"` + ProductWarehouses []ProductWarehouse `gorm:"foreignKey:ProductId;references:Id"` } diff --git a/internal/entities/product_warehouse.go b/internal/entities/product_warehouse.go index 745dd298..0837cc45 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -1,23 +1,14 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) - type ProductWarehouse struct { - Id uint `gorm:"primaryKey;autoIncrement"` - ProductId uint `gorm:"not null"` - WarehouseId uint `gorm:"not null"` - Quantity float64 `gorm:"default:0"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - CreatedBy uint `gorm:"not null"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Id uint `gorm:"primaryKey;column:id"` + ProductId uint `gorm:"column:product_id;not null"` + WarehouseId uint `gorm:"column:warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` // Relations - Product Product `gorm:"foreignKey:ProductId;references:Id"` - Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Product Product `gorm:"foreignKey:ProductId;references:Id"` + Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` + StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go new file mode 100644 index 00000000..614762a1 --- /dev/null +++ b/internal/entities/stock_allocation.go @@ -0,0 +1,33 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + StockAllocationStatusPending = "PENDING" + StockAllocationStatusActive = "ACTIVE" + StockAllocationStatusReleased = "RELEASED" +) + +// StockAllocation links a usable record (consumption) with an incoming stock record. +// The combination lets us trace FIFO deductions while keeping each module focused on its own fields. +type StockAllocation struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null;index"` + StockableType string `gorm:"size:100;not null;index:stock_allocations_lookup,priority:1"` + StockableId uint `gorm:"not null;index:stock_allocations_lookup,priority:2"` + UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` + UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Status string `gorm:"size:20;not null;default:ACTIVE"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ReleasedAt *time.Time `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 6546e790..310d8cf8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -1,10 +1,6 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) +import "time" const ( LogTypeAdjustment = "ADJUSTMENT" @@ -17,19 +13,18 @@ const ( ) type StockLog struct { - Id uint `gorm:"primaryKey;column:id"` - TransactionType string `gorm:"type:varchar(20);not null"` - Quantity float64 `gorm:"type:numeric(15,3);not null"` - BeforeQuantity float64 `gorm:"type:numeric(15,3);not null"` - AfterQuantity float64 `gorm:"type:numeric(15,3);not null"` - LogType string `gorm:"type:varchar(50);not null;index:stock_logs_flaggable_lookup,priority:1"` - LogId uint `gorm:"not null;index:stock_logs_flaggable_lookup,priority:2"` - Note string `gorm:"type:text"` - ProductWarehouseId uint `gorm:"not null;index"` - CreatedBy uint `gorm:"index"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Id uint `gorm:"primaryKey;column:id"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` + CreatedBy uint `gorm:"column:created_by;not null;index"` + + Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"` + Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"` + + LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"` + LoggableId uint `gorm:"column:loggable_id;not null"` + + Notes string `gorm:"column:notes;type:text"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` ProductWarehouse *ProductWarehouse `json:"product_warehouse,omitempty" gorm:"foreignKey:ProductWarehouseId;references:Id"` CreatedUser *User `json:"created_user,omitempty" gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go new file mode 100644 index 00000000..4918c28f --- /dev/null +++ b/internal/modules/closings/controllers/closing.controller.go @@ -0,0 +1,76 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ClosingController struct { + ClosingService service.ClosingService +} + +func NewClosingController(closingService service.ClosingService) *ClosingController { + return &ClosingController{ + ClosingService: closingService, + } +} + +func (u *ClosingController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ClosingService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all closings successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToClosingListDTOs(result), + }) +} + +func (u *ClosingController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ClosingService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing successfully", + Data: dto.ToClosingListDTO(*result), + }) +} diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go new file mode 100644 index 00000000..ccb014e6 --- /dev/null +++ b/internal/modules/closings/dto/closing.dto.go @@ -0,0 +1,64 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ClosingRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ClosingListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ClosingDetailDTO struct { + ClosingListDTO +} + +// === Mapper Functions === + +func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO { + return ClosingRelationDTO{ + Id: e.Id, + } +} + +func ToClosingListDTO(e entity.ProjectFlock) ClosingListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ClosingListDTO{ + Id: e.Id, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToClosingListDTOs(e []entity.ProjectFlock) []ClosingListDTO { + result := make([]ClosingListDTO, len(e)) + for i, r := range e { + result[i] = ToClosingListDTO(r) + } + return result +} + +func ToClosingDetailDTO(e entity.ProjectFlock) ClosingDetailDTO { + return ClosingDetailDTO{ + ClosingListDTO: ToClosingListDTO(e), + } +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go new file mode 100644 index 00000000..d831195c --- /dev/null +++ b/internal/modules/closings/module.go @@ -0,0 +1,26 @@ +package closings + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ClosingModule struct{} + +func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + closingRepo := rClosing.NewClosingRepository(db) + userRepo := rUser.NewUserRepository(db) + + closingService := sClosing.NewClosingService(closingRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ClosingRoutes(router, userService, closingService) +} + diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go new file mode 100644 index 00000000..946797fd --- /dev/null +++ b/internal/modules/closings/repositories/closing.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ClosingRepository interface { + repository.BaseRepository[entity.ProjectFlock] +} + +type ClosingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlock] +} + +func NewClosingRepository(db *gorm.DB) ClosingRepository { + return &ClosingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), + } +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go new file mode 100644 index 00000000..6570a17d --- /dev/null +++ b/internal/modules/closings/route.go @@ -0,0 +1,25 @@ +package closings + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers" + closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) { + ctrl := controller.NewClosingController(s) + + route := v1.Group("/closings") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go new file mode 100644 index 00000000..fd1b42eb --- /dev/null +++ b/internal/modules/closings/services/closing.service.go @@ -0,0 +1,72 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ClosingService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) +} + +type closingService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository +} + +func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService { + return &closingService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s closingService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get closings: %+v", err) + return nil, 0, err + } + return closings, total, nil +} + +func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found") + } + if err != nil { + s.Log.Errorf("Failed get closing by id: %+v", err) + return nil, err + } + return closing, nil +} diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/closings/validations/closing.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index f91e6eda..556050f4 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -104,12 +104,12 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { return AdjustmentRelationDTO{ - Id: e.Id, - TransactionType: e.TransactionType, - Quantity: e.Quantity, - BeforeQuantity: e.BeforeQuantity, - AfterQuantity: e.AfterQuantity, - Note: e.Note, + Id: e.Id, + // TransactionType: e.LoggableType, + // Quantity: e.Q, + // BeforeQuantity: e.BeforeQuantity, + // AfterQuantity: e.AfterQuantity, + Note: e.Notes, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } @@ -136,6 +136,6 @@ func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - UpdatedAt: e.UpdatedAt, + // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 1a7dcfc1..78f4fbde 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -66,7 +66,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LogType != entity.LogTypeAdjustment { + if stockLog.LoggableType != entity.LogTypeAdjustment { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -110,7 +110,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ProductId: uint(req.ProductID), WarehouseId: uint(req.WarehouseID), Quantity: 0, - CreatedBy: actorID, + // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { @@ -128,25 +128,23 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } afterQuantity := productWarehouse.Quantity + newLog := &entity.StockLog{ + // TransactionType: transactionType, + LoggableType: entity.LogTypeAdjustment, + LoggableId: 0, + Notes: req.Note, + ProductWarehouseId: productWarehouse.Id, + CreatedBy: actorID, // TODO: should Get from auth middleware + } if transactionType == entity.TransactionTypeIncrease { afterQuantity += req.Quantity + newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") } afterQuantity -= req.Quantity - } - - newLog := &entity.StockLog{ - TransactionType: transactionType, - Quantity: req.Quantity, - BeforeQuantity: productWarehouse.Quantity, - AfterQuantity: afterQuantity, - LogType: entity.LogTypeAdjustment, - LogId: 0, - Note: req.Note, - ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, + newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { diff --git a/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go b/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go new file mode 100644 index 00000000..430941ae --- /dev/null +++ b/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go @@ -0,0 +1,77 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" + // entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ProductStockController struct { + ProductStockService service.ProductStockService +} + +func NewProductStockController(productStockService service.ProductStockService) *ProductStockController { + return &ProductStockController{ + ProductStockService: productStockService, + } +} + +func (u *ProductStockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductStockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductStockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productStocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductStockListDTOs(result), + }) +} + +func (u *ProductStockController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + res, err := u.ProductStockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved product successfully", + Data: dto.ToProductStockDetailDTO(*res), + }) +} diff --git a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go new file mode 100644 index 00000000..e571d2b6 --- /dev/null +++ b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go @@ -0,0 +1,224 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductStockRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductStockListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Brand string `json:"brand"` + Sku *string `json:"sku,omitempty"` + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price,omitempty"` + Tax *float64 `json:"tax,omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty"` + Flags []string `json:"flags"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Suppliers []SupplierDTO `json:"suppliers,omitempty"` + ProductWarehouses []ProductWarehouseDTO `json:"product_warehouses,omitempty"` + TotalStock float64 `json:"total_stock"` +} + +type ProductStockDetailDTO struct { + ProductStockListDTO +} + +type SupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type ProductWarehouseDTO struct { + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + WarehouseName string `json:"warehouse_name"` + Location *locationDTO.LocationRelationDTO `json:"location"` + CurrentStock float64 `json:"current_stock"` + StockLogs []StockLogDetailDTO `json:"stock_logs"` +} + +type StockLogDetailDTO struct { + Id uint `json:"id"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` + LoggableType string `json:"loggable_type"` + LoggableId uint `json:"loggable_id"` + Notes *string `json:"notes"` + ProductWarehouseId uint `json:"product_warehouse_id"` + CreatedBy uint `json:"created_by"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// === Mapper Functions === +func ToProductStockListDTO(e entity.Product) ProductStockListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + var categoryRef *productCategoryDTO.ProductCategoryRelationDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory) + categoryRef = &mapped + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + var uomRef *uomDTO.UomRelationDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomRelationDTO(e.Uom) + uomRef = &mapped + } + + return ProductStockListDTO{ + Id: e.Id, + Name: e.Name, + Flags: flags, + Uom: uomRef, + Brand: e.Brand, + Sku: e.Sku, + ProductPrice: e.ProductPrice, + SellingPrice: e.SellingPrice, + Tax: e.Tax, + ExpiryPeriod: e.ExpiryPeriod, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + ProductCategory: categoryRef, + Suppliers: mapSupplierDTOs(e.ProductSuppliers), + TotalStock: calculateTotalStock(e.ProductWarehouses), + } +} + +func ToProductStockListDTOs(e []entity.Product) []ProductStockListDTO { + result := make([]ProductStockListDTO, len(e)) + for i, r := range e { + result[i] = ToProductStockListDTO(r) + } + return result +} + +func ToProductStockDetailDTO(e entity.Product) ProductStockDetailDTO { + base := ToProductStockListDTO(e) + base.ProductWarehouses = mapProductWarehouseDTOs(e.ProductWarehouses) + + return ProductStockDetailDTO{ + ProductStockListDTO: base, + } +} + +// --- helpers --- + +func mapSupplierDTOs(src []entity.ProductSupplier) []SupplierDTO { + if len(src) == 0 { + return nil + } + result := make([]SupplierDTO, 0, len(src)) + for _, ps := range src { + if ps.Supplier.Id == 0 { + continue + } + result = append(result, SupplierDTO{ + Id: ps.Supplier.Id, + Name: ps.Supplier.Name, + Alias: ps.Supplier.Alias, + Category: ps.Supplier.Category, + }) + } + return result +} + +func mapProductWarehouseDTOs(src []entity.ProductWarehouse) []ProductWarehouseDTO { + if len(src) == 0 { + return []ProductWarehouseDTO{} + } + result := make([]ProductWarehouseDTO, 0, len(src)) + for _, pw := range src { + dto := ProductWarehouseDTO{ + Id: pw.Id, + ProductId: pw.ProductId, + WarehouseId: pw.WarehouseId, + CurrentStock: pw.Quantity, + StockLogs: mapStockLogs(pw.StockLogs), + } + if pw.Warehouse.Id != 0 { + dto.WarehouseName = pw.Warehouse.Name + if pw.Warehouse.Location != nil { + mapped := locationDTO.ToLocationRelationDTO(*pw.Warehouse.Location) + dto.Location = &mapped + } + } + result = append(result, dto) + } + return result +} + +func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO { + if len(src) == 0 { + return []StockLogDetailDTO{} + } + result := make([]StockLogDetailDTO, 0, len(src)) + for _, log := range src { + var notes *string + if log.Notes != "" { + n := log.Notes + notes = &n + } + + result = append(result, StockLogDetailDTO{ + Id: log.Id, + Increase: log.Increase, + Decrease: log.Decrease, + LoggableType: log.LoggableType, + LoggableId: log.LoggableId, + Notes: notes, + ProductWarehouseId: log.ProductWarehouseId, + CreatedBy: log.CreatedBy, + CreatedUser: mapCreatedUser(log.CreatedUser), + CreatedAt: log.CreatedAt, + }) + } + return result +} + +func mapCreatedUser(user *entity.User) *userDTO.UserRelationDTO { + if user == nil || user.Id == 0 { + return nil + } + mapped := userDTO.ToUserRelationDTO(*user) + return &mapped +} + +func calculateTotalStock(productWarehouses []entity.ProductWarehouse) float64 { + var total float64 + for _, pw := range productWarehouses { + total += pw.Quantity + } + return total +} diff --git a/internal/modules/inventory/product-stocks/module.go b/internal/modules/inventory/product-stocks/module.go new file mode 100644 index 00000000..43bcd1be --- /dev/null +++ b/internal/modules/inventory/product-stocks/module.go @@ -0,0 +1,25 @@ +package productStocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sProductStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductStockModule struct{} + +func (ProductStockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productRepo := rProduct.NewProductRepository(db) + userRepo := rUser.NewUserRepository(db) + + productStockService := sProductStock.NewProductStockService(productRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProductStockRoutes(router, userService, productStockService) +} diff --git a/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go b/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go new file mode 100644 index 00000000..d6e5368d --- /dev/null +++ b/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go @@ -0,0 +1,21 @@ +package repository + +// import ( +// entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/common/repository" +// "gorm.io/gorm" +// ) + +// type ProductStockRepository interface { +// repository.BaseRepository[entity.ProductStock] +// } + +// type ProductStockRepositoryImpl struct { +// *repository.BaseRepositoryImpl[entity.ProductStock] +// } + +// func NewProductStockRepository(db *gorm.DB) ProductStockRepository { +// return &ProductStockRepositoryImpl{ +// BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductStock](db), +// } +// } diff --git a/internal/modules/inventory/product-stocks/route.go b/internal/modules/inventory/product-stocks/route.go new file mode 100644 index 00000000..c7bb37f8 --- /dev/null +++ b/internal/modules/inventory/product-stocks/route.go @@ -0,0 +1,25 @@ +package productStocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/controllers" + productStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductStockRoutes(v1 fiber.Router, u user.UserService, s productStock.ProductStockService) { + ctrl := controller.NewProductStockController(s) + + route := v1.Group("/product-stocks") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) +} diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go new file mode 100644 index 00000000..a0765d84 --- /dev/null +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -0,0 +1,91 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" + productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProductStockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Product, error) +} + +type productStockService struct { + Log *logrus.Logger + Validate *validator.Validate + ProductRepository productRepository.ProductRepository +} + +func NewProductStockService( + productRepo productRepository.ProductRepository, + validate *validator.Validate, +) ProductStockService { + return &productStockService{ + Log: utils.Log, + Validate: validate, + ProductRepository: productRepo, + } +} + +func (s productStockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Uom"). + Preload("ProductCategory"). + Preload("Flags"). + Preload("ProductWarehouses"). + Preload("ProductWarehouses.Warehouse"). + Preload("ProductWarehouses.Warehouse.Location"). + Preload("ProductWarehouses.Warehouse.Location.Area"). + Preload("ProductWarehouses.StockLogs", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC") + }). + Preload("ProductWarehouses.StockLogs.CreatedUser"). + Preload("ProductSuppliers"). + Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB { + return db.Order("suppliers.name ASC") + }) +} + +func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name ILIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productStocks: %+v", err) + return nil, 0, err + } + return productStocks, total, nil +} + +func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + if err != nil { + s.Log.Errorf("Failed get product by id: %+v", err) + return nil, err + } + return product, nil +} diff --git a/internal/modules/inventory/product-stocks/validations/product-stock.validation.go b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 06889670..81fbec1f 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -98,8 +98,8 @@ func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNeste func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, + // CreatedAt: e.CreatedAt, + // UpdatedAt: e.UpdatedAt, } // Map Product relation jika ada @@ -140,13 +140,13 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT } // Map CreatedUser relation jika ada - if e.CreatedUser.Id != 0 { - user := UserRelationDTO{ - Id: e.CreatedUser.Id, - Username: e.CreatedUser.Name, - } - dto.CreatedUser = &user - } + // if e.CreatedUser.Id != 0 { + // user := UserRelationDTO{ + // Id: e.CreatedUser.Id, + // Username: e.CreatedUser.Name, + // } + // dto.CreatedUser = &user + // } return dto } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index b285bbc6..94652000 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -213,11 +213,11 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ProductId: productID, WarehouseId: warehouseID, Quantity: 0, - CreatedBy: uint(createdBy), - } - if entity.CreatedBy == 0 { - entity.CreatedBy = 1 + // CreatedBy: uint(createdBy), } + // if entity.CreatedBy == 0 { + // entity.CreatedBy = 1 + // } if err := r.CreateOne(ctx, entity, nil); err != nil { return 0, err diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index a0e98154..0d4d2f4b 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks" productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS @@ -21,6 +22,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida adjustments.AdjustmentModule{}, transfers.TransferModule{}, + productStocks.ProductStockModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index a21126a6..ef273664 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -271,15 +271,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) // create stock log for decrease (source) - beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased + // beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased decreaseLog := &entity.StockLog{ - TransactionType: entity.TransactionTypeDecrease, - Quantity: product.ProductQty, - BeforeQuantity: beforeQty, - AfterQuantity: sourcePW.Quantity, - LogType: entity.LogTypeTransfer, - LogId: uint(entityTransfer.Id), - Note: "", + // TransactionType: entity.TransactionTypeDecrease, + // Quantity: product.ProductQty, + // BeforeQuantity: beforeQty, + // AfterQuantity: sourcePW.Qty, + // LogType: entity.LogTypeTransfer, + // LogId: uint(entityTransfer.Id), + Decrease: product.ProductQty, + Notes: "", + LoggableType: entity.LogTypeTransfer, + LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, } @@ -302,7 +305,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProductId: uint(product.ProductID), WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, - CreatedBy: actorID, + // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { s.Log.Errorf("Failed to create destination product warehouse: %+v", err) @@ -319,15 +322,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) // create stock log for increase (destination) - beforeDestQty := destPW.Quantity - product.ProductQty + // beforeDestQty := destPW.Quantity - product.ProductQty increaseLog := &entity.StockLog{ - TransactionType: entity.TransactionTypeIncrease, - Quantity: product.ProductQty, - BeforeQuantity: beforeDestQty, - AfterQuantity: destPW.Quantity, - LogType: entity.LogTypeTransfer, - LogId: uint(entityTransfer.Id), - Note: "", + // TransactionType: entity.TransactionTypeIncrease, + // Quantity: product.ProductQty, + // BeforeQuantity: beforeDestQty, + // AfterQuantity: destPW.Qty, + Increase: product.ProductQty, + LoggableType: entity.LogTypeTransfer, + LoggableId: uint(entityTransfer.Id), + Notes: "", ProductWarehouseId: destPW.Id, CreatedBy: actorID, } diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/sales-orders/services/sales-orders.service.go index 4ef59bef..8acef29d 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/sales-orders/services/sales-orders.service.go @@ -344,7 +344,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return fiber.NewError(fiber.StatusInternalServerError, "Failed to create update approval") } } - } return nil diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 4d06aef7..660f1e7e 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -557,7 +557,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId ProductId: product.Id, WarehouseId: warehouseId, Quantity: 0, - CreatedBy: actorID, + // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index ff6b4ea0..341031e1 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,6 +2,7 @@ package recordings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -14,6 +15,7 @@ import ( rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -26,6 +28,25 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { @@ -41,6 +62,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, approvalRepo, approvalService, + fifoService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d9512edd..5feb8d6b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -25,6 +25,7 @@ type RecordingRepository interface { CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) + UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error @@ -120,6 +121,15 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e return items, nil } +func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error { + return tx.Model(&entity.RecordingStock{}). + Where("id = ?", stockID). + Updates(map[string]any{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 4ed99685..82f60433 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -14,6 +14,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "math" "strings" @@ -36,6 +37,13 @@ type RecordingService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } +type RecordingFIFOIntegrationService interface { + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error +} + +var recordingStockUsableKey = fifo.UsableKeyRecordingStock + type recordingService struct { Log *logrus.Logger Validate *validator.Validate @@ -45,6 +53,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewRecordingService( @@ -54,6 +63,7 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -65,6 +75,20 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, + } +} + +func NewRecordingFIFOIntegrationService( + repo repository.RecordingRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + fifoSvc commonSvc.FifoService, +) RecordingFIFOIntegrationService { + return &recordingService{ + Log: utils.Log, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + FifoSvc: fifoSvc, } } @@ -222,6 +246,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + return err + } + mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) @@ -234,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -347,6 +375,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } + if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil { + return err + } + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear stocks: %+v", err) return err @@ -358,8 +390,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } } @@ -691,7 +722,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil { return err } @@ -746,6 +781,77 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } +func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desired, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.consumeRecordingStocks(ctx, tx, stocks) +} + +func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.releaseRecordingStocks(ctx, tx, stocks) +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldStocks, newStocks []entity.RecordingStock, @@ -758,12 +864,6 @@ func buildWarehouseDeltas( for _, item := range newDepletions { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) } - for _, item := range oldStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty)) - } - for _, item := range newStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty)) - } for _, item := range oldEggs { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) } @@ -773,13 +873,6 @@ func buildWarehouseDeltas( return deltas } -func usageQtyValue(val *float64) float64 { - if val == nil { - return 0 - } - return *val -} - func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { if id == 0 || value == 0 { return diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index bb6d44b1..bf2c2ae3 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -778,7 +778,7 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, ProductId: productID, WarehouseId: warehouseID, Quantity: quantity, - CreatedBy: actorID, + // CreatedBy: actorID, } if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { diff --git a/internal/route/route.go b/internal/route/route.go index ac7fb486..4d1c1bae 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" + closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" @@ -40,6 +41,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, expenses.ExpenseModule{}, ssoModule.Module{}, + closings.ClosingModule{}, // MODULE REGISTRY } diff --git a/internal/utils/fifo/README.md b/internal/utils/fifo/README.md new file mode 100644 index 00000000..86f2af6d --- /dev/null +++ b/internal/utils/fifo/README.md @@ -0,0 +1,67 @@ +# Mesin Stok FIFO + +Utilitas FIFO bersifat reusable dan dibagi menjadi dua lapis: + +1. **Registry (`internal/utils/fifo`)** – mendeklarasikan tabel mana yang bersifat `Stockable` (sumber stok) atau `Usable` (pemakai stok). Setiap modul cukup menyebutkan nama tabel dan kolom wajib: + - Stockable: `id`, `product_warehouse_id`, `total_qty`, `total_used_qty`, `created_at` + - Usable: `id`, `product_warehouse_id`, `usage_qty`, `pending_qty`, `created_at` +2. **Service (`internal/common/service/common.fifo.service.go`)** – memakai registry tersebut untuk: + - Menambah stok baru (`Replenish`). + - Menyinkronkan total pemakaian (`Consume`). Method ini idempotent: panggil dengan *total kuantitas yang diinginkan* (mis. saat create/update/delete). Service menghitung selisih terhadap `usage_qty + pending_qty`, kemudian otomatis mengalokasikan tambahan atau melepaskan selisihnya. + - Membatalkan pemakaian (`ReleaseUsage`) yang mengembalikan stok lalu memicu alokasi ulang ke antrian pending. + - Baik `Replenish` maupun pelepasan stok akan menjalankan `resolvePendingForWarehouse`, sehingga pending tertua langsung terisi ketika stok tersedia. + +## Registrasi tabel + +```go +import ( + commonservice "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func init() { + fifoSvc := commonservice.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_DETAIL"), + Table: "purchase_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }) + + fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("RECORDING_STOCK"), + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) +} +``` + +Each registration optionally accepts an order clause or base scope (e.g. to exclude drafts). + +Setiap registrasi bisa diberi klausa urutan atau scope dasar (mis. untuk mengecualikan draft). + +## Menggunakan service di modul + +1. **Saat stok masuk** (mis. purchase selesai): panggil `fifoSvc.Replenish(...)` dengan key stockable, id record, id product warehouse, dan kuantitas yang baru tersedia. Service akan: + - Menambah `total_qty` pada tabel stockable, + - Menambah `product_warehouses.quantity`, + - Mencoba membersihkan `pending_qty` dari semua usable yang terdaftar (sesuai urutan FIFO). +2. **Saat modul memakai stok** (recording, marketing, dsb.) panggil `fifoSvc.Consume(...)` dengan total qty terbaru. + - Jika qty baru lebih besar, service mengambil stok FIFO dan menambah `usage_qty`; kekurangan dicatat sebagai `pending_qty`. + - Jika qty baru lebih kecil, service otomatis menurunkan `pending_qty` lebih dulu, lalu melepaskan alokasi aktif (stok kembali ke gudang) dan langsung dipakai untuk mengisi pending milik entitas lain. + - Hapus data? panggil `Consume` dengan qty 0 atau gunakan `ReleaseUsage`. +3. **Jika dibatalkan penuh**: `fifoSvc.ReleaseUsage(...)` mengosongkan `usage_qty/pending_qty` dan menandai baris pivot sebagai `RELEASED`. + +Tabel pivot (`stock_allocations`) menyimpan asal pemakaian secara presisi, sehingga audit trail dan rollback stok menjadi deterministik. diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go new file mode 100644 index 00000000..c47d3cd7 --- /dev/null +++ b/internal/utils/fifo/constants.go @@ -0,0 +1,5 @@ +package fifo + +const ( + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" +) diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go new file mode 100644 index 00000000..61fed294 --- /dev/null +++ b/internal/utils/fifo/registry.go @@ -0,0 +1,204 @@ +package fifo + +import ( + "errors" + "fmt" + "strings" + "sync" + + "gorm.io/gorm" +) + +// QueryScope allows callers to inject custom query modifiers (preloads, filters, etc). +type QueryScope func(*gorm.DB) *gorm.DB + +type StockableKey string +type UsableKey string + +func (k StockableKey) String() string { + return string(k) +} + +func (k UsableKey) String() string { + return string(k) +} + +// StockableColumns describes the minimum columns required for a stock-bearing row. +type StockableColumns struct { + ID string + ProductWarehouseID string + TotalQuantity string + TotalUsedQuantity string + CreatedAt string +} + +// UsableColumns describes the required columns for rows that consume stock. +type UsableColumns struct { + ID string + ProductWarehouseID string + UsageQuantity string + PendingQuantity string + CreatedAt string +} + +// StockableConfig registers a table that introduces stock into the system (purchases, transfers, etc). +type StockableConfig struct { + Key StockableKey + Table string + Columns StockableColumns + // OrderBy accepts raw column expressions, evaluated in-order (e.g. []string{"created_at ASC", "id ASC"}). + OrderBy []string + // Scope lets a module append base filters (e.g. exclude drafts). + Scope QueryScope +} + +// UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc). +type UsableConfig struct { + Key UsableKey + Table string + Columns UsableColumns + OrderBy []string + Scope QueryScope +} + +var ( + stockableRegistry = make(map[StockableKey]StockableConfig) + usableRegistry = make(map[UsableKey]UsableConfig) + registryMu sync.RWMutex +) + +// RegisterStockable stores the configuration so services can perform FIFO operations generically. +func RegisterStockable(cfg StockableConfig) error { + if err := validateStockableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := StockableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := stockableRegistry[key]; exists { + return fmt.Errorf("stockable key %q already registered", key) + } + + stockableRegistry[key] = cfg + return nil +} + +// RegisterUsable stores the configuration for stock-consuming tables. +func RegisterUsable(cfg UsableConfig) error { + if err := validateUsableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := UsableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := usableRegistry[key]; exists { + return fmt.Errorf("usable key %q already registered", key) + } + + usableRegistry[key] = cfg + return nil +} + +// Stockable returns the registered configuration for the key (if any). +func Stockable(key StockableKey) (StockableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := stockableRegistry[key] + return cfg, ok +} + +// Usable returns the registered configuration for the key (if any). +func Usable(key UsableKey) (UsableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := usableRegistry[key] + return cfg, ok +} + +// Stockables exposes a copy of the current registry (useful for iterating pending requests). +func Stockables() map[StockableKey]StockableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(stockableRegistry) == 0 { + return nil + } + + result := make(map[StockableKey]StockableConfig, len(stockableRegistry)) + for key, cfg := range stockableRegistry { + result[key] = cfg + } + return result +} + +// Usables exposes a copy of the usable registry. +func Usables() map[UsableKey]UsableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(usableRegistry) == 0 { + return nil + } + + result := make(map[UsableKey]UsableConfig, len(usableRegistry)) + for key, cfg := range usableRegistry { + result[key] = cfg + } + return result +} + +func validateStockableConfig(cfg StockableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("stockable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for stockable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalQuantity) == "": + return fmt.Errorf("column total quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalUsedQuantity) == "": + return fmt.Errorf("column total used quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for stockable %q", cfg.Key) + } + + return nil +} + +func validateUsableConfig(cfg UsableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("usable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for usable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.UsageQuantity) == "": + return fmt.Errorf("column usage quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.PendingQuantity) == "": + return fmt.Errorf("column pending quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for usable %q", cfg.Key) + } + + return nil +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go new file mode 100644 index 00000000..a845e1a2 --- /dev/null +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -0,0 +1,446 @@ +package test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) { + db, svc, _, _ := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (pending) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available") + assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request") + assertWarehouseQuantity(t, db, productWarehouse.Id, 0) + assertAllocationCount(t, db, 0) + + assertAllocationCount(t, db, 0) +} + +func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 12, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (initial) failed: %v", err) + } + + assertWarehouseQuantity(t, db, productWarehouse.Id, 2) + + desired := 4.0 + stock.UsageQty = &desired + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (edit) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize") + assertWarehouseQuantity(t, db, productWarehouse.Id, 8) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusActive { + t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status) + } + if mathAbs(alloc.Qty-4) > 1e-6 { + t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty) + } +} + +func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 10, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks failed: %v", err) + } + + if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("releaseRecordingStocks failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete") + assertWarehouseQuantity(t, db, productWarehouse.Id, 10) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusReleased { + t.Fatalf("expected allocation to be released, got %s", alloc.Status) + } +} + +// --- helpers ---------------------------------------------------------------- + +type recordingStockTable struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (recordingStockTable) TableName() string { return "recording_stocks" } + +type productWarehouseTable struct { + Id uint `gorm:"primaryKey"` + ProductId uint `gorm:"column:product_id"` + WarehouseId uint `gorm:"column:warehouse_id"` + Quantity float64 `gorm:"column:quantity"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (productWarehouseTable) TableName() string { return "product_warehouses" } + +type stockAllocationTable struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null"` + StockableType string `gorm:"size:100"` + StockableId uint + UsableType string `gorm:"size:100"` + UsableId uint + Qty float64 `gorm:"column:qty"` + Status string `gorm:"size:20"` + Note *string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time + ReleasedAt *time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (stockAllocationTable) TableName() string { return "stock_allocations" } + +type testStockSource struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsedQty float64 `gorm:"column:total_used_qty"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time +} + +func (testStockSource) TableName() string { return "test_fifo_stockables" } + +func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &recordingStockTable{}, + &productWarehouseTable{}, + &stockAllocationTable{}, + &testStockSource{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.RecordingStock{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := newFifoTestStockAllocationRepo(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + registerRecordingUsable(t, fifoSvc) + + key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano())) + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: key, + Table: "test_fifo_stockables", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }); err != nil { + t.Fatalf("register stockable: %v", err) + } + + svc := servicePkg.NewRecordingFIFOIntegrationService( + recordingRepo.NewRecordingRepository(db), + productWarehouseRepo, + fifoSvc, + ) + + return db, svc, fifoSvc, key +} + +func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) { + t.Helper() + err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register usable: %v", err) + } + if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok { + t.Fatal("recording stock usable key not registered") + } +} + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + CreatedBy: 1, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock { + t.Helper() + stock := entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: productWarehouseID, + UsageQty: floatPtr(0), + PendingQty: floatPtr(0), + } + if err := db.Create(&stock).Error; err != nil { + t.Fatalf("create recording stock: %v", err) + } + stock.UsageQty = floatPtr(desired) + return stock +} + +func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource { + t.Helper() + lot := testStockSource{ + ProductWarehouseId: productWarehouseID, + CreatedAt: time.Now(), + } + if err := db.Create(&lot).Error; err != nil { + t.Fatalf("create stock lot: %v", err) + } + return lot +} + +func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock { + t.Helper() + var stock entity.RecordingStock + if err := db.First(&stock, id).Error; err != nil { + t.Fatalf("fetch recording stock: %v", err) + } + return stock +} + +func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation { + t.Helper() + var alloc entity.StockAllocation + if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil { + t.Fatalf("fetch allocation: %v", err) + } + return alloc +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if count != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, id).Error; err != nil { + t.Fatalf("fetch product warehouse: %v", err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity) + } +} + +func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) { + t.Helper() + if value == nil { + t.Fatalf("expected %s %.3f, got nil", msg, expected) + } + if mathAbs(*value-expected) > 1e-6 { + t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value) + } +} + +func floatPtr(v float64) *float64 { + p := new(float64) + *p = v + return p +} + +func mathAbs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func sanitizeKey(name string) string { + if name == "" { + return "CASE" + } + clean := strings.Map(func(r rune) rune { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + if r >= 'a' && r <= 'z' { + return r - 32 + } + return '_' + }, name) + return clean +} + +type fifoTestStockAllocationRepo struct { + commonRepo.StockAllocationRepository + db *gorm.DB +} + +func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository { + return &fifoTestStockAllocationRepo{ + StockAllocationRepository: commonRepo.NewStockAllocationRepository(db), + db: db, + } +} + +func (r *fifoTestStockAllocationRepo) PatchOne( + ctx context.Context, + id uint, + updates map[string]any, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClauses := make([]string, 0, len(updates)) + args := make([]any, 0, len(updates)+1) + for column, value := range updates { + colName := column + if strings.EqualFold(column, "quantity") { + colName = "qty" + } + setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName)) + args = append(args, value) + } + args = append(args, id) + sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", ")) + + result := base.Exec(sql, args...) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *fifoTestStockAllocationRepo) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClause := "status = ?, released_at = ?" + args := []any{entity.StockAllocationStatusReleased, time.Now()} + if note != nil { + setClause += ", note = ?" + args = append(args, *note) + } + args = append(args, usableType, usableID, entity.StockAllocationStatusActive) + sql := fmt.Sprintf( + "UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?", + setClause, + ) + + result := base.Exec(sql, args...) + return result.Error +}