diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..aa0dc969 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,20 @@ -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_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..511a9eff --- /dev/null +++ b/ci/production.yml @@ -0,0 +1,133 @@ +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..511a9eff --- /dev/null +++ b/ci/staging.yml @@ -0,0 +1,133 @@ +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/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 8b79fc92..29c89f33 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -237,6 +237,8 @@ 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), Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { @@ -248,6 +250,10 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { query.KandangID = &kandangUint } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } @@ -282,8 +288,6 @@ func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { query := &validation.ClosingSapronakQuery{ 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 != "" { @@ -295,10 +299,6 @@ func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { query.KandangID = &kandangUint } - if query.Page < 1 || query.Limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } 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/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/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/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index f6337c8a..b0914853 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -751,6 +751,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)) @@ -835,6 +838,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation 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 @@ -862,14 +866,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltaQty := prep.receivedQty - item.TotalQty switch { case deltaQty > 0 && newPWID != nil: - fifoAdds = append(fifoAdds, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + 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{}{} + totalQtyDeltas[item.Id] += deltaQty } dateCopy := prep.receivedDate @@ -892,7 +902,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, @@ -919,6 +929,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } + 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) @@ -1371,10 +1394,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)