mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-21 22:05:44 +00:00
Merge branch 'feat/BE/Sprint-6' of https://gitlab.com/mbugroup/lti-api into dev/teguh
This commit is contained in:
+27
-6
@@ -6,24 +6,45 @@ deploy-dev:
|
||||
image: alpine:3.20
|
||||
variables:
|
||||
DEPLOY_APP: "LTI-MBUGROUP"
|
||||
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
GIT_DEPTH: "1"
|
||||
|
||||
before_script:
|
||||
- echo "🧰 Installing dependencies..."
|
||||
- apk update && apk add --no-cache openssh git curl
|
||||
- apk update && apk add --no-cache openssh git curl bash
|
||||
|
||||
# Setup SSH di runner
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- eval $(ssh-agent -s)
|
||||
- eval "$(ssh-agent -s)"
|
||||
- ssh-add ~/.ssh/id_rsa
|
||||
|
||||
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
|
||||
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
|
||||
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||
|
||||
script:
|
||||
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
|
||||
|
||||
- >
|
||||
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
|
||||
cd /home/devops/docker/deployment/development/lti-api &&
|
||||
git fetch origin development &&
|
||||
git reset --hard origin/development &&
|
||||
set -e
|
||||
|
||||
cd /home/devops/docker/deployment/development/lti-api
|
||||
|
||||
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
|
||||
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
|
||||
|
||||
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||
|
||||
# Fetch/reset pakai SSH
|
||||
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
|
||||
git reset --hard origin/development
|
||||
|
||||
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
|
||||
"; then
|
||||
STATUS='success';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,75 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type StockAllocationRepository interface {
|
||||
BaseRepository[entity.StockAllocation]
|
||||
FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error)
|
||||
ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error
|
||||
}
|
||||
|
||||
type StockAllocationRepositoryImpl struct {
|
||||
*BaseRepositoryImpl[entity.StockAllocation]
|
||||
}
|
||||
|
||||
func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository {
|
||||
return &StockAllocationRepositoryImpl{
|
||||
BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
|
||||
ctx context.Context,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
modifier func(*gorm.DB) *gorm.DB,
|
||||
) ([]entity.StockAllocation, error) {
|
||||
var allocations []entity.StockAllocation
|
||||
|
||||
q := r.DB().WithContext(ctx).
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
|
||||
|
||||
if modifier != nil {
|
||||
q = modifier(q)
|
||||
}
|
||||
|
||||
if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return allocations, nil
|
||||
}
|
||||
|
||||
func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
|
||||
ctx context.Context,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
note *string,
|
||||
modifier func(*gorm.DB) *gorm.DB,
|
||||
) error {
|
||||
now := time.Now()
|
||||
|
||||
updates := map[string]any{
|
||||
"status": entity.StockAllocationStatusReleased,
|
||||
"released_at": now,
|
||||
}
|
||||
if note != nil {
|
||||
updates["note"] = *note
|
||||
}
|
||||
|
||||
q := r.DB().WithContext(ctx).
|
||||
Model(&entity.StockAllocation{}).
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
|
||||
|
||||
if modifier != nil {
|
||||
q = modifier(q)
|
||||
}
|
||||
|
||||
return q.Updates(updates).Error
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type FifoService interface {
|
||||
RegisterStockable(cfg fifo.StockableConfig) error
|
||||
RegisterUsable(cfg fifo.UsableConfig) error
|
||||
|
||||
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
|
||||
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
|
||||
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
|
||||
}
|
||||
|
||||
type fifoService struct {
|
||||
db *gorm.DB
|
||||
logger *logrus.Logger
|
||||
allocations commonRepo.StockAllocationRepository
|
||||
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
|
||||
defaultOrderBy []string
|
||||
pendingBatchPerUsable int
|
||||
maxLotsPerStockable int
|
||||
defaultAllocationNotes string
|
||||
}
|
||||
|
||||
func NewFifoService(
|
||||
db *gorm.DB,
|
||||
allocations commonRepo.StockAllocationRepository,
|
||||
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository,
|
||||
logger *logrus.Logger,
|
||||
) FifoService {
|
||||
if logger == nil {
|
||||
logger = logrus.StandardLogger()
|
||||
}
|
||||
return &fifoService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
allocations: allocations,
|
||||
productWarehouseRepo: productWarehouseRepo,
|
||||
defaultOrderBy: []string{"created_at ASC", "id ASC"},
|
||||
pendingBatchPerUsable: 25,
|
||||
maxLotsPerStockable: 50,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fifoService) withTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
fn func(*gorm.DB) error,
|
||||
) error {
|
||||
if tx != nil {
|
||||
return fn(tx.WithContext(ctx))
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return fn(inner)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB {
|
||||
if tx != nil {
|
||||
return tx
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error {
|
||||
return fifo.RegisterStockable(cfg)
|
||||
}
|
||||
|
||||
func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error {
|
||||
return fifo.RegisterUsable(cfg)
|
||||
}
|
||||
|
||||
type StockReplenishRequest struct {
|
||||
StockableKey fifo.StockableKey
|
||||
StockableID uint
|
||||
ProductWarehouseID uint
|
||||
Quantity float64
|
||||
Note *string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type PendingResolution struct {
|
||||
UsableKey fifo.UsableKey
|
||||
UsableID uint
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
type StockReplenishResult struct {
|
||||
AddedQuantity float64
|
||||
PendingResolved []PendingResolution
|
||||
RemainingPending float64
|
||||
}
|
||||
|
||||
type StockConsumeRequest struct {
|
||||
UsableKey fifo.UsableKey
|
||||
UsableID uint
|
||||
ProductWarehouseID uint
|
||||
Quantity float64
|
||||
AllowPending bool
|
||||
Note *string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type AllocationDetail struct {
|
||||
StockableKey fifo.StockableKey
|
||||
StockableID uint
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
type StockConsumeResult struct {
|
||||
RequestedQuantity float64
|
||||
UsageQuantity float64
|
||||
PendingQuantity float64
|
||||
AddedAllocations []AllocationDetail
|
||||
ReleasedQuantity float64
|
||||
}
|
||||
|
||||
type StockReleaseRequest struct {
|
||||
UsableKey fifo.UsableKey
|
||||
UsableID uint
|
||||
Reason *string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
|
||||
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
|
||||
return nil, errors.New("stockable key and id are required")
|
||||
}
|
||||
if req.ProductWarehouseID == 0 {
|
||||
return nil, errors.New("product warehouse id is required")
|
||||
}
|
||||
if req.Quantity <= 0 {
|
||||
return nil, errors.New("quantity must be greater than zero")
|
||||
}
|
||||
|
||||
cfg, ok := fifo.Stockable(req.StockableKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey)
|
||||
}
|
||||
|
||||
result := &StockReplenishResult{
|
||||
AddedQuantity: req.Quantity,
|
||||
}
|
||||
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||
req.ProductWarehouseID: req.Quantity,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.PendingResolved = resolved
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
|
||||
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
|
||||
return nil, errors.New("usable key and id are required")
|
||||
}
|
||||
if req.Quantity < 0 {
|
||||
return nil, errors.New("quantity must be zero or greater")
|
||||
}
|
||||
|
||||
cfg, ok := fifo.Usable(req.UsableKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
|
||||
}
|
||||
|
||||
result := &StockConsumeResult{
|
||||
RequestedQuantity: req.Quantity,
|
||||
}
|
||||
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
productWarehouseID := ctxRow.ProductWarehouseID
|
||||
if productWarehouseID == 0 {
|
||||
return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID)
|
||||
}
|
||||
if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID {
|
||||
return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID)
|
||||
}
|
||||
|
||||
currentUsage := ctxRow.UsageQty
|
||||
currentPending := ctxRow.PendingQty
|
||||
currentTotal := currentUsage + currentPending
|
||||
delta := req.Quantity - currentTotal
|
||||
|
||||
var (
|
||||
usageDelta float64
|
||||
pendingDelta float64
|
||||
addedAlloc []AllocationDetail
|
||||
releasedAmount float64
|
||||
)
|
||||
|
||||
switch {
|
||||
case delta > 0:
|
||||
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if allocationRes.pending > 0 && !req.AllowPending {
|
||||
return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated)
|
||||
}
|
||||
|
||||
usageDelta += allocationRes.allocated
|
||||
pendingDelta += allocationRes.pending
|
||||
addedAlloc = allocationRes.allocations
|
||||
|
||||
if allocationRes.allocated > 0 {
|
||||
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||
productWarehouseID: -allocationRes.allocated,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case delta < 0:
|
||||
reductionTarget := -delta
|
||||
|
||||
if currentPending > 0 {
|
||||
pendingReduction := math.Min(currentPending, reductionTarget)
|
||||
if pendingReduction > 0 {
|
||||
pendingDelta -= pendingReduction
|
||||
reductionTarget -= pendingReduction
|
||||
}
|
||||
}
|
||||
|
||||
if reductionTarget > 0 {
|
||||
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if released+1e-6 < reductionTarget {
|
||||
return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released)
|
||||
}
|
||||
usageDelta -= released
|
||||
releasedAmount = released
|
||||
}
|
||||
default:
|
||||
// no change
|
||||
}
|
||||
|
||||
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.AddedAllocations = addedAlloc
|
||||
result.ReleasedQuantity = releasedAmount
|
||||
result.UsageQuantity = currentUsage + usageDelta
|
||||
result.PendingQuantity = currentPending + pendingDelta
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error {
|
||||
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
|
||||
return errors.New("usable key and id are required")
|
||||
}
|
||||
|
||||
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
cfg, ok := fifo.Usable(req.UsableKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("usable %q is not registered", req.UsableKey)
|
||||
}
|
||||
|
||||
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var usageDelta, pendingDelta float64
|
||||
if ctxRow.UsageQty > 0 {
|
||||
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
|
||||
return err
|
||||
}
|
||||
usageDelta -= ctxRow.UsageQty
|
||||
}
|
||||
if ctxRow.PendingQty > 0 {
|
||||
pendingDelta -= ctxRow.PendingQty
|
||||
}
|
||||
|
||||
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type usableContextRow struct {
|
||||
ProductWarehouseID uint
|
||||
UsageQty float64
|
||||
PendingQty float64
|
||||
}
|
||||
|
||||
func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) {
|
||||
var row usableContextRow
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
if err := query.Take(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("usable record %d not found", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
|
||||
column := cfg.Columns.TotalQuantity
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty),
|
||||
}
|
||||
if cfg.Columns.TotalUsedQuantity != "" {
|
||||
updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity))
|
||||
}
|
||||
|
||||
return query.Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
|
||||
if qty == 0 {
|
||||
return nil
|
||||
}
|
||||
column := cfg.Columns.TotalUsedQuantity
|
||||
query := tx.Table(cfg.Table).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error
|
||||
}
|
||||
|
||||
type allocationOutcome struct {
|
||||
allocated float64
|
||||
pending float64
|
||||
allocations []AllocationDetail
|
||||
}
|
||||
|
||||
type stockLot struct {
|
||||
StockableKey fifo.StockableKey
|
||||
RecordID uint
|
||||
AvailableQty float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *fifoService) allocateFromStock(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
productWarehouseID uint,
|
||||
usableKey fifo.UsableKey,
|
||||
usableID uint,
|
||||
requestQty float64,
|
||||
) (*allocationOutcome, error) {
|
||||
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lots) == 0 {
|
||||
return &allocationOutcome{pending: requestQty}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
remaining = requestQty
|
||||
applied float64
|
||||
allocations []*entities.StockAllocation
|
||||
allocationSummaries []AllocationDetail
|
||||
usageAdjustments = make(map[fifo.StockableKey]map[uint]float64)
|
||||
)
|
||||
|
||||
for _, lot := range lots {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if lot.AvailableQty <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
portion := lot.AvailableQty
|
||||
if portion > remaining {
|
||||
portion = remaining
|
||||
}
|
||||
|
||||
applied += portion
|
||||
remaining -= portion
|
||||
|
||||
allocationSummaries = append(allocationSummaries, AllocationDetail{
|
||||
StockableKey: lot.StockableKey,
|
||||
StockableID: lot.RecordID,
|
||||
Quantity: portion,
|
||||
})
|
||||
|
||||
allocations = append(allocations, &entities.StockAllocation{
|
||||
ProductWarehouseId: productWarehouseID,
|
||||
StockableType: lot.StockableKey.String(),
|
||||
StockableId: lot.RecordID,
|
||||
UsableType: usableKey.String(),
|
||||
UsableId: usableID,
|
||||
Qty: portion,
|
||||
Status: entities.StockAllocationStatusActive,
|
||||
})
|
||||
|
||||
if _, ok := usageAdjustments[lot.StockableKey]; !ok {
|
||||
usageAdjustments[lot.StockableKey] = make(map[uint]float64)
|
||||
}
|
||||
usageAdjustments[lot.StockableKey][lot.RecordID] += portion
|
||||
}
|
||||
|
||||
if len(allocations) > 0 {
|
||||
if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, deltas := range usageAdjustments {
|
||||
cfg, ok := fifo.Stockable(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for id, qty := range deltas {
|
||||
if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &allocationOutcome{
|
||||
allocated: applied,
|
||||
pending: remaining,
|
||||
allocations: allocationSummaries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) {
|
||||
configs := fifo.Stockables()
|
||||
if len(configs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var lots []stockLot
|
||||
for key, cfg := range configs {
|
||||
selectStmt := fmt.Sprintf(
|
||||
"%s AS id, %s AS available_qty, %s AS created_at",
|
||||
cfg.Columns.ID,
|
||||
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
|
||||
cfg.Columns.CreatedAt,
|
||||
)
|
||||
|
||||
var rows []struct {
|
||||
ID uint
|
||||
AvailableQty float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(selectStmt).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||
Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity))
|
||||
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||
query = query.Order(order)
|
||||
}
|
||||
query = query.Limit(s.maxLotsPerStockable)
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.AvailableQty <= 0 {
|
||||
continue
|
||||
}
|
||||
lots = append(lots, stockLot{
|
||||
StockableKey: key,
|
||||
RecordID: row.ID,
|
||||
AvailableQty: row.AvailableQty,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lots) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sort.SliceStable(lots, func(i, j int) bool {
|
||||
if lots[i].CreatedAt.Equal(lots[j].CreatedAt) {
|
||||
return lots[i].RecordID < lots[j].RecordID
|
||||
}
|
||||
return lots[i].CreatedAt.Before(lots[j].CreatedAt)
|
||||
})
|
||||
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error {
|
||||
if usageDelta == 0 && pendingDelta == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if usageDelta != 0 {
|
||||
updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta)
|
||||
}
|
||||
if pendingDelta != 0 {
|
||||
updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta)
|
||||
}
|
||||
|
||||
query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
return query.Updates(updates).Error
|
||||
}
|
||||
|
||||
type pendingCandidate struct {
|
||||
UsableKey fifo.UsableKey
|
||||
Config fifo.UsableConfig
|
||||
UsableID uint
|
||||
Pending float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) {
|
||||
candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var resolutions []PendingResolution
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Pending <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if outcome.allocated <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||
productWarehouseID: -outcome.allocated,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolutions = append(resolutions, PendingResolution{
|
||||
UsableKey: candidate.UsableKey,
|
||||
UsableID: candidate.UsableID,
|
||||
Quantity: outcome.allocated,
|
||||
})
|
||||
|
||||
if outcome.pending > 0 {
|
||||
// No more stock available for this warehouse at the moment.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return resolutions, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) releaseUsagePortion(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
usableKey fifo.UsableKey,
|
||||
usableID uint,
|
||||
target float64,
|
||||
) (float64, error) {
|
||||
if target <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB {
|
||||
target := s.txOrDB(tx, db)
|
||||
return target.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(allocations) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var (
|
||||
remaining = target
|
||||
totalReleased float64
|
||||
warehouseAdjustments = make(map[uint]float64)
|
||||
stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64)
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- {
|
||||
allocation := allocations[i]
|
||||
releaseAmt := allocation.Qty
|
||||
if releaseAmt > remaining {
|
||||
releaseAmt = remaining
|
||||
}
|
||||
|
||||
remaining -= releaseAmt
|
||||
totalReleased += releaseAmt
|
||||
warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt
|
||||
|
||||
key := fifo.StockableKey(allocation.StockableType)
|
||||
if _, ok := stockableAdjustments[key]; !ok {
|
||||
stockableAdjustments[key] = make(map[uint]float64)
|
||||
}
|
||||
stockableAdjustments[key][allocation.StockableId] += releaseAmt
|
||||
|
||||
if releaseAmt == allocation.Qty {
|
||||
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
|
||||
"status": entities.StockAllocationStatusReleased,
|
||||
"released_at": now,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
|
||||
"quantity": allocation.Qty - releaseAmt,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalReleased == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
for key, deltas := range stockableAdjustments {
|
||||
cfg, ok := fifo.Stockable(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for id, qty := range deltas {
|
||||
if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(warehouseAdjustments) > 0 {
|
||||
if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for warehouseID := range warehouseAdjustments {
|
||||
if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalReleased, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) {
|
||||
configs := fifo.Usables()
|
||||
if len(configs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var candidates []pendingCandidate
|
||||
|
||||
for key, cfg := range configs {
|
||||
selectStmt := fmt.Sprintf(
|
||||
"%s AS id, %s AS pending_qty, %s AS created_at",
|
||||
cfg.Columns.ID,
|
||||
cfg.Columns.PendingQuantity,
|
||||
cfg.Columns.CreatedAt,
|
||||
)
|
||||
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Pending float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(selectStmt).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||
Limit(s.pendingBatchPerUsable)
|
||||
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||
query = query.Order(order)
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Pending <= 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, pendingCandidate{
|
||||
UsableKey: key,
|
||||
Config: cfg,
|
||||
UsableID: row.ID,
|
||||
Pending: row.Pending,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) {
|
||||
return candidates[i].UsableID < candidates[j].UsableID
|
||||
}
|
||||
return candidates[i].CreatedAt.Before(candidates[j].CreatedAt)
|
||||
})
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) orderClauses(custom []string) []string {
|
||||
if len(custom) > 0 {
|
||||
return custom
|
||||
}
|
||||
return s.defaultOrderBy
|
||||
}
|
||||
@@ -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,7 @@
|
||||
DROP INDEX IF EXISTS stock_allocations_released_at_idx;
|
||||
DROP INDEX IF EXISTS stock_allocations_status_idx;
|
||||
DROP INDEX IF EXISTS stock_allocations_usage_lookup;
|
||||
DROP INDEX IF EXISTS stock_allocations_lookup;
|
||||
DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx;
|
||||
|
||||
DROP TABLE IF EXISTS stock_allocations;
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS stock_allocations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id),
|
||||
stockable_type VARCHAR(100) NOT NULL,
|
||||
stockable_id BIGINT NOT NULL,
|
||||
usable_type VARCHAR(100) NOT NULL,
|
||||
usable_id BIGINT NOT NULL,
|
||||
qty NUMERIC(15,3) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
note TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
released_at TIMESTAMPTZ NULL,
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx
|
||||
ON stock_allocations (product_warehouse_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stock_allocations_lookup
|
||||
ON stock_allocations (stockable_type, stockable_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup
|
||||
ON stock_allocations (usable_type, usable_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stock_allocations_status_idx
|
||||
ON stock_allocations (status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx
|
||||
ON stock_allocations (released_at);
|
||||
@@ -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);
|
||||
+35
@@ -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;
|
||||
+41
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProductWarehouse struct {
|
||||
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||
ProductId uint `gorm:"not null"`
|
||||
WarehouseId uint `gorm:"not null"`
|
||||
Quantity float64 `gorm:"default:0"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
CreatedBy uint `gorm:"not null"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Id uint `gorm:"primaryKey;column:id"`
|
||||
ProductId uint `gorm:"column:product_id;not null"`
|
||||
WarehouseId uint `gorm:"column:warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
|
||||
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
|
||||
|
||||
// Relations
|
||||
Product Product `gorm:"foreignKey:ProductId;references:Id"`
|
||||
Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"`
|
||||
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
Product Product `gorm:"foreignKey:ProductId;references:Id"`
|
||||
Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"`
|
||||
StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
StockAllocationStatusPending = "PENDING"
|
||||
StockAllocationStatusActive = "ACTIVE"
|
||||
StockAllocationStatusReleased = "RELEASED"
|
||||
)
|
||||
|
||||
// StockAllocation links a usable record (consumption) with an incoming stock record.
|
||||
// The combination lets us trace FIFO deductions while keeping each module focused on its own fields.
|
||||
type StockAllocation struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
ProductWarehouseId uint `gorm:"not null;index"`
|
||||
StockableType string `gorm:"size:100;not null;index:stock_allocations_lookup,priority:1"`
|
||||
StockableId uint `gorm:"not null;index:stock_allocations_lookup,priority:2"`
|
||||
UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"`
|
||||
UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
|
||||
Qty float64 `gorm:"type:numeric(15,3);not null"`
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE"`
|
||||
Note *string `gorm:"type:text"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
ReleasedAt *time.Time `gorm:"index"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
|
||||
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/response"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ClosingController struct {
|
||||
ClosingService service.ClosingService
|
||||
}
|
||||
|
||||
func NewClosingController(closingService service.ClosingService) *ClosingController {
|
||||
return &ClosingController{
|
||||
ClosingService: closingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetAll(c *fiber.Ctx) error {
|
||||
query := &validation.Query{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
}
|
||||
|
||||
if query.Page < 1 || query.Limit < 1 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
||||
}
|
||||
|
||||
result, totalResults, err := u.ClosingService.GetAll(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get all closings successfully",
|
||||
Meta: response.Meta{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||
TotalResults: totalResults,
|
||||
},
|
||||
Data: dto.ToClosingListDTOs(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetOne(c *fiber.Ctx) error {
|
||||
param := c.Params("id")
|
||||
|
||||
id, err := strconv.Atoi(param)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
|
||||
}
|
||||
|
||||
result, err := u.ClosingService.GetOne(c, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get closing successfully",
|
||||
Data: dto.ToClosingListDTO(*result),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
|
||||
)
|
||||
|
||||
// === DTO Structs ===
|
||||
|
||||
type ClosingRelationDTO struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ClosingListDTO struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ClosingDetailDTO struct {
|
||||
ClosingListDTO
|
||||
}
|
||||
|
||||
// === Mapper Functions ===
|
||||
|
||||
func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO {
|
||||
return ClosingRelationDTO{
|
||||
Id: e.Id,
|
||||
}
|
||||
}
|
||||
|
||||
func ToClosingListDTO(e entity.ProjectFlock) ClosingListDTO {
|
||||
var createdUser *userDTO.UserRelationDTO
|
||||
if e.CreatedUser.Id != 0 {
|
||||
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
|
||||
createdUser = &mapped
|
||||
}
|
||||
|
||||
return ClosingListDTO{
|
||||
Id: e.Id,
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
CreatedUser: createdUser,
|
||||
}
|
||||
}
|
||||
|
||||
func ToClosingListDTOs(e []entity.ProjectFlock) []ClosingListDTO {
|
||||
result := make([]ClosingListDTO, len(e))
|
||||
for i, r := range e {
|
||||
result[i] = ToClosingListDTO(r)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ToClosingDetailDTO(e entity.ProjectFlock) ClosingDetailDTO {
|
||||
return ClosingDetailDTO{
|
||||
ClosingListDTO: ToClosingListDTO(e),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package closings
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||
sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||
|
||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||
)
|
||||
|
||||
type ClosingModule struct{}
|
||||
|
||||
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
||||
closingRepo := rClosing.NewClosingRepository(db)
|
||||
userRepo := rUser.NewUserRepository(db)
|
||||
|
||||
closingService := sClosing.NewClosingService(closingRepo, validate)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
ClosingRoutes(router, userService, closingService)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClosingRepository interface {
|
||||
repository.BaseRepository[entity.ProjectFlock]
|
||||
}
|
||||
|
||||
type ClosingRepositoryImpl struct {
|
||||
*repository.BaseRepositoryImpl[entity.ProjectFlock]
|
||||
}
|
||||
|
||||
func NewClosingRepository(db *gorm.DB) ClosingRepository {
|
||||
return &ClosingRepositoryImpl{
|
||||
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package closings
|
||||
|
||||
import (
|
||||
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers"
|
||||
closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services"
|
||||
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) {
|
||||
ctrl := controller.NewClosingController(s)
|
||||
|
||||
route := v1.Group("/closings")
|
||||
|
||||
// route.Get("/", m.Auth(u), ctrl.GetAll)
|
||||
// route.Post("/", m.Auth(u), ctrl.CreateOne)
|
||||
// route.Get("/:id", m.Auth(u), ctrl.GetOne)
|
||||
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
|
||||
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
|
||||
|
||||
route.Get("/", ctrl.GetAll)
|
||||
route.Get("/:id", ctrl.GetOne)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClosingService interface {
|
||||
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error)
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
|
||||
}
|
||||
|
||||
type closingService struct {
|
||||
Log *logrus.Logger
|
||||
Validate *validator.Validate
|
||||
Repository repository.ClosingRepository
|
||||
}
|
||||
|
||||
func NewClosingService(repo repository.ClosingRepository, validate *validator.Validate) ClosingService {
|
||||
return &closingService{
|
||||
Log: utils.Log,
|
||||
Validate: validate,
|
||||
Repository: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s closingService) withRelations(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("CreatedUser")
|
||||
}
|
||||
|
||||
func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
|
||||
if err := s.Validate.Struct(params); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (params.Page - 1) * params.Limit
|
||||
|
||||
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||
db = s.withRelations(db)
|
||||
if params.Search != "" {
|
||||
return db.Where("name LIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to get closings: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
return closings, total, nil
|
||||
}
|
||||
|
||||
func (s closingService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
|
||||
closing, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fiber.NewError(fiber.StatusNotFound, "Closing not found")
|
||||
}
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed get closing by id: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
return closing, nil
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -344,7 +344,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create update approval")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package recordings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||
@@ -26,6 +28,25 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
|
||||
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
|
||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
|
||||
|
||||
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
|
||||
if err := fifoService.RegisterUsable(fifo.UsableConfig{
|
||||
Key: fifo.UsableKeyRecordingStock,
|
||||
Table: "recording_stocks",
|
||||
Columns: fifo.UsableColumns{
|
||||
ID: "id",
|
||||
ProductWarehouseID: "product_warehouse_id",
|
||||
UsageQuantity: "usage_qty",
|
||||
PendingQuantity: "pending_qty",
|
||||
CreatedAt: "created_at",
|
||||
},
|
||||
}); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
|
||||
panic(fmt.Sprintf("failed to register recording usable workflow: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
approvalRepo := commonRepo.NewApprovalRepository(db)
|
||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
|
||||
@@ -41,6 +62,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
projectFlockPopulationRepo,
|
||||
approvalRepo,
|
||||
approvalService,
|
||||
fifoService,
|
||||
validate,
|
||||
)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
@@ -25,6 +25,7 @@ type RecordingRepository interface {
|
||||
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
|
||||
DeleteStocks(tx *gorm.DB, recordingID uint) error
|
||||
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
|
||||
UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error
|
||||
|
||||
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
|
||||
DeleteDepletions(tx *gorm.DB, recordingID uint) error
|
||||
@@ -120,6 +121,15 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error {
|
||||
return tx.Model(&entity.RecordingStock{}).
|
||||
Where("id = ?", stockID).
|
||||
Updates(map[string]any{
|
||||
"usage_qty": usageQty,
|
||||
"pending_qty": pendingQty,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
|
||||
if len(depletions) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
|
||||
"math"
|
||||
"strings"
|
||||
@@ -36,6 +37,13 @@ type RecordingService interface {
|
||||
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error)
|
||||
}
|
||||
|
||||
type RecordingFIFOIntegrationService interface {
|
||||
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
|
||||
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
|
||||
}
|
||||
|
||||
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
|
||||
|
||||
type recordingService struct {
|
||||
Log *logrus.Logger
|
||||
Validate *validator.Validate
|
||||
@@ -45,6 +53,7 @@ type recordingService struct {
|
||||
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
|
||||
ApprovalRepo commonRepo.ApprovalRepository
|
||||
ApprovalSvc commonSvc.ApprovalService
|
||||
FifoSvc commonSvc.FifoService
|
||||
}
|
||||
|
||||
func NewRecordingService(
|
||||
@@ -54,6 +63,7 @@ func NewRecordingService(
|
||||
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
|
||||
approvalRepo commonRepo.ApprovalRepository,
|
||||
approvalSvc commonSvc.ApprovalService,
|
||||
fifoSvc commonSvc.FifoService,
|
||||
validate *validator.Validate,
|
||||
) RecordingService {
|
||||
return &recordingService{
|
||||
@@ -65,6 +75,20 @@ func NewRecordingService(
|
||||
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
||||
ApprovalRepo: approvalRepo,
|
||||
ApprovalSvc: approvalSvc,
|
||||
FifoSvc: fifoSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRecordingFIFOIntegrationService(
|
||||
repo repository.RecordingRepository,
|
||||
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
|
||||
fifoSvc commonSvc.FifoService,
|
||||
) RecordingFIFOIntegrationService {
|
||||
return &recordingService{
|
||||
Log: utils.Log,
|
||||
Repository: repo,
|
||||
ProductWarehouseRepo: productWarehouseRepo,
|
||||
FifoSvc: fifoSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +246,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
|
||||
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
|
||||
s.Log.Errorf("Failed to persist depletions: %+v", err)
|
||||
@@ -234,7 +262,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil {
|
||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, nil, nil, mappedEggs)); err != nil {
|
||||
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
|
||||
return err
|
||||
}
|
||||
@@ -347,6 +375,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil {
|
||||
s.Log.Errorf("Failed to clear stocks: %+v", err)
|
||||
return err
|
||||
@@ -358,8 +390,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil {
|
||||
s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err)
|
||||
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -691,7 +722,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil {
|
||||
if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, nil, nil, oldEggs, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -746,6 +781,77 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
||||
if len(stocks) == 0 || s.FifoSvc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, stock := range stocks {
|
||||
if stock.Id == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var desired float64
|
||||
if stock.UsageQty != nil {
|
||||
desired = *stock.UsageQty
|
||||
}
|
||||
|
||||
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
|
||||
UsableKey: recordingStockUsableKey,
|
||||
UsableID: stock.Id,
|
||||
ProductWarehouseID: stock.ProductWarehouseId,
|
||||
Quantity: desired,
|
||||
AllowPending: true,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
||||
return s.consumeRecordingStocks(ctx, tx, stocks)
|
||||
}
|
||||
|
||||
func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
||||
if len(stocks) == 0 || s.FifoSvc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, stock := range stocks {
|
||||
if stock.Id == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
|
||||
UsableKey: recordingStockUsableKey,
|
||||
UsableID: stock.Id,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
||||
return s.releaseRecordingStocks(ctx, tx, stocks)
|
||||
}
|
||||
|
||||
func buildWarehouseDeltas(
|
||||
oldDepletions, newDepletions []entity.RecordingDepletion,
|
||||
oldStocks, newStocks []entity.RecordingStock,
|
||||
@@ -758,12 +864,6 @@ func buildWarehouseDeltas(
|
||||
for _, item := range newDepletions {
|
||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty)
|
||||
}
|
||||
for _, item := range oldStocks {
|
||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty))
|
||||
}
|
||||
for _, item := range newStocks {
|
||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty))
|
||||
}
|
||||
for _, item := range oldEggs {
|
||||
accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty))
|
||||
}
|
||||
@@ -773,13 +873,6 @@ func buildWarehouseDeltas(
|
||||
return deltas
|
||||
}
|
||||
|
||||
func usageQtyValue(val *float64) float64 {
|
||||
if val == nil {
|
||||
return 0
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) {
|
||||
if id == 0 || value == 0 {
|
||||
return
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals"
|
||||
closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings"
|
||||
constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants"
|
||||
expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses"
|
||||
inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory"
|
||||
@@ -40,6 +41,7 @@ func Routes(app *fiber.App, db *gorm.DB) {
|
||||
ssoModule.Module{},
|
||||
expenses.ExpenseModule{},
|
||||
ssoModule.Module{},
|
||||
closings.ClosingModule{},
|
||||
// MODULE REGISTRY
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Mesin Stok FIFO
|
||||
|
||||
Utilitas FIFO bersifat reusable dan dibagi menjadi dua lapis:
|
||||
|
||||
1. **Registry (`internal/utils/fifo`)** – mendeklarasikan tabel mana yang bersifat `Stockable` (sumber stok) atau `Usable` (pemakai stok). Setiap modul cukup menyebutkan nama tabel dan kolom wajib:
|
||||
- Stockable: `id`, `product_warehouse_id`, `total_qty`, `total_used_qty`, `created_at`
|
||||
- Usable: `id`, `product_warehouse_id`, `usage_qty`, `pending_qty`, `created_at`
|
||||
2. **Service (`internal/common/service/common.fifo.service.go`)** – memakai registry tersebut untuk:
|
||||
- Menambah stok baru (`Replenish`).
|
||||
- Menyinkronkan total pemakaian (`Consume`). Method ini idempotent: panggil dengan *total kuantitas yang diinginkan* (mis. saat create/update/delete). Service menghitung selisih terhadap `usage_qty + pending_qty`, kemudian otomatis mengalokasikan tambahan atau melepaskan selisihnya.
|
||||
- Membatalkan pemakaian (`ReleaseUsage`) yang mengembalikan stok lalu memicu alokasi ulang ke antrian pending.
|
||||
- Baik `Replenish` maupun pelepasan stok akan menjalankan `resolvePendingForWarehouse`, sehingga pending tertua langsung terisi ketika stok tersedia.
|
||||
|
||||
## Registrasi tabel
|
||||
|
||||
```go
|
||||
import (
|
||||
commonservice "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fifoSvc := commonservice.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
|
||||
|
||||
fifoSvc.RegisterStockable(fifo.StockableConfig{
|
||||
Key: fifo.StockableKey("PURCHASE_DETAIL"),
|
||||
Table: "purchase_details",
|
||||
Columns: fifo.StockableColumns{
|
||||
ID: "id",
|
||||
ProductWarehouseID: "product_warehouse_id",
|
||||
TotalQuantity: "total_qty",
|
||||
TotalUsedQuantity: "total_used_qty",
|
||||
CreatedAt: "created_at",
|
||||
},
|
||||
})
|
||||
|
||||
fifoSvc.RegisterUsable(fifo.UsableConfig{
|
||||
Key: fifo.UsableKey("RECORDING_STOCK"),
|
||||
Table: "recording_stocks",
|
||||
Columns: fifo.UsableColumns{
|
||||
ID: "id",
|
||||
ProductWarehouseID: "product_warehouse_id",
|
||||
UsageQuantity: "usage_qty",
|
||||
PendingQuantity: "pending_qty",
|
||||
CreatedAt: "created_at",
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Each registration optionally accepts an order clause or base scope (e.g. to exclude drafts).
|
||||
|
||||
Setiap registrasi bisa diberi klausa urutan atau scope dasar (mis. untuk mengecualikan draft).
|
||||
|
||||
## Menggunakan service di modul
|
||||
|
||||
1. **Saat stok masuk** (mis. purchase selesai): panggil `fifoSvc.Replenish(...)` dengan key stockable, id record, id product warehouse, dan kuantitas yang baru tersedia. Service akan:
|
||||
- Menambah `total_qty` pada tabel stockable,
|
||||
- Menambah `product_warehouses.quantity`,
|
||||
- Mencoba membersihkan `pending_qty` dari semua usable yang terdaftar (sesuai urutan FIFO).
|
||||
2. **Saat modul memakai stok** (recording, marketing, dsb.) panggil `fifoSvc.Consume(...)` dengan total qty terbaru.
|
||||
- Jika qty baru lebih besar, service mengambil stok FIFO dan menambah `usage_qty`; kekurangan dicatat sebagai `pending_qty`.
|
||||
- Jika qty baru lebih kecil, service otomatis menurunkan `pending_qty` lebih dulu, lalu melepaskan alokasi aktif (stok kembali ke gudang) dan langsung dipakai untuk mengisi pending milik entitas lain.
|
||||
- Hapus data? panggil `Consume` dengan qty 0 atau gunakan `ReleaseUsage`.
|
||||
3. **Jika dibatalkan penuh**: `fifoSvc.ReleaseUsage(...)` mengosongkan `usage_qty/pending_qty` dan menandai baris pivot sebagai `RELEASED`.
|
||||
|
||||
Tabel pivot (`stock_allocations`) menyimpan asal pemakaian secara presisi, sehingga audit trail dan rollback stok menjadi deterministik.
|
||||
@@ -0,0 +1,5 @@
|
||||
package fifo
|
||||
|
||||
const (
|
||||
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
package fifo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QueryScope allows callers to inject custom query modifiers (preloads, filters, etc).
|
||||
type QueryScope func(*gorm.DB) *gorm.DB
|
||||
|
||||
type StockableKey string
|
||||
type UsableKey string
|
||||
|
||||
func (k StockableKey) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
func (k UsableKey) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
// StockableColumns describes the minimum columns required for a stock-bearing row.
|
||||
type StockableColumns struct {
|
||||
ID string
|
||||
ProductWarehouseID string
|
||||
TotalQuantity string
|
||||
TotalUsedQuantity string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// UsableColumns describes the required columns for rows that consume stock.
|
||||
type UsableColumns struct {
|
||||
ID string
|
||||
ProductWarehouseID string
|
||||
UsageQuantity string
|
||||
PendingQuantity string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// StockableConfig registers a table that introduces stock into the system (purchases, transfers, etc).
|
||||
type StockableConfig struct {
|
||||
Key StockableKey
|
||||
Table string
|
||||
Columns StockableColumns
|
||||
// OrderBy accepts raw column expressions, evaluated in-order (e.g. []string{"created_at ASC", "id ASC"}).
|
||||
OrderBy []string
|
||||
// Scope lets a module append base filters (e.g. exclude drafts).
|
||||
Scope QueryScope
|
||||
}
|
||||
|
||||
// UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc).
|
||||
type UsableConfig struct {
|
||||
Key UsableKey
|
||||
Table string
|
||||
Columns UsableColumns
|
||||
OrderBy []string
|
||||
Scope QueryScope
|
||||
}
|
||||
|
||||
var (
|
||||
stockableRegistry = make(map[StockableKey]StockableConfig)
|
||||
usableRegistry = make(map[UsableKey]UsableConfig)
|
||||
registryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// RegisterStockable stores the configuration so services can perform FIFO operations generically.
|
||||
func RegisterStockable(cfg StockableConfig) error {
|
||||
if err := validateStockableConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
key := StockableKey(strings.TrimSpace(cfg.Key.String()))
|
||||
if _, exists := stockableRegistry[key]; exists {
|
||||
return fmt.Errorf("stockable key %q already registered", key)
|
||||
}
|
||||
|
||||
stockableRegistry[key] = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterUsable stores the configuration for stock-consuming tables.
|
||||
func RegisterUsable(cfg UsableConfig) error {
|
||||
if err := validateUsableConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
|
||||
key := UsableKey(strings.TrimSpace(cfg.Key.String()))
|
||||
if _, exists := usableRegistry[key]; exists {
|
||||
return fmt.Errorf("usable key %q already registered", key)
|
||||
}
|
||||
|
||||
usableRegistry[key] = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stockable returns the registered configuration for the key (if any).
|
||||
func Stockable(key StockableKey) (StockableConfig, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
cfg, ok := stockableRegistry[key]
|
||||
return cfg, ok
|
||||
}
|
||||
|
||||
// Usable returns the registered configuration for the key (if any).
|
||||
func Usable(key UsableKey) (UsableConfig, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
cfg, ok := usableRegistry[key]
|
||||
return cfg, ok
|
||||
}
|
||||
|
||||
// Stockables exposes a copy of the current registry (useful for iterating pending requests).
|
||||
func Stockables() map[StockableKey]StockableConfig {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
if len(stockableRegistry) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[StockableKey]StockableConfig, len(stockableRegistry))
|
||||
for key, cfg := range stockableRegistry {
|
||||
result[key] = cfg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Usables exposes a copy of the usable registry.
|
||||
func Usables() map[UsableKey]UsableConfig {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
if len(usableRegistry) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[UsableKey]UsableConfig, len(usableRegistry))
|
||||
for key, cfg := range usableRegistry {
|
||||
result[key] = cfg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateStockableConfig(cfg StockableConfig) error {
|
||||
if strings.TrimSpace(cfg.Key.String()) == "" {
|
||||
return errors.New("stockable key is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Table) == "" {
|
||||
return fmt.Errorf("table name is required for stockable %q", cfg.Key)
|
||||
}
|
||||
|
||||
cols := cfg.Columns
|
||||
switch {
|
||||
case strings.TrimSpace(cols.ID) == "":
|
||||
return fmt.Errorf("column id is required for stockable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.ProductWarehouseID) == "":
|
||||
return fmt.Errorf("column product warehouse id is required for stockable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.TotalQuantity) == "":
|
||||
return fmt.Errorf("column total quantity is required for stockable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.TotalUsedQuantity) == "":
|
||||
return fmt.Errorf("column total used quantity is required for stockable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.CreatedAt) == "":
|
||||
return fmt.Errorf("column created_at is required for stockable %q", cfg.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsableConfig(cfg UsableConfig) error {
|
||||
if strings.TrimSpace(cfg.Key.String()) == "" {
|
||||
return errors.New("usable key is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Table) == "" {
|
||||
return fmt.Errorf("table name is required for usable %q", cfg.Key)
|
||||
}
|
||||
|
||||
cols := cfg.Columns
|
||||
switch {
|
||||
case strings.TrimSpace(cols.ID) == "":
|
||||
return fmt.Errorf("column id is required for usable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.ProductWarehouseID) == "":
|
||||
return fmt.Errorf("column product warehouse id is required for usable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.UsageQuantity) == "":
|
||||
return fmt.Errorf("column usage quantity is required for usable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.PendingQuantity) == "":
|
||||
return fmt.Errorf("column pending quantity is required for usable %q", cfg.Key)
|
||||
case strings.TrimSpace(cols.CreatedAt) == "":
|
||||
return fmt.Errorf("column created_at is required for usable %q", cfg.Key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||
servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
)
|
||||
|
||||
func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) {
|
||||
db, svc, _, _ := setupRecordingFIFOTableTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
recordingID := uint(1)
|
||||
productWarehouse := createProductWarehouseRow(t, db, 0)
|
||||
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
|
||||
|
||||
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
|
||||
t.Fatalf("consumeRecordingStocks (pending) failed: %v", err)
|
||||
}
|
||||
|
||||
updated := fetchRecordingStock(t, db, stock.Id)
|
||||
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available")
|
||||
assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request")
|
||||
assertWarehouseQuantity(t, db, productWarehouse.Id, 0)
|
||||
assertAllocationCount(t, db, 0)
|
||||
|
||||
assertAllocationCount(t, db, 0)
|
||||
}
|
||||
|
||||
func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) {
|
||||
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
recordingID := uint(1)
|
||||
productWarehouse := createProductWarehouseRow(t, db, 0)
|
||||
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
|
||||
lot := createStockLot(t, db, productWarehouse.Id)
|
||||
|
||||
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
|
||||
StockableKey: stockableKey,
|
||||
StockableID: lot.Id,
|
||||
ProductWarehouseID: productWarehouse.Id,
|
||||
Quantity: 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("replenish failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
|
||||
t.Fatalf("consumeRecordingStocks (initial) failed: %v", err)
|
||||
}
|
||||
|
||||
assertWarehouseQuantity(t, db, productWarehouse.Id, 2)
|
||||
|
||||
desired := 4.0
|
||||
stock.UsageQty = &desired
|
||||
|
||||
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
|
||||
t.Fatalf("consumeRecordingStocks (edit) failed: %v", err)
|
||||
}
|
||||
|
||||
updated := fetchRecordingStock(t, db, stock.Id)
|
||||
assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request")
|
||||
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize")
|
||||
assertWarehouseQuantity(t, db, productWarehouse.Id, 8)
|
||||
|
||||
alloc := fetchSingleAllocation(t, db, stock.Id)
|
||||
if alloc.Status != entity.StockAllocationStatusActive {
|
||||
t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status)
|
||||
}
|
||||
if mathAbs(alloc.Qty-4) > 1e-6 {
|
||||
t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) {
|
||||
db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
recordingID := uint(1)
|
||||
productWarehouse := createProductWarehouseRow(t, db, 0)
|
||||
stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10)
|
||||
lot := createStockLot(t, db, productWarehouse.Id)
|
||||
|
||||
if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
|
||||
StockableKey: stockableKey,
|
||||
StockableID: lot.Id,
|
||||
ProductWarehouseID: productWarehouse.Id,
|
||||
Quantity: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("replenish failed: %v", err)
|
||||
}
|
||||
if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
|
||||
t.Fatalf("consumeRecordingStocks failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil {
|
||||
t.Fatalf("releaseRecordingStocks failed: %v", err)
|
||||
}
|
||||
|
||||
updated := fetchRecordingStock(t, db, stock.Id)
|
||||
assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete")
|
||||
assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete")
|
||||
assertWarehouseQuantity(t, db, productWarehouse.Id, 10)
|
||||
|
||||
alloc := fetchSingleAllocation(t, db, stock.Id)
|
||||
if alloc.Status != entity.StockAllocationStatusReleased {
|
||||
t.Fatalf("expected allocation to be released, got %s", alloc.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
type recordingStockTable struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (recordingStockTable) TableName() string { return "recording_stocks" }
|
||||
|
||||
type productWarehouseTable struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
ProductId uint `gorm:"column:product_id"`
|
||||
WarehouseId uint `gorm:"column:warehouse_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
CreatedBy uint `gorm:"column:created_by"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
func (productWarehouseTable) TableName() string { return "product_warehouses" }
|
||||
|
||||
type stockAllocationTable struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
ProductWarehouseId uint `gorm:"not null"`
|
||||
StockableType string `gorm:"size:100"`
|
||||
StockableId uint
|
||||
UsableType string `gorm:"size:100"`
|
||||
UsableId uint
|
||||
Qty float64 `gorm:"column:qty"`
|
||||
Status string `gorm:"size:20"`
|
||||
Note *string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ReleasedAt *time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
func (stockAllocationTable) TableName() string { return "stock_allocations" }
|
||||
|
||||
type testStockSource struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
TotalQty float64 `gorm:"column:total_qty"`
|
||||
TotalUsedQty float64 `gorm:"column:total_used_qty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (testStockSource) TableName() string { return "test_fifo_stockables" }
|
||||
|
||||
func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&recordingStockTable{},
|
||||
&productWarehouseTable{},
|
||||
&stockAllocationTable{},
|
||||
&testStockSource{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&entity.ProductWarehouse{},
|
||||
&entity.StockAllocation{},
|
||||
&entity.RecordingStock{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate entities: %v", err)
|
||||
}
|
||||
|
||||
stockAllocRepo := newFifoTestStockAllocationRepo(db)
|
||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
|
||||
|
||||
registerRecordingUsable(t, fifoSvc)
|
||||
|
||||
key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano()))
|
||||
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
|
||||
Key: key,
|
||||
Table: "test_fifo_stockables",
|
||||
Columns: fifo.StockableColumns{
|
||||
ID: "id",
|
||||
ProductWarehouseID: "product_warehouse_id",
|
||||
TotalQuantity: "total_qty",
|
||||
TotalUsedQuantity: "total_used_qty",
|
||||
CreatedAt: "created_at",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("register stockable: %v", err)
|
||||
}
|
||||
|
||||
svc := servicePkg.NewRecordingFIFOIntegrationService(
|
||||
recordingRepo.NewRecordingRepository(db),
|
||||
productWarehouseRepo,
|
||||
fifoSvc,
|
||||
)
|
||||
|
||||
return db, svc, fifoSvc, key
|
||||
}
|
||||
|
||||
func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) {
|
||||
t.Helper()
|
||||
err := fifoSvc.RegisterUsable(fifo.UsableConfig{
|
||||
Key: fifo.UsableKeyRecordingStock,
|
||||
Table: "recording_stocks",
|
||||
Columns: fifo.UsableColumns{
|
||||
ID: "id",
|
||||
ProductWarehouseID: "product_warehouse_id",
|
||||
UsageQuantity: "usage_qty",
|
||||
PendingQuantity: "pending_qty",
|
||||
CreatedAt: "created_at",
|
||||
},
|
||||
})
|
||||
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
|
||||
t.Fatalf("register usable: %v", err)
|
||||
}
|
||||
if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok {
|
||||
t.Fatal("recording stock usable key not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse {
|
||||
t.Helper()
|
||||
pw := entity.ProductWarehouse{
|
||||
ProductId: 1,
|
||||
WarehouseId: 1,
|
||||
Quantity: qty,
|
||||
CreatedBy: 1,
|
||||
}
|
||||
if err := db.Create(&pw).Error; err != nil {
|
||||
t.Fatalf("create product warehouse: %v", err)
|
||||
}
|
||||
return pw
|
||||
}
|
||||
|
||||
func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock {
|
||||
t.Helper()
|
||||
stock := entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: productWarehouseID,
|
||||
UsageQty: floatPtr(0),
|
||||
PendingQty: floatPtr(0),
|
||||
}
|
||||
if err := db.Create(&stock).Error; err != nil {
|
||||
t.Fatalf("create recording stock: %v", err)
|
||||
}
|
||||
stock.UsageQty = floatPtr(desired)
|
||||
return stock
|
||||
}
|
||||
|
||||
func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource {
|
||||
t.Helper()
|
||||
lot := testStockSource{
|
||||
ProductWarehouseId: productWarehouseID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&lot).Error; err != nil {
|
||||
t.Fatalf("create stock lot: %v", err)
|
||||
}
|
||||
return lot
|
||||
}
|
||||
|
||||
func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock {
|
||||
t.Helper()
|
||||
var stock entity.RecordingStock
|
||||
if err := db.First(&stock, id).Error; err != nil {
|
||||
t.Fatalf("fetch recording stock: %v", err)
|
||||
}
|
||||
return stock
|
||||
}
|
||||
|
||||
func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation {
|
||||
t.Helper()
|
||||
var alloc entity.StockAllocation
|
||||
if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil {
|
||||
t.Fatalf("fetch allocation: %v", err)
|
||||
}
|
||||
return alloc
|
||||
}
|
||||
|
||||
func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count allocations: %v", err)
|
||||
}
|
||||
if count != expected {
|
||||
t.Fatalf("expected %d allocations, got %d", expected, count)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) {
|
||||
t.Helper()
|
||||
var pw entity.ProductWarehouse
|
||||
if err := db.First(&pw, id).Error; err != nil {
|
||||
t.Fatalf("fetch product warehouse: %v", err)
|
||||
}
|
||||
if mathAbs(pw.Quantity-expected) > 1e-6 {
|
||||
t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) {
|
||||
t.Helper()
|
||||
if value == nil {
|
||||
t.Fatalf("expected %s %.3f, got nil", msg, expected)
|
||||
}
|
||||
if mathAbs(*value-expected) > 1e-6 {
|
||||
t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value)
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(v float64) *float64 {
|
||||
p := new(float64)
|
||||
*p = v
|
||||
return p
|
||||
}
|
||||
|
||||
func mathAbs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func sanitizeKey(name string) string {
|
||||
if name == "" {
|
||||
return "CASE"
|
||||
}
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return r - 32
|
||||
}
|
||||
return '_'
|
||||
}, name)
|
||||
return clean
|
||||
}
|
||||
|
||||
type fifoTestStockAllocationRepo struct {
|
||||
commonRepo.StockAllocationRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository {
|
||||
return &fifoTestStockAllocationRepo{
|
||||
StockAllocationRepository: commonRepo.NewStockAllocationRepository(db),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fifoTestStockAllocationRepo) PatchOne(
|
||||
ctx context.Context,
|
||||
id uint,
|
||||
updates map[string]any,
|
||||
modifier func(*gorm.DB) *gorm.DB,
|
||||
) error {
|
||||
base := r.db
|
||||
|
||||
setClauses := make([]string, 0, len(updates))
|
||||
args := make([]any, 0, len(updates)+1)
|
||||
for column, value := range updates {
|
||||
colName := column
|
||||
if strings.EqualFold(column, "quantity") {
|
||||
colName = "qty"
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName))
|
||||
args = append(args, value)
|
||||
}
|
||||
args = append(args, id)
|
||||
sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", "))
|
||||
|
||||
result := base.Exec(sql, args...)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fifoTestStockAllocationRepo) ReleaseByUsable(
|
||||
ctx context.Context,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
note *string,
|
||||
modifier func(*gorm.DB) *gorm.DB,
|
||||
) error {
|
||||
base := r.db
|
||||
|
||||
setClause := "status = ?, released_at = ?"
|
||||
args := []any{entity.StockAllocationStatusReleased, time.Now()}
|
||||
if note != nil {
|
||||
setClause += ", note = ?"
|
||||
args = append(args, *note)
|
||||
}
|
||||
args = append(args, usableType, usableID, entity.StockAllocationStatusActive)
|
||||
sql := fmt.Sprintf(
|
||||
"UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?",
|
||||
setClause,
|
||||
)
|
||||
|
||||
result := base.Exec(sql, args...)
|
||||
return result.Error
|
||||
}
|
||||
Reference in New Issue
Block a user