Compare commits

...

13 Commits

Author SHA1 Message Date
ragilap b4da37731c base example counting sapronak 2025-12-05 19:02:08 +07:00
Hafizh A. Y. b8a769dc72 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
adjust for changes erd product_warehouse

See merge request mbugroup/lti-api!81
2025-12-05 03:38:55 +00:00
giovanni-ce 1e9a637202 adjust for changes erd product_warehouse 2025-12-04 20:00:46 +07:00
Hafizh A. Y. c064fb1765 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
adjust response inventory stock-product

See merge request mbugroup/lti-api!79
2025-12-04 09:39:25 +00:00
giovanni-ce 4af631a1d3 adjust response inventory stock-product 2025-12-04 16:08:30 +07:00
Hafizh A. Y. 91e4762945 Merge branch 'dev/hafizh' into 'feat/BE/Sprint-6'
feat(BE-308,309): utility document and implementation s3 bucket

See merge request mbugroup/lti-api!78
2025-12-04 08:46:50 +00:00
Hafizh A. Y b8403f1c7e feat(BE-308,309): utility document and implementation s3 bucket 2025-12-04 15:46:06 +07:00
Hafizh A. Y. 753d8575c4 Merge branch 'dev/gio' into 'feat/BE/Sprint-6'
[FEAT/BE][US#286/TASK#296,297] : Adjust Schema Database table product_warehouse, product and stock_logs; create sub module inventory product_stock and api list and get one

See merge request mbugroup/lti-api!77
2025-12-04 06:37:45 +00:00
giovanni-ce 4c5266da23 resolve conflict to sprint 6 2025-12-04 12:20:47 +07:00
Hafizh A. Y. d79b1868fc Merge branch 'dev/ragil' into 'feat/BE/Sprint-6'
[FIX/BE][US#74] : Project flock dto

See merge request mbugroup/lti-api!72
2025-12-04 04:41:36 +00:00
giovanni-ce 33a9d7806e adjust response location to object 2025-12-04 11:27:02 +07:00
giovanni-ce 730fb22cc2 Feat[BE-297]: Create sub module inventory product stock; create api list product stock and api get one product stock 2025-12-03 19:47:12 +07:00
giovanni-ce 94fc9219af Feat[BE-296]: adjust schema db and entity products, product_warehouse, stock_logs 2025-12-03 18:43:03 +07:00
41 changed files with 2026 additions and 107 deletions
+20 -1
View File
@@ -4,11 +4,16 @@ go 1.23
require (
github.com/MicahParks/keyfunc/v2 v2.1.0
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/bytedance/sonic v1.12.1
github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/contrib/jwt v1.0.10
github.com/gofiber/fiber/v2 v2.52.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgconn v1.14.1
github.com/redis/go-redis/v9 v9.14.0
github.com/sirupsen/logrus v1.9.3
@@ -20,6 +25,21 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
@@ -30,7 +50,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
+38
View File
@@ -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=
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
}
+18
View File
@@ -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")
@@ -0,0 +1,2 @@
ALTER TABLE products
DROP COLUMN IF EXISTS is_visible;
@@ -0,0 +1,2 @@
ALTER TABLE products
ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS documents_documentable_polymorphic;
DROP TABLE IF EXISTS documents;
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+1 -1
View File
@@ -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
+18
View File
@@ -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"`
}
+2
View File
@@ -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"`
ProductWarehouses []ProductWarehouse `gorm:"foreignKey:ProductId;references:Id"`
}
+6 -15
View File
@@ -1,23 +1,14 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type ProductWarehouse struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ProductId uint `gorm:"not null"`
WarehouseId uint `gorm:"not null"`
Quantity float64 `gorm:"default:0"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
CreatedBy uint `gorm:"not null"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
// Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"`
Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
+12 -17
View File
@@ -1,10 +1,6 @@
package entities
import (
"time"
"gorm.io/gorm"
)
import "time"
const (
LogTypeAdjustment = "ADJUSTMENT"
@@ -18,18 +14,17 @@ 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"`
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"`
@@ -74,3 +74,24 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error {
Data: dto.ToClosingListDTO(*result),
})
}
func (u *ClosingController) GetSapronakReport(c *fiber.Ctx) error {
query := &validation.SapronakQuery{
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
KandangID: uint(c.QueryInt("kandang_id", 0)),
Status: c.Query("status"),
}
result, err := u.ClosingService.GetSapronakReport(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get sapronak report successfully",
Data: result,
})
}
@@ -0,0 +1,30 @@
package dto
import "time"
type SapronakItemDTO struct {
ProductID uint `json:"product_id"`
ProductName string `json:"product_name"`
Flag string `json:"flag"`
IncomingQty float64 `json:"incoming_qty"`
IncomingValue float64 `json:"incoming_value"`
UsageQty float64 `json:"usage_qty"`
UsageValue float64 `json:"usage_value"`
RemainingQty float64 `json:"remaining_qty"`
AveragePrice float64 `json:"average_price"`
}
type SapronakReportDTO struct {
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
ProjectFlockID uint `json:"project_flock_id"`
ProjectName string `json:"project_name"`
KandangID uint `json:"kandang_id"`
KandangName string `json:"kandang_name"`
Period int `json:"period"`
Status string `json:"status"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
TotalIncomingValue float64 `json:"total_incoming_value"`
TotalUsageValue float64 `json:"total_usage_value"`
Items []SapronakItemDTO `json:"items"`
}
+3 -2
View File
@@ -7,6 +7,7 @@ import (
rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
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"
@@ -16,11 +17,11 @@ type ClosingModule struct{}
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
userRepo := rUser.NewUserRepository(db)
closingService := sClosing.NewClosingService(closingRepo, validate)
closingService := sClosing.NewClosingService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
ClosingRoutes(router, userService, closingService)
}
+1
View File
@@ -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("/sapronak/report", ctrl.GetSapronakReport)
route.Get("/:id", ctrl.GetOne)
}
@@ -1,11 +1,17 @@
package service
import (
"context"
"errors"
"sort"
"strings"
"time"
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"
projectFlockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
@@ -17,19 +23,26 @@ import (
type ClosingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
GetSapronakReport(ctx *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error)
}
type closingService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.ClosingRepository
ProjectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository
}
func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService {
func NewClosingService(
repo repository.ClosingRepository,
projectFlockKandangRepo projectFlockRepo.ProjectFlockKandangRepository,
validate *validator.Validate,
) ClosingService {
return &closingService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
}
}
@@ -70,3 +83,362 @@ func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, err
}
return closing, nil
}
var sapronakFlags = []string{
string(utils.FlagDOC),
string(utils.FlagPakan),
string(utils.FlagOVK),
}
type sapronakIncomingRow struct {
ProductID uint
ProductName string
Flag string
Qty float64
Value float64
DefaultPrice float64
}
type sapronakUsageRow struct {
ProductID uint
ProductName string
Flag string
Qty float64
DefaultPrice float64
}
func (s closingService) GetSapronakReport(c *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, err
}
pfks, err := s.loadProjectFlockKandangs(c, params)
if err != nil {
return nil, err
}
if len(pfks) == 0 {
return []dto.SapronakReportDTO{}, nil
}
startMap, err := s.mapStartDates(c.Context(), pfks)
if err != nil {
s.Log.Errorf("Failed to prepare start dates for sapronak report: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare sapronak report")
}
statusMap, nextStartMap := s.computeStatusAndNextStart(pfks, startMap)
filterStatus := strings.ToLower(strings.TrimSpace(params.Status))
if filterStatus == "" {
filterStatus = "all"
}
results := make([]dto.SapronakReportDTO, 0, len(pfks))
for _, pfk := range pfks {
status := statusMap[pfk.Id]
if status == "" {
status = "closing"
}
if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") {
continue
}
start := startMap[pfk.Id]
var startPtr *time.Time
if !start.IsZero() {
startCopy := start
startPtr = &startCopy
}
var endPtr *time.Time
if end, ok := nextStartMap[pfk.Id]; ok {
endCopy := end
endPtr = &endCopy
}
items, totalIncoming, totalUsage, err := s.buildSapronakItems(c.Context(), pfk, startPtr, endPtr)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
}
results = append(results, dto.SapronakReportDTO{
ProjectFlockKandangID: pfk.Id,
ProjectFlockID: pfk.ProjectFlockId,
ProjectName: pfk.ProjectFlock.FlockName,
KandangID: pfk.KandangId,
KandangName: pfk.Kandang.Name,
Period: pfk.Period,
Status: status,
StartDate: startPtr,
EndDate: endPtr,
TotalIncomingValue: totalIncoming,
TotalUsageValue: totalUsage,
Items: items,
})
}
sort.Slice(results, func(i, j int) bool {
if results[i].KandangID == results[j].KandangID {
if results[i].Period == results[j].Period {
return results[i].ProjectFlockKandangID < results[j].ProjectFlockKandangID
}
return results[i].Period < results[j].Period
}
return results[i].KandangID < results[j].KandangID
})
return results, nil
}
func (s closingService) loadProjectFlockKandangs(c *fiber.Ctx, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().
WithContext(c.Context()).
Preload("ProjectFlock").
Preload("Kandang")
if params != nil {
if params.ProjectFlockID > 0 {
db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID)
}
if params.KandangID > 0 {
db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID)
}
}
var pfks []entity.ProjectFlockKandang
if err := db.Find(&pfks).Error; err != nil {
s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs")
}
return pfks, nil
}
func (s closingService) mapStartDates(ctx context.Context, pfks []entity.ProjectFlockKandang) (map[uint]time.Time, error) {
result := make(map[uint]time.Time, len(pfks))
if len(pfks) == 0 {
return result, nil
}
ids := make([]uint, len(pfks))
for i, pfk := range pfks {
ids[i] = pfk.Id
}
var rows []struct {
ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"`
StartDate *time.Time `gorm:"column:start_date"`
}
if err := s.ProjectFlockKandangRepo.DB().
WithContext(ctx).
Table("project_chickins").
Select("project_flock_kandang_id, MIN(chick_in_date) AS start_date").
Where("project_flock_kandang_id IN ?", ids).
Group("project_flock_kandang_id").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.StartDate != nil {
result[row.ProjectFlockKandangID] = row.StartDate.UTC()
}
}
for _, pfk := range pfks {
if _, exists := result[pfk.Id]; !exists {
result[pfk.Id] = pfk.CreatedAt.UTC()
}
}
return result, nil
}
func (s closingService) computeStatusAndNextStart(pfks []entity.ProjectFlockKandang, startMap map[uint]time.Time) (map[uint]string, map[uint]time.Time) {
statusMap := make(map[uint]string, len(pfks))
nextStartMap := make(map[uint]time.Time, len(pfks))
if len(pfks) == 0 {
return statusMap, nextStartMap
}
grouped := make(map[uint][]entity.ProjectFlockKandang)
for _, pfk := range pfks {
grouped[pfk.KandangId] = append(grouped[pfk.KandangId], pfk)
}
for _, list := range grouped {
sort.Slice(list, func(i, j int) bool {
if list[i].Period == list[j].Period {
return startMap[list[i].Id].Before(startMap[list[j].Id])
}
return list[i].Period < list[j].Period
})
for idx, item := range list {
if idx < len(list)-1 {
next := list[idx+1]
if start, ok := startMap[next.Id]; ok {
nextStartMap[item.Id] = start
}
statusMap[item.Id] = "closing"
continue
}
statusMap[item.Id] = "active"
}
}
return statusMap, nextStartMap
}
func (s closingService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time) ([]dto.SapronakItemDTO, float64, float64, error) {
incoming, err := s.fetchIncomingSapronak(ctx, pfk.KandangId, start, end)
if err != nil {
return nil, 0, 0, err
}
usage, err := s.fetchUsageSapronak(ctx, pfk.Id)
if err != nil {
return nil, 0, 0, err
}
itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage))
for _, row := range incoming {
avgPrice := row.DefaultPrice
if row.Qty > 0 && row.Value > 0 {
avgPrice = row.Value / row.Qty
}
itemMap[row.ProductID] = dto.SapronakItemDTO{
ProductID: row.ProductID,
ProductName: row.ProductName,
Flag: row.Flag,
IncomingQty: row.Qty,
IncomingValue: row.Value,
RemainingQty: row.Qty,
AveragePrice: avgPrice,
}
}
for _, row := range usage {
existing := itemMap[row.ProductID]
price := existing.AveragePrice
if price == 0 {
price = row.DefaultPrice
}
usageValue := row.Qty * price
existing.ProductID = row.ProductID
if existing.ProductName == "" {
existing.ProductName = row.ProductName
}
if existing.Flag == "" {
existing.Flag = row.Flag
}
existing.AveragePrice = price
existing.UsageQty += row.Qty
existing.UsageValue += usageValue
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
itemMap[row.ProductID] = existing
}
items := make([]dto.SapronakItemDTO, 0, len(itemMap))
var totalIncoming, totalUsage float64
for _, item := range itemMap {
totalIncoming += item.IncomingValue
totalUsage += item.UsageValue
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Flag == items[j].Flag {
return strings.ToLower(items[i].ProductName) < strings.ToLower(items[j].ProductName)
}
return items[i].Flag < items[j].Flag
})
return items, totalIncoming, totalUsage, nil
}
func (s closingService) fetchIncomingSapronak(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint]sapronakIncomingRow, error) {
rows := make([]sapronakIncomingRow, 0)
db := s.Repository.DB().
WithContext(ctx).
Table("purchase_items AS pi").
Select(`
pi.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(SUM(pi.total_qty), 0) AS qty,
COALESCE(SUM(pi.total_qty * pi.price), 0) AS value,
COALESCE(p.product_price, 0) AS default_price
`).
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlags).
Where("pi.received_date IS NOT NULL")
if start != nil {
db = db.Where("pi.received_date >= ?", *start)
}
if end != nil {
db = db.Where("pi.received_date < ?", *end)
}
if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]sapronakIncomingRow, len(rows))
for _, row := range rows {
result[row.ProductID] = row
}
return result, nil
}
func (s closingService) fetchUsageSapronak(ctx context.Context, pfkID uint) (map[uint]sapronakUsageRow, error) {
rows := make([]sapronakUsageRow, 0)
if pfkID == 0 {
return map[uint]sapronakUsageRow{}, nil
}
db := s.Repository.DB().
WithContext(ctx).
Table("recording_stocks AS rs").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(SUM(rs.usage_qty), 0) AS qty,
COALESCE(p.product_price, 0) AS default_price
`).
Joins("JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id = ?", pfkID).
Where("f.name IN ?", sapronakFlags)
if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]sapronakUsageRow, len(rows))
for _, row := range rows {
result[row.ProductID] = row
}
return result, nil
}
@@ -0,0 +1,7 @@
package validation
type SapronakQuery struct {
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"`
Status string `query:"status" validate:"omitempty,oneof=active closing all"`
}
@@ -105,11 +105,11 @@ 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,
// 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,
}
}
@@ -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 {
@@ -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),
})
}
@@ -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
}
@@ -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)
}
@@ -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),
// }
// }
@@ -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)
}
@@ -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
}
@@ -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"`
}
@@ -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
}
@@ -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 {
+2
View File
@@ -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
}
@@ -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,
}
@@ -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 {
@@ -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 {