diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..6a4778a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,21 @@ -stages: - - deploy +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' + - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "production"' + - when: never -deploy-dev: - stage: deploy - 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" +include: + - local: "ci/development.yml" + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + - local: "ci/staging.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' - # Setup SSH di runner - - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - 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" " - 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'; - else - STATUS='failed'; - fi; - - RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; - - if [ "$STATUS" = "success" ]; then - COLOR=3066993; - TITLE="✅ Deployment API Succeeded"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; - else - COLOR=15158332; - TITLE="❌ Deployment API Failed Gaes"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; - fi; - - echo "{ - \"username\": \"CI Bot\", - \"embeds\": [{ - \"title\": \"$TITLE\", - \"description\": \"$DESC\", - \"color\": $COLOR, - \"fields\": [ - {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, - {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, - {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, - {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} - ] - }] - }" > payload.json; - - echo "📡 Sending notification to Discord..."; - curl -sS -H "Content-Type: application/json" \ - -d @payload.json "$DISCORD_WEBHOOK_URL"; - - only: - - development - - environment: - name: development \ No newline at end of file + - local: "ci/production.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "production"' diff --git a/ci/development.yml b/ci/development.yml new file mode 100644 index 00000000..43d574b9 --- /dev/null +++ b/ci/development.yml @@ -0,0 +1,90 @@ +stages: + - deploy + +deploy-dev: + stage: deploy + 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 bash + + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - 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" " + 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'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development diff --git a/ci/production.yml b/ci/production.yml new file mode 100644 index 00000000..16626929 --- /dev/null +++ b/ci/production.yml @@ -0,0 +1,131 @@ +stages: + - build +# - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +#migrate_production: +# stage: migrate +# rules: +# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' +# when: manual +# allow_failure: false +# needs: +# - job: build_production +# artifacts: false +# script: | +# set -e +# cd /opt/deploy/lti +# test -f .env || (echo "❌ .env not found" && exit 1) + +# set -a +# . ./.env +# set +a + + # Validasi env wajib +# : "${DB_HOST:?DB_HOST not set}" +# : "${DB_PORT:?DB_PORT not set}" +# : "${DB_USER:?DB_USER not set}" +# : "${DB_PASSWORD:?DB_PASSWORD not set}" +# : "${DB_NAME:?DB_NAME not set}" + +# DB_SSLMODE="${DB_SSLMODE:-require}" +# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + +# echo "✅ Running migrations (production)..." +# docker run --rm \ +# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ +# migrate/migrate:v4.15.2 \ +# -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: +# - job: migrate_production +# artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed diff --git a/ci/staging.yml b/ci/staging.yml new file mode 100644 index 00000000..e3eaabb0 --- /dev/null +++ b/ci/staging.yml @@ -0,0 +1,173 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-stg + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + + DEPLOY_DIR: "/opt/deploy/stg-lti-api" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_staging: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (AUTO) +# ========================= +migrate_staging: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: build_staging + artifacts: false + script: | + set -e + echo "✅ Running migrations (staging) ..." + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + # ✅ load env dari server + set -a + . ./.env + set +a + + # ✅ validasi + test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) + test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) + test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) + test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) + test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) + + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL=$DATABASE_URL" + + # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) + echo "✅ Ensuring postgres & redis running ..." + docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true + + # ✅ Ambil network key dari compose + COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" + + # ✅ Cari network name yang dipakai docker + NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" + test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) + + echo "✅ Docker network detected: $NETWORK_NAME" + + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + + echo "✅ Running migrations via migrate/migrate container" + set +e + out=$(docker run --rm \ + --network "$NETWORK_NAME" \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up 2>&1) + code=$? + set -e + + echo "$out" + + # ✅ Handle no change dengan benar (tidak false-success) + if echo "$out" | grep -qi "no change"; then + echo "✅ No change (already up to date)" + exit 0 + fi + + if [ $code -ne 0 ]; then + echo "❌ Migration failed with exit code $code" + exit $code + fi + + echo "✅ Migration applied successfully" + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_staging: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: migrate_staging + artifacts: false + - job: build_staging + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_staging: + stage: seed + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false + script: | + set -e + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + test -f .env || (echo "❌ .env not found" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed% diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index b99e6c35..14cbb5c1 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -25,6 +25,7 @@ type FifoService interface { Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error + AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error } type fifoService struct { @@ -95,6 +96,15 @@ type StockReplenishRequest struct { Tx *gorm.DB } +type StockAdjustRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + type PendingResolution struct { UsableKey fifo.UsableKey UsableID uint @@ -137,6 +147,37 @@ type StockReleaseRequest struct { Reason *string Tx *gorm.DB } +func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return errors.New("product warehouse id is required") + } + if req.Quantity == 0 { + return nil + } + if req.Quantity > 0 { + return errors.New("quantity must be negative") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + return 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 + } + + return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index bbc93167..ef27d0c2 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -2,28 +2,17 @@ package entities import "time" -// AdjustmentStock tracks FIFO allocation for stock adjustments -// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse -// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse type AdjustmentStock struct { - Id uint `gorm:"primaryKey"` - StockLogId uint `gorm:"column:stock_log_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty;default:0"` + TotalUsed float64 `gorm:"column:total_used;default:0"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` + PendingQty float64 `gorm:"column:pending_qty;default:0"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === - // Tracks stock added to warehouse via adjustment INCREASE - TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available - TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot - - // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === - // Tracks stock consumed from warehouse via adjustment DECREASE - UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed - PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) - - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` - UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - - // Relations StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index ed3cfcbc..b1b02886 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -28,10 +28,31 @@ func NewClosingController(closingService service.ClosingService, sapronakService } func (u *ClosingController) GetAll(c *fiber.Ctx) error { + var projectStatus *int + if raw := c.Query("project_status"); raw != "" { + statusValue, err := strconv.Atoi(raw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status") + } + projectStatus = &statusValue + } + + var locationID *uint + if raw := c.Query("location_id"); raw != "" { + locationValue, err := strconv.Atoi(raw) + if err != nil || locationValue <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") + } + locationUint := uint(locationValue) + locationID = &locationUint + } + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectStatus: projectStatus, + LocationID: locationID, } if query.Page < 1 || query.Limit < 1 { @@ -160,7 +181,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } @@ -190,7 +211,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan by project flock kandang successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } @@ -236,9 +257,10 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { } query := &validation.ClosingSapronakQuery{ - Type: strings.ToLower(c.Query("type")), - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { kandangInt, convErr := strconv.Atoi(raw) @@ -277,6 +299,45 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Search: c.Query("search"), + } + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + query.KandangID = &kandangUint + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak summary) successfully", + Data: result, + }) +} + func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { param := c.Params("project_flock_id") flag := c.Query("flag", "") diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 1c191d29..82e11f49 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` - HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd float64 `json:"hen_day_std"` - EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd float64 `json:"egg_mass_std"` - EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd float64 `json:"egg_weight_std"` - HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd float64 `json:"hen_housed_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct float64 `json:"hen_day_act,omitempty"` + HendayStd float64 `json:"hen_day_std"` + EggMass float64 `json:"egg_mass,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeight float64 `json:"egg_weight,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` + HenHouseAct float64 `json:"hen_housed_act,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..d725b430 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,30 +12,39 @@ import ( // === Response DTO === type SalesDTO struct { - Id uint `json:"id"` - RealizationDate time.Time `json:"realization_date"` - Age int `json:"age"` - DoNumber string `json:"do_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AvgWeight float64 `json:"avg_weight"` - Price float64 `json:"price"` - TotalPrice float64 `json:"total_price"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - PaymentStatus string `json:"payment_status"` + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + Week int `json:"week"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + SalesPrice float64 `json:"sales_price"` + TotalSalesPrice float64 `json:"total_sales_price"` + ActualPrice float64 `json:"actual_price"` + TotalActualPrice float64 `json:"total_actual_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` +} +type SummaryDTO struct { + TotalSalesPrice float64 `json:"total_sales_price"` + AvgSalesPrice float64 `json:"avg_sales_price"` + TotalActualPrice float64 `json:"total_actual_price"` + AvgActualPrice float64 `json:"avg_actual_price"` } type PenjualanRealisasiResponseDTO struct { - Sales []SalesDTO `json:"sales"` + Sales []SalesDTO `json:"sales"` + Summary SummaryDTO `json:"summary"` } // === Mapper Functions === func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { - age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) + ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -63,19 +72,42 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ - Id: e.Id, - RealizationDate: realizationDate, - Age: age, - DoNumber: doNumber, - Product: product, - Customer: customer, - Qty: e.UsageQty, - Weight: e.TotalWeight, - AvgWeight: e.AvgWeight, - Price: e.UnitPrice, - TotalPrice: e.TotalPrice, - Kandang: kandang, - PaymentStatus: "Paid", + Id: e.Id, + RealizationDate: realizationDate, + Age: ageInDay, + Week: ageInWeeks, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.UsageQty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + SalesPrice: e.MarketingProduct.UnitPrice, + TotalSalesPrice: e.MarketingProduct.TotalPrice, + ActualPrice: e.UnitPrice, + TotalActualPrice: e.TotalPrice, + Kandang: kandang, + } +} + +func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { + + var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + sumSales += item.MarketingProduct.UnitPrice + sumActual += item.UnitPrice + + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } @@ -87,28 +119,16 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { - +func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ - - Sales: ToSalesDTOs(e), + Sales: ToSalesDTOs(e), + Summary: ToSummaryDto(e), } } -func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { - if len(realisasi) > 0 { - for _, item := range realisasi { - if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period - } - } - } - return 0 -} - -func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { - return 0 + return 0, 0 } earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate @@ -118,7 +138,16 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de } } - ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) - ageInWeeks := ageInDays / 7 - return ageInWeeks + diff := deliveryDate.Sub(earliestChickinDate) + ageInDays := int(diff.Hours() / 24) + + var ageInWeeks int + if ageInDays <= 0 { + ageInWeeks = 0 + } else { + + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } + + return ageInDays, ageInWeeks } diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 0067b9d2..ad66d5a7 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -114,6 +114,17 @@ type ClosingSapronakDTO struct { OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` } +type ClosingSapronakSummaryItemDTO struct { + Category string `json:"category"` + TotalQty int64 `json:"total_qty"` + Uom UomSummaryDTO `json:"uom"` +} + +type UomSummaryDTO struct { + ID uint `json:"id"` + Name string `json:"name"` +} + // === Mapper Functions for Aggregated Sapronak Response === func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6d7df1cc..120c3e5c 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -17,6 +17,7 @@ import ( type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) + GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) @@ -60,10 +61,18 @@ type SapronakRow struct { DestinationWarehouse string `gorm:"column:destination_warehouse"` Destination string `gorm:"column:destination"` Quantity float64 `gorm:"column:quantity"` + UnitID uint `gorm:"column:unit_id"` Unit string `gorm:"column:unit"` Notes string `gorm:"column:notes"` } +type SapronakSummaryRow struct { + Category string `gorm:"column:category"` + TotalQty int64 `gorm:"column:total_qty"` + UomID uint `gorm:"column:uom_id"` + UomName string `gorm:"column:uom_name"` +} + type ExpeditionHPPRow struct { SupplierName string `gorm:"column:supplier_name"` TotalAmount float64 `gorm:"column:total_amount"` @@ -75,6 +84,7 @@ type SapronakQueryParams struct { ProjectFlockKandangIDs []uint Limit int Offset int + Search string } func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { @@ -110,14 +120,36 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak unionSQL := strings.Join(unionParts, " UNION ALL ") + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + var totalResults int64 - countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) - if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) + countArgs := append(append([]any{}, args...), searchArgs...) + if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { return nil, 0, err } - dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) - dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + dataArgs := append(append([]any{}, args...), searchArgs...) + dataArgs = append(dataArgs, params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) var rows []SapronakRow if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { @@ -127,6 +159,79 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak return rows, totalResults, nil } +func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakSummaryRow{}, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakSummaryRow{}, nil + } + default: + return nil, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + + querySQL := fmt.Sprintf(` +SELECT + product_category AS category, + CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty, + unit_id AS uom_id, + unit AS uom_name +FROM (%s) AS combined%s +GROUP BY product_category, unit_id, unit +ORDER BY product_category ASC, unit ASC +`, unionSQL, searchClause) + queryArgs := append(append([]any{}, args...), searchArgs...) + + var rows []SapronakSummaryRow + if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { if len(projectFlockKandangIDs) == 0 { return 0, 0, nil @@ -380,6 +485,7 @@ SELECT w.name AS destination_warehouse, '' AS destination, pi.total_qty AS quantity, + u.id AS unit_id, u.name AS unit, COALESCE(p.notes, '') AS notes FROM purchase_items pi @@ -428,6 +534,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Stock Refill' AS notes FROM stock_transfer_details std @@ -477,6 +584,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Transfer to other unit' AS notes FROM stock_transfer_details std @@ -523,13 +631,15 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, w.name AS source_warehouse, - 'RETAIL CUSTOMER' AS destination_warehouse, + COALESCE(c.name, '') AS destination_warehouse, '' AS destination, mp.qty AS quantity, + u.id AS unit_id, u.name AS unit, m.notes AS notes FROM marketing_products mp JOIN marketings m ON m.id = mp.marketing_id +LEFT JOIN customers c ON c.id = m.customer_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN products prod ON prod.id = pw.product_id JOIN uoms u ON u.id = prod.uom_id diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index f0a6ca2a..c507b042 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -30,6 +30,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) + route.Get("/:projectFlockId/sapronak/summary", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronakSummary) route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8cda7220..372d38fd 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,6 +40,7 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) + GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -98,9 +99,31 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl } offset := (params.Page - 1) * params.Limit + statusFilter := "" + if params.ProjectStatus != nil { + switch *params.ProjectStatus { + case 1: + statusFilter = "Pengajuan" + case 2: + statusFilter = "Aktif" + } + } closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) + if params.LocationID != nil { + db = db.Where("location_id = ?", *params.LocationID) + } + if statusFilter != "" { + latestApprovalSubQuery := s.Repository.DB(). + WithContext(c.Context()). + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). + Where("approvable_type = ?", utils.ApprovalWorkflowProjectFlock.String()). + Order("approvable_id, id DESC") + db = db.Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). + Where("LOWER(latest_approval.step_name) = LOWER(?)", statusFilter) + } if params.Search != "" { return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } @@ -353,6 +376,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa ProjectFlockKandangIDs: projectFlockKandangIDs, Limit: params.Limit, Offset: offset, + Search: params.Search, }) if err != nil { s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) @@ -387,6 +411,74 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa return items, totalResults, nil } +func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") + } + + var projectFlockKandangIDs []uint + if params.KandangID != nil && *params.KandangID > 0 { + projectFlockKandangIDs = []uint{*params.KandangID} + } else if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Search: params.Search, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data") + } + + items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.ClosingSapronakSummaryItemDTO{ + Category: row.Category, + TotalQty: row.TotalQty, + Uom: dto.UomSummaryDTO{ + ID: row.UomID, + Name: row.UomName, + }, + }) + } + + return items, nil +} + func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { var kandangIDs []uint db := s.Repository.DB().WithContext(ctx) @@ -860,19 +952,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg - performance.HenDayAct = &henDayAct + performance.HenDayAct = henDayAct } if targetAverages.HenHouseCount > 0 { henHouseAct := targetAverages.HenHouseAvg - performance.HenHouseAct = &henHouseAct + performance.HenHouseAct = henHouseAct } if targetAverages.EggWeightCount > 0 { eggWeight := targetAverages.EggWeightAvg - performance.EggWeight = &eggWeight + performance.EggWeight = eggWeight } if targetAverages.EggMassCount > 0 { eggMass := targetAverages.EggMassAvg - performance.EggMass = &eggMass + performance.EggMass = eggMass } } performance.DeffFcr = performance.FcrStd - performance.FcrAct @@ -1030,4 +1122,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl return closest.Mortality, closest.FcrNumber } - diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 0c738407..9d3ad573 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -9,9 +9,11 @@ type Update struct { } 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"` + 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"` + ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"` + LocationID *uint `query:"location_id" validate:"omitempty,gt=0"` } const ( @@ -24,4 +26,5 @@ type ClosingSapronakQuery 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"` KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` } diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 493f4cb9..b9c9cc48 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -83,7 +83,7 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "KANDANG", }, "stock_log": map[string][]string{ - "log_types": []string{"TRANSFER", "ADJUSTMENT"}, + "log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"}, "transaction_types": []string{"INCREASE", "DECREASE"}, }, "supplier_categories": []string{ diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index 35ef8bb9..9157c4e2 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -52,7 +52,7 @@ type SummaryQuery struct { type ReportQuery struct { Page int `query:"page" validate:"required,number,min=1,gt=0"` - Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"required,number,min=1,gt=0"` Month int `query:"bulan" validate:"required,number,min=1,max=12"` Year int `query:"tahun" validate:"required,number,min=1900"` AreaID *uint `query:"area_id" validate:"omitempty"` diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 7582680b..828dd96c 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -285,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Select("COALESCE(SUM(re.weight * 1000), 0)"). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). @@ -648,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s Table("recording_eggs AS re"). Select(` ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 60ec97a7..0ccab661 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -70,7 +70,8 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). - Where("expenses.realization_date IS NOT NULL") + Where("expenses.realization_date IS NOT NULL"). + Where("expenses.category = ?", "BOP") if projectFlockKandangID != nil { db = db.Where(`( diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 008f9966..1ce3da1b 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -100,38 +100,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { } } -func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { +func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - Note: e.Notes, - Increase: e.Increase, - Decrease: e.Decrease, + Note: e.StockLog.Notes, + Increase: e.TotalQty, + Decrease: e.UsageQty, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } } -func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { +func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { var createdUser *userDTO.UserRelationDTO - if e.CreatedUser != nil { + if e.StockLog != nil && e.StockLog.CreatedUser != nil { createdUser = &userDTO.UserRelationDTO{ - Id: e.CreatedUser.Id, - IdUser: e.CreatedUser.IdUser, - Email: e.CreatedUser.Email, - Name: e.CreatedUser.Name, + Id: e.StockLog.CreatedUser.Id, + IdUser: e.StockLog.CreatedUser.IdUser, + Email: e.StockLog.CreatedUser.Email, + Name: e.StockLog.CreatedUser.Name, } } + createdAt := time.Time{} + if e.StockLog != nil { + createdAt = e.StockLog.CreatedAt + } + return AdjustmentListDTO{ AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), CreatedUser: createdUser, - CreatedAt: e.CreatedAt, + CreatedAt: createdAt, } } -func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { +func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index 8d62b05c..fa2685e7 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -33,6 +33,14 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { var record entity.AdjustmentStock err := r.db.WithContext(ctx). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). Where("stock_log_id = ?", stockLogID). First(&record).Error if err != nil { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 71b985c2..c92d059b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -25,9 +25,9 @@ import ( ) type AdjustmentService interface { - Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) - AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) + Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) + AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } type adjustmentService struct { @@ -73,10 +73,8 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser") } -func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { - stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") - }) +func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { + adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -85,14 +83,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { - return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") - } - - return stockLog, nil + return adjustmentStock, nil } -func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { +func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -111,12 +105,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if req.Quantity <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } + transactionType := strings.ToUpper(req.TransactionType) if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } - var createdLogId uint + var createdAdjustmentStockId uint var projectFlockKandangID *uint pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) @@ -151,7 +146,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) + + productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") @@ -171,14 +167,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Current: %.2f, Requested: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log: %+v", err) + return err } @@ -187,7 +183,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ProductWarehouseId: productWarehouse.Id, } if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { - s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } @@ -212,7 +208,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, - AllowPending: false, // Don't allow pending for adjustment + AllowPending: false, Tx: tx, }) if err != nil { @@ -220,24 +216,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - // Update ProductWarehouse quantity (for backward compatibility/reporting) - + // LEGACY: Update ProductWarehouse quantity (for backward compatibility/reporting) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) return err } - createdLogId = newLog.Id + createdAdjustmentStockId = adjustmentStock.Id return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return nil, err + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") } - return s.GetOne(c, createdLogId) + return s.GetOne(c, createdAdjustmentStockId) } func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { @@ -266,13 +265,15 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } -func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { +func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err } offset := (query.Page - 1) * query.Limit + var isProductsExist bool isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } @@ -280,7 +281,8 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } - isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { s.Log.Errorf("Failed to check product existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") @@ -289,28 +291,51 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } - stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { + var adjustmentStocks []entity.AdjustmentStock + var total int64 - db = s.withRelations(db) + q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse") - db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) + if query.ProductID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.product_id = ?", query.ProductID) + } - if query.TransactionType != "" { - db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) - } - db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) + if query.WarehouseID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.warehouse_id = ?", query.WarehouseID) + } - return db.Order("created_at DESC") - }) + if query.TransactionType != "" { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) + } + + if err = q.Count(&total).Error; err != nil { + s.Log.Errorf("Failed to get adjustments: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") + } + + err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } - result := make([]*entity.StockLog, len(stockLogs)) - for i, v := range stockLogs { - result[i] = &v + result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) + for i := range adjustmentStocks { + result[i] = &adjustmentStocks[i] } return result, total, nil diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 57a13021..b8f51c52 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" ) // === DTO Structs === @@ -16,60 +17,29 @@ type ProductWarehouseRelationDTO struct { Quantity float64 `json:"quantity"` } -type ProductWarehousNestedDTO struct { - Id uint `json:"id"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` -} - type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type UserRelationDTO struct { - Id uint `json:"id"` - Username string `json:"username"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProductWarehouseDetailDTO struct { ProductWarehouseListDTO } -// Nested DTOs for relations -type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` - Flags []string `json:"flags"` +type ProductWarehousNestedDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` } -type WarehouseRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Kandang *KandangRelationDTO `json:"kandang,omitempty"` - Location *LocationRelationDTO `json:"location,omitempty"` - Area *AreaRelationDTO `json:"area,omitempty"` -} - -type KandangRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type UserRelationDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProjectFlockKandangRelationDTO struct { @@ -96,65 +66,28 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe } } -func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { - product := productDTO.ToProductRelationDTO(e.Product) - - return ProductWarehousNestedDTO{ - Id: e.Id, - Product: &product, - Warehouse: &WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - }, - } -} - func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - // CreatedAt: e.CreatedAt, - // UpdatedAt: e.UpdatedAt, } // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) - // Tambahkan flock name ke product name jika ada project flock + // Create a copy with flock name appended if exists if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { - product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + productCopy := product + productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + dto.Product = &productCopy + } else { + dto.Product = &product } - - dto.Product = &product } // Map Warehouse relation jika ada if e.Warehouse.Id != 0 { - warehouse := WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - } - // Map Kandang jika ada - if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { - warehouse.Kandang = &KandangRelationDTO{ - Id: e.Warehouse.Kandang.Id, - Name: e.Warehouse.Kandang.Name, - } - } - // Map Location jika ada - if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { - warehouse.Location = &LocationRelationDTO{ - Id: e.Warehouse.Location.Id, - Name: e.Warehouse.Location.Name, - } - } - - if e.Warehouse.Area.Id != 0 { - warehouse.Area = &AreaRelationDTO{ - Id: e.Warehouse.Area.Id, - Name: e.Warehouse.Area.Name, - } - } + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) dto.Warehouse = &warehouse } @@ -168,7 +101,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Period: e.ProjectFlockKandang.Period, } - // Map ProjectFlock jika ada if e.ProjectFlockKandang.ProjectFlock.Id != 0 { pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ Id: e.ProjectFlockKandang.ProjectFlock.Id, @@ -179,15 +111,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.ProjectFlockKandang = pfkDTO } - // Map CreatedUser relation jika ada - // if e.CreatedUser.Id != 0 { - // user := UserRelationDTO{ - // Id: e.CreatedUser.Id, - // Username: e.CreatedUser.Name, - // } - // dto.CreatedUser = &user - // } - return dto } @@ -205,23 +128,13 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta } } -func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { - return KandangRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} +func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { + product := productDTO.ToProductRelationDTO(e.Product) + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) -func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { - return LocationRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} - -func ToAreaRelationDTO(e entity.Area) AreaRelationDTO { - return AreaRelationDTO{ - Id: e.Id, - Name: e.Name, + return ProductWarehousNestedDTO{ + Id: e.Id, + Product: &product, + Warehouse: &warehouse, } } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 152bfa24..5b89808c 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -3,15 +3,14 @@ package service import ( "errors" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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" ) @@ -40,6 +39,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Product.Flags"). Preload("Product"). + Preload("Product.Uom"). Preload("Warehouse"). Preload("Warehouse.Location"). Preload("Warehouse.Area"). diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go index cd314901..9d9d6aeb 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context if err != nil { return "", err } - movementNumber := fmt.Sprintf("ST-%05d", seq) + movementNumber := fmt.Sprintf("PND-LTI-%05d", seq) return movementNumber, nil } diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index a6eea180..4bcbacca 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -9,6 +9,7 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -68,10 +69,10 @@ type DeliveryItemDTO struct { } type DeliveryGroupDTO struct { - DoNumber string `json:"do_number"` - DeliveryDate *time.Time `json:"delivery_date"` - Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` - Deliveries []DeliveryItemDTO `json:"deliveries"` + DoNumber string `json:"do_number"` + DeliveryDate *time.Time `json:"delivery_date"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Deliveries []DeliveryItemDTO `json:"deliveries"` } type DeliveryMarketingProductDTO struct { @@ -286,7 +287,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri if !exists { group = &DeliveryGroupDTO{ DeliveryDate: product.DeliveryDate, - Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ + Warehouse: &warehouseDTO.WarehouseRelationDTO{ Id: warehouseId, Name: warehouseName, }, diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index e219b041..1ec0bddf 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -225,8 +225,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } } - if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { - if filters.FilterBy == "so_date" { + if filters.StartDate != "" || filters.EndDate != "" { + filterBy := filters.FilterBy + if filterBy == "" { + filterBy = "so_date" + } + if filterBy == "so_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketings.so_date >= ?", startDate) @@ -238,7 +242,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("marketings.so_date < ?", nextDate) } } - } else if filters.FilterBy == "realization_date" { + } else if filterBy == "realization_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) diff --git a/internal/modules/marketing/repositories/salesorder_product.repository.go b/internal/modules/marketing/repositories/salesorder_product.repository.go index 4d5eb43f..95003939 100644 --- a/internal/modules/marketing/repositories/salesorder_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_product.repository.go @@ -26,7 +26,10 @@ func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository { func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) { var products []entity.MarketingProduct - if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil { + if err := r.DB().WithContext(ctx). + Preload("ProductWarehouse.Product.Flags"). + Where("marketing_id = ?", marketingID). + Find(&products).Error; err != nil { return nil, err } if len(products) == 0 { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a521e5bc..b4e3eea0 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -247,9 +247,27 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - // Hitung total_weight dan total_price otomatis + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { + for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice + } deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -361,9 +379,27 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty - // Hitung total_weight dan total_price otomatis + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { + for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice + } deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -435,7 +471,13 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor } if pw == nil || pw.Quantity < requestedQty { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { + if pw != nil { + return pw.Quantity + } else { + return 0 + } + }(), requestedQty)) } if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index e73184dd..e2cfcabb 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -292,9 +292,35 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { - // Hitung total_weight dan total_price otomatis + // Get product untuk cek flag PAKAN atau OVK + productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + return err + } + + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { + for _, flag := range productWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = rp.Qty * rp.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * rp.UnitPrice + } updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, @@ -592,9 +618,34 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { - // Hitung total_weight dan total_price otomatis + // Get product untuk cek flag PAKAN atau OVK + productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + return err + } + + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { + for _, flag := range productWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = rp.Qty * rp.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * rp.UnitPrice + } marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, diff --git a/internal/modules/master/config-checklists/services/config-checklist.service.go b/internal/modules/master/config-checklists/services/config-checklist.service.go index 0c96e3d5..97cd42c7 100644 --- a/internal/modules/master/config-checklists/services/config-checklist.service.go +++ b/internal/modules/master/config-checklists/services/config-checklist.service.go @@ -76,6 +76,9 @@ func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad > req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } date, err := time.Parse("2006-01-02", req.Date) if err != nil { @@ -100,6 +103,11 @@ func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad != nil && req.PercentageThresholdEnough != nil { + if *req.PercentageThresholdBad > *req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } + } updateBody := make(map[string]any) diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 1c6b15ce..c34e6a31 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -110,6 +110,17 @@ func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) ( return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") } + existing, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("phase_id = ? AND name = ? AND time_type = ?", phase.Id, name, timeType) + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to check phaseActivity uniqueness: %+v", err) + return nil, err + } + if existing != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase activity with same name and time_type already exists") + } + createBody := &entity.PhaseActivity{ PhaseId: phase.Id, Name: name, diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 71981a9e..23c97788 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -16,6 +16,7 @@ import ( rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -31,6 +32,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) @@ -113,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo, approvalService, fifoService, + stockLogRepo, productionStandardService, validate, ) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9e783134..6cb65c6c 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -171,6 +171,7 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda var days []int if err := tx.Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL"). Where("day IS NOT NULL"). Pluck("day", &days).Error; err != nil { return 0, err @@ -399,7 +400,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin } err = tx. Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams"). Where("recording_eggs.recording_id = ?", recordingID). Scan(&result).Error if err != nil { @@ -485,7 +486,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct var result float64 err := r.DB().WithContext(ctx). Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). + Select("COALESCE(SUM(recording_eggs.weight), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 80611109..b408995f 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -13,6 +13,7 @@ import ( sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" 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" @@ -39,8 +40,8 @@ type RecordingService interface { } 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 + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error } var recordingStockUsableKey = fifo.UsableKeyRecordingStock @@ -57,6 +58,7 @@ type recordingService struct { ApprovalSvc commonSvc.ApprovalService ProductionStandardSvc sProductionStandard.ProductionStandardService FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewRecordingService( @@ -67,6 +69,7 @@ func NewRecordingService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, validate *validator.Validate, ) RecordingService { @@ -81,6 +84,7 @@ func NewRecordingService( ApprovalSvc: approvalSvc, ProductionStandardSvc: productionStandardSvc, FifoSvc: fifoSvc, + StockLogRepo: stockLogRepo, } } @@ -88,12 +92,14 @@ func NewRecordingFIFOIntegrationService( repo repository.RecordingRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, fifoSvc commonSvc.FifoService, + stockLogRepo rStockLogs.StockLogRepository, ) RecordingFIFOIntegrationService { return &recordingService{ Log: utils.Log, Repository: repo, ProductWarehouseRepo: productWarehouseRepo, FifoSvc: fifoSvc, + StockLogRepo: stockLogRepo, } } @@ -159,14 +165,13 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) ( return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - db := s.Repository.DB().WithContext(c.Context()) - next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) + day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, time.Now().UTC()) if err != nil { - s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) + s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) return 0, err } - return next, nil + return day, nil } func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { @@ -208,6 +213,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } } + day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime) + if err != nil { + return nil, err + } + if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } @@ -221,13 +231,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var createdRecording entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) - if err != nil { - s.Log.Errorf("Failed to determine recording day: %+v", err) - return err - } if s.ProductionStandardSvc != nil { - if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { + if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, day); err != nil { return err } } @@ -241,7 +246,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") } - day := nextDay createdRecording = entity.Recording{ ProjectFlockKandangId: req.ProjectFlockKandangId, RecordDatetime: recordTime, @@ -274,7 +278,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) - if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { return err } @@ -293,7 +298,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } if s.FifoSvc != nil { - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } } @@ -304,7 +310,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } if s.FifoSvc != nil { - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } } @@ -346,6 +353,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } var recordingEntity *entity.Recording var updatedRecording *entity.Recording @@ -431,14 +442,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasStockChanges { - if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { return err } } if hasDepletionChanges { if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { return err } } @@ -464,7 +477,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if s.FifoSvc != nil { - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } } @@ -480,6 +494,28 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := ensureRecordingEggsUnused(existingEggs); err != nil { return err } + if s.StockLogRepo != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + logs := make([]*entity.StockLog, 0, len(existingEggs)) + for _, egg := range existingEggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + logs = append(logs, &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: recordingEntity.Id, + Notes: note, + }) + } + if len(logs) > 0 { + if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil { + return err + } + } + } if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err @@ -498,7 +534,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if s.FifoSvc != nil { - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } } else { @@ -675,7 +712,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions); err != nil { + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil { return err } } @@ -697,7 +734,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil { return err } @@ -756,10 +793,19 @@ 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 { +func (s *recordingService) consumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { if len(stocks) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, stock := range stocks { if stock.Id == 0 { @@ -792,15 +838,42 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { +func (s *recordingService) consumeRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { if len(depletions) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, depletion := range depletions { if depletion.Id == 0 { @@ -832,19 +905,67 @@ func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *g if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { return err } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Increase: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); 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) ConsumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) } -func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { +func (s *recordingService) releaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { if len(stocks) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, stock := range stocks { if stock.Id == 0 { @@ -863,15 +984,38 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err } + + if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: *stock.UsageQty, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { +func (s *recordingService) releaseRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { if len(depletions) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, depletion := range depletions { if depletion.Id == 0 { @@ -898,13 +1042,52 @@ func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *g if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { return err } + + logIncrease := depletion.Qty + if depletion.PendingQty > 0 { + logIncrease += depletion.PendingQty + } + if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Increase: logIncrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Decrease: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); 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 (s *recordingService) ReleaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) } func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { @@ -929,6 +1112,40 @@ func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, pro return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") } +func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + + var chickinDate time.Time + for _, pop := range populations { + if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { + continue + } + if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) { + chickinDate = pop.ProjectChickin.ChickInDate + } + } + if chickinDate.IsZero() { + return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan") + } + + chickinDay := time.Date(chickinDate.Year(), chickinDate.Month(), chickinDate.Day(), 0, 0, 0, 0, time.UTC) + recordDay := time.Date(recordTime.Year(), recordTime.Month(), recordTime.Day(), 0, 0, 0, 0, time.UTC) + diff := int(recordDay.Sub(chickinDay).Hours() / 24) + if diff < 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in") + } + + return diff + 1, nil +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldEggs, newEggs []entity.RecordingEgg, @@ -963,27 +1180,48 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } -func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error { +func (s *recordingService) replenishRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { if len(eggs) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, egg := range eggs { if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } - note := fmt.Sprintf("Recording egg #%d", egg.Id) if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyRecordingEgg, StockableID: egg.Id, ProductWarehouseID: egg.ProductWarehouseId, Quantity: float64(egg.Qty), - Note: ¬e, Tx: tx, }); err != nil { s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) return err } + + if strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Increase: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil @@ -1034,6 +1272,8 @@ func (s *recordingService) syncRecordingStocks( recordingID uint, existing []entity.RecordingStock, incoming []validation.Stock, + note string, + actorID uint, ) error { if s.FifoSvc == nil { if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { @@ -1080,7 +1320,7 @@ func (s *recordingService) syncRecordingStocks( leftovers = append(leftovers, list...) } if len(leftovers) > 0 { - if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { return err } ids := make([]uint, 0, len(leftovers)) @@ -1099,7 +1339,7 @@ func (s *recordingService) syncRecordingStocks( if len(stocksToConsume) == 0 { return nil } - return s.consumeRecordingStocks(ctx, tx, stocksToConsume) + return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) } type eggTotals struct { @@ -1157,7 +1397,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := existingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight existingTotals[egg.ProductWarehouseId] = current } @@ -1169,7 +1409,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := incomingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight incomingTotals[egg.ProductWarehouseId] = current } @@ -1328,7 +1568,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = totalEggWeightGrams / remainingChick + eggMass = (totalEggWeightGrams / remainingChick) / 1000 updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 13c39334..d0ee5061 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -70,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) + result, approval, err := u.TransferLayingService.GetOne(c, uint(id)) if err != nil { return err } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e64b9cc2..8e0269cf 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -28,8 +28,7 @@ import ( type TransferLayingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) - GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -156,14 +155,15 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return transferLayings, total, nil } -func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { +func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") } if err != nil { s.Log.Errorf("Failed get transferLaying by id: %+v", err) - return nil, err + return nil, nil, err } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -174,15 +174,6 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran transferLaying.LatestApproval = latestApproval } - return transferLaying, nil -} - -func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { - transferLaying, err := s.GetOne(c, id) - if err != nil { - return nil, nil, err - } - return transferLaying, transferLaying.LatestApproval, nil } @@ -406,7 +397,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } - return s.GetOne(c, createBody.Id) + laying_transfer, _, err := s.GetOne(c, createBody.Id) + if err != nil { + return nil, err + } + return laying_transfer, nil + } func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { @@ -582,7 +578,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, err } - return s.GetOne(c, id) + layingTransfer, _, err := s.GetOne(c, id) + + return layingTransfer, err } func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -773,7 +771,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) for _, approvableID := range approvableIDs { - transfer, err := s.GetOne(c, approvableID) + transfer, _, err := s.GetOne(c, approvableID) if err != nil { return nil, err } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index f6337c8a..6b423d33 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -18,6 +18,7 @@ import ( rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -751,6 +752,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if receivedQty > item.SubQty { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } + if receivedQty < item.TotalUsed { + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) + } if _, dup := visitedItems[payload.PurchaseItemID]; dup { return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) @@ -827,19 +831,37 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation receivingAction = entity.ApprovalActionUpdated } } + noteSuffix := "receive" + if receivingAction == entity.ApprovalActionUpdated { + noteSuffix = "edit-receive" + } + receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix) + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) + totalQtyDeltas := make(map[uint]float64) fifoAdds := make([]struct { itemID uint pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) + logEntries := make([]struct { + itemID uint + pwID uint + delta float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -860,16 +882,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty - switch { - case deltaQty > 0 && newPWID != nil: - fifoAdds = append(fifoAdds, struct { + if newPWID != nil && deltaQty != 0 { + logEntries = append(logEntries, struct { itemID uint pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + delta float64 + }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) + } + switch { + case deltaQty > 0 && newPWID != nil: + if s.FifoSvc != nil { + fifoAdds = append(fifoAdds, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + } else { + deltas[*newPWID] += deltaQty + totalQtyDeltas[item.Id] += deltaQty + } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} + if s.FifoSvc != nil { + fifoSubs = append(fifoSubs, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + affected[*newPWID] = struct{}{} + } else { + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} + totalQtyDeltas[item.Id] += deltaQty + } } dateCopy := prep.receivedDate @@ -892,7 +936,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation updates = append(updates, update) - if item.Price > 0 && prep.receivedQty >= 0 { + if prep.receivedQty >= 0 { priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ ItemID: item.Id, Price: item.Price, @@ -909,16 +953,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err } } + if len(totalQtyDeltas) > 0 { + for itemID, delta := range totalQtyDeltas { + if delta == 0 { + continue + } + if err := tx.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchase.Id, itemID). + Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil { + return err + } + } + } + // Update due_date based on earliest received date when receiving approved. if earliestReceived != nil { due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) @@ -944,6 +997,53 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } } + for _, adj := range fifoSubs { + if adj.pwID == 0 || adj.qty >= 0 { + continue + } + if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyPurchaseItems, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } + } + + if len(logEntries) > 0 { + logs := make([]*entity.StockLog, 0, len(logEntries)) + for _, entry := range logEntries { + if entry.pwID == 0 || entry.delta == 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: entry.pwID, + CreatedBy: actorID, + LoggableType: string(utils.StockLogTypePurchase), + LoggableId: purchase.Id, + Notes: receiveNote, + } + if entry.delta > 0 { + log.Increase = entry.delta + } else { + log.Decrease = -entry.delta + } + logs = append(logs, log) + } + if len(logs) > 0 { + if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil { + return err + } + } + } + + if len(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil @@ -1371,10 +1471,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( qtyCopy := effectiveQty update.Quantity = &qtyCopy } - if syncReceiving { - qtyCopy := effectiveQty - update.TotalQty = &qtyCopy - } updates = append(updates, update) delete(requestItems, item.Id) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,98 +40,22 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesPrice float64 `json:"average_sales_price"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` } -type RepportMarketingResponseDTO struct { - Items []RepportMarketingItemDTO `json:"items"` - Total *Summary `json:"total,omitempty"` -} - type ProductRelationDTOFixed struct { productDTO.ProductRelationDTO ProductPrice float64 `json:"product_price"` SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { - soDate := time.Time{} - agingDays := 0 - if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { - soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) - } - - realizationDate := time.Time{} - if mdp.DeliveryDate != nil { - realizationDate = *mdp.DeliveryDate - } - - doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - - totalWeightKg := mdp.UsageQty * mdp.AvgWeight - salesAmount := totalWeightKg * mdp.UnitPrice - - var hpp float64 - var hppAmount float64 - if isProductEligibleForHpp(mdp, category) { - hpp = hppPricePerKg - hppAmount = totalWeightKg * hppPricePerKg - } - - item := RepportMarketingItemDTO{ - ID: int(mdp.Id), - SoDate: soDate, - RealizationDate: realizationDate, - AgingDays: agingDays, - DoNumber: doNumber, - MarketingType: getMarketingType(mdp), - Qty: mdp.UsageQty, - AverageWeightKg: mdp.AvgWeight, - TotalWeightKg: totalWeightKg, - SalesPricePerKg: mdp.UnitPrice, - HppPricePerKg: hpp, - SalesAmount: salesAmount, - HppAmount: hppAmount, - } - - if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { - mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) - item.Warehouse = &mapped - } - - if mdp.MarketingProduct.Marketing.CustomerId != 0 { - mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) - item.Customer = &mapped - } - - if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { - mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) - item.Sales = &mapped - } - - item.VehicleNumber = mdp.VehicleNumber - - if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { - mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = newProductRelationDTOFixedPtr(&mapped) - } - - return item -} - -func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) - for _, mdp := range mdps { - items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category)) - } - return items -} -func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { - items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { hppPerKg := float64(0) category := "" @@ -142,101 +66,113 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + soDate := time.Time{} + agingDays := 0 + if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { + soDate = mdp.MarketingProduct.Marketing.SoDate + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } + } + + realizationDate := time.Time{} + if mdp.DeliveryDate != nil { + realizationDate = *mdp.DeliveryDate + } + + totalWeightKg := mdp.UsageQty * mdp.AvgWeight + salesAmount := totalWeightKg * mdp.UnitPrice + + var hpp float64 + var hppAmount float64 + + var hasAyam, hasTelur, hasTrading bool + for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags { + ft := utils.FlagType(flag.Name) + + if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { + hasAyam = true + } + + if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || + ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { + hasTelur = true + } + + if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || + ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { + hasTrading = true + } + } + + marketingType := "trading" + if hasTrading { + marketingType = "trading" + } else if hasTelur { + marketingType = "telur" + } else if hasAyam { + marketingType = "ayam" + } + + eligibleForHpp := false + + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + eligibleForHpp = hasAyam + } else { + eligibleForHpp = hasAyam || hasTelur + } + + if eligibleForHpp { + hpp = hppPerKg + hppAmount = totalWeightKg * hppPerKg + } + + item := RepportMarketingItemDTO{ + ID: int(mdp.Id), + SoDate: soDate, + RealizationDate: realizationDate, + AgingDays: agingDays, + DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId), + MarketingType: marketingType, + Qty: mdp.UsageQty, + AverageWeightKg: mdp.AvgWeight, + TotalWeightKg: totalWeightKg, + SalesPricePerKg: mdp.UnitPrice, + HppPricePerKg: hpp, + SalesAmount: salesAmount, + HppAmount: hppAmount, + VehicleNumber: mdp.VehicleNumber, + } + + if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { + mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) + item.Warehouse = &mapped + } + + if mdp.MarketingProduct.Marketing.CustomerId != 0 { + mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) + item.Customer = &mapped + } + + if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { + mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) + item.Sales = &mapped + } + + if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + item.Product = newProductRelationDTOFixedPtr(&mapped) + } + items = append(items, item) } + return items } -func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if hasAyam { - return "ayam" - } - if hasTelur { - return "telur" - } - if hasTrading { - return "trading" - } - return "trading" // default to trading if no flags found -} - -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { - if len(flags) == 0 { - return false, false, false - } - - for _, flag := range flags { - ft := utils.FlagType(flag.Name) - - if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || - ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { - hasAyam = true - } - - if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || - ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { - hasTelur = true - } - - if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || - ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { - hasTrading = true - } - } - - return hasAyam, hasTelur, hasTrading -} - -func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { - return hasAyam - } - - return hasAyam || hasTelur -} - -func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary { - if len(mdps) == 0 { - return nil - } - - totalQty := 0 - totalWeightKg := 0.0 - totalEligibleWeightKg := 0.0 - totalSalesAmount := int64(0) - totalHppAmount := int64(0) - - for _, mdp := range mdps { - calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight - totalQty += int(mdp.UsageQty) - totalWeightKg += calculatedTotalWeight - totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) - - if isProductEligibleForHpp(mdp, category) { - totalEligibleWeightKg += calculatedTotalWeight - totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg) - } - } - - totalHppPricePerKg := float64(0) - if totalEligibleWeightKg > 0 { - totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg - } - - return &Summary{ - TotalQty: totalQty, - TotalWeightKg: totalWeightKg, - TotalSalesAmount: totalSalesAmount, - TotalHppAmount: totalHppAmount, - TotalHppPricePerKg: totalHppPricePerKg, - } -} - func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { if len(items) == 0 { return nil @@ -244,6 +180,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesPrice := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -252,6 +190,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalWeightKg += item.TotalWeightKg totalSalesAmount += int64(item.SalesAmount) totalHppAmount += int64(item.HppAmount) + avgSalesPrice += item.SalesPricePerKg } totalHppPricePerKg := float64(0) @@ -259,25 +198,25 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesPrice = avgSalesPrice / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, } } -func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO { - items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category) - total := ToSummary(mdps, hppPricePerKg, category) - - return RepportMarketingResponseDTO{ - Items: items, - Total: total, - } -} - func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { if original == nil { return nil diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 977db610..74039ebf 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -37,6 +37,21 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { return &debtSupplierRepositoryImpl{db: db} } +func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.step_number, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowPurchase), + ) +} + func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { case "po_date": @@ -54,7 +69,11 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt db := r.db.WithContext(ctx). Model(&entity.Supplier{}). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). - Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if len(filters.SupplierIDs) > 0 { db = db.Where("suppliers.id IN ?", filters.SupplierIDs) @@ -207,7 +226,11 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie Table("purchases"). Select("DISTINCT purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). - Where("purchases.supplier_id IN ?", supplierIDs) + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { @@ -355,7 +378,11 @@ func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Con Table("purchases"). Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL"). Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom). Group("purchases.supplier_id"). Scan(&rows).Error; err != nil { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..bbe1d111 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,46 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return nil, 0, err } + customerGroups := make(map[uint][]entity.MarketingDeliveryProduct) + for _, dp := range deliveryProducts { + customerID := dp.MarketingProduct.Marketing.CustomerId + customerGroups[customerID] = append(customerGroups[customerID], dp) + } + + agingMap := make(map[int]int) + for customerID := range customerGroups { + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) + if err != nil { + continue + } + + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID) + if err != nil { + initialBalance = 0 + } + + runningBalance := initialBalance + for i, tx := range transactions { + if tx.TransactionType == "SALES" { + previousBalance := runningBalance + runningBalance -= tx.TotalPrice + currentBalance := runningBalance + + _, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance) + + if paymentDate != nil { + agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } else { + agingDays := int(time.Since(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + } + } + } + projectFlockIDMap := make(map[uint]bool) hppMap := make(map[uint]float64) @@ -181,7 +221,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +462,10 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return nil, 0, err } - // Determine customer IDs to process var customerIDs []uint var totalCustomers int64 if len(params.CustomerIDs) > 0 { - // Specific customer IDs mode (no pagination) customerIDs = params.CustomerIDs totalCustomers = int64(len(customerIDs)) @@ -435,7 +473,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return []dto.CustomerPaymentReportItem{}, 0, nil } } else { - // Multiple customers mode with pagination page := params.Page limit := params.Limit if page < 1 { @@ -574,15 +611,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] - // Status Logic: - // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) - // 2. LUNAS: future payments make AR >= 0 (eventually paid) - // 3. DIBAYAR SEBAGIAN: has payment but not enough - // 4. BELUM LUNAS: no payment at all - if previousBalance >= currentSales.TotalPrice { - // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO - // Track payment allocations that are consumed by previous sales type paymentAllocation struct { date time.Time amount float64 @@ -591,7 +620,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo allocations := []paymentAllocation{} runningBalance := 0.0 - // Process all transactions before current sales to build allocation map for i := 0; i < currentIndex; i++ { if transactions[i].TransactionType == "PAYMENT" { allocations = append(allocations, paymentAllocation{ @@ -604,7 +632,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo salesAmount := transactions[i].TotalPrice remainingToConsume := salesAmount - // Consume from oldest allocations first (FIFO) for j := range allocations { if remainingToConsume <= 0 { break @@ -623,22 +650,18 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo } } - // Now find which allocation covers the current sales amountNeeded := currentSales.TotalPrice for _, alloc := range allocations { available := alloc.amount - alloc.consumed if available > 0 { if amountNeeded <= available { - // This allocation fully covers the sales return "LUNAS", &alloc.date } else { - // This allocation partially covers, continue to next amountNeeded -= available } } } - // If we get here, use the oldest allocation if len(allocations) > 0 { return "LUNAS", &allocations[0].date } @@ -690,7 +713,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe if record.Day != nil { result.Woa = float64(*record.Day) } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { result.Bw = avgWeight @@ -838,7 +860,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa var rows []entity.ProjectFlockKandangUniformity if err := s.DB.WithContext(ctx). Model(&entity.ProjectFlockKandangUniformity{}). - Select("week, uniformity, uniform_date, id"). + Select("week, uniformity, uniform_date, id, chart_data"). Where("project_flock_kandang_id = ?", projectFlockKandangID). Where("week IN ?", weeks). Order("uniform_date DESC"). @@ -1134,12 +1156,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } - references := collectDebtSupplierReferences(purchases) - paymentSummaries, err := s.DebtSupplierRepo.GetPaymentSummariesByReferences(c.Context(), supplierIDs, references) - if err != nil { - return nil, 0, err - } - location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") @@ -1154,6 +1170,16 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance float64 CountTotals bool } + type debtSupplierAllocation struct { + RowIndex int + SortTime time.Time + Amount float64 + Purchase entity.Purchase + } + type paymentAllocation struct { + Date time.Time + Amount float64 + } for _, supplierID := range supplierIDs { supplier, exists := supplierMap[supplierID] @@ -1167,19 +1193,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu total := dto.DebtSupplierTotalDTO{} combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) + purchaseAllocations := make([]debtSupplierAllocation, 0, len(items)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, now, location) - if reference := resolveDebtSupplierReference(purchase); reference != "" { - if summary, ok := paymentSummaries[reference]; ok { - if isDebtSupplierPaid(row.TotalPrice, summary.Total) { - row.Status = "Lunas" - if !summary.LatestPaymentDate.IsZero() { - row.Aging = calculateDebtSupplierAging(purchase, summary.LatestPaymentDate, location) - } - } - } - } sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) + rowIndex := len(combinedRows) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, SortTime: sortTime, @@ -1187,6 +1205,24 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: -row.TotalPrice, CountTotals: true, }) + purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ + RowIndex: rowIndex, + SortTime: sortTime, + Amount: row.TotalPrice, + Purchase: purchase, + }) + } + + paymentAllocations := make([]paymentAllocation, 0, len(paymentItems)+1) + initialAllocation := initialBalanceTotals[supplierID] + initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + paymentCarry := 0.0 + if initialAllocation > 0 && len(purchaseAllocations) > 0 { + paymentAllocations = append(paymentAllocations, paymentAllocation{ + Date: purchaseAllocations[0].SortTime, + Amount: initialAllocation, + }) + } else if initialAllocation < 0 { + paymentCarry = -initialAllocation } for _, payment := range paymentItems { @@ -1199,6 +1235,53 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: payment.Nominal, CountTotals: false, }) + paymentAllocations = append(paymentAllocations, paymentAllocation{ + Date: sortTime, + Amount: payment.Nominal, + }) + } + + if len(purchaseAllocations) > 0 && len(paymentAllocations) > 0 { + sort.SliceStable(purchaseAllocations, func(i, j int) bool { + return purchaseAllocations[i].SortTime.Before(purchaseAllocations[j].SortTime) + }) + sort.SliceStable(paymentAllocations, func(i, j int) bool { + return paymentAllocations[i].Date.Before(paymentAllocations[j].Date) + }) + remaining := make([]float64, len(purchaseAllocations)) + for i := range purchaseAllocations { + remaining[i] = purchaseAllocations[i].Amount + } + purchaseIndex := 0 + for _, pay := range paymentAllocations { + amount := pay.Amount + if amount <= 0 { + continue + } + if paymentCarry > 0 { + used := math.Min(amount, paymentCarry) + paymentCarry -= used + amount -= used + } + for amount > 0 && purchaseIndex < len(remaining) { + if remaining[purchaseIndex] <= 0 { + purchaseIndex++ + continue + } + used := math.Min(amount, remaining[purchaseIndex]) + remaining[purchaseIndex] -= used + amount -= used + if remaining[purchaseIndex] <= 0.000001 { + allocation := purchaseAllocations[purchaseIndex] + combinedRows[allocation.RowIndex].Row.Status = "Lunas" + combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location) + purchaseIndex++ + } + } + if purchaseIndex >= len(remaining) { + break + } + } } sort.SliceStable(combinedRows, func(i, j int) bool { @@ -1570,12 +1653,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - // var totalRemainingValueRp int64 var totalEggValueRp int64 - // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1589,14 +1669,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - // birdsFloat := row.RemainingChickenBirds - // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - // birdsFloat = 0 - // } - // weightFloat := row.RemainingChickenWeight - // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - // weightFloat = 0 - // } eggPiecesFloatRemaining := row.EggProductionPiecesRemaining if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { eggPiecesFloatRemaining = 0 @@ -1632,13 +1704,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - // hppRp := 0.0 - // if weightFloat > 0 { - // hppRp = totalCost / weightFloat - // } eggHpp := 0.0 if eggWeightFloat > 0 { eggHpp = (totalCost / eggWeightFloat) / 1000 @@ -1646,7 +1713,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) - // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1673,35 +1739,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, - NameWithPeriode: nameWithPeriod, - // FeedCostRp: costEntry.FeedCost, - // OvkCostRp: costEntry.OvkCost, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, - // EggProductionTotalWeightKg: eggWeightFloat, - // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), - AverageDocPriceRp: avgDocPrice, - // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - // RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + AverageDocPriceRp: avgDocPrice, + EggHppRpPerKg: eggHpp, + EggValueRp: rowEggValue, }) - // totalBirds += rowBirds - // totalWeight += weightFloat totalEggPieces += rowEggPieces totalEggKg += eggRemainingWeightFloatRemaining - // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - // if weightFloat > 0 { - // totalHppSum += hppRp - // totalHppCount++ - // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1728,8 +1781,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - // rangeAgg.RemainingBirds += rowBirds - // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { @@ -1744,7 +1795,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining - // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..48024dbc 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -26,7 +26,7 @@ type MarketingQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` @@ -70,7 +70,7 @@ type HppPerKandangQuery struct { type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 7d12f5c6..d27b07ef 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -113,6 +113,8 @@ const ( StockLogTypeTransfer StockLogType = "TRANSFER" StockLogTypeMarketing StockLogType = "MARKETING" StockLogTypeChikin StockLogType = "CHICKIN" + StockLogTypePurchase StockLogType = "PURCHASE" + StockLogTypeRecording StockLogType = "RECORDING" ) // -------------------------------------------------------------------