From b6a60d50093313292b8b7199077f7118ed1b232d Mon Sep 17 00:00:00 2001 From: GitLab Deploy Bot Date: Mon, 15 Dec 2025 09:25:50 +0700 Subject: [PATCH 01/17] remove --- .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 0c534172..00000000 --- a/.air.toml +++ /dev/null @@ -1,13 +0,0 @@ -# .air.toml -root = "." -tmp_dir = "tmp" - -[build] -cmd = "go build -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 1e9fdd2b0da1d1541baca8174668a7512d54b9bc Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:41:04 +0000 Subject: [PATCH 02/17] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 127 +++++++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 73 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..62acf585 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,71 @@ stages: + - build - deploy -deploy-dev: +variables: + DOCKER_BUILDKIT: "1" + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST_STG_EC2: "${CI_REGISTRY_IMAGE}:staging_latest" + +build:staging: + stage: build + image: docker:27.0.3 + services: + - name: docker:27.0.3-dind + command: ["--mtu=1460"] + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + before_script: + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + script: + - docker build -t "$IMAGE_NAME" -f Dockerfile . + - docker push "$IMAGE_NAME" + - docker tag "$IMAGE_NAME" "$IMAGE_LATEST_STG_EC2" + - docker push "$IMAGE_LATEST_STG_EC2" + +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_COMMIT_BRANCH == "staging"' + needs: + - job: build:staging before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash - - # Setup SSH di runner + - apk add --no-cache openssh-client bash ca-certificates - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 700 ~/.ssh + + # SSH_PRIVATE_KEY = multiline private key (bukan File) + - printf "%s\n" "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - sed -i 's/\r$//' ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa + + - head -n 1 ~/.ssh/id_rsa + - tail -n 1 ~/.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 + ssh "$SERVER_USER@$SERVER_IP" + "export CI_REGISTRY_USER='$CI_REGISTRY_USER'; + export CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD'; + export CI_REGISTRY='$CI_REGISTRY'; + set -e; + cd /home/ubuntu/docker/deployment/staging/stg-lti-api; + echo \"\$CI_REGISTRY_PASSWORD\" | docker login -u \"\$CI_REGISTRY_USER\" --password-stdin \"\$CI_REGISTRY\"; + docker compose pull; + docker compose up -d; + docker image prune -f" environment: - name: development \ No newline at end of file + name: staging \ No newline at end of file From 81f4a5e33ed35f886b0d3f29f2bd8dc2512c8a7c Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:50:22 +0000 Subject: [PATCH 03/17] Delete docker-compose.local.yml --- docker-compose.local.yml | 77 ---------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 docker-compose.local.yml diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index cdc4652d..00000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,77 +0,0 @@ -services: - postgresdb: - image: postgres:alpine - restart: always - ports: - - "${DB_PORT_HOST:-5542}:5432" - environment: - POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} - POSTGRES_DB: ${DB_NAME:-db_lti_erp} - volumes: - - dbdata:/var/lib/postgresql/data - - ./internal/database/init:/docker-entrypoint-initdb.d - networks: [go-network] - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}", - ] - interval: 10s - timeout: 5s - retries: 5 - redis: - image: redis:7-alpine - restart: unless-stopped - ports: - - "${REDIS_PORT_HOST:-6381}:6379" - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 5s - timeout: 3s - retries: 10 - networks: [go-network] - - app: - build: - context: . - dockerfile: Dockerfile.local - image: cosmtrek/air:v1.52.3 - working_dir: /lti-api - volumes: - - .:/lti-api - - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key - - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub - command: air -c .air.toml - env_file: - - .env - environment: - DB_HOST: postgresdb - DB_PORT: 5432 - DB_USER: ${DB_USER:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-postgres} - DB_NAME: ${DB_NAME:-db_lti_erp} - REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} - ports: - - "${APP_PORT:-8081}:8081" - depends_on: - postgresdb: - condition: service_healthy - networks: [go-network] - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 10s - -volumes: - dbdata: - go-mod-cache: - go-build-cache: - -networks: - go-network: - name: lti-api_go-network - driver: bridge From e738a97e4c4ba8b6dba9c4ec42219799ea388bce Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:50:41 +0000 Subject: [PATCH 04/17] Delete docker-compose.yaml --- docker-compose.yaml | 98 --------------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index ab6daeba..00000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,98 +0,0 @@ -services: - dev-api-lti: - build: - context: . - dockerfile: Dockerfile - container_name: dev-api-lti - working_dir: /lti-api - command: ["/bin/sh", "scripts/entrypoint.sh"] - ports: - - "8081:8081" - env_file: - - .env - environment: - # override agar koneksi ke container internal - DB_HOST: dev-postgres-lti - DB_PORT: 5432 - REDIS_URL: redis://dev-redis-lti:6379/0 - volumes: - - .:/lti-api - - ./.air.toml:/lti-api/.air.toml:ro - - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key - - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub - depends_on: - - dev-postgres-lti - - dev-redis-lti - networks: - - lti-network - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 10s - deploy: - resources: - limits: - cpus: "2.0" - memory: 2G - reservations: - cpus: "1.0" - memory: 512M - - dev-postgres-lti: - image: postgres:15-alpine - container_name: dev-postgres-lti - restart: always - env_file: - - credential/.env.db - ports: - - "5433:5432" - volumes: - - dev-postgres-lti-data:/var/lib/postgresql/data - - ./credential:/docker-entrypoint-initdb.d:ro - networks: - - lti-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - deploy: - resources: - limits: - cpus: "1.0" - memory: 2G - reservations: - cpus: "0.5" - memory: 512M - - dev-redis-lti: - image: redis:7-alpine - container_name: dev-redis-lti - restart: always - ports: - - "6380:6379" - networks: - - lti-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 3s - retries: 10 - deploy: - resources: - limits: - cpus: "0.5" - memory: 512M - reservations: - cpus: "0.2" - memory: 256M - -networks: - lti-network: - driver: bridge - -volumes: - dev-postgres-lti-data: From 30231fabe9ffb77b3f0b582bcea3db7266aa1bae Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:51:52 +0000 Subject: [PATCH 05/17] Edit Dockerfile --- Dockerfile | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 87781228..abe12eb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,35 @@ -FROM golang:1.23-alpine +# ========================= +# Builder stage +# ========================= +FROM golang:1.23-alpine AS builder -# Install dependensi dasar -RUN apk add --no-cache git curl bash build-base +RUN apk add --no-cache git ca-certificates tzdata +WORKDIR /app -# Install Air (pakai repo baru air-verse) -RUN go install github.com/air-verse/air@v1.52.3 - -WORKDIR /lti-api - -# Cache dependencies COPY go.mod go.sum ./ RUN go mod download -# Copy source code COPY . . +# Build binary dari cmd/api +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api + +# ========================= +# Runtime stage +# ========================= +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata curl \ + && adduser -D -H -u 10001 appuser + +WORKDIR /app + +COPY --from=builder /app/lti-api /app/lti-api + +USER appuser + +# Samakan dengan APP_PORT default kamu (8081) EXPOSE 8081 -CMD ["air", "-c", ".air.toml"] +CMD ["/app/lti-api"] \ No newline at end of file From f8aee4be7bdde9d8b7003fd301ac9096eb62cf33 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Fri, 9 Jan 2026 10:58:11 +0700 Subject: [PATCH 06/17] penyesuaian flow cicid --- .DS_Store | Bin 6148 -> 0 bytes .gitlab-ci.yml | 180 +++++++++++++++++++++++++++++++++++-------------- Dockerfile | 13 ++-- 3 files changed, 137 insertions(+), 56 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4c14efd89e4d913a63e6242a245ab626c5fffe6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~&2H2%5XZ;c6-vv8$_)-k;RRMIPY_nwLk~!Zl@Q0W$&ywin^nI=Ipx9$A;b&t z3Oq*p0vve|PVk@c?ADIkazhAtB>SJ(GyeFk9j}SVj8DoPqHQ8dkXVOX$gVK1=M>mL zOCCavv@xP%YN?@mw+_5xK_n0f{A&bw?{3nFUef^`Lf8AZEoOB)LoGfH<`H!COH3wk z7oH_{dO>e#j<^G=Xo2@bn(x+L8to5)^ibm-crqyCn4f5V_4gYlRq*lI1bV7|kH9_b4CP1~oBIHH206rWfF0f!B z2L3B3IFE2>^&0c>eA>Ip#|8D|{i_wIpsl98M0S&(2XD}!ON{Forp7zx4MrHOefowS zch6ZBoAiPvSOq!aCIY>W9Q)IY;9mZ0%m|j;wi@$DAQFfK)&%(bkRq{Ws-0E&bRd%} z0Cb3AF+B5Kf@4zEOtrI$8kn)6P#bFWieYRx%2V~rR6DCSoQz&RjP`8whGL|5w4aJQ znapa;BY{Z3C$M2(xB2{^Tz~)fgW_8x5DENO1k5m>=3`7r&(>gaeAcE&dq`~T*IAVi jWO^LS10Tf?ki<}z@&hze?X02%7XJtchL}YHf0e**B}4%m diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 62acf585..60e132fd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,71 +1,149 @@ 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" - DOCKER_DRIVER: overlay2 - DOCKER_HOST: tcp://docker:2375 - DOCKER_TLS_CERTDIR: "" - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST_STG_EC2: "${CI_REGISTRY_IMAGE}:staging_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" -build:staging: +# ========================= +# BUILD (AUTO) +# ========================= +build_staging: stage: build - image: docker:27.0.3 - services: - - name: docker:27.0.3-dind - command: ["--mtu=1460"] rules: - - if: '$CI_COMMIT_BRANCH == "staging"' - before_script: - - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - script: - - docker build -t "$IMAGE_NAME" -f Dockerfile . - - docker push "$IMAGE_NAME" - - docker tag "$IMAGE_NAME" "$IMAGE_LATEST_STG_EC2" - - docker push "$IMAGE_LATEST_STG_EC2" + - 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" -deploy:staging: - stage: deploy - image: alpine:3.20 + 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) - migrations diambil dari repo GitLab +# ========================= +migrate_staging: + stage: migrate rules: - - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: build:staging + - job: build_staging + artifacts: false + script: | + set -e - before_script: - - apk add --no-cache openssh-client bash ca-certificates - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh + # ✅ Load env dari server (.env hanya ada di server) + cd "$DEPLOY_DIR" + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + set -a + . ./.env + set +a - # SSH_PRIVATE_KEY = multiline private key (bukan File) - - printf "%s\n" "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - - sed -i 's/\r$//' ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa + # ✅ Generate DATABASE_URL dari DB_* + 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) - - head -n 1 ~/.ssh/id_rsa - - tail -n 1 ~/.ssh/id_rsa + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL ready" - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + # ✅ migrations dari repo + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - script: - - > - ssh "$SERVER_USER@$SERVER_IP" - "export CI_REGISTRY_USER='$CI_REGISTRY_USER'; - export CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD'; - export CI_REGISTRY='$CI_REGISTRY'; - set -e; - cd /home/ubuntu/docker/deployment/staging/stg-lti-api; - echo \"\$CI_REGISTRY_PASSWORD\" | docker login -u \"\$CI_REGISTRY_USER\" --password-stdin \"\$CI_REGISTRY\"; - docker compose pull; - docker compose up -d; - docker image prune -f" + echo "✅ Running migrations via migrate/migrate container" + set +e + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + code=$? + set -e - environment: - name: staging \ No newline at end of file + if [ $code -eq 0 ]; then + echo "✅ Migration applied successfully" + elif [ $code -eq 1 ]; then + echo "✅ No change (already up to date)" + else + echo "❌ Migration failed with exit code $code" + exit $code + fi + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_staging: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: migrate_staging + artifacts: false + - job: build_staging + artifacts: false + script: | + set -e + + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose pull + docker compose up -d --force-recreate + docker image prune -f + +# ========================= +# SEED (MANUAL) +# ========================= +seed_staging: + stage: seed + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false + script: | + set -e + + cd "$DEPLOY_DIR" + test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + echo "✅ Pull latest seed image" + docker compose pull seed || true + + echo "🌱 Running seeder..." + docker compose run --rm seed + + echo "✅ Seed completed" diff --git a/Dockerfile b/Dockerfile index abe12eb9..32e0688d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,25 +11,28 @@ RUN go mod download COPY . . -# Build binary dari cmd/api +# Build API binary RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api +# Build SEED binary (pastikan cmd/seed ada) +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed + # ========================= # Runtime stage # ========================= FROM alpine:3.20 -RUN apk add --no-cache ca-certificates tzdata curl \ +RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \ && adduser -D -H -u 10001 appuser WORKDIR /app COPY --from=builder /app/lti-api /app/lti-api +COPY --from=builder /app/lti-seed /app/lti-seed USER appuser - -# Samakan dengan APP_PORT default kamu (8081) EXPOSE 8081 -CMD ["/app/lti-api"] \ No newline at end of file +CMD ["/app/lti-api"] From 29933a5df9f0003d7708f7c3b85f9e8579c67b5c Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Fri, 9 Jan 2026 11:17:49 +0700 Subject: [PATCH 07/17] change cicd --- .gitlab-ci.yml | 169 ++++++++++++++++++++++++++++--------------------- 1 file changed, 97 insertions(+), 72 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60e132fd..65df90e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,10 +16,13 @@ workflow: 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" + + DEPLOY_DIR: "/opt/deploy/stg-lti-api" + COMPOSE_FILE: "docker-compose.yaml" # ========================= # BUILD (AUTO) @@ -28,23 +31,23 @@ 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" + 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 "✅ Build image: $IMAGE_NAME" + - docker build -t "$IMAGE_NAME" -f Dockerfile . - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" + - echo "✅ Push image: $IMAGE_NAME" + - docker push "$IMAGE_NAME" - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" + - echo "✅ Tag latest: $IMAGE_LATEST" + - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + - docker push "$IMAGE_LATEST" # ========================= -# MIGRATE (AUTO) - migrations diambil dari repo GitLab +# MIGRATE (AUTO) - JOIN COMPOSE NETWORK # ========================= migrate_staging: stage: migrate @@ -53,47 +56,76 @@ migrate_staging: needs: - job: build_staging artifacts: false - script: | - set -e + script: + - set -e + - echo "✅ Running migrations (staging) ..." - # ✅ Load env dari server (.env hanya ada di server) - cd "$DEPLOY_DIR" - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - set -a - . ./.env - set +a + # ✅ masuk deploy dir (ada .env + docker-compose) + - 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) - # ✅ Generate DATABASE_URL dari DB_* - 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) + # ✅ load env dari server + - set -a + - . ./.env + - set +a - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL ready" + # ✅ pastikan DB env ada + - 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) - # ✅ migrations dari repo - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + # ✅ generate DATABASE_URL + - 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 via migrate/migrate container" - set +e - docker run --rm \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up - code=$? - set -e + # ✅ pastikan postgres container hidup supaya network exist + - echo "✅ Ensuring postgres & redis running ..." + - docker compose -f "$COMPOSE_FILE" up -d postgres-sso redis-sso || true + # NOTE: ganti postgres-sso/redis-sso sesuai nama service di docker-compose lti kamu: + # kalau lti compose pakai stg-postgres-lti / stg-redis-lti, ganti di line ini. + + # ✅ ambil network name compose (1st network) + - export COMPOSE_NETWORK="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + - echo "✅ Compose network key: $COMPOSE_NETWORK" + + # ✅ ambil nama network aktual di docker (prefix foldername_) + - export NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK}$" | head -n 1)" + - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK)" && 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" + + # ✅ run migrate (JOIN NETWORK) + - 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 properly + - | + 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 - if [ $code -eq 0 ]; then echo "✅ Migration applied successfully" - elif [ $code -eq 1 ]; then - echo "✅ No change (already up to date)" - else - echo "❌ Migration failed with exit code $code" - exit $code - fi # ========================= # DEPLOY (AUTO) @@ -107,22 +139,21 @@ deploy_staging: artifacts: false - job: build_staging artifacts: false - script: | - set -e + script: + - set -e + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - 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) - cd "$DEPLOY_DIR" - test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - docker compose pull - docker compose up -d --force-recreate - docker image prune -f + - docker compose -f "$COMPOSE_FILE" pull + - docker compose -f "$COMPOSE_FILE" up -d --force-recreate + - docker image prune -f # ========================= -# SEED (MANUAL) +# SEED (MANUAL) - OPTIONAL # ========================= seed_staging: stage: seed @@ -133,17 +164,11 @@ seed_staging: artifacts: false when: manual allow_failure: false - script: | - set -e + 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) - cd "$DEPLOY_DIR" - test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - echo "✅ Pull latest seed image" - docker compose pull seed || true - - echo "🌱 Running seeder..." - docker compose run --rm seed - - echo "✅ Seed completed" + - docker compose -f "$COMPOSE_FILE" pull seed || true + - docker compose -f "$COMPOSE_FILE" run --rm seed From b7a3882f20ff2e9e3e270bf5fa3c7d4262e09f2a Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 04:19:24 +0000 Subject: [PATCH 08/17] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 171 ++++++++++++++++++++++++------------------------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 65df90e6..637677a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,7 +21,7 @@ variables: IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - DEPLOY_DIR: "/opt/deploy/stg-lti-api" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" COMPOSE_FILE: "docker-compose.yaml" # ========================= @@ -31,20 +31,22 @@ 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" + script: | + set -e + docker info - - echo "✅ Build image: $IMAGE_NAME" - - docker build -t "$IMAGE_NAME" -f Dockerfile . + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Push image: $IMAGE_NAME" - - docker push "$IMAGE_NAME" + 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" - - echo "✅ Tag latest: $IMAGE_LATEST" - - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - - docker push "$IMAGE_LATEST" # ========================= # MIGRATE (AUTO) - JOIN COMPOSE NETWORK @@ -56,76 +58,72 @@ migrate_staging: needs: - job: build_staging artifacts: false - script: - - set -e - - echo "✅ Running migrations (staging) ..." + script: | + set -e + echo "✅ Running migrations (staging) ..." - # ✅ masuk deploy dir (ada .env + docker-compose) - - 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) + 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 + set -a + . ./.env + set +a - # ✅ pastikan DB env ada - - 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 + 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) - # ✅ generate DATABASE_URL - - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - - echo "✅ DATABASE_URL=$DATABASE_URL" + 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 container hidup supaya network exist - - echo "✅ Ensuring postgres & redis running ..." - - docker compose -f "$COMPOSE_FILE" up -d postgres-sso redis-sso || true - # NOTE: ganti postgres-sso/redis-sso sesuai nama service di docker-compose lti kamu: - # kalau lti compose pakai stg-postgres-lti / stg-redis-lti, ganti di line ini. + # ✅ 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 name compose (1st network) - - export COMPOSE_NETWORK="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - - echo "✅ Compose network key: $COMPOSE_NETWORK" + # ✅ 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" - # ✅ ambil nama network aktual di docker (prefix foldername_) - - export NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK}$" | head -n 1)" - - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" + # ✅ 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) - # ✅ migrations dari repo (CI workspace) - - echo "✅ Checking migrations from repo..." - - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + echo "✅ Docker network detected: $NETWORK_NAME" - # ✅ run migrate (JOIN NETWORK) - - 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 + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "$out" + 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 - # ✅ handle no change properly - - | - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi + echo "$out" - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi + # ✅ 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" - echo "✅ Migration applied successfully" # ========================= # DEPLOY (AUTO) @@ -139,21 +137,22 @@ deploy_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" + 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) + 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 - - docker compose -f "$COMPOSE_FILE" pull - - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - - docker image prune -f # ========================= -# SEED (MANUAL) - OPTIONAL +# SEED (MANUAL) # ========================= seed_staging: stage: seed @@ -164,11 +163,11 @@ seed_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: | + set -e + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + test -f .env || (echo "❌ .env not found" && exit 1) - - docker compose -f "$COMPOSE_FILE" pull seed || true - - docker compose -f "$COMPOSE_FILE" run --rm seed + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file From 3d76854273ce239602c5ea589d3fa9f36ea0499c Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 04:27:45 +0000 Subject: [PATCH 09/17] 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 637677a4..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) - JOIN COMPOSE NETWORK +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From d33119661afbfa8c4d430cb5ec071878a37bf935 Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 08:37:55 +0000 Subject: [PATCH 10/17] 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..b0e3883e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -126,7 +126,7 @@ migrate_staging: # ========================= -# DEPLOY (AUTO) +# DEPLOY (AUTO) # ========================= deploy_staging: stage: deploy From 3422fceec759cf140ac7eef58b2a166daea122fa Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 11 Jan 2026 20:10:19 +0700 Subject: [PATCH 11/17] feat(BE-ExpenseApproval): add unit vice president approval step and permissions --- internal/middleware/permissions.go | 25 +++++++------- .../controllers/expense.controller.go | 2 ++ internal/modules/expenses/route.go | 3 ++ .../expenses/services/expense.service.go | 17 +++++++--- .../services/transfer_expense_bridge.go | 34 +++++++------------ .../purchases/services/expense_bridge.go | 3 ++ internal/utils/constant.go | 24 +++++++------ 7 files changed, 60 insertions(+), 48 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index e9148927..5820db27 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -19,18 +19,19 @@ const ( ) const ( - P_ExpenseGetAll = "lti.expense.list" - P_ExpenseCreateOne = "lti.expense.create" - P_ExpenseUpdateOne = "lti.expense.update" - P_ExpenseGetOne = "lti.expense.detail" - P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" - P_ExpenseApprovalFinance = "lti.expense.approve.finance" - P_ExpenseCreateRealizations = "lti.expense.create.realization" - P_ExpenseUpdateRealizations = "lti.expense.update.realization" - P_ExpenseCompleteExpense = "lti.expense.complete.expense" - P_ExpenseDocument = "lti.expense.document" - P_ExpenseDocumentRealizations = "lti.expense.document.realization" + P_ExpenseGetAll = "lti.expense.list" + P_ExpenseCreateOne = "lti.expense.create" + P_ExpenseUpdateOne = "lti.expense.update" + P_ExpenseGetOne = "lti.expense.detail" + P_ExpenseDeleteOne = "lti.expense.delete" + P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalFinance = "lti.expense.approve.finance" + P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" + P_ExpenseCreateRealizations = "lti.expense.create.realization" + P_ExpenseUpdateRealizations = "lti.expense.update.realization" + P_ExpenseCompleteExpense = "lti.expense.complete.expense" + P_ExpenseDocument = "lti.expense.document" + P_ExpenseDocumentRealizations = "lti.expense.document.realization" ) const ( P_AdjustmentGetAll = "lti.inventory.list" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 666642ca..125aeb0c 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -233,6 +233,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { approvalType = "manager" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" + } else if strings.Contains(path, "/approvals/unit-vice-president") { + approvalType = "unit-vice-president" } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval path") } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 9c22bde3..cfb4dd23 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -27,8 +27,11 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) + route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) + route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) + route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3e50da26..8afbac28 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1055,15 +1055,24 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Manager step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) + fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName)) } - } else if approvalType == "finance" { + } else if approvalType == "unit-vice-president" { - stepNumber = utils.ExpenseStepFinance + stepNumber = utils.ExpenseStepUnitVicePresident if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Manager", currentStepName)) + fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) + } + + } else if approvalType == "finance" { + + stepNumber = utils.ExpenseStepFinance + if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) { + currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Unit Vice President", currentStepName)) } } else { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index 90350c18..d4322be6 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -39,12 +39,12 @@ type TransferExpenseReceivingPayload struct { } type groupedTransferItem struct { - detail *entity.StockTransferDetail - payload TransferExpenseReceivingPayload - projectFK *uint - kandangID *uint - totalPrice float64 - shippingCostTotal float64 + detail *entity.StockTransferDetail + payload TransferExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 + shippingCostTotal float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { @@ -84,7 +84,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it expenseIDs := make(map[uint64]struct{}) expenseNonstockIDs := make([]uint64, 0) - for _, item := range items { if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) @@ -92,7 +91,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } if len(expenseNonstockIDs) > 0 { - + for _, nsID := range expenseNonstockIDs { var expenseID uint64 if err := tx.Model(&entity.ExpenseNonstock{}). @@ -106,13 +105,11 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } } - if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { return err } } - approvalRepoTx := commonRepo.NewApprovalRepository(tx) for expenseID := range expenseIDs { var count int64 @@ -122,7 +119,6 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it return err } - if count == 0 { if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { return err @@ -220,7 +216,6 @@ func (b *transferExpenseBridge) createExpenseViaService( for _, gi := range items { note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id) - price := gi.shippingCostTotal if gi.payload.TransportPerItem != nil { price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty @@ -228,7 +223,7 @@ func (b *transferExpenseBridge) createExpenseViaService( costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, - Quantity: 1, + Quantity: 1, Price: price, Notes: note, }) @@ -251,7 +246,6 @@ func (b *transferExpenseBridge) createExpenseViaService( return nil, err } - action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { @@ -261,6 +255,9 @@ func (b *transferExpenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } @@ -328,7 +325,6 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 ctx := c.Context() - transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB { return db. Preload("Details"). @@ -348,11 +344,10 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 for i := range transfer.Details { detailMap[transfer.Details[i].Id] = &transfer.Details[i] - for _, deliveryItem := range transfer.Details[i].DeliveryItems { if deliveryItem.StockTransferDelivery != nil { shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal - break + break } } } @@ -395,17 +390,14 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 } } - shippingCostTotal := shippingCostMap[detail.Id] - totalPrice := shippingCostTotal if payload.TransportPerItem != nil { - + totalPrice = *payload.TransportPerItem * payload.DeliveredQty } - warehouseID := uint(payload.WarehouseID) if warehouseID == 0 && transfer.ToWarehouse != nil { warehouseID = uint(transfer.ToWarehouse.Id) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 23b95c58..094b99c1 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -621,6 +621,9 @@ func (b *expenseBridge) createExpenseViaService( if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { return nil, err } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { + return nil, err + } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { return nil, err } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6ec50447..44c79e35 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -354,20 +354,22 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ // ------------------------------------------------------------------- const ( - ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") - ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 - ExpenseStepFinance approvalutils.ApprovalStep = 3 - ExpenseStepRealisasi approvalutils.ApprovalStep = 4 - ExpenseStepSelesai approvalutils.ApprovalStep = 5 + ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") + ExpenseStepPengajuan approvalutils.ApprovalStep = 1 + ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 + ExpenseStepFinance approvalutils.ApprovalStep = 4 + ExpenseStepRealisasi approvalutils.ApprovalStep = 5 + ExpenseStepSelesai approvalutils.ApprovalStep = 6 ) var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ - ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Manager", - ExpenseStepFinance: "Approval Finance", - ExpenseStepRealisasi: "Realisasi", - ExpenseStepSelesai: "Selesai", + ExpenseStepPengajuan: "Pengajuan", + ExpenseStepManager: "Approval Head Area", + ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", + ExpenseStepFinance: "Approval Finance", + ExpenseStepRealisasi: "Realisasi", + ExpenseStepSelesai: "Selesai", } // ------------------------------------------------------------------- From 9515848d8ff86b438eb685c29a6c39d4e8141ca4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 10:11:31 +0700 Subject: [PATCH 12/17] feat(BE): update approval flow to use head area instead of manager --- internal/middleware/permissions.go | 2 +- internal/modules/expenses/controllers/expense.controller.go | 4 ++-- internal/modules/expenses/route.go | 2 +- internal/modules/expenses/services/expense.service.go | 6 +++--- .../inventory/transfers/services/transfer_expense_bridge.go | 2 +- internal/modules/purchases/services/expense_bridge.go | 2 +- internal/utils/constant.go | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 5820db27..6e4fe6db 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -24,7 +24,7 @@ const ( P_ExpenseUpdateOne = "lti.expense.update" P_ExpenseGetOne = "lti.expense.detail" P_ExpenseDeleteOne = "lti.expense.delete" - P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalHeadArea = "lti.expense.approve.head_area" P_ExpenseApprovalFinance = "lti.expense.approve.finance" P_ExpenseApprovalUnitVicePresident = "lti.expense.approve.unit_vice_president" P_ExpenseCreateRealizations = "lti.expense.create.realization" diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 125aeb0c..49c8f356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -229,8 +229,8 @@ func (u *ExpenseController) Approval(c *fiber.Ctx) error { path := c.Path() approvalType := "" - if strings.Contains(path, "/approvals/manager") { - approvalType = "manager" + if strings.Contains(path, "/approvals/head-area") { + approvalType = "head-area" } else if strings.Contains(path, "/approvals/finance") { approvalType = "finance" } else if strings.Contains(path, "/approvals/unit-vice-president") { diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index cfb4dd23..6ddceb14 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -28,7 +28,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) - route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) + route.Post("/approvals/head-area", m.RequirePermissions(m.P_ExpenseApprovalHeadArea), ctrl.Approval) route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) route.Post("/approvals/unit-vice-president", m.RequirePermissions(m.P_ExpenseApprovalUnitVicePresident), ctrl.Approval) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 8afbac28..9a994bc9 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -1049,9 +1049,9 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } var stepNumber approvalutils.ApprovalStep - if approvalType == "manager" { + if approvalType == "head-area" { - stepNumber = utils.ExpenseStepManager + stepNumber = utils.ExpenseStepHeadArea if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, @@ -1060,7 +1060,7 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else if approvalType == "unit-vice-president" { stepNumber = utils.ExpenseStepUnitVicePresident - if latestApproval.StepNumber != uint16(utils.ExpenseStepManager) { + if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName)) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index d4322be6..c4f28354 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -252,7 +252,7 @@ func (b *transferExpenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 094b99c1..7e5cbd91 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -618,7 +618,7 @@ func (b *expenseBridge) createExpenseViaService( actorID = 1 } approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepHeadArea, &action, actorID, nil); err != nil { return nil, err } if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &action, actorID, nil); err != nil { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 44c79e35..ba0f51f1 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -356,7 +356,7 @@ var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowExpense approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("EXPENSES") ExpenseStepPengajuan approvalutils.ApprovalStep = 1 - ExpenseStepManager approvalutils.ApprovalStep = 2 + ExpenseStepHeadArea approvalutils.ApprovalStep = 2 ExpenseStepUnitVicePresident approvalutils.ApprovalStep = 3 ExpenseStepFinance approvalutils.ApprovalStep = 4 ExpenseStepRealisasi approvalutils.ApprovalStep = 5 @@ -365,7 +365,7 @@ const ( var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepPengajuan: "Pengajuan", - ExpenseStepManager: "Approval Head Area", + ExpenseStepHeadArea: "Approval Head Area", ExpenseStepUnitVicePresident: "Approval Business Unit Vice President", ExpenseStepFinance: "Approval Finance", ExpenseStepRealisasi: "Realisasi", From 15be8dcbeab9fc877c528fb91a82a23c3ce078a5 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:09:32 +0700 Subject: [PATCH 13/17] fix route daily checklist --- internal/modules/daily-checklists/route.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 0f6657c0..9e576a05 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -1,7 +1,7 @@ package dailyChecklists import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. ctrl := controller.NewDailyChecklistController(s) route := v1.Group("/daily-checklists") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/report", ctrl.GetReport) @@ -22,7 +22,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route.Get("/report", ctrl.GetReport) - // create daily checklist + // upsert daily checklist route.Post("/", ctrl.CreateOne) // get detail data daily checklist by id From e2d352721cb803203c4ec22c04bc265e6cc64ae0 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Mon, 12 Jan 2026 11:15:59 +0700 Subject: [PATCH 14/17] Merge from development --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d6a26e97..4a814ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,8 @@ bin/ Makefile docker-compose.local.yml docker-compose.yaml -Dockerfile.local +Dockerfile +.gitlab-ci.yml # Go build cache .gocache/ vendor From b47f26d448c7081e9a3f26070e8d9bcb14ef6f46 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Mon, 12 Jan 2026 11:30:57 +0700 Subject: [PATCH 15/17] adjust max limit location and kandang --- .../modules/master/kandangs/validations/kandang.validation.go | 2 +- .../modules/master/locations/validations/location.validation.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index f4adc55e..63f03d12 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -20,7 +20,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Search string `query:"search" validate:"omitempty,max=50"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 61ab4125..a2ac6175 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -14,7 +14,7 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=500"` Search string `query:"search" validate:"omitempty,max=50"` AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` } From 80b2cafd2fda0557a49ebe5aa17fe3f37a654736 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 13:29:24 +0700 Subject: [PATCH 16/17] FIX[BE] ; fixing chikin replenish. and other logic --- .../modules/production/chickins/module.go | 3 + .../chickins/services/chickin.service.go | 288 +++++------------- 2 files changed, 76 insertions(+), 215 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 143ebad2..09514f0d 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -14,6 +14,7 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" @@ -38,6 +39,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + productRepo := rProduct.NewProductRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) @@ -88,6 +90,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * kandangRepo, warehouseRepo, productWarehouseRepo, + productRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index de49bb1e..eabe596c 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -12,6 +12,7 @@ import ( m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" @@ -44,6 +45,7 @@ type chickinService struct { KandangRepo KandangRepo.KandangRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProductRepo rProduct.ProductRepository ProjectFlockRepo rProjectFlock.ProjectflockRepository ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository @@ -52,7 +54,7 @@ type chickinService struct { StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -60,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan KandangRepo: kandangRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, ProjectFlockRepo: projectFlockRepo, ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, @@ -99,7 +102,6 @@ func (s chickinService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return db.Order("created_at DESC").Order("updated_at DESC") }) if err != nil { - s.Log.Errorf("Failed to get chickins: %+v", err) return nil, 0, err } return chickins, total, nil @@ -347,7 +349,6 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") } - s.Log.Errorf("Failed to update chickin: %+v", err) return nil, err } @@ -380,7 +381,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { warehouseDeltas := make(map[uint]float64) warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) return err } } @@ -449,6 +449,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) + ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -479,39 +480,55 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit category := strings.ToUpper(strings.TrimSpace(kandangForApproval.ProjectFlock.Category)) + var targetFlag utils.FlagType if category == string(utils.ProjectFlockCategoryGrowing) { - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") - } - - pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") - } - if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") - } + targetFlag = utils.FlagPullet } else if category == string(utils.ProjectFlockCategoryLaying) { - warehouse, err := s.WarehouseRepo.GetByKandangID(c.Context(), kandangForApproval.KandangId) + targetFlag = utils.FlagLayer + } else { + continue + } + + for _, chickin := range chickins { + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse for kandang %d not found", kandangForApproval.KandangId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) + } + if populationExists { + continue } - pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) + sourcePW, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get product warehouse for chickin %d", chickin.Id)) } - if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") + + if err := s.autoAddFlagToProduct(c.Context(), dbTransaction, sourcePW.Product.Id, targetFlag); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to auto-add flag to product %d", sourcePW.Product.Id)) + } + + population := &entity.ProjectFlockPopulation{ + ProjectChickinId: chickin.Id, + ProductWarehouseId: sourcePW.Id, + TotalQty: 0, + TotalUsedQty: 0, + Notes: chickin.Notes, + CreatedBy: actorID, + } + if err := ProjectFlockPopulationRepotx.CreateOne(c.Context(), population, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id)) + } + + if err := chickinRepoTx.PatchOne(c.Context(), chickin.Id, map[string]any{ + "pending_usage_qty": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset pending usage qty for chickin %d", chickin.Id)) + } + + if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id)) } } } @@ -534,7 +551,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit warehouseDeltas := make(map[uint]float64) warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) return err } @@ -568,104 +584,35 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return updated, nil } -func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId uint, categoryCode string, dbTransaction *gorm.DB, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { - - products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) - if err == nil && len(products) > 0 { - existingPW := &products[0] - - if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { - existingPW.ProjectFlockKandangId = projectFlockKandangId - if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { - return nil, fmt.Errorf("failed to update %s product warehouse with project_flock_kandang_id: %w", categoryCode, err) - } - } - return existingPW, nil +// autoAddFlagToProduct adds target flag to product if not already present (idempotent) +func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { + if s.ProductRepo == nil { + return nil } - product, err := s.ProductWarehouseRepo.GetFirstProductByFlag(ctx.Context(), categoryCode) + currentFlags, err := s.ProductRepo.GetFlags(ctx, productID) if err != nil { - return nil, fmt.Errorf("failed to get %s product: %w", categoryCode, err) - } - if product == nil { - return nil, fmt.Errorf("no %s product found in system", categoryCode) + return fmt.Errorf("failed to get product flags: %w", err) } - newPW := &entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouseId, - ProjectFlockKandangId: projectFlockKandangId, - Quantity: 0, + hasTargetFlag := false + currentFlagNames := make([]string, 0, len(currentFlags)) + for _, flag := range currentFlags { + currentFlagNames = append(currentFlagNames, flag.Name) + if flag.Name == string(targetFlag) { + hasTargetFlag = true + } } - if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { - return nil, fmt.Errorf("failed to create %s product warehouse: %w", categoryCode, err) + if hasTargetFlag { + return nil } - return newPW, nil -} - -func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []entity.ProjectChickin, targetPW *entity.ProductWarehouse, dbTransaction *gorm.DB, actorID uint) error { - - if targetPW == nil || targetPW.Id == 0 { - return fmt.Errorf("invalid target product warehouse") + newFlags := append(currentFlagNames, string(targetFlag)) + if err := s.ProductRepo.SyncFlags(ctx, tx, productID, newFlags); err != nil { + return fmt.Errorf("failed to sync flags: %w", err) } - ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) - chickinRepoTx := s.Repository.WithTx(dbTransaction) - - var totalQuantityAdded float64 - - for _, chickin := range chickins { - - populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) - if err != nil { - return fmt.Errorf("failed to check population existence for chickin %d: %w", chickin.Id, err) - } - - if populationExists { - s.Log.Infof("population already exists for chickin %d, skipping", chickin.Id) - continue - } - - quantityToConvert := chickin.UsageQty - - population := &entity.ProjectFlockPopulation{ - ProjectChickinId: chickin.Id, - ProductWarehouseId: targetPW.Id, - TotalQty: 0, // Will be set by FIFO Replenish - TotalUsedQty: 0, - Notes: chickin.Notes, - CreatedBy: actorID, - } - if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { - return err - } - - // Reset PendingUsageQty to 0 since population has been created - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to reset pending usage qty for chickin %d: %w", chickin.Id, err) - } - - // Replenish stock to target ProductWarehouse based on source flag - // StockableKey is PROJECT_CHICKIN but StockableID refers to Population ID - if err := s.ReplenishChickinStocks(ctx.Context(), dbTransaction, &chickin, targetPW, population, actorID); err != nil { - s.Log.Errorf("Failed to replenish stock for chickin %d: %+v", chickin.Id, err) - return err - } - - totalQuantityAdded += quantityToConvert - } - - // NOTE: ProductWarehouse target sudah ditambah melalui ReplenishChickinStocks - // yang dipanggil di atas untuk setiap chickin berdasarkan flag source: - // - DOC → replenish ke PULLET - // - PULLET → replenish ke LAYER - // - LAYER → tidak perlu replenish (sudah final) - // - DOC+PULLET+LAYER → replenish ke dirinya sendiri - return nil } @@ -674,9 +621,6 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return nil } - s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", - chickin.Id, chickin.ProductWarehouseId, desiredQty) - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -686,13 +630,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, Tx: tx, }) if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) return err } - s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", - result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } @@ -706,10 +646,7 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d", chickin.Id), } - if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) - - } + s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) } return nil @@ -720,93 +657,17 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB return nil } - sourcePW, err := s.ProductWarehouseRepo.GetByID(ctx, chickin.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags") + _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyProjectFlockPopulation, + StockableID: population.Id, + ProductWarehouseID: targetPW.Id, + Quantity: chickin.UsageQty, + Tx: tx, }) if err != nil { - return err } - if sourcePW == nil || sourcePW.Product.Id == 0 { - return fmt.Errorf("source product warehouse or product not found for chickin %d", chickin.Id) - } - sourceFlags := sourcePW.Product.Flags - if len(sourceFlags) == 0 { - s.Log.Warnf("Source product %d has no flags, skipping replenish for chickin %d", sourcePW.Product.Id, chickin.Id) - return nil - } - - hasDoc := false - hasPullet := false - hasLayer := false - for _, flag := range sourceFlags { - flagName := utils.FlagType(flag.Name) - if flagName == utils.FlagDOC { - hasDoc = true - } else if flagName == utils.FlagPullet { - hasPullet = true - } else if flagName == utils.FlagLayer { - hasLayer = true - } - } - - if hasDoc && hasPullet && hasLayer { - s.Log.Infof("Chickin %d has mixed flags (DOC+PULLET+LAYER), replenishing to source PW %d", chickin.Id, sourcePW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: sourcePW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to source PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - // LAYER only - no replenish needed - if hasLayer && !hasDoc && !hasPullet { - s.Log.Infof("Chickin %d has LAYER flag only, skipping replenish", chickin.Id) - return nil - } - - if hasDoc && !hasPullet && !hasLayer { - s.Log.Infof("Chickin %d has DOC flag, replenishing to PULLET PW %d", chickin.Id, targetPW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to PULLET PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - if hasPullet && !hasDoc && !hasLayer { - s.Log.Infof("Chickin %d has PULLET flag, replenishing to LAYER PW %d", chickin.Id, targetPW.Id) - _, err = s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock to LAYER PW for chickin %d: %+v", chickin.Id, err) - return err - } - return nil - } - - // Other combinations (e.g., DOC + PULLET without LAYER) - skip for now - s.Log.Warnf("Chickin %d has unsupported flag combination, skipping replenish", chickin.Id) return nil } @@ -825,7 +686,6 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, UsableID: chickin.Id, Tx: tx, }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) return err } @@ -842,9 +702,7 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, CreatedBy: actorID, Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), } - if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log for released chickin %d: %+v", chickin.Id, err) - } + s.StockLogRepo.CreateOne(ctx, increaseLog, nil) } return nil From ac5edb36e70606cd974e449d2cafd536d03c3508 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 12 Jan 2026 16:28:03 +0700 Subject: [PATCH 17/17] [FIX/BE] adjustment response --- .../repports/dto/repportDebtSupplier.dto.go | 10 +- .../repositories/debt_supplier.repository.go | 113 ++++++++++- .../repports/services/repport.service.go | 177 ++++++++++++++++-- .../validations/repport.validation.go | 2 +- 4 files changed, 277 insertions(+), 25 deletions(-) diff --git a/internal/modules/repports/dto/repportDebtSupplier.dto.go b/internal/modules/repports/dto/repportDebtSupplier.dto.go index 5dce055f..8699ca60 100644 --- a/internal/modules/repports/dto/repportDebtSupplier.dto.go +++ b/internal/modules/repports/dto/repportDebtSupplier.dto.go @@ -9,8 +9,8 @@ import ( type DebtSupplierRowDTO struct { PrNumber string `json:"pr_number"` PoNumber string `json:"po_number"` - PrDate string `json:"pr_date"` PoDate string `json:"po_date"` + ReceivedDate string `json:"received_date"` Aging int `json:"aging"` Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` @@ -21,6 +21,7 @@ type DebtSupplierRowDTO struct { DebtPrice float64 `json:"debt_price"` Status string `json:"status"` TravelNumber string `json:"travel_number"` + Balance float64 `json:"balance"` } type DebtSupplierTotalDTO struct { @@ -31,7 +32,8 @@ type DebtSupplierTotalDTO struct { } type DebtSupplierDTO struct { - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - Rows []DebtSupplierRowDTO `json:"rows"` - Total DebtSupplierTotalDTO `json:"total"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + InitialBalance float64 `json:"initial_balance"` + Rows []DebtSupplierRowDTO `json:"rows"` + Total DebtSupplierTotalDTO `json:"total"` } diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 84e9402d..3d415606 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -15,7 +15,10 @@ import ( type DebtSupplierRepository interface { GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.DebtSupplierQuery) ([]entity.Supplier, int64, error) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) + GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) + GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) + GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } type debtSupplierRepositoryImpl struct { @@ -28,10 +31,10 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "receive_date": + return "purchases.receive_date" case "po_date": return "purchases.po_date" - case "pr_date": - return "purchases.created_at" case "do_date", "received_date", "": return "purchase_items.received_date" default: @@ -157,6 +160,39 @@ func (r *debtSupplierRepositoryImpl) GetPurchasesBySuppliers(ctx context.Context return purchases, nil } +func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) { + if len(supplierIDs) == 0 { + return []entity.Payment{}, nil + } + + db := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs) + + if strings.TrimSpace(filters.StartDate) != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("DATE(payment_date) >= ?", dateFrom) + } + } + + if strings.TrimSpace(filters.EndDate) != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where("DATE(payment_date) <= ?", dateTo) + } + } + + var payments []entity.Payment + if err := db. + Order("payment_date ASC, id ASC"). + Find(&payments).Error; err != nil { + return nil, err + } + + return payments, nil +} + func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]uint, error) { dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) @@ -219,3 +255,76 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co return result, nil } + +func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { + if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { + return map[uint]float64{}, nil + } + + dateFrom, err := utils.ParseDateString(filters.StartDate) + if err != nil { + return map[uint]float64{}, nil + } + + dateColumn := resolveDebtSupplierDateColumn(filters.FilterBy) + + type purchaseTotalRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]purchaseTotalRow, 0) + if err := r.db.WithContext(ctx). + 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"). + Where("purchases.supplier_id IN ?", supplierIDs). + Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom). + Group("purchases.supplier_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} + +func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { + if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { + return map[uint]float64{}, nil + } + + dateFrom, err := utils.ParseDateString(filters.StartDate) + if err != nil { + return map[uint]float64{}, nil + } + + type paymentTotalRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]paymentTotalRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS supplier_id, SUM(nominal) AS total"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs). + Where("DATE(payment_date) < ?", dateFrom). + Group("party_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c7576e5f..5f3cbbad 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -642,8 +642,8 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu } func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) { - if params.FilterBy == "" { - params.FilterBy = "do_date" + if params.FilterBy == "" || strings.EqualFold(strings.TrimSpace(params.FilterBy), "do_date") { + params.FilterBy = "received_date" } if err := s.Validate.Struct(params); err != nil { @@ -675,6 +675,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + payments, err := s.DebtSupplierRepo.GetPaymentsBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) references := make([]string, 0) seenRefs := make(map[string]struct{}) @@ -697,6 +702,21 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) + for _, payment := range payments { + paymentsBySupplier[payment.PartyId] = append(paymentsBySupplier[payment.PartyId], payment) + } + + initialPurchaseTotals, err := s.DebtSupplierRepo.GetPurchaseTotalsBeforeDate(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + + initialPaymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsBeforeDate(c.Context(), supplierIDs, params) + 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") @@ -710,29 +730,81 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } + initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + items := purchasesBySupplier[supplierID] - rows := make([]dto.DebtSupplierRowDTO, 0, len(items)) + paymentItems := paymentsBySupplier[supplierID] + rows := make([]dto.DebtSupplierRowDTO, 0, len(items)+len(paymentItems)) total := dto.DebtSupplierTotalDTO{} + type debtSupplierRowItem struct { + Row dto.DebtSupplierRowDTO + SortTime time.Time + Order int + DeltaBalance float64 + CountTotals bool + } + + combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, paymentTotals, now, location) - rows = append(rows, row) + sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 0, + DeltaBalance: -row.TotalPrice, + CountTotals: true, + }) + } - if row.Aging > total.Aging { - total.Aging = row.Aging + for _, payment := range paymentItems { + row := buildDebtSupplierPaymentRow(payment, location) + sortTime := payment.PaymentDate.In(location) + combinedRows = append(combinedRows, debtSupplierRowItem{ + Row: row, + SortTime: sortTime, + Order: 1, + DeltaBalance: payment.Nominal, + CountTotals: false, + }) + } + + sort.SliceStable(combinedRows, func(i, j int) bool { + if combinedRows[i].SortTime.Equal(combinedRows[j].SortTime) { + return combinedRows[i].Order < combinedRows[j].Order + } + return combinedRows[i].SortTime.Before(combinedRows[j].SortTime) + }) + + balance := initialBalance + for i := range combinedRows { + balance += combinedRows[i].DeltaBalance + combinedRows[i].Row.Balance = balance + + if combinedRows[i].CountTotals { + row := combinedRows[i].Row + if row.Aging > total.Aging { + total.Aging = row.Aging + } + total.TotalPrice += row.TotalPrice + total.PaymentPrice += row.PaymentPrice + total.DebtPrice += row.DebtPrice + } else { + combinedRows[i].Row.DebtPrice = balance } - total.TotalPrice += row.TotalPrice - total.PaymentPrice += row.PaymentPrice - total.DebtPrice += row.DebtPrice } sortDesc := strings.EqualFold(params.SortOrder, "desc") - sort.SliceStable(rows, func(i, j int) bool { - if sortDesc { - return rows[i].PrDate > rows[j].PrDate + if sortDesc { + for i := len(combinedRows) - 1; i >= 0; i-- { + rows = append(rows, combinedRows[i].Row) } - return rows[i].PrDate < rows[j].PrDate - }) + } else { + for i := range combinedRows { + rows = append(rows, combinedRows[i].Row) + } + } var supplierDTORef *supplierDTO.SupplierRelationDTO if supplier.Id != 0 { @@ -741,9 +813,10 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } result = append(result, dto.DebtSupplierDTO{ - Supplier: supplierDTORef, - Rows: rows, - Total: total, + Supplier: supplierDTORef, + InitialBalance: initialBalance, + Rows: rows, + Total: total, }) } @@ -769,6 +842,7 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo totalPrice := 0.0 travelNumber := "-" + receivedDate := "" var area *areaDTO.AreaRelationDTO var warehouse *warehouseDTO.WarehouseRelationDTO @@ -787,8 +861,19 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } + earliestReceived := time.Time{} for _, item := range purchase.Items { totalPrice += item.TotalPrice + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue + } + received := item.ReceivedDate.In(loc) + if earliestReceived.IsZero() || received.Before(earliestReceived) { + earliestReceived = received + } + } + if !earliestReceived.IsZero() { + receivedDate = earliestReceived.Format("2006-01-02") } } @@ -820,8 +905,8 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo return dto.DebtSupplierRowDTO{ PrNumber: prNumber, PoNumber: poNumber, - PrDate: prDate.Format("2006-01-02"), PoDate: poDate, + ReceivedDate: receivedDate, Aging: aging, Area: area, Warehouse: warehouse, @@ -835,6 +920,62 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } +func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto.DebtSupplierRowDTO { + referenceNumber := "" + if payment.ReferenceNumber != nil { + referenceNumber = *payment.ReferenceNumber + } + + prNumber := payment.PaymentCode + if strings.TrimSpace(prNumber) == "" { + prNumber = referenceNumber + } + + return dto.DebtSupplierRowDTO{ + PrNumber: prNumber, + PoNumber: referenceNumber, + PoDate: "-", + ReceivedDate: payment.PaymentDate.In(loc).Format("2006-01-02"), + Aging: 0, + Area: nil, + Warehouse: nil, + DueDate: "-", + DueStatus: "-", + TotalPrice: 0, + PaymentPrice: payment.Nominal, + DebtPrice: 0, + Status: "Pembayaran", + TravelNumber: "-", + } +} + +func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { + switch strings.ToLower(strings.TrimSpace(filterBy)) { + case "po_date": + if purchase.PoDate != nil && !purchase.PoDate.IsZero() { + return purchase.PoDate.In(loc) + } + case "pr_date": + return purchase.CreatedAt.In(loc) + default: + earliest := time.Time{} + for _, item := range purchase.Items { + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue + } + received := item.ReceivedDate.In(loc) + if earliest.IsZero() || received.Before(earliest) { + earliest = received + } + } + if !earliest.IsZero() { + return earliest + } + } + + return purchase.CreatedAt.In(loc) +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6c80275f..5b60a31f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -49,7 +49,7 @@ type DebtSupplierQuery struct { SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=do_date po_date pr_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date pr_date do_date"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` }