diff --git a/go.mod b/go.mod index fc28567b..355f8e5c 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,17 @@ 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/glebarez/sqlite v1.11.0 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 @@ -21,6 +26,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 @@ -33,7 +53,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 ea477c5d..188b0dae 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/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/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/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..8e1ece25 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -1,23 +1,15 @@ 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"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/entities/project_budget.go b/internal/entities/project_budget.go index 23c521ac..c74455b6 100644 --- a/internal/entities/project_budget.go +++ b/internal/entities/project_budget.go @@ -5,11 +5,13 @@ import ( ) type ProjectBudget struct { - Id uint `gorm:"primaryKey"` - Qty float64 `gorm:"type:numeric(15,3);not null"` - Price float64 `gorm:"type:numeric(15,3);not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null"` + NonstockId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` - Nonstock *Nonstock `gorm:"foreignKey:Id;references:Id"` - ProjectFlock *ProjectFlock `gorm:"foreignKey:Id;references:Id"` + Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index e8745455..0a92b54b 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -24,6 +24,7 @@ type ProjectFlock struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` + Budgets []ProjectBudget `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` LatestApproval *Approval `gorm:"-" json:"-"` } 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 index 4918c28f..a9282f21 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -57,11 +57,11 @@ 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") + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid id") } - result, err := u.ClosingService.GetOne(c, uint(id)) + result, err := u.ClosingService.GetProjectFlockByID(c, uint(id)) if err != nil { return err } @@ -70,7 +70,56 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error { JSON(response.Success{ Code: fiber.StatusOK, Status: "success", - Message: "Get closing successfully", - Data: dto.ToClosingListDTO(*result), + Message: "Retrieved closing information successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + result, err := u.ClosingService.GetClosingSummary(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved project information successfully", + Data: result, + }) +} + +func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID)) + if err != nil { + return err + } + + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing penjualan successfully", + Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), }) } diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index ccb014e6..6a280312 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -1,6 +1,7 @@ package dto import ( + "fmt" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -26,6 +27,67 @@ type ClosingDetailDTO struct { ClosingListDTO } +type ClosingSummaryDTO struct { + LocationID uint `json:"location_id"` + Periode int `json:"periode"` + JenisProduk string `json:"jenis_produk"` + LabelPopulasi string `json:"label_populasi"` + JumlahPopulasi int `json:"jumlah_populasi"` + JumlahPopulasiFormatted string `json:"jumlah_populasi_formatted"` + JenisProject string `json:"jenis_project"` + KandangAktif int `json:"kandang_aktif"` + KandangAktifFormatted string `json:"kandang_aktif_formatted"` + StatusPembayaranPenjualan string `json:"status_pembayaran_penjualan"` + StatusPembayaranMitra string `json:"status_pembayaran_mitra"` + StatusProject string `json:"status_project"` + StatusClosing string `json:"status_closing"` +} + +func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosing string) ClosingSummaryDTO { + history := project.KandangHistory + + period := maxPeriod(history) + kandangCount := len(history) + population := sumPopulation(history) + populationInt := int(population) + + return ClosingSummaryDTO{ + LocationID: project.LocationId, + Periode: period, + JenisProduk: project.Category, + LabelPopulasi: "", + JumlahPopulasi: populationInt, + JumlahPopulasiFormatted: fmt.Sprintf("%d Ekor", populationInt), + JenisProject: "", + KandangAktif: kandangCount, + KandangAktifFormatted: fmt.Sprintf("%d Kandang", kandangCount), + StatusPembayaranPenjualan: "Tempo", + StatusPembayaranMitra: "", + StatusProject: statusProject, + StatusClosing: statusClosing, + } +} + +func maxPeriod(history []entity.ProjectFlockKandang) int { + max := 0 + for _, h := range history { + if h.Period > max { + max = h.Period + } + } + return max +} + +func sumPopulation(history []entity.ProjectFlockKandang) float64 { + var total float64 + for _, h := range history { + for _, chickin := range h.Chickins { + total += chickin.UsageQty + chickin.PendingUsageQty + } + } + return total +} + // === Mapper Functions === func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO { diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go new file mode 100644 index 00000000..4c47a7e0 --- /dev/null +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -0,0 +1,109 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" +) + +// === Response DTO === + +type SalesDTO struct { + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + PaymentStatus string `json:"payment_status"` +} + +type PenjualanRealisasiResponseDTO struct { + ProjectType string `json:"project_type"` + FlockId uint `json:"flock_id"` + Period int `json:"period"` + Sales []SalesDTO `json:"sales"` +} + +// === Mapper Functions === + +func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + + // todo: usia ayam masih dummy + age := 0 + + var product *productDTO.ProductRelationDTO + if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { + mapped := productDTO.ToProductRelationDTO(e.MarketingProduct.ProductWarehouse.Product) + product = &mapped + } + + var customer *customerDTO.CustomerRelationDTO + if e.MarketingProduct.Marketing.Customer.Id != 0 { + mapped := customerDTO.ToCustomerRelationDTO(e.MarketingProduct.Marketing.Customer) + customer = &mapped + } + + var kandang *kandangDTO.KandangRelationDTO + if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang) + kandang = &mapped + } + + doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) + + return SalesDTO{ + Id: e.Id, + RealizationDate: *e.DeliveryDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.Qty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + Price: e.UnitPrice, + TotalPrice: e.TotalPrice, + Kandang: kandang, + PaymentStatus: "Paid", + } +} + +func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { + result := make([]SalesDTO, len(e)) + for i, r := range e { + result[i] = ToSalesDTO(r) + } + return result +} + +func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { + period := extractPeriodFromRealisasi(e) + return PenjualanRealisasiResponseDTO{ + ProjectType: projectType, + FlockId: projectFlockID, + Period: period, + Sales: ToSalesDTOs(e), + } +} + +func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { + if len(realisasi) > 0 { + for _, item := range realisasi { + if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period + } + } + } + return 0 +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index d831195c..77941256 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -5,8 +5,12 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -17,10 +21,14 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) userRepo := rUser.NewUserRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + marketingRepo := rMarketings.NewMarketingRepository(db) + marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ClosingRoutes(router, userService, closingService) } - diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 6570a17d..ba18f3b9 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -12,7 +12,7 @@ import ( func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) { ctrl := controller.NewClosingController(s) - route := v1.Group("/closings") + route := v1.Group("/closing") // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) @@ -21,5 +21,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", ctrl.GetAll) - route.Get("/:id", ctrl.GetOne) + route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan) + route.Get("/:projectFlockId", ctrl.GetClosingSummary) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index fd1b42eb..7fcd51ec 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -1,12 +1,19 @@ package service import ( + "context" "errors" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -16,20 +23,30 @@ import ( type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) + GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) } type closingService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ClosingRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + MarketingRepo marketingRepository.MarketingRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ApprovalSvc commonSvc.ApprovalService } -func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService { return &closingService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockRepo: projectFlockRepo, + MarketingRepo: marketingRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ApprovalSvc: approvalSvc, } } @@ -37,6 +54,12 @@ func (s closingService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser") } +func (s closingService) withClosingRelations(db *gorm.DB) *gorm.DB { + return s.withRelations(db). + Preload("KandangHistory"). + Preload("KandangHistory.Chickins") +} + 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 @@ -59,14 +82,109 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity 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) +func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found") + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") } if err != nil { - s.Log.Errorf("Failed get closing by id: %+v", err) return nil, err } - return closing, nil + return projectFlock, nil +} + +func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) { + + realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + }) + if err != nil { + return nil, err + } + if len(realisasi) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found") + } + return realisasi, nil +} + +func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + if err != nil { + s.Log.Errorf("Failed get project flock %d for closing summary: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + statusProject, statusClosing, err := s.getApprovalStatuses(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status") + } + + summary := dto.ToClosingSummaryDTO(*project, statusProject, statusClosing) + + return &summary, nil +} + +func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID uint) (string, string, error) { + if s.ApprovalSvc == nil { + return "", "Belum Selesai", nil + } + + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "") + if err != nil { + return "", "", err + } + + var ( + minStep uint16 + statusProject string + completed int + ) + + for _, rec := range records { + if minStep == 0 || rec.StepNumber < minStep { + minStep = rec.StepNumber + statusProject = rec.StepName + } + if rec.StepNumber == uint16(utils.ProjectFlockStepSelesai) { + completed++ + } + } + + if statusProject == "" && minStep > 0 { + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlock, approvalutils.ApprovalStep(minStep)); ok { + statusProject = label + } + } + + statusClosing := "Belum Selesai" + switch { + case len(records) == 0 || completed == 0: + statusClosing = "Belum Selesai" + case completed < len(records): + statusClosing = "Sebagian" + default: + statusClosing = "Selesai" + } + + return statusProject, statusClosing, nil } diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 25806ebb..55114ec8 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -151,12 +151,16 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { } req.Documents = form.File["documents"] - if transactionDate := c.FormValue("transaction_date"); transactionDate != "" { + + transactionDate := c.FormValue("transaction_date") + if transactionDate != "" { req.TransactionDate = &transactionDate } categoryVal := c.FormValue("category") - req.Category = &categoryVal + if categoryVal != "" { + req.Category = &categoryVal + } supplierIDVal := c.FormValue("supplier_id") if supplierIDVal != "" { @@ -312,13 +316,18 @@ func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error { req.Documents = form.File["documents"] - req.RealizationDate = c.FormValue("realization_date") + realizationDate := c.FormValue("realization_date") + if realizationDate != "" { + req.RealizationDate = &realizationDate + } realizationsJSON := c.FormValue("realizations") if realizationsJSON != "" { - if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { + var realizations []validation.RealizationItem + if err := json.Unmarshal([]byte(realizationsJSON), &realizations); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err)) } + req.Realizations = &realizations } expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 5a8b66fc..1fc5c07a 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -14,6 +14,8 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route := v1.Group("/expenses") route.Use(m.Auth(u)) + + // route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 2bd00a0f..363c52ff 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -732,10 +732,10 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va expenseRepoTx := repository.NewExpenseRepository(tx) // Check if only updating documents - updateDataOnly := len(req.Realizations) == 0 && len(req.Documents) > 0 + updateDataOnly := req.Realizations == nil && len(req.Documents) > 0 - if len(req.Realizations) > 0 { - for _, realizationItem := range req.Realizations { + if req.Realizations != nil { + for _, realizationItem := range *req.Realizations { expenseNonstockID := realizationItem.ExpenseNonstockID @@ -770,6 +770,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } + if req.RealizationDate != nil { + if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") + } + } + if len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { return err diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index abe6198c..9dc2b07b 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -5,11 +5,11 @@ import ( ) type Create struct { - PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` - TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` - Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` - SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` + SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } @@ -26,11 +26,11 @@ type CostItem struct { } type Update struct { - TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` - Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` - SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` + Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` } type Query struct { @@ -46,9 +46,9 @@ type CreateRealization struct { } type UpdateRealization struct { - RealizationDate string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` + RealizationDate *string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` - Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` + Realizations *[]RealizationItem `form:"realizations" json:"realizations" validate:"omitempty,min=1,dive"` } type RealizationItem struct { 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..be4ae7a2 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 { @@ -200,7 +198,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("log_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go b/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go deleted file mode 100644 index 512a5786..00000000 --- a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go +++ /dev/null @@ -1,51 +0,0 @@ -package repository - -import ( - "context" - - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gorm.io/gorm" -) - -type MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] - GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) - GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) -} - -type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] -} - -func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { - return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), - } -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { - var deliveryProduct entity.MarketingDeliveryProduct - if err := r.DB().WithContext(ctx).Where("marketing_product_id = ?", marketingProductID).First(&deliveryProduct).Error; err != nil { - return nil, err - } - return &deliveryProduct, nil -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - // Raw query untuk mengambil delivery products berdasarkan marketing ID dengan preload MarketingProduct - // Filter: hanya ambil yang sudah memiliki delivery_date (delivery date tidak null) - if err := r.DB().WithContext(ctx). - Preload("MarketingProduct"). - Joins("INNER JOIN marketing_products mp ON marketing_delivery_products.marketing_product_id = mp.id"). - Where("mp.marketing_id = ?", marketingId). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Order("marketing_delivery_products.id ASC"). - Find(&deliveryProducts).Error; err != nil { - return nil, err - } - - return deliveryProducts, 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..641ce531 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -151,7 +151,7 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d } if err := base.Model(&entity.ProductWarehouse{}). Where("id = ?", id). - Update("quantity", gorm.Expr("COALESCE(quantity,0) + ?", delta)).Error; err != nil { + Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error; err != nil { return err } } @@ -171,7 +171,7 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec var emptyIDs []uint if err := r.DB().WithContext(ctx). Model(&entity.ProductWarehouse{}). - Where("id IN ? AND COALESCE(quantity,0) <= 0", ids). + Where("id IN ? AND COALESCE(qty,0) <= 0", ids). Pluck("id", &emptyIDs).Error; err != nil { return err } @@ -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 @@ -257,7 +257,7 @@ func (r *ProductWarehouseRepositoryImpl) GetByFlagAndWarehouseID(ctx context.Con Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Where("flags.name = ? AND product_warehouses.warehouse_id = ?", flagName, warehouseId). - Order("product_warehouses.created_at DESC"). + Order("product_warehouses.id DESC"). Preload("Product").Preload("Warehouse"). Find(&productWarehouses).Error if err != nil { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index cc7d5b85..f690b2a2 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("CreatedUser") + Preload("ProjectFlockKandang") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -104,7 +104,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = s.Repository.ApplyFlagsFilter(db, cleanFlags) - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("product_warehouses.id DESC") }) if err != nil { 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/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go index d2f29fe9..69037499 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go @@ -319,15 +319,20 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri }) for i := range groups { - if groups[i].DeliveryDate != nil { - dateStr := groups[i].DeliveryDate.Format("20060102") - groups[i].DoNumber = fmt.Sprintf("%s-%s-%d", soNumber, dateStr, groups[i].Warehouse.Id) - } + groups[i].DoNumber = GenerateDeliveryOrderNumber(soNumber, groups[i].DeliveryDate, groups[i].Warehouse.Id) } return groups } +func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { + dateStr := "" + if deliveryDate != nil { + dateStr = deliveryDate.Format("20060102") + } + return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId) +} + func getVehicleNumber(e entity.MarketingProduct) string { if e.DeliveryProduct != nil && e.DeliveryProduct.VehicleNumber != "" { return e.DeliveryProduct.VehicleNumber diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go index 99bd8396..efe3737d 100644 --- a/internal/modules/marketing/delivery-orderss/module.go +++ b/internal/modules/marketing/delivery-orderss/module.go @@ -9,7 +9,6 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -22,7 +21,7 @@ type DeliveryOrdersModule struct{} func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { marketingRepo := rMarketing.NewMarketingRepository(db) marketingProductRepo := rMarketing.NewMarketingProductRepository(db) - marketingDeliveryProductRepo := rMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(db) + marketingDeliveryProductRepo := rMarketing.NewMarketingDeliveryProductRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go index 09e48f29..c83330da 100644 --- a/internal/modules/marketing/delivery-orderss/route.go +++ b/internal/modules/marketing/delivery-orderss/route.go @@ -1,7 +1,7 @@ package delivery_orderss import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -17,6 +17,7 @@ func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders. // Sisanya di group /delivery-orders route := v1.Group("/delivery-orders") + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go index 92809f19..52ced7d7 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go @@ -8,9 +8,8 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - marketingDeliveryProductRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" @@ -35,14 +34,14 @@ type deliveryOrdersService struct { Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository - MarketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository + MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService } func NewDeliveryOrdersService( marketingRepo marketingRepo.MarketingRepository, marketingProductRepo marketingRepo.MarketingProductRepository, - marketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository, + marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) DeliveryOrdersService { @@ -200,7 +199,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) @@ -259,7 +258,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) } - approvalAction := entity.ApprovalActionApproved if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -301,7 +299,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go index 95e9b3bb..a3c2af88 100644 --- a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go +++ b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,9 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] + GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) } type MarketingDeliveryProductRepositoryImpl struct { @@ -19,3 +24,53 @@ func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProduct BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), } } + +func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // JOIN digunakan untuk filter WHERE clause ke ProjectFlockID yang berada 3 level relasi atas + // Entity relations digunakan di Preload (callback) untuk load data, bukan untuk filter + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Distinct("marketing_delivery_products.*") + + if callback != nil { + db = callback(db) + } + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // JOIN untuk filter by marketing_id yang ada di related table + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Where("marketing_products.marketing_id = ?", marketingId) + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { + var deliveryProduct entity.MarketingDeliveryProduct + + if err := r.DB().WithContext(ctx). + Where("marketing_product_id = ?", marketingProductID). + First(&deliveryProduct).Error; err != nil { + return nil, err + } + + return &deliveryProduct, nil +} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go index ae6d7a81..f87cea66 100644 --- a/internal/modules/marketing/sales-orders/route.go +++ b/internal/modules/marketing/sales-orders/route.go @@ -1,7 +1,7 @@ package sales_orders import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -14,6 +14,7 @@ func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesO v1.Delete("/:id", ctrl.DeleteOne) route := v1.Group("/sales-orders") + route.Use(m.Auth(u)) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) 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 8acef29d..061ffaf7 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/sales-orders/services/sales-orders.service.go @@ -10,7 +10,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" @@ -125,7 +124,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) marketing = &entity.Marketing{ @@ -220,7 +219,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) updateBody := make(map[string]any) if req.CustomerId != 0 { @@ -527,7 +526,7 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return updated, nil } -func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo rInvMarketingDeliveryProduct.MarketingDeliveryProductRepository) error { +func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, 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/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 6d78520e..52d53be5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -329,3 +329,29 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { Message: "Get projectflock kandang successfully", Data: dtoResult}) } + +func (u *ProjectflockController) Resubmit(c *fiber.Ctx) error { + param := c.Params("id") + req := new(validation.Resubmit) + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectflockService.Resubmit(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Resubmit projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 8324dd71..0922b160 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -9,6 +9,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" // pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" @@ -24,15 +25,16 @@ type ProjectFlockRelationDTO struct { type ProjectFlockListDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` + ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type KandangWithProjectFlockIdDTO struct { @@ -51,6 +53,13 @@ type KandangPeriodSummaryDTO struct { Period int `json:"period"` } +type ProjectBudgetDTO struct { + Id uint `json:"id"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` +} + func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectFlockListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser.Id != 0 { @@ -110,6 +119,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF ProjectFlockRelationDTO: createProjectFlockRelationDTO(e, period), Area: areaSummary, Kandangs: kandangSummaries, + ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), Category: e.Category, Fcr: fcrSummary, Location: locationSummary, @@ -184,3 +194,26 @@ func createProjectFlockRelationDTO(e entity.ProjectFlock, period int) ProjectFlo FlockName: e.FlockName, } } + +func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO { + var nonstockRef *nonstockDTO.NonstockRelationDTO + if e.Nonstock != nil && e.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*e.Nonstock) + nonstockRef = &mapped + } + + return ProjectBudgetDTO{ + Id: e.Id, + Qty: e.Qty, + Price: e.Price, + Nonstock: nonstockRef, + } +} + +func ToProjectBudgetDTOs(e []entity.ProjectBudget) []ProjectBudgetDTO { + result := make([]ProjectBudgetDTO, len(e)) + for i, r := range e { + result[i] = ToProjectBudgetDTO(r) + } + return result +} diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 4fd932a4..acd77338 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -12,7 +12,9 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,10 +29,12 @@ type ProjectflockModule struct{} func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { flockRepo := rFlock.NewFlockRepository(db) kandangRepo := rKandang.NewKandangRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -39,7 +43,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index eede3638..15afaf59 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -54,7 +54,10 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm Preload("Location"). Preload("Kandangs"). Preload("KandangHistory"). - Preload("KandangHistory.Kandang") + Preload("KandangHistory.Kandang"). + Preload("Budgets"). + Preload("Budgets.Nonstock"). + Preload("Budgets.Nonstock.Uom") } } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index c1e37cd5..710f5225 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -23,5 +23,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) route.Get("/locations/:location_id/periods", ctrl.GetPeriodSummary) + route.Put("/:id/resubmit", ctrl.Resubmit) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 827e5b19..1a7fc6f2 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -15,7 +15,9 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + nonstockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectBudgetRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -39,6 +41,7 @@ type ProjectflockService interface { GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) + Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) } type projectflockService struct { @@ -47,8 +50,10 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository + NonstockRepo nonstockRepository.NonstockRepository WarehouseRepo warehouseRepository.WarehouseRepository ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + ProjectBudgetRepo projectBudgetRepository.ProjectBudgetRepository PivotRepo repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey @@ -67,8 +72,11 @@ func NewProjectflockService( pivotRepo repository.ProjectFlockKandangRepository, warehouseRepo warehouseRepository.WarehouseRepository, productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, + projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository, + nonstockRepo nonstockRepository.NonstockRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, + ) ProjectflockService { return &projectflockService{ Log: utils.Log, @@ -76,6 +84,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + NonstockRepo: nonstockRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PivotRepo: pivotRepo, @@ -289,7 +298,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - // Generate unique flock name (sequential per base name, starting from 1) generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, 1, nil) if err != nil { return err @@ -300,7 +308,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - // Compute period per kandang so every kandang maintains its own cycle history. periods, err := projectRepo.GetNextPeriodsForKandangs(c.Context(), kandangIDs) if err != nil { return err @@ -309,6 +316,10 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, createBody.Id, req.ProjectBudgets); err != nil { + return err + } + action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( @@ -1044,3 +1055,139 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka } return kandangRepository.NewKandangRepository(s.Repository.DB()) } + +func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + + kandangIDs := uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data kandang") + } + if len(kandangs) != len(kandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + + for _, pb := range req.ProjectBudgets { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &pb.NonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return nil, err + } + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + var period int = 1 + if len(existing.KandangHistory) > 0 { + period = existing.KandangHistory[0].Period + } + + periods := make(map[uint]int, len(kandangIDs)) + for _, kandangID := range kandangIDs { + periods[kandangID] = period + } + + if err := s.attachKandangs(c.Context(), dbTransaction, existing.Id, kandangIDs, periods); err != nil { + return err + } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, existing.Id, req.ProjectBudgets); err != nil { + return err + } + + action := entity.ApprovalActionUpdated + _, err = approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + existing.Id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ) + return err + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengajukan ulang project flock") + } + + return s.getOneEntityOnly(c, id) +} + +func (s projectflockService) UpsertProjectBudget(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, budgets []validation.ProjectBudget) error { + + if len(budgets) == 0 { + return nil + } + budgetRepo := projectBudgetRepository.NewProjectBudgetRepository(dbTransaction) + + nonstockMap := make(map[uint]bool) + relationChecks := make([]commonSvc.RelationCheck, 0, len(budgets)) + for _, b := range budgets { + if nonstockMap[b.NonstockId] { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate nonstock_id: %d", b.NonstockId)) + } + nonstockMap[b.NonstockId] = true + nonstockID := b.NonstockId + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Nonstock", + ID: &nonstockID, + Exists: s.NonstockRepo.IdExists, + }) + } + + if err := commonSvc.EnsureRelations(ctx, relationChecks...); err != nil { + return err + } + + if err := budgetRepo.DeleteMany(ctx, func(q *gorm.DB) *gorm.DB { + return q.Where("project_flock_id = ?", projectFlockID) + }); err != nil && err != gorm.ErrRecordNotFound { + return err + } + + records := make([]*entity.ProjectBudget, 0, len(budgets)) + for _, b := range budgets { + records = append(records, &entity.ProjectBudget{ + ProjectFlockId: projectFlockID, + NonstockId: b.NonstockId, + Price: b.Price, + Qty: b.Qty, + }) + } + + if err := budgetRepo.CreateMany(ctx, records, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to save project budgets") + } + + return nil + +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 33f20725..00b01456 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,12 +1,13 @@ package validation type Create struct { - FlockName string `json:"flock_name" validate:"required_strict"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Update struct { @@ -36,3 +37,14 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type ProjectBudget struct { + NonstockId uint `json:"nonstock_id" validate:"required_strict,number,gt=0"` + Price float64 `json:"price" validate:"required_strict,number,gt=0"` + Qty float64 `json:"qty" validate:"required_strict,number,gt=0"` +} + +type Resubmit struct { + KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"` +} 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/modules/shared/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go index 77ed78ce..ad1e8974 100644 --- a/internal/modules/shared/repositories/stock-logs.repository.go +++ b/internal/modules/shared/repositories/stock-logs.repository.go @@ -30,7 +30,7 @@ func (r *StockLogRepositoryImpl) GetByFlaggable(ctx context.Context, logType str var stockLogs []*entity.StockLog err := r.DB().WithContext(ctx). - Where("log_type = ? AND log_id = ?", logType, logId). + Where("loggable_type = ? AND log_id = ?", logType, logId). Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse"). diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 4316989a..6594ac6b 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -154,12 +154,14 @@ const ( ApprovalWorkflowProjectFlock approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCKS") ProjectFlockStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockStepAktif approvalutils.ApprovalStep = 2 + ProjectFlockStepSelesai approvalutils.ApprovalStep = 3 ) // projectFlockApprovalSteps keeps the workflow step definitions for project flock approvals. var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepPengajuan: "Pengajuan", ProjectFlockStepAktif: "Aktif", + ProjectFlockStepSelesai: "Selesai", } // ------------------------------------------------------------------- diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go index a845e1a2..755e9e95 100644 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -263,7 +263,6 @@ func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.Pr ProductId: 1, WarehouseId: 1, Quantity: qty, - CreatedBy: 1, } if err := db.Create(&pw).Error; err != nil { t.Fatalf("create product warehouse: %v", err)