From 64fe8451281357265a1e71188a3125fc4c4861fe Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 13 Jan 2026 10:46:55 +0700 Subject: [PATCH 01/57] Update CICD --- .gitlab-ci.yml | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b0e3883e..e5f0f4ac 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,31 +6,31 @@ stages: default: tags: - - self-hosted-stg + - self-hosted-prod workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' when: always - when: never variables: DOCKER_BUILDKIT: "1" - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" - DEPLOY_DIR: "/opt/deploy/stg-lti-api" + DEPLOY_DIR: "/opt/deploy/lti" COMPOSE_FILE: "docker-compose.yaml" # ========================= # BUILD (AUTO) # ========================= -build_staging: +build_production: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' script: | set -e docker info @@ -51,16 +51,16 @@ build_staging: # ========================= # MIGRATE (AUTO) # ========================= -migrate_staging: +migrate_production: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: build_staging + - job: build_production artifacts: false script: | set -e - echo "✅ Running migrations (staging) ..." + echo "✅ Running migrations (production) ..." cd "$DEPLOY_DIR" test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) @@ -128,14 +128,14 @@ migrate_staging: # ========================= # DEPLOY (AUTO) # ========================= -deploy_staging: +deploy_production: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: migrate_staging + - job: migrate_production artifacts: false - - job: build_staging + - job: build_production artifacts: false script: | set -e @@ -154,12 +154,12 @@ deploy_staging: # ========================= # SEED (MANUAL) # ========================= -seed_staging: +seed_production: stage: seed rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: deploy_staging + - job: deploy_production artifacts: false when: manual allow_failure: false @@ -170,4 +170,6 @@ seed_staging: 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 \ No newline at end of file + docker compose -f "$COMPOSE_FILE" run --rm seed + + From f8415ea15d5e44c5b2434db3941daf52fc5f01c2 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 13 Jan 2026 10:59:51 +0700 Subject: [PATCH 02/57] Update gitlab --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e5f0f4ac..0985896e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -83,7 +83,7 @@ migrate_production: # ✅ 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 + docker compose -f "$COMPOSE_FILE" up -d postgres-lti redis-lti || true # ✅ Ambil network key dari compose COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" @@ -172,4 +172,3 @@ seed_production: docker compose -f "$COMPOSE_FILE" pull seed || true docker compose -f "$COMPOSE_FILE" run --rm seed - From d26c2dba3f1f6192b8f728f428dcfc1cc7843e36 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:15:08 +0000 Subject: [PATCH 03/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 71 ++++++++++++-------------------------------------- 1 file changed, 16 insertions(+), 55 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0985896e..52a49bd6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,80 +49,41 @@ build_production: # ========================= -# MIGRATE (AUTO) +# 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 - echo "✅ Running migrations (production) ..." + cd /opt/deploy/lti-prod + test -f .env || (echo "❌ .env not found" && exit 1) - 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) + # 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}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL=$DATABASE_URL" + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d postgres-lti 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" \ + 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 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" + -path=/migrations -database "$DATABASE_URL" up # ========================= From 7fd90f32681df609351091a7d255a6e02713ff6c Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:19:00 +0000 Subject: [PATCH 04/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 52a49bd6..7fc8bac8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ migrate_production: artifacts: false script: | set -e - cd /opt/deploy/lti-prod + cd /opt/deploy/lti test -f .env || (echo "❌ .env not found" && exit 1) set -a From 4c434899aaeccd0fc2107859ceb695a66d629d3f Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:36:34 +0000 Subject: [PATCH 05/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7fc8bac8..abe16f61 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -118,18 +118,15 @@ deploy_production: seed_production: stage: seed rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - needs: - - job: deploy_production - artifacts: false - when: manual - allow_failure: false + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual script: | set -e - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + cd /opt/deploy/lti-prod 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 + 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 From cfbe431222f1c78a8be8e4aeeafd31fdbae5a112 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:43:44 +0000 Subject: [PATCH 06/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index abe16f61..c99f940f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ seed_production: when: manual script: | set -e - cd /opt/deploy/lti-prod + 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" From 894fa0b22a48ea1f5adaebdf2ca0d3c5a2b4f07c Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 06:55:28 +0000 Subject: [PATCH 07/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 245 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 164 insertions(+), 81 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,173 @@ stages: + - build + - migrate - deploy + - seed -deploy-dev: +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 - 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" + 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" - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + 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) - # 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 + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f - # 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" +# ========================= +# 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) - - > - 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 + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file From dbf72c72484fff68acca67d0c476641182ed7534 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 07:06:37 +0000 Subject: [PATCH 08/57] Delete .air.toml --- .air.toml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .air.toml diff --git a/.air.toml b/.air.toml deleted file mode 100644 index c463b5b2..00000000 --- a/.air.toml +++ /dev/null @@ -1,13 +0,0 @@ -# .air.toml -root = "." -tmp_dir = "tmp" - -[build] -cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" -bin = "tmp/main" -full_bin = "APP_ENV=dev ./tmp/main" -include_ext = ["go", "tpl", "tmpl", "html"] -exclude_dir = ["vendor", "tmp"] - -[log] -time = true From e0ff6e6d7902b21990d04ef501c337eedd0402a7 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 08:26:07 +0000 Subject: [PATCH 09/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..80e4f608 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From cad15bcd786d90d0ef637b18c33ca6738ac75f17 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 09:46:39 +0000 Subject: [PATCH 10/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 80e4f608..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From 3f4d6c630a34a9041ecb7271bd68bcc3e13ddec0 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 15 Jan 2026 06:46:07 +0000 Subject: [PATCH 11/57] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..41aa41be 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_staging: stage: build From bc771660be77fa128a5cf1af231f589477be5715 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 10:03:57 +0700 Subject: [PATCH 12/57] adjust closing tap sapronak; add api summart total kuantitas per category and uom --- .../controllers/closing.controller.go | 54 +++++++- .../closings/dto/closingSapronak.dto.go | 11 ++ .../repositories/closing.repository.go | 120 +++++++++++++++++- .../closings/services/closing.service.go | 71 ++++++++++- .../validations/closing.validation.go | 1 + .../repositories/stock_transfer.repository.go | 2 +- 6 files changed, 245 insertions(+), 14 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index ed3cfcbc..a43687ac 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -236,9 +236,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), + Type: strings.ToLower(c.Query("type")), + Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { kandangInt, convErr := strconv.Atoi(raw) @@ -249,10 +248,6 @@ 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") } @@ -277,6 +272,51 @@ 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")), + 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) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + 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") + } + + 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/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..6d59294e 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 582a1207..7b1fb6cf 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) @@ -59,10 +60,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"` @@ -74,6 +83,7 @@ type SapronakQueryParams struct { ProjectFlockKandangIDs []uint Limit int Offset int + Search string } func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { @@ -109,14 +119,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 { @@ -126,6 +158,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 @@ -379,6 +484,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 @@ -427,6 +533,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 @@ -476,6 +583,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 @@ -522,13 +630,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/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8cda7220..443eec7f 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) } @@ -353,6 +354,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 +389,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) @@ -1030,4 +1100,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..454bbdfc 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -24,4 +24,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/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 } From 2aaaab91f7aa502040d499ceabdebc0f9882c76c Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 12:07:16 +0700 Subject: [PATCH 13/57] fix endpoint not found --- .../closings/controllers/closing.controller.go | 12 ++++++------ internal/modules/closings/route.go | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a43687ac..d348fd34 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) From 1b7ce3c62c99704cd41d74a2bbe24959221c58b6 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 20 Jan 2026 12:08:41 +0700 Subject: [PATCH 14/57] Revert "Merge branch 'staging' into 'development'" This reverts merge request !212 --- .air.toml | 13 +++ .gitlab-ci.yml | 245 ++++++++++++++++--------------------------------- 2 files changed, 94 insertions(+), 164 deletions(-) create mode 100644 .air.toml diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..c463b5b2 --- /dev/null +++ b/.air.toml @@ -0,0 +1,13 @@ +# .air.toml +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["vendor", "tmp"] + +[log] +time = true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 41aa41be..53f28b3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,173 +1,90 @@ 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: +deploy-dev: 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" + 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" - 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) + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash - docker compose -f "$COMPOSE_FILE" pull - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - docker image prune -f + # 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 -# ========================= -# 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) + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file + - > + 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 From fc06b3e4dbc467af50b13f005edac8c7cc460713 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 14:19:02 +0700 Subject: [PATCH 15/57] [FIX/BE-US] purchase edit qty approval staf --- .../purchases/services/purchase.service.go | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) 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) From edd77c52653e128dcb1cb9a4357b8ba657f26ff0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 16:40:37 +0700 Subject: [PATCH 16/57] [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system --- .../common/service/common.fifo.service.go | 41 +++++++++++++++++ .../purchases/services/purchase.service.go | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) 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/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0914853..b7efbc05 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -844,6 +844,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -877,9 +882,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation totalQtyDeltas[item.Id] += deltaQty } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty + 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 @@ -919,10 +933,6 @@ 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 @@ -967,6 +977,26 @@ 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(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil From e4e17f16f9129c893aa4061011ac751d5591f534 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:18:41 +0700 Subject: [PATCH 17/57] fix data produksi not show response --- internal/modules/closings/dto/closing.dto.go | 40 +++++++++---------- .../closings/services/closing.service.go | 8 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) 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/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 443eec7f..daca980f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -930,19 +930,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 From aa4da6868067bc189a70974e2bf9fdce41f326b0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:07:07 +0700 Subject: [PATCH 18/57] refactor: unify GetOne method to return approval alongside transfer laying --- .../controllers/transfer_laying.controller.go | 2 +- .../services/transfer_laying.service.go | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) 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 } From dd4dcc1c39573eef867d0f71e32b3a193a07d421 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:10:47 +0700 Subject: [PATCH 19/57] FEAT[BE[: add avg weight and avg amount on get penjualan harian --- .../repports/dto/repportMarketing.dto.go | 296 +++++++----------- .../repports/services/repport.service.go | 2 +- 2 files changed, 118 insertions(+), 180 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..edb2887f 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,99 +40,24 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesAmount float64 `json:"average_sales_amount"` 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) []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 { + // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -142,101 +67,111 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + // Calculate dates + 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 + } + + 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 + } + } + + // Determine marketing type + 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 +179,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesAmount := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -259,25 +196,26 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesAmount: avgSalesAmount, 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/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..090a284b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -181,7 +181,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap) return items, total, nil } From ad3bb0e29a348e5e8f2fb021663b01a331a47984 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:28:34 +0700 Subject: [PATCH 20/57] FEAT[BE[: enhance marketing report items with aging days calculation --- .../repports/dto/repportMarketing.dto.go | 17 ++- .../repports/services/repport.service.go | 104 ++++++++---------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index edb2887f..751796e9 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,11 +53,10 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []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 { - // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -67,12 +66,15 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u category = projectFlockKandang.ProjectFlock.Category } - // Calculate dates soDate := time.Time{} agingDays := 0 if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } } realizationDate := time.Time{} @@ -106,7 +108,6 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u } } - // Determine marketing type marketingType := "trading" if hasTrading { marketingType = "trading" @@ -196,13 +197,9 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } - if len(items) > 0 { - avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) - } - if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 090a284b..579436eb 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,47 @@ 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 { + s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) + 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 +222,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +463,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 +474,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 +612,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 +621,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 +633,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 +651,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 +714,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 @@ -1570,12 +1593,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 +1609,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 +1644,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 +1653,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 +1679,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 +1721,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 +1735,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 From d50ab7cc977c0724dada0abc7050dbdadd43d881 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:42:16 +0700 Subject: [PATCH 21/57] FEAT[BE]: add default filterby become so_date in report markeing --- .../salesorder_delivery_product.repository.go | 10 +++++++--- internal/modules/repports/services/repport.service.go | 1 - .../modules/repports/validations/repport.validation.go | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) 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/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 579436eb..03b1b370 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -175,7 +175,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing for customerID := range customerGroups { transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) if err != nil { - s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) continue } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..8047f718 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"` From 67ecdbc1ddef985e2854fa196ccc8292224a81f2 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 23:01:58 +0700 Subject: [PATCH 22/57] fix duplicate name, time type and phase id create phase activity --- .../services/phase-activity.service.go | 11 +++++++++++ 1 file changed, 11 insertions(+) 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, From d0625e7d21d128c3bfb894c2895bc476c6605d9e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:45:19 +0700 Subject: [PATCH 23/57] FIX[BE]: fixing closing penjualan add sumary --- .../controllers/closing.controller.go | 4 +- .../closings/dto/closingMarketing.dto.go | 99 +++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a43687ac..8b79fc92 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -160,7 +160,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 +190,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), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..223b9d11 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,23 +12,31 @@ 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"` + 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 === @@ -63,19 +71,38 @@ 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: age, + 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 float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: totalSalesPrice / float64(count), + AvgActualPrice: totalActualPrice / float64(count), } } @@ -87,25 +114,13 @@ 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 { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0 From 894efa7aa54ccdcc88173eb35b19eea6af11c678 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:46:03 +0700 Subject: [PATCH 24/57] FIX[BE]: fixing report penjualan add avg weight and price to response --- .../modules/repports/dto/repportMarketing.dto.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 751796e9..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -41,7 +41,7 @@ type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` AverageWeightKg float64 `json:"average_weight_kg"` - AverageSalesAmount float64 `json:"average_sales_amount"` + 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"` @@ -180,7 +180,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 - avgSalesAmount := 0.0 + avgSalesPrice := 0.0 avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -190,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) @@ -197,16 +198,19 @@ 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) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, AverageWeightKg: avgWeightKg, - AverageSalesAmount: avgSalesAmount, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, From c2d2701d728c8b33a8cfce0858d5f86d2c3c13c0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:57:44 +0700 Subject: [PATCH 25/57] FIX[BE] fix wrong calculation on summary report marketing --- internal/modules/closings/dto/closingMarketing.dto.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 223b9d11..eb6ff23f 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -90,19 +90,22 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice float64 + 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: totalSalesPrice / float64(count), - AvgActualPrice: totalActualPrice / float64(count), + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } From 16a0b848bcf33babb0656ad45eff9376ba1c25cb Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 21 Jan 2026 13:06:45 +0700 Subject: [PATCH 26/57] [FIX/BE-US] adjustment recording --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- .../recordings/repositories/recording.repository.go | 5 +++-- .../production/recordings/services/recording.service.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) 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/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..a5486ab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1157,7 +1157,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 +1169,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 +1328,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 { From e8a89f0f17aa8ecb465f421ee985a71bf22b41a1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 13:52:46 +0700 Subject: [PATCH 27/57] FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload --- .../dto/product_warehouse.dto.go | 143 ++++-------------- .../services/product_warehouse.service.go | 1 + .../marketing/dto/deliveryorder.dto.go | 11 +- 3 files changed, 35 insertions(+), 120 deletions(-) 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..ea194c36 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -40,6 +40,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/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, }, From ca6d0b160b4db8248f028e22dddb5ba5fdeb6b01 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 21 Jan 2026 14:17:43 +0700 Subject: [PATCH 28/57] fix --- .../validations/daily-checklist.validation.go | 2 +- .../services/config-checklist.service.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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) From a73b44808ff2e6bebbc090d7e309f7016c9ede6d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 15:16:30 +0700 Subject: [PATCH 29/57] FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table --- internal/entities/adjustment_stock.go | 29 ++---- .../adjustments/dto/adjustment.dto.go | 30 +++--- .../adjustment_stock.repository.go | 8 ++ .../services/adjustment.service.go | 99 ++++++++++++------- .../services/product_warehouse.service.go | 7 +- 5 files changed, 99 insertions(+), 74 deletions(-) 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/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/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index ea194c36..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" ) From 32a8557a3b52324d9a677a291a61711765d6f02c Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Wed, 21 Jan 2026 15:56:58 +0700 Subject: [PATCH 30/57] Change rules cicd no conflicts --- .gitlab-ci.yml | 104 ++++++----------------------------- ci/development.yml | 90 ++++++++++++++++++++++++++++++ ci/production.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ ci/staging.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 87 deletions(-) create mode 100644 ci/development.yml create mode 100644 ci/production.yml create mode 100644 ci/staging.yml 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 + + From 2ad0c17fbe095c7954ff43a357309883d6f74e64 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 10:00:24 +0700 Subject: [PATCH 31/57] add filter project status and location id --- .../controllers/closing.controller.go | 27 ++++++++++++++++--- .../closings/services/closing.service.go | 22 +++++++++++++++ .../validations/closing.validation.go | 8 +++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 29c89f33..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 { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index daca980f..372d38fd 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -99,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+"%") } diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 454bbdfc..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 ( From cf37822a07f6baecdc7c1c57f88db4e2d2986662 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Wed, 21 Jan 2026 15:56:58 +0700 Subject: [PATCH 32/57] Change rules cicd no conflicts --- .gitlab-ci.yml | 140 +++++---------------------------------------- ci/development.yml | 90 +++++++++++++++++++++++++++++ ci/production.yml | 133 ++++++++++++++++++++++++++++++++++++++++++ ci/staging.yml | 133 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 126 deletions(-) create mode 100644 ci/development.yml create mode 100644 ci/production.yml create mode 100644 ci/staging.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3cca12bf..aa0dc969 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,132 +1,20 @@ -stages: - - build - - migrate - - deploy - - seed - -default: - tags: - - self-hosted-prod - workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: always + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' + - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "production"' - when: never -variables: - DOCKER_BUILDKIT: "1" +include: + - local: "ci/development.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "development"' - 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 + - local: "ci/staging.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + - 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 + + From 32153f02b8b79d93dea259fb6a199beefaa80b78 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 16:40:37 +0700 Subject: [PATCH 33/57] [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system --- .../common/service/common.fifo.service.go | 41 +++++++++++++++++ .../purchases/services/purchase.service.go | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) 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/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0914853..b7efbc05 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -844,6 +844,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -877,9 +882,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation totalQtyDeltas[item.Id] += deltaQty } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty + 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 @@ -919,10 +933,6 @@ 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 @@ -967,6 +977,26 @@ 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(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil From 0b708cd57b29f8e186e8f2ba751ccf36524d186b Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:18:41 +0700 Subject: [PATCH 34/57] fix data produksi not show response --- internal/modules/closings/dto/closing.dto.go | 40 +++++++++---------- .../closings/services/closing.service.go | 8 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) 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/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 443eec7f..daca980f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -930,19 +930,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 From ad0504f49e65e2439cde1f07d8f1b5a03ba6217f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:07:07 +0700 Subject: [PATCH 35/57] refactor: unify GetOne method to return approval alongside transfer laying --- .../controllers/transfer_laying.controller.go | 2 +- .../services/transfer_laying.service.go | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) 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 } From 96ba9479526a0001bdbadcadbd6f5f9aa91bd4d0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:10:47 +0700 Subject: [PATCH 36/57] FEAT[BE[: add avg weight and avg amount on get penjualan harian --- .../repports/dto/repportMarketing.dto.go | 296 +++++++----------- .../repports/services/repport.service.go | 2 +- 2 files changed, 118 insertions(+), 180 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..edb2887f 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,99 +40,24 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesAmount float64 `json:"average_sales_amount"` 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) []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 { + // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -142,101 +67,111 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + // Calculate dates + 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 + } + + 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 + } + } + + // Determine marketing type + 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 +179,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesAmount := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -259,25 +196,26 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesAmount: avgSalesAmount, 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/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..090a284b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -181,7 +181,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap) return items, total, nil } From 1d726afa6f9eb99596a717f31e8901af9e2b333c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:28:34 +0700 Subject: [PATCH 37/57] FEAT[BE[: enhance marketing report items with aging days calculation --- .../repports/dto/repportMarketing.dto.go | 17 ++- .../repports/services/repport.service.go | 104 ++++++++---------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index edb2887f..751796e9 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,11 +53,10 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []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 { - // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -67,12 +66,15 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u category = projectFlockKandang.ProjectFlock.Category } - // Calculate dates soDate := time.Time{} agingDays := 0 if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } } realizationDate := time.Time{} @@ -106,7 +108,6 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u } } - // Determine marketing type marketingType := "trading" if hasTrading { marketingType = "trading" @@ -196,13 +197,9 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } - if len(items) > 0 { - avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) - } - if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 090a284b..579436eb 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,47 @@ 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 { + s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) + 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 +222,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +463,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 +474,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 +612,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 +621,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 +633,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 +651,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 +714,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 @@ -1570,12 +1593,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 +1609,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 +1644,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 +1653,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 +1679,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 +1721,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 +1735,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 From 30f5ed417c18d2e162861e419e8eb9c88c598b72 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:42:16 +0700 Subject: [PATCH 38/57] FEAT[BE]: add default filterby become so_date in report markeing --- .../salesorder_delivery_product.repository.go | 10 +++++++--- internal/modules/repports/services/repport.service.go | 1 - .../modules/repports/validations/repport.validation.go | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) 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/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 579436eb..03b1b370 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -175,7 +175,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing for customerID := range customerGroups { transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) if err != nil { - s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) continue } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..8047f718 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"` From 8f7fc622f64b622391dff9898faf056bc2d913e0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:45:19 +0700 Subject: [PATCH 39/57] FIX[BE]: fixing closing penjualan add sumary --- .../controllers/closing.controller.go | 4 +- .../closings/dto/closingMarketing.dto.go | 99 +++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index d348fd34..29c89f33 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -160,7 +160,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 +190,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), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..223b9d11 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,23 +12,31 @@ 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"` + 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 === @@ -63,19 +71,38 @@ 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: age, + 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 float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: totalSalesPrice / float64(count), + AvgActualPrice: totalActualPrice / float64(count), } } @@ -87,25 +114,13 @@ 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 { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0 From f6bdb176993c3c043e5bbef8f9784a934965ed8e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:46:03 +0700 Subject: [PATCH 40/57] FIX[BE]: fixing report penjualan add avg weight and price to response --- .../modules/repports/dto/repportMarketing.dto.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 751796e9..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -41,7 +41,7 @@ type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` AverageWeightKg float64 `json:"average_weight_kg"` - AverageSalesAmount float64 `json:"average_sales_amount"` + 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"` @@ -180,7 +180,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 - avgSalesAmount := 0.0 + avgSalesPrice := 0.0 avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -190,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) @@ -197,16 +198,19 @@ 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) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, AverageWeightKg: avgWeightKg, - AverageSalesAmount: avgSalesAmount, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, From e99af367962bbd0a068da5bcf45c8c7924b92695 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:57:44 +0700 Subject: [PATCH 41/57] FIX[BE] fix wrong calculation on summary report marketing --- internal/modules/closings/dto/closingMarketing.dto.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 223b9d11..eb6ff23f 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -90,19 +90,22 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice float64 + 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: totalSalesPrice / float64(count), - AvgActualPrice: totalActualPrice / float64(count), + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } From 132e043597556afe71e39ebaf5b3a0a2af0a46d4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 13:52:46 +0700 Subject: [PATCH 42/57] FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload --- .../dto/product_warehouse.dto.go | 143 ++++-------------- .../services/product_warehouse.service.go | 1 + .../marketing/dto/deliveryorder.dto.go | 11 +- 3 files changed, 35 insertions(+), 120 deletions(-) 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..ea194c36 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -40,6 +40,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/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, }, From ec4b849778e6bbbe25aeb33f431d459a196fa9d3 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 15:16:30 +0700 Subject: [PATCH 43/57] FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table --- internal/entities/adjustment_stock.go | 29 ++---- .../adjustments/dto/adjustment.dto.go | 30 +++--- .../adjustment_stock.repository.go | 8 ++ .../services/adjustment.service.go | 99 ++++++++++++------- .../services/product_warehouse.service.go | 7 +- 5 files changed, 99 insertions(+), 74 deletions(-) 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/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/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index ea194c36..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" ) From f04cbd24bd36ac26f340c437dad41ad94a965ac2 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 21 Jan 2026 13:06:45 +0700 Subject: [PATCH 44/57] [FIX/BE-US] adjustment recording --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- .../recordings/repositories/recording.repository.go | 5 +++-- .../production/recordings/services/recording.service.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) 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/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..a5486ab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1157,7 +1157,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 +1169,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 +1328,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 { From d7ed768d14b1619b2be1f399ff770c54b6c85cad Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 10:00:24 +0700 Subject: [PATCH 45/57] add filter project status and location id --- .../controllers/closing.controller.go | 27 ++++++++++++++++--- .../closings/services/closing.service.go | 22 +++++++++++++++ .../validations/closing.validation.go | 8 +++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 29c89f33..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 { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index daca980f..372d38fd 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -99,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+"%") } diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 454bbdfc..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 ( From 06e92d1c77530641b0af3fcf58a92a6759e8155b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 11:17:02 +0700 Subject: [PATCH 46/57] [FEAT][BE}: add umur week and day on closing penjualan --- .../closings/dto/closingMarketing.dto.go | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index eb6ff23f..d725b430 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -15,6 +15,7 @@ type SalesDTO struct { 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"` @@ -43,7 +44,7 @@ type PenjualanRealisasiResponseDTO struct { 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 { @@ -73,7 +74,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { return SalesDTO{ Id: e.Id, RealizationDate: realizationDate, - Age: age, + Age: ageInDay, + Week: ageInWeeks, DoNumber: doNumber, Product: product, Customer: customer, @@ -124,9 +126,9 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } -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 @@ -136,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 } From 04ec8560a765d339a98f19893c12529d32f7e779 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 13:35:46 +0700 Subject: [PATCH 47/57] [FIX/BE-US] adjustment recording and purchase stock log --- .../repositories/constant.repository.go | 2 +- .../modules/production/recordings/module.go | 3 + .../recordings/services/recording.service.go | 256 ++++++++++++++++-- .../purchases/services/purchase.service.go | 47 ++++ internal/utils/constant.go | 2 + 5 files changed, 285 insertions(+), 25 deletions(-) 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/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/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a5486ab7..4465a039 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, } } @@ -274,7 +280,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 +300,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 +312,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 +355,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 +444,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 +479,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 +496,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 +536,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 +714,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 +736,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 +795,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 +840,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 +907,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 +986,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 +1044,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) { @@ -963,27 +1148,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 +1240,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 +1288,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 +1307,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 { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b7efbc05..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" @@ -830,9 +831,16 @@ 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{}) @@ -849,6 +857,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation 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 @@ -869,6 +882,13 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty + if newPWID != nil && deltaQty != 0 { + logEntries = append(logEntries, struct { + itemID uint + pwID uint + delta float64 + }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) + } switch { case deltaQty > 0 && newPWID != nil: if s.FifoSvc != nil { @@ -993,6 +1013,33 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } + 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 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" ) // ------------------------------------------------------------------- From 645a97b460db1e30ecee7ceb0ab1447dcedc3cf0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 13:42:36 +0700 Subject: [PATCH 48/57] remove max limit production result --- internal/modules/repports/validations/repport.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 8047f718..48024dbc 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -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"` } From bac0361df5b4bca4d58dee3cc2a4a6574fb93144 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 13:53:43 +0700 Subject: [PATCH 49/57] [FIX/BE-US] debt-supplier only show receive purchase --- .../repositories/debt_supplier.repository.go | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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 { From 78a45b11e78fce15b649ac226cccbe30bacbcbb4 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 13:59:52 +0700 Subject: [PATCH 50/57] fixing pipeline --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa0dc969..6a4778a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,7 @@ workflow: include: - local: "ci/development.yml" rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == "development"' - local: "ci/staging.yml" From 1ca6c6a104fb9bb8669b5b4abea66049a81658b5 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 14:12:20 +0700 Subject: [PATCH 51/57] Fixing staging cicd --- ci/staging.yml | 122 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/ci/staging.yml b/ci/staging.yml index 511a9eff..e3eaabb0 100644 --- a/ci/staging.yml +++ b/ci/staging.yml @@ -6,31 +6,31 @@ stages: default: tags: - - self-hosted-prod + - self-hosted-stg workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' when: always - when: never variables: DOCKER_BUILDKIT: "1" - IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - DEPLOY_DIR: "/opt/deploy/lti" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= -build_production: +build_staging: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' script: | set -e docker info @@ -49,54 +49,93 @@ build_production: # ========================= -# MIGRATE (PRODUCTION - MANUAL) +# MIGRATE (AUTO) # ========================= -migrate_production: +migrate_staging: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: manual - allow_failure: false + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: build_production + - job: build_staging artifacts: false script: | set -e - cd /opt/deploy/lti - test -f .env || (echo "❌ .env not found" && exit 1) + 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 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}" + # ✅ 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) - DB_SSLMODE="${DB_SSLMODE:-require}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL=$DATABASE_URL" - echo "✅ Running migrations (production)..." - docker run --rm \ + # ✅ 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 + -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_production: +deploy_staging: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: migrate_production + - job: migrate_staging artifacts: false - - job: build_production + - job: build_staging artifacts: false script: | set -e @@ -115,19 +154,20 @@ deploy_production: # ========================= # SEED (MANUAL) # ========================= -seed_production: +seed_staging: stage: seed rules: - - if: '$CI_COMMIT_BRANCH == "production"' - when: manual + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false script: | set -e - cd /opt/deploy/lti + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) 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 - - + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed% From 87973a6c9f7365551793ec9a6d9f298dad3529f8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 14:15:03 +0700 Subject: [PATCH 52/57] HOTFIX[BE]: update total price calculation based on product flags for delivery and sales orders --- .../salesorder_product.repository.go | 5 +- .../services/deliveryorder.service.go | 52 ++++++++++++++-- .../marketing/services/salesorder.service.go | 59 +++++++++++++++++-- 3 files changed, 106 insertions(+), 10 deletions(-) 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, From 2e0827dec529f45a260c88bc743da2595791e506 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 14:15:26 +0700 Subject: [PATCH 53/57] [FIX/BE-US] debt-supplier only show receive and changes lunas --- .../repports/services/repport.service.go | 93 +++++++++++++++---- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 03b1b370..92434b00 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1156,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") @@ -1176,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] @@ -1189,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, @@ -1209,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 { @@ -1221,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 { From 6bc5e7d2931f4aca15df93adbe098faa1a261759 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 14:19:16 +0700 Subject: [PATCH 54/57] fix get average bw --- internal/modules/repports/services/repport.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 03b1b370..effec76a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -860,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"). From 202a8ffc6673a780a21e40ebcf7f73c03a00bbab Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 14:29:56 +0700 Subject: [PATCH 55/57] HOTFIX[BE]: filter closing overhead by expense category "BOP" --- .../expenses/repositories/expense_realization.repository.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(`( From 12ed9cd753fef32429fb54b4d01ac9c48e95c7c4 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 14:44:53 +0700 Subject: [PATCH 56/57] [FIX/BE-US] fix day in recording --- .../recordings/services/recording.service.go | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a5486ab7..c0d0f78b 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -159,14 +159,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 +207,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 +225,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 +240,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, @@ -929,6 +927,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, From c085888ca96f25a2370f0db5f0159dde2450d9d3 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 15:57:16 +0700 Subject: [PATCH 57/57] Update cicd no migrate prod --- ci/production.yml | 66 +++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/ci/production.yml b/ci/production.yml index 511a9eff..16626929 100644 --- a/ci/production.yml +++ b/ci/production.yml @@ -1,6 +1,6 @@ stages: - build - - migrate +# - migrate - deploy - seed @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_production: stage: build @@ -51,39 +51,39 @@ build_production: # ========================= # 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) +#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 +# 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_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}" +# 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 +# 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 # ========================= @@ -94,8 +94,8 @@ deploy_production: rules: - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: migrate_production - artifacts: false +# - job: migrate_production +# artifacts: false - job: build_production artifacts: false script: | @@ -129,5 +129,3 @@ seed_production: docker compose --env-file .env pull seed docker compose --env-file .env run --rm seed - -