Compare commits

...

106 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi c9e3905a65 Merge branch 'feat/filter' into 'development'
adjust export format purchase and filter

See merge request mbugroup/lti-api!547
2026-05-21 04:49:46 +00:00
giovanni 495f5f5cc1 adjust export format purchase and filter 2026-05-21 11:48:24 +07:00
Giovanni Gabriel Septriadi 71e80634b1 Merge branch 'feat/bop-finance' into 'development'
add vendor ekspedisi to laporan keuangan

See merge request mbugroup/lti-api!546
2026-05-21 01:42:48 +00:00
giovanni af2b3366ba add vendor ekspedisi to laporan keuanga 2026-05-20 23:10:08 +07:00
Giovanni Gabriel Septriadi e015e20b5c Merge branch 'fix/expenses' into 'development'
[FIX][BE]: adjust response get report expense

See merge request mbugroup/lti-api!545
2026-05-20 07:40:50 +00:00
giovanni d92d28c892 adjust response get report expense 2026-05-20 14:39:36 +07:00
Giovanni Gabriel Septriadi 60bdd4a31a Merge branch 'feat/monitoring-saldo' into 'development'
add api monitoring saldo customer

See merge request mbugroup/lti-api!544
2026-05-20 03:35:50 +00:00
Giovanni Gabriel Septriadi cce0d44f83 Merge branch 'feat/lap-keuangan' into 'development'
add export customer payment control

See merge request mbugroup/lti-api!543
2026-05-20 01:49:58 +00:00
giovanni c8623e2f7c add export customer payment control 2026-05-19 22:42:46 +07:00
giovanni 6fc4ad5773 add api monitoring saldo customer 2026-05-19 18:42:57 +07:00
Giovanni Gabriel Septriadi e61625d2f7 Merge branch 'feat/lap-keuangan' into 'development'
[FEAT][BE]: add export laporan keuangan hutang ke supplier

See merge request mbugroup/lti-api!542
2026-05-19 11:41:05 +00:00
giovanni 907b695526 add export laporang keuangan hutang ke supplier 2026-05-19 18:38:58 +07:00
Giovanni Gabriel Septriadi 32c34be2c6 Merge branch 'fix/30' into 'development'
[FIX][BE]: fix status marketing when edit sales order and edit marketing delivery

See merge request mbugroup/lti-api!540
2026-05-19 05:16:40 +00:00
giovanni d2aa3ebac7 fix status marketing when edit sales order and edit marketing delivery 2026-05-19 12:15:53 +07:00
Giovanni Gabriel Septriadi 02b86be4c5 Merge branch 'feat/edit-dc' into 'development'
[FIX][BE]: fix detail daily checklist empty kandang; add sorting to report biaya operasional

See merge request mbugroup/lti-api!539
2026-05-19 01:47:53 +00:00
giovanni 99e185a16a ad sorting to laporan biaya operasional 2026-05-19 00:15:08 +07:00
giovanni 995d585f54 fix get detail kandang kosong 2026-05-18 22:45:30 +07:00
Giovanni Gabriel Septriadi d05be1aef4 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: fix get search keuangan; add bank name to supplier and customer

See merge request mbugroup/lti-api!538
2026-05-17 13:50:15 +00:00
giovanni 872a71efda fix get search keuangan; add bank name to supplier and customer 2026-05-17 20:48:13 +07:00
Giovanni Gabriel Septriadi 6a2d6eec92 Merge branch 'feat/edit-dc' into 'development'
[FEAT][BE]: daily checklist can edit empty kandanG

See merge request mbugroup/lti-api!537
2026-05-17 12:12:27 +00:00
giovanni 18f9da1eaf daily checklist can edit empty kandang kosong 2026-05-17 19:11:13 +07:00
Giovanni Gabriel Septriadi 45bbe2ab1b Merge branch 'fix/sorting' into 'development'
[FIX][BE]: add sorting transaction, report keuangan

See merge request mbugroup/lti-api!536
2026-05-16 16:41:46 +00:00
giovanni 18bd8ad1d9 add sorting transaction, report keuangan 2026-05-16 23:40:52 +07:00
Giovanni Gabriel Septriadi a40adc22d2 Merge branch 'feat/sort-po-ex' into 'development'
[FIX][BE]: adjust sorting pembelian dan expenses

See merge request mbugroup/lti-api!535
2026-05-13 08:35:17 +00:00
giovanni 04626560eb adjust sorting pembelian dan expenses 2026-05-13 15:34:24 +07:00
Giovanni Gabriel Septriadi 945683bdf5 Merge branch 'feat/sort-po-ex' into 'development'
[FEAT][BE]: add sorting server side po and expense

See merge request mbugroup/lti-api!534
2026-05-13 06:31:27 +00:00
giovanni 490c7fc9fd add sorting server side po and expense 2026-05-13 13:28:37 +07:00
Giovanni Gabriel Septriadi 4f03b631ef Merge branch 'fix/pay' into 'development'
[FIX][BE]: adjust calculate total price  marketing ayam pullet

See merge request mbugroup/lti-api!533
2026-05-13 02:41:24 +00:00
giovanni eac671fa80 adjust calculate total price marketing ayam pullet 2026-05-12 19:55:06 +07:00
Giovanni Gabriel Septriadi 845c14cf95 Merge branch 'fix/pay' into 'development'
[FIX][BE]: fix patch lunas BOP

See merge request mbugroup/lti-api!531
2026-05-12 08:52:37 +00:00
giovanni a04ae14271 fix patch lunas BOP 2026-05-12 15:50:18 +07:00
Giovanni Gabriel Septriadi 0caad642be Merge branch 'feat/paid' into 'development'
[FEAT][BE]: add api for update is paid to expense

See merge request mbugroup/lti-api!529
2026-05-11 07:08:52 +00:00
giovanni a76ab69a84 add api for update is paid to expense 2026-05-11 14:07:56 +07:00
Giovanni Gabriel Septriadi e940c30050 Merge branch 'fix/kosong-' into 'development'
[FIX][BE]: add api for update data daily checklist by id

See merge request mbugroup/lti-api!528
2026-05-11 06:46:31 +00:00
giovanni aab1c3a2d5 add api for update data daily checklist by id 2026-05-11 13:45:23 +07:00
Giovanni Gabriel Septriadi f9226a0b41 Merge branch 'fix/kandang-kosong' into 'development'
[FIX][BE]: adjust edit kandang kosong DAILY CHECKLIST

See merge request mbugroup/lti-api!527
2026-05-11 04:13:10 +00:00
giovanni bd8b149f11 adjust edit kandang kosong 2026-05-11 11:10:35 +07:00
Rivaldi A N S 68a132f4bb Merge branch 'chore/docs-openapi-postman' into 'development'
[CHORE][BE] Docs OpenAPI & Postman

See merge request mbugroup/lti-api!526
2026-05-11 03:45:00 +00:00
ValdiANS e576b73049 chore: update postman 2026-05-11 10:43:21 +07:00
ValdiANS e138547f3b chore: update openapi 2026-05-11 10:43:15 +07:00
Rivaldi A N S e7038a394b Merge branch 'fix/marketing-report-export' into 'development'
[FIX][BE] Marketing Report Export

See merge request mbugroup/lti-api!525
2026-05-11 03:21:59 +00:00
ValdiANS e0b9192e91 fix: format vehicle number in generated excel file 2026-05-11 10:04:57 +07:00
ValdiANS 748375b269 feat: add chickin list permission 2026-05-11 10:04:22 +07:00
Giovanni Gabriel Septriadi 09bc31c602 Merge branch 'fix/day-record' into 'development'
[FIX][BE]: add migration for change harga doc

See merge request mbugroup/lti-api!524
2026-05-08 09:55:31 +00:00
giovanni 6474dd57b3 add migration for change harga doc 2026-05-08 16:54:44 +07:00
Giovanni Gabriel Septriadi ececc5e5e1 Merge branch 'fix/day-record' into 'development'
add response excess day and week

See merge request mbugroup/lti-api!523
2026-05-08 08:05:51 +00:00
giovanni 83aa23f677 add response excess day and week 2026-05-08 15:04:54 +07:00
Giovanni Gabriel Septriadi 3d2bc11058 Merge branch 'fix/day-record' into 'development'
[FIX][BE]: fix calculate day recording if has laying transfer

See merge request mbugroup/lti-api!522
2026-05-08 06:59:34 +00:00
giovanni c328b9a880 fix calculate day recording if has laying transfer 2026-05-08 13:55:48 +07:00
Giovanni Gabriel Septriadi e29ceffa37 Merge branch 'feat/api-stock' into 'development'
[FEAT][BE]: add api get list stock log by product warehouse id

See merge request mbugroup/lti-api!521
2026-05-08 05:24:30 +00:00
Giovanni Gabriel Septriadi 8f7762f769 Merge branch 'fix/week-record' into 'development'
[FIX][BE]: adjust calculate for week at recording list

See merge request mbugroup/lti-api!520
2026-05-08 05:15:42 +00:00
Giovanni Gabriel Septriadi 4c31587771 Merge branch 'fix/record' into 'development'
[FIX][BE]: adjust export recording jumlah sapronak

See merge request mbugroup/lti-api!519
2026-05-08 04:29:04 +00:00
giovanni ecac927583 adjust export recording jumlah sapronak 2026-05-08 11:26:26 +07:00
giovanni 126294d288 add api get list stock log by product warehouse id 2026-05-07 22:39:36 +07:00
giovanni aa5d4ab818 adjust calculate for week at recording list 2026-05-07 21:09:10 +07:00
Giovanni Gabriel Septriadi 06070871c7 Merge branch 'fix/chickinsql' into 'development'
[FIX][BE]: add migration for update day recording pullet cikaum 1 dan 2

See merge request mbugroup/lti-api!517
2026-05-07 10:26:29 +00:00
giovanni 5511dc78dc add migration for update day recording pullet cikaum 1 dan 2 2026-05-07 17:24:46 +07:00
Rivaldi A N S 29f8b4fbdd Merge branch 'fix/marketing-report-export' into 'development'
[FIX][BE] Marketing Report Export

See merge request mbugroup/lti-api!516
2026-05-07 10:17:03 +00:00
ValdiANS 4c6942c7b7 fix: adjust exported file column order and copywriting 2026-05-07 17:16:06 +07:00
Giovanni Gabriel Septriadi 699e4448d1 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: add field name to document

See merge request mbugroup/lti-api!515
2026-05-07 09:05:05 +00:00
giovanni 6f02387d69 add field name to document 2026-05-07 16:03:47 +07:00
Giovanni Gabriel Septriadi fc5d5d8ad4 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: adjust path document for detail biaya

See merge request mbugroup/lti-api!514
2026-05-07 08:46:29 +00:00
giovanni 0d6ab5e718 adjust path document for detail biaya 2026-05-07 15:45:39 +07:00
Giovanni Gabriel Septriadi 547fc309f5 Merge branch 'fix/marketing' into 'development'
add query sort by grand total

See merge request mbugroup/lti-api!513
2026-05-07 07:12:11 +00:00
giovanni 094e8f904b add query sort by grand total 2026-05-07 14:11:39 +07:00
Giovanni Gabriel Septriadi 0d928d5856 Merge branch 'fix/marketing' into 'development'
[FIX][BE]: add sorting at marketing

See merge request mbugroup/lti-api!512
2026-05-07 07:04:22 +00:00
giovanni 0357531e73 add sorting at marketing 2026-05-07 14:03:17 +07:00
Giovanni Gabriel Septriadi 2fa279c073 Merge branch 'feat/export-recording-a' into 'development'
[FEAT][BE]: add feed use at export excel recording

See merge request mbugroup/lti-api!511
2026-05-07 03:58:19 +00:00
giovanni 90ed035abd add feed use at export excel recording 2026-05-07 10:56:30 +07:00
Rivaldi A N S 81b9e88bb6 Merge branch 'fix/marketing-report-pdf' into 'development'
[FIX][BE] Marketing Report PDF

See merge request mbugroup/lti-api!510
2026-05-06 06:14:45 +00:00
ValdiANS 7e01d8afb9 fix: adjust marketing report pdf column and copywriting 2026-05-06 13:13:02 +07:00
Giovanni Gabriel Septriadi d5a98b95dc Merge branch 'fix/result' into 'development'
[FIX][BE]: fix woa at hasil produksi

See merge request mbugroup/lti-api!508
2026-05-05 09:39:35 +00:00
giovanni 8900937e71 fix woa at hasil produksi 2026-05-05 16:38:37 +07:00
Giovanni Gabriel Septriadi cad80e2216 Merge branch 'fix/chickin' into 'development'
[FIX][BE]: add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2

See merge request mbugroup/lti-api!507
2026-05-05 08:45:52 +00:00
Giovanni Gabriel Septriadi 34dad85734 Merge branch 'fix/kosong' into 'development'
[FIX][BE]: fix laporan daily checklist kandang kosong

See merge request mbugroup/lti-api!506
2026-05-05 08:44:51 +00:00
giovanni b1d2d30773 add migration for edit chickin_date pullet cikaum 1 dan pullet cikaum 2 2026-05-05 15:44:07 +07:00
giovanni f910d165e4 fix laporan daily checklist kandang kosong 2026-05-05 14:06:41 +07:00
M1 AIR 6f6985ef32 ci: ignore partial aws env during ecr login 2026-05-05 13:32:16 +07:00
M1 AIR d07f074fb1 fix(migrate): align recording day constraint with zero-based migration 2026-05-05 12:09:02 +07:00
Giovanni Gabriel Septriadi 561481679e Merge branch 'fix/stock-log' into 'development'
[FIX][BE]: fixing stock log when editing recording

See merge request mbugroup/lti-api!503
2026-05-05 03:08:41 +00:00
Giovanni Gabriel Septriadi d86577f007 Merge branch 'feat/export-po' into 'development'
[FEAT][BE]: add kolom gudang tujuan to export PURCHASE

See merge request mbugroup/lti-api!504
2026-05-05 03:08:01 +00:00
giovanni 49ea3f0295 add command for fix stock log missmatch 2026-05-05 10:06:35 +07:00
giovanni 7bab8c66c1 add gudang tujuan to po 2026-05-05 06:41:10 +07:00
giovanni f9de4d28f9 fixing stock log when editing recording 2026-05-04 23:06:13 +07:00
Rivaldi A N S 45028212e1 Merge branch 'fix/daily-checklist' into 'development'
[FIX][BE] Daily Checklist

See merge request mbugroup/lti-api!502
2026-05-04 09:39:23 +00:00
ValdiANS 0f285dc684 Merge branch 'fix/daily-checklist' of https://gitlab.com/mbugroup/lti-api into fix/daily-checklist 2026-05-04 16:36:05 +07:00
ValdiANS d0cd82c703 Merge branch 'development' into fix/daily-checklist 2026-05-04 16:29:07 +07:00
ValdiANS 48351661c5 fix: add order_by and sort_by query to master data employee 2026-05-04 16:28:03 +07:00
ValdiANS 19d7cd33ca fix: add search for Kandang Kosong 2026-05-04 16:27:50 +07:00
Giovanni Gabriel Septriadi 03474dc1fa Merge branch 'feat/umur' into 'development'
[FEAT][BE]: adjust calculate umur ayam at recording

See merge request mbugroup/lti-api!500
2026-05-04 06:38:19 +00:00
Rivaldi A N S 916fa4205b Merge branch 'fix/master-data-kandang' into 'development'
[FIX][BE] Master Data Kandang

See merge request mbugroup/lti-api!501
2026-05-04 04:59:12 +00:00
ValdiANS b2be67e052 fix: add sort_by and order_by query in master data kandang and kandang groups API 2026-05-04 11:54:19 +07:00
giovanni 0ac40adb5a adjust calculate umur ayam at recording 2026-05-04 11:30:53 +07:00
Adnan Zahir cee59c2b99 Merge branch 'fix/recording-validation' into 'development'
fix: allow editing sold egg weight on recording

See merge request mbugroup/lti-api!499
2026-05-02 17:05:00 +07:00
Adnan Zahir da99bf1429 fix: allow editing sold egg weight on recording 2026-05-02 17:03:57 +07:00
Adnan Zahir 28fd711ece Merge branch 'feat/fix-openapi-dashboard-integration' into 'development'
fix: resolve dashboard OpenAPI integration issues

See merge request mbugroup/lti-api!497
2026-05-02 10:59:28 +07:00
Adnan Zahir 3768892a17 fix: resolve dashboard OpenAPI integration issues
- FCRs & Transfer to Laying: add ExampleResponse field to routeMeta and
  inject example payloads into OpenAPI 200 responses for list and detail
  endpoints so dashboard consumers have concrete response shapes to work with

- Chick In: enable GET /api/production/chickins/ list endpoint (was
  commented out); add P_ChickinsGetAll permission constant and wire it
  into the route; add OpenAPI spec entry with query params and example

- Recording GET all: fix N+1 query bottleneck (2-3s response time) by
  pre-fetching approved transfer maps per PFK ID in two batch queries
  before the per-recording loop; add evaluatePopulationMutationStateFromCaches
  that uses the pre-fetched maps and caches hasAnyRecordingOnTransferTargets
  results by transfer ID — reducing per-page query count from ~20-40 to ~10-12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 10:57:45 +07:00
Giovanni Gabriel Septriadi c804c59f05 Merge branch 'feat/empty' into 'development'
[FEAT][BE]: add search nominal keuangan

See merge request mbugroup/lti-api!496
2026-04-30 08:56:19 +00:00
giovanni b219bf829f add search nominal keuangan 2026-04-30 15:54:56 +07:00
Giovanni Gabriel Septriadi f97b1a6484 Merge branch 'feat/empty' into 'development'
[FIX][BE]: adjust empty kandang daily checklist

See merge request mbugroup/lti-api!495
2026-04-30 04:16:19 +00:00
giovanni 0d2cdef10f adjust empty kandang daily checklist 2026-04-30 11:15:11 +07:00
Adnan Zahir 128c8e0d08 Merge branch 'feat/toggle-negative-usgae' into 'development'
fix: hide legace unflagged products to be consistent with the validation

See merge request mbugroup/lti-api!493
2026-04-29 12:18:56 +07:00
Adnan Zahir cf4e723f64 fix: hide legace unflagged products to be consistent with the validation 2026-04-29 11:55:35 +07:00
Giovanni Gabriel Septriadi a3156a156f Merge branch 'feat/export-penjualan' into 'development'
[FEAT][BE]: add export excel and pdf report penjualan

See merge request mbugroup/lti-api!492
2026-04-29 04:53:03 +00:00
giovanni 88b6e2f294 adjust sql migration 2026-04-02 11:40:38 +07:00
giovanni 36b0f97897 fix upser daily checklist status rejected; fix search list daily checklist 2026-04-02 11:24:53 +07:00
110 changed files with 7142 additions and 720 deletions
+16 -2
View File
@@ -27,11 +27,25 @@ workflow:
.ecr_login: &ecr_login | .ecr_login: &ecr_login |
AWS_CLI_ENV_ARGS="" AWS_CLI_ENV_ARGS=""
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION" AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_REGION=$AWS_REGION"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}" HAS_ACCESS_KEY="false"
HAS_SECRET_KEY="false"
if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
HAS_ACCESS_KEY="true"
fi
if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
HAS_SECRET_KEY="true"
fi
if [ "$HAS_ACCESS_KEY" = "true" ] && [ "$HAS_SECRET_KEY" = "true" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"
if [ -n "${AWS_SESSION_TOKEN:-}" ]; then if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" AWS_CLI_ENV_ARGS="$AWS_CLI_ENV_ARGS -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN"
fi fi
elif [ "$HAS_ACCESS_KEY" = "true" ] || [ "$HAS_SECRET_KEY" = "true" ] || [ -n "${AWS_SESSION_TOKEN:-}" ]; then
echo "WARN: Incomplete AWS_* env vars detected; ignoring injected AWS credentials for ECR login."
fi
PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \ PASS="$(docker run --rm $AWS_CLI_ENV_ARGS public.ecr.aws/aws-cli/aws-cli:latest \
ecr get-login-password --region "$AWS_REGION" || true)" ecr get-login-password --region "$AWS_REGION" || true)"
+387
View File
@@ -0,0 +1,387 @@
// Command: fix-stock-log-drift
//
// Tujuan:
// Sinkronkan `stock_logs.stock` (running ledger) dengan `product_warehouses.qty`
// (FIFO truth) ketika keduanya drift.
//
// Drift biasanya terjadi karena bug di Recording-Edit (sebelum fix) yang
// hanya menulis -decrease tanpa +increase saat in-place update. Akibatnya
// running ledger di stock_logs tertinggal dari qty riil di product_warehouses.
//
// Cara kerja:
// 1. Ambil product_warehouses.qty (sebagai truth)
// 2. Ambil last_stock_log.stock
// 3. Cari recording yang berkontribusi pada drift (untuk notes)
// 4. Hitung drift = qty - last_stock_log.stock
// 5. Jika drift != 0, insert 1 stock_log corrective:
// - drift > 0 → increase = drift
// - drift < 0 → decrease = |drift|
// stock akhir akan sama dengan qty (truth).
// Notes otomatis berisi daftar recording IDs yang berkontribusi pada drift.
//
// Mode:
// --apply=false (default) → dry-run, hanya tampilkan rencana
// --apply=true → eksekusi insert
//
// Contoh:
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply
// go run cmd/fix-stock-log-drift/main.go --product-warehouse-id=1261 --apply \
// --actor-id=1 --notes="Koreksi manual drift"
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
const (
qtyEpsilon = 1e-6
defaultActorID uint = 1
maxSuspectInNotes = 30
)
type driftRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
WarehouseName string `gorm:"column:warehouse_name"`
CurrentQty float64 `gorm:"column:current_qty"`
LastLogStock float64 `gorm:"column:last_log_stock"`
LastLogID uint `gorm:"column:last_log_id"`
FifoExpected float64 `gorm:"column:fifo_expected"`
}
type suspectRecording struct {
RecordingID uint `gorm:"column:recording_id"`
FifoUsage float64 `gorm:"column:fifo_usage"`
NetLogConsumed float64 `gorm:"column:net_log_consumed"`
Phantom float64 `gorm:"column:phantom"`
}
func main() {
var (
productWarehouseID uint
apply bool
actorID uint
notes string
)
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Target product_warehouse_id (required)")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.UintVar(&actorID, "actor-id", defaultActorID, "User id yang akan dicatat sebagai created_by stock_log corrective")
flag.StringVar(&notes, "notes", "", "Custom notes untuk stock_log corrective (opsional, default=auto-generate dari data recording)")
flag.Parse()
notes = strings.TrimSpace(notes)
if err := validateFlags(productWarehouseID, actorID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
row, err := loadDriftRow(ctx, db, productWarehouseID)
if err != nil {
log.Fatalf("failed to load product warehouse: %v", err)
}
suspects, err := loadSuspectRecordings(ctx, db, productWarehouseID)
if err != nil {
log.Fatalf("failed to load suspect recordings: %v", err)
}
drift := row.CurrentQty - row.LastLogStock
// Print info header
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Target product_warehouse_id: %d\n", productWarehouseID)
fmt.Printf("Product: %q\n", row.ProductName)
fmt.Printf("Warehouse: %q\n", row.WarehouseName)
fmt.Printf("Current qty (product_warehouses): %.3f\n", row.CurrentQty)
fmt.Printf("FIFO expected (sum total_qty - total_used): %.3f\n", row.FifoExpected)
fmt.Printf("Last stock_log: id=%d stock=%.3f\n", row.LastLogID, row.LastLogStock)
fmt.Printf("Drift (qty - last_log_stock): %+.3f\n", drift)
if !nearlyEqual(row.CurrentQty, row.FifoExpected) {
fmt.Println()
fmt.Println("⚠️ WARNING: product_warehouses.qty TIDAK match dengan FIFO expected.")
fmt.Println(" Disarankan jalankan dulu cmd/reflow-quantity-product-warehouse-from-stock-allocation")
fmt.Println(" sebelum fix stock_log drift, agar truth source-nya sudah benar.")
}
// Print suspect recordings
fmt.Println()
if len(suspects) > 0 {
totalPhantom := 0.0
for _, s := range suspects {
totalPhantom += s.Phantom
}
fmt.Printf("Suspect recordings (drift contributors): %d\n", len(suspects))
for _, s := range suspects {
fmt.Printf(
" #%-6d fifo=%-10.3f net_log=%-10.3f phantom=%+.3f\n",
s.RecordingID, s.FifoUsage, s.NetLogConsumed, s.Phantom,
)
}
fmt.Printf("Total suspect phantom: %+.3f\n", totalPhantom)
} else {
fmt.Println("Suspect recordings: none found (drift origin unknown)")
}
fmt.Println()
if nearlyEqual(drift, 0) {
fmt.Println("✓ Tidak ada drift. Stock_log sudah sinkron dengan product_warehouses.qty.")
fmt.Println("Summary: planned=0 inserted=0 skipped=1 failed=0")
return
}
// Build notes if not provided
if notes == "" {
notes = buildDefaultNotes(row, drift, suspects)
}
plan := buildCorrectiveLog(row, drift, actorID, notes)
fmt.Printf(
"PLAN insert stock_log:\n pw=%d increase=%.3f decrease=%.3f stock=%.3f\n notes=%q\n",
plan.ProductWarehouseId,
plan.Increase,
plan.Decrease,
plan.Stock,
plan.Notes,
)
if !apply {
fmt.Println()
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=0 (dry-run)")
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Re-check di dalam transaction agar aman dari race condition
current, err := loadDriftRow(ctx, tx, productWarehouseID)
if err != nil {
return fmt.Errorf("re-read product_warehouse_id=%d: %w", productWarehouseID, err)
}
currentDrift := current.CurrentQty - current.LastLogStock
if nearlyEqual(currentDrift, 0) {
fmt.Println("Drift hilang sebelum insert (kemungkinan ada operasi paralel). Skip.")
return nil
}
fresh := buildCorrectiveLog(current, currentDrift, actorID, notes)
if err := tx.Create(&fresh).Error; err != nil {
return fmt.Errorf("insert corrective stock_log for pw=%d: %w", productWarehouseID, err)
}
fmt.Printf(
"DONE inserted stock_log id=%d pw=%d increase=%.3f decrease=%.3f stock=%.3f\n",
fresh.Id,
fresh.ProductWarehouseId,
fresh.Increase,
fresh.Decrease,
fresh.Stock,
)
return nil
}); err != nil {
fmt.Println()
fmt.Println("Summary: planned=1 inserted=0 skipped=0 failed=1")
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Println("Summary: planned=1 inserted=1 skipped=0 failed=0")
}
func validateFlags(productWarehouseID uint, actorID uint) error {
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required (must be > 0)")
}
if actorID == 0 {
return errors.New("--actor-id must be > 0")
}
return nil
}
func loadDriftRow(ctx context.Context, db *gorm.DB, productWarehouseID uint) (driftRow, error) {
row := driftRow{}
lastLogSub := db.WithContext(ctx).
Table("stock_logs").
Select("id, product_warehouse_id, stock").
Where("product_warehouse_id = ?", productWarehouseID).
Order("id DESC").
Limit(1)
fifoSub := db.WithContext(ctx).
Table("purchase_items").
Select(`
product_warehouse_id,
COALESCE(SUM(COALESCE(total_qty, 0) - COALESCE(total_used, 0)), 0) AS fifo_expected
`).
Where("product_warehouse_id = ?", productWarehouseID).
Group("product_warehouse_id")
if err := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
COALESCE(p.name, '') AS product_name,
COALESCE(w.name, '') AS warehouse_name,
COALESCE(pw.qty, 0) AS current_qty,
COALESCE(last_log.stock, 0) AS last_log_stock,
COALESCE(last_log.id, 0) AS last_log_id,
COALESCE(fifo.fifo_expected, 0) AS fifo_expected
`).
Joins("LEFT JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN (?) last_log ON last_log.product_warehouse_id = pw.id", lastLogSub).
Joins("LEFT JOIN (?) fifo ON fifo.product_warehouse_id = pw.id", fifoSub).
Where("pw.id = ?", productWarehouseID).
Scan(&row).Error; err != nil {
return row, err
}
if row.ProductWarehouseID == 0 {
return row, fmt.Errorf("product_warehouse_id=%d not found", productWarehouseID)
}
return row, nil
}
// loadSuspectRecordings mencari recording yang net stock_log consumed-nya
// melebihi FIFO usage_qty — ini adalah recording yang berkontribusi pada drift
// akibat bug Recording-Edit yang hanya menulis -decrease tanpa +increase.
func loadSuspectRecordings(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]suspectRecording, error) {
rows := make([]suspectRecording, 0)
if err := db.WithContext(ctx).
Table("recording_stocks rs").
Select(`
rs.recording_id,
COALESCE(rs.usage_qty, 0) AS fifo_usage,
(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0)
) AS net_log_consumed,
(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
COALESCE(rs.usage_qty, 0)
) AS phantom
`).
Joins(`
JOIN stock_logs sl ON sl.loggable_type = ?
AND sl.loggable_id = rs.recording_id
AND sl.product_warehouse_id = rs.product_warehouse_id
`, string(utils.StockLogTypeRecording)).
Where("rs.product_warehouse_id = ?", productWarehouseID).
Group("rs.recording_id, rs.usage_qty").
Having(`
ABS(
COALESCE(SUM(CASE WHEN sl.decrease > 0 THEN sl.decrease ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN sl.increase > 0 THEN sl.increase ELSE 0 END), 0) -
COALESCE(rs.usage_qty, 0)
) > ?
`, qtyEpsilon).
Order("rs.recording_id ASC").
Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// buildDefaultNotes membuat notes otomatis yang berisi penjelasan drift
// beserta daftar recording_id yang berkontribusi + phantom amount masing-masing.
func buildDefaultNotes(row driftRow, drift float64, suspects []suspectRecording) string {
sign := "+"
if drift < 0 {
sign = ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"Koreksi drift stock_log akibat bug Recording-Edit (in-place update menulis -decrease tanpa +increase). PW=%d (%s) drift=%s%.3f.",
row.ProductWarehouseID,
row.WarehouseName,
sign,
drift,
))
if len(suspects) == 0 {
return sb.String()
}
sb.WriteString(" Recordings affected:")
limit := len(suspects)
truncated := 0
if limit > maxSuspectInNotes {
truncated = limit - maxSuspectInNotes
limit = maxSuspectInNotes
}
for i := 0; i < limit; i++ {
s := suspects[i]
phantomSign := "+"
if s.Phantom < 0 {
phantomSign = ""
}
sb.WriteString(fmt.Sprintf(" #%d(%s%.0f)", s.RecordingID, phantomSign, s.Phantom))
if i < limit-1 || truncated > 0 {
sb.WriteString(",")
}
}
if truncated > 0 {
sb.WriteString(fmt.Sprintf(" ... (+%d more)", truncated))
}
sb.WriteString(".")
return sb.String()
}
func buildCorrectiveLog(row driftRow, drift float64, actorID uint, notes string) entity.StockLog {
corrective := entity.StockLog{
ProductWarehouseId: row.ProductWarehouseID,
CreatedBy: actorID,
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0,
Stock: row.CurrentQty,
Notes: notes,
CreatedAt: time.Now(),
}
if drift > 0 {
corrective.Increase = drift
corrective.Decrease = 0
} else {
corrective.Increase = 0
corrective.Decrease = -drift
}
return corrective
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}
+429
View File
@@ -3215,6 +3215,55 @@
] ]
} }
}, },
"/api/inventory/stock-logs/": {
"get": {
"description": "Read access to `/api/inventory/stock-logs`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / inventory / stock logs",
"tags": [
"Inventory"
]
}
},
"/api/inventory/transfers/": { "/api/inventory/transfers/": {
"get": { "get": {
"description": "Read access to `/api/inventory/transfers`.", "description": "Read access to `/api/inventory/transfers`.",
@@ -4318,6 +4367,29 @@
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"example": {
"code": 200,
"data": [
{
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
}
],
"message": "Get all fcrs successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": { "schema": {
"$ref": "#/components/schemas/PaginatedEnvelope" "$ref": "#/components/schemas/PaginatedEnvelope"
} }
@@ -4379,6 +4451,41 @@
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"example": {
"code": 200,
"data": {
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"fcr_standards": [
{
"fcr_number": 1.2,
"id": 1,
"mortality": 0.5,
"weight": 0.5
},
{
"fcr_number": 1.35,
"id": 2,
"mortality": 0.3,
"weight": 1
},
{
"fcr_number": 1.5,
"id": 3,
"mortality": 0.25,
"weight": 1.5
}
],
"id": 1,
"name": "FCR Broiler Standard",
"updated_at": "2026-01-01T00:00:00Z"
},
"message": "Get fcr successfully",
"status": "success"
},
"schema": { "schema": {
"$ref": "#/components/schemas/SuccessEnvelope" "$ref": "#/components/schemas/SuccessEnvelope"
} }
@@ -6457,6 +6564,126 @@
] ]
} }
}, },
"/api/production/chickins/": {
"get": {
"description": "Read access to `/api/production/chickins`.",
"parameters": [
{
"description": "Page number.",
"example": 1,
"in": "query",
"name": "page",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Page size.",
"example": 10,
"in": "query",
"name": "limit",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Project flock kandang id filter.",
"example": 1,
"in": "query",
"name": "project_flock_kandang_id",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"code": 200,
"data": [
{
"chick_in_date": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"created_user": {
"id": 1,
"name": "Admin"
},
"id": 1,
"notes": "",
"pending_usage_qty": 0,
"product_warehouse": {
"id": 1,
"product": {
"id": 1,
"name": "DOC Broiler"
},
"warehouse": {
"id": 1,
"name": "Gudang DOC"
}
},
"product_warehouse_id": 1,
"project_flock_kandang_id": 1,
"updated_at": "2026-01-01T00:00:00Z",
"usage_qty": 10000
}
],
"message": "Get all chickins successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / production / chickins",
"tags": [
"Production"
]
}
},
"/api/production/chickins/{id}": { "/api/production/chickins/{id}": {
"get": { "get": {
"description": "Read access to `/api/production/chickins/:id`.", "description": "Read access to `/api/production/chickins/:id`.",
@@ -7517,6 +7744,47 @@
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"example": {
"code": 200,
"data": [
{
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
}
],
"message": "Get all transferLayings successfully",
"meta": {
"limit": 10,
"page": 1,
"total_pages": 1,
"total_results": 1
},
"status": "success"
},
"schema": { "schema": {
"$ref": "#/components/schemas/PaginatedEnvelope" "$ref": "#/components/schemas/PaginatedEnvelope"
} }
@@ -7700,6 +7968,69 @@
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"example": {
"code": 200,
"data": {
"approval": {
"action": null,
"step_name": "Pengajuan",
"step_number": 1
},
"created_at": "2026-01-15T00:00:00Z",
"created_by": 1,
"created_user": {
"id": 1,
"name": "Admin"
},
"economic_cutoff_date": "2026-01-20T00:00:00Z",
"effective_move_date": "2026-01-18T00:00:00Z",
"executed_at": null,
"from_project_flock": {
"flock_name": "Flock A Period 1",
"id": 1
},
"id": 1,
"notes": "",
"sources": [
{
"note": "",
"qty": 5000,
"source_project_flock_kandang": {
"id": 1,
"kandang": {
"id": 1,
"name": "Kandang A"
},
"kandang_id": 1,
"project_flock_id": 1
}
}
],
"targets": [
{
"note": "",
"qty": 5000,
"target_project_flock_kandang": {
"id": 2,
"kandang": {
"id": 2,
"name": "Kandang B"
},
"kandang_id": 2,
"project_flock_id": 2
}
}
],
"to_project_flock": {
"flock_name": "Flock B Period 1",
"id": 2
},
"transfer_date": "2026-01-15T00:00:00Z",
"transfer_number": "TL-00001"
},
"message": "Get transferLaying successfully",
"status": "success"
},
"schema": { "schema": {
"$ref": "#/components/schemas/SuccessEnvelope" "$ref": "#/components/schemas/SuccessEnvelope"
} }
@@ -8912,6 +9243,55 @@
] ]
} }
}, },
"/api/reports/hpp-v2-breakdown": {
"get": {
"description": "Read access to `/api/reports/hpp-v2-breakdown`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / reports / hpp v2 breakdown",
"tags": [
"Reports"
]
}
},
"/api/reports/marketing": { "/api/reports/marketing": {
"get": { "get": {
"description": "Read access to `/api/reports/marketing`.", "description": "Read access to `/api/reports/marketing`.",
@@ -9555,6 +9935,55 @@
] ]
} }
}, },
"/api/system-settings/": {
"get": {
"description": "Read access to `/api/system-settings`.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedEnvelope"
}
}
},
"description": "Successful response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"BearerAuth": []
}
],
"summary": "GET api / system settings",
"tags": [
"API"
]
}
},
"/api/users/": { "/api/users/": {
"get": { "get": {
"description": "Read access to `/api/users`.", "description": "Read access to `/api/users`.",
+285
View File
@@ -2006,6 +2006,34 @@ paths:
summary: GET api / inventory / product warehouses / :id summary: GET api / inventory / product warehouses / :id
tags: tags:
- Inventory - Inventory
/api/inventory/stock-logs/:
get:
description: Read access to `/api/inventory/stock-logs`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / inventory / stock logs
tags:
- Inventory
/api/inventory/transfers/: /api/inventory/transfers/:
get: get:
description: Read access to `/api/inventory/transfers`. description: Read access to `/api/inventory/transfers`.
@@ -2686,6 +2714,23 @@ paths:
"200": "200":
content: content:
application/json: application/json:
example:
code: 200
data:
- created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get all fcrs successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema: schema:
$ref: '#/components/schemas/PaginatedEnvelope' $ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response description: Successful response
@@ -2722,6 +2767,31 @@ paths:
"200": "200":
content: content:
application/json: application/json:
example:
code: 200
data:
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
fcr_standards:
- fcr_number: 1.2
id: 1
mortality: 0.5
weight: 0.5
- fcr_number: 1.35
id: 2
mortality: 0.3
weight: 1
- fcr_number: 1.5
id: 3
mortality: 0.25
weight: 1.5
id: 1
name: FCR Broiler Standard
updated_at: "2026-01-01T00:00:00Z"
message: Get fcr successfully
status: success
schema: schema:
$ref: '#/components/schemas/SuccessEnvelope' $ref: '#/components/schemas/SuccessEnvelope'
description: Successful response description: Successful response
@@ -3994,6 +4064,86 @@ paths:
summary: GET api / master data / warehouses / :id summary: GET api / master data / warehouses / :id
tags: tags:
- Master Data - Master Data
/api/production/chickins/:
get:
description: Read access to `/api/production/chickins`.
parameters:
- description: Page number.
example: 1
in: query
name: page
required: false
schema:
type: integer
- description: Page size.
example: 10
in: query
name: limit
required: false
schema:
type: integer
- description: Project flock kandang id filter.
example: 1
in: query
name: project_flock_kandang_id
required: false
schema:
type: integer
responses:
"200":
content:
application/json:
example:
code: 200
data:
- chick_in_date: "2026-01-01T00:00:00Z"
created_at: "2026-01-01T00:00:00Z"
created_user:
id: 1
name: Admin
id: 1
notes: ""
pending_usage_qty: 0
product_warehouse:
id: 1
product:
id: 1
name: DOC Broiler
warehouse:
id: 1
name: Gudang DOC
product_warehouse_id: 1
project_flock_kandang_id: 1
updated_at: "2026-01-01T00:00:00Z"
usage_qty: 10000
message: Get all chickins successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / production / chickins
tags:
- Production
/api/production/chickins/{id}: /api/production/chickins/{id}:
get: get:
description: Read access to `/api/production/chickins/:id`. description: Read access to `/api/production/chickins/:id`.
@@ -4664,6 +4814,38 @@ paths:
"200": "200":
content: content:
application/json: application/json:
example:
code: 200
data:
- approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get all transferLayings successfully
meta:
limit: 10
page: 1
total_pages: 1
total_results: 1
status: success
schema: schema:
$ref: '#/components/schemas/PaginatedEnvelope' $ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response description: Successful response
@@ -4700,6 +4882,53 @@ paths:
"200": "200":
content: content:
application/json: application/json:
example:
code: 200
data:
approval:
action: null
step_name: Pengajuan
step_number: 1
created_at: "2026-01-15T00:00:00Z"
created_by: 1
created_user:
id: 1
name: Admin
economic_cutoff_date: "2026-01-20T00:00:00Z"
effective_move_date: "2026-01-18T00:00:00Z"
executed_at: null
from_project_flock:
flock_name: Flock A Period 1
id: 1
id: 1
notes: ""
sources:
- note: ""
qty: 5000
source_project_flock_kandang:
id: 1
kandang:
id: 1
name: Kandang A
kandang_id: 1
project_flock_id: 1
targets:
- note: ""
qty: 5000
target_project_flock_kandang:
id: 2
kandang:
id: 2
name: Kandang B
kandang_id: 2
project_flock_id: 2
to_project_flock:
flock_name: Flock B Period 1
id: 2
transfer_date: "2026-01-15T00:00:00Z"
transfer_number: TL-00001
message: Get transferLaying successfully
status: success
schema: schema:
$ref: '#/components/schemas/SuccessEnvelope' $ref: '#/components/schemas/SuccessEnvelope'
description: Successful response description: Successful response
@@ -5545,6 +5774,34 @@ paths:
summary: GET api / reports / hpp per kandang summary: GET api / reports / hpp per kandang
tags: tags:
- Reports - Reports
/api/reports/hpp-v2-breakdown:
get:
description: Read access to `/api/reports/hpp-v2-breakdown`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / reports / hpp v2 breakdown
tags:
- Reports
/api/reports/marketing: /api/reports/marketing:
get: get:
description: Read access to `/api/reports/marketing`. description: Read access to `/api/reports/marketing`.
@@ -5955,6 +6212,34 @@ paths:
summary: GET api / sso / userinfo summary: GET api / sso / userinfo
tags: tags:
- SSO - SSO
/api/system-settings/:
get:
description: Read access to `/api/system-settings`.
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedEnvelope'
description: Successful response
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
description: Forbidden
security:
- ApiKeyAuth: []
- BearerAuth: []
summary: GET api / system settings
tags:
- API
/api/users/: /api/users/:
get: get:
description: Read access to `/api/users`. description: Read access to `/api/users`.
+52
View File
@@ -109,6 +109,19 @@
"method": "GET", "method": "GET",
"url": "{{base_url}}/api/closings/?page=1\u0026limit=10\u0026search=kandang\u0026project_status=1\u0026location_id={{location_id}}" "url": "{{base_url}}/api/closings/?page=1\u0026limit=10\u0026search=kandang\u0026project_status=1\u0026location_id={{location_id}}"
} }
},
{
"name": "GET api / system settings",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/system-settings/"
}
} }
], ],
"name": "API" "name": "API"
@@ -582,6 +595,19 @@
"url": "{{base_url}}/api/inventory/product-warehouses/{{id}}" "url": "{{base_url}}/api/inventory/product-warehouses/{{id}}"
} }
}, },
{
"name": "GET api / inventory / stock logs",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/inventory/stock-logs/"
}
},
{ {
"name": "GET api / inventory / transfers", "name": "GET api / inventory / transfers",
"request": { "request": {
@@ -1143,6 +1169,19 @@
}, },
{ {
"item": [ "item": [
{
"name": "GET api / production / chickins",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/production/chickins/?page=1\u0026limit=10\u0026project_flock_kandang_id={{project_flock_kandang_id}}"
}
},
{ {
"name": "GET api / production / chickins / :id", "name": "GET api / production / chickins / :id",
"request": { "request": {
@@ -1478,6 +1517,19 @@
"url": "{{base_url}}/api/reports/hpp-per-kandang?page=1\u0026limit=10\u0026period=2026-01-01\u0026show_unrecorded=false\u0026area_id=1,2\u0026location_id=1,2\u0026kandang_id=1,2\u0026weight_min=1.2\u0026weight_max=1.8" "url": "{{base_url}}/api/reports/hpp-per-kandang?page=1\u0026limit=10\u0026period=2026-01-01\u0026show_unrecorded=false\u0026area_id=1,2\u0026location_id=1,2\u0026kandang_id=1,2\u0026weight_min=1.2\u0026weight_max=1.8"
} }
}, },
{
"name": "GET api / reports / hpp v2 breakdown",
"request": {
"header": [
{
"key": "Accept",
"value": "application/json"
}
],
"method": "GET",
"url": "{{base_url}}/api/reports/hpp-v2-breakdown"
}
},
{ {
"name": "GET api / reports / marketing", "name": "GET api / reports / marketing",
"request": { "request": {
+1
View File
@@ -89,5 +89,6 @@ func DefaultDashboardPermissions() []string {
"lti.users.detail", "lti.users.detail",
"lti.users.list", "lti.users.list",
"lti.daily_checklist.master_data.kandang", "lti.daily_checklist.master_data.kandang",
"lti.production.chickins.list",
} }
} }
@@ -31,7 +31,7 @@ func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
if period.Before(origin) { if period.Before(origin) {
return 0 return 0
} }
return int(period.Sub(origin).Hours()/24) + 1 return int(period.Sub(origin).Hours() / 24)
} }
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int { func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_daily_checklists_unique_non_rejected;
ALTER TABLE daily_checklists
ADD CONSTRAINT daily_checklists_date_kandang_category_key
UNIQUE (date, kandang_id, category);
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE daily_checklists
DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_checklists_unique_non_rejected
ON daily_checklists (date, kandang_id, category)
WHERE (status IS NULL OR status <> 'REJECTED');
COMMIT;
@@ -0,0 +1,21 @@
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int + 1
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,21 @@
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = (
SELECT (r.record_datetime::date - MIN(pc.chick_in_date)::date)::int
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
)
WHERE r.deleted_at IS NULL
AND (
SELECT MIN(pc.chick_in_date)
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
) IS NOT NULL;
@@ -0,0 +1,13 @@
-- Revert chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 23 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-23',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Revert chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 15 Desember 2025
UPDATE public.project_chickins
SET chick_in_date = DATE '2025-12-15',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,13 @@
-- Update chick_in_date Pullet Cikaum 1 (project_flock_kandang_id = 70) -> 24 Maret 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-03-24',
updated_at = NOW()
WHERE project_flock_kandang_id = 70
AND deleted_at IS NULL;
-- Update chick_in_date Pullet Cikaum 2 (project_flock_kandang_id = 71) -> 6 April 2026
UPDATE public.project_chickins
SET chick_in_date = DATE '2026-04-06',
updated_at = NOW()
WHERE project_flock_kandang_id = 71
AND deleted_at IS NULL;
@@ -0,0 +1,21 @@
-- Revert: hitung ulang recording.day menggunakan chick_in_date sebelum perubahan
-- PFK 70: old chick_in_date = 2026-03-23
-- PFK 71: old chick_in_date = 2025-12-15
-- Kembalikan constraint chk_recordings_day ke >= 1
UPDATE recordings r
SET day = GREATEST(1, (r.record_datetime::date -
CASE r.project_flock_kandangs_id
WHEN 70 THEN DATE '2026-03-23'
WHEN 71 THEN DATE '2025-12-15'
END)::int + 1),
updated_at = NOW()
WHERE r.project_flock_kandangs_id IN (70, 71)
AND r.deleted_at IS NULL;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1);
@@ -0,0 +1,23 @@
-- Normalize recording.day untuk Pullet Cikaum 1 & 2
-- Setelah migrasi 20260505083754_update_pullet_cikaum_chick_in_date mengubah chick_in_date:
-- PFK 70: 2026-03-23 → 2026-03-24 (shift +1 hari)
-- PFK 71: 2025-12-15 → 2026-04-06 (shift +112 hari)
-- Recording.day perlu dihitung ulang: day = record_datetime::date - chick_in_date::date
-- Edge case: PFK 70 punya 1 recording (2026-03-23) sebelum chick_in_date baru → di-clamp ke 0
-- Note: constraint chk_recordings_day diubah ke >= 0 karena zero-indexed day
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_day;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 0);
UPDATE recordings r
SET day = GREATEST(0, (r.record_datetime::date - pc.chick_in_date::date)::int),
updated_at = NOW()
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
AND pc.deleted_at IS NULL
AND r.deleted_at IS NULL
AND r.project_flock_kandangs_id IN (70, 71);
@@ -0,0 +1,5 @@
-- Rollback price adjustment_stock id=531
UPDATE adjustment_stocks
SET price = 9535,
grand_total = ROUND(9000 * 9535, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1,7 @@
-- Fix price adjustment_stock id=531 (ADJ-00506)
-- Old: price=9535, grand_total=85,815,000
-- New: price=12635, grand_total=113,715,000
UPDATE adjustment_stocks
SET price = 12635,
grand_total = ROUND(9000 * 12635, 3)
WHERE id = 531 AND adj_number = 'ADJ-00506';
@@ -0,0 +1 @@
ALTER TABLE expenses DROP COLUMN is_paid;
@@ -0,0 +1 @@
ALTER TABLE expenses ADD COLUMN is_paid BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS daily_checklist_empty_kandangs;
COMMIT;
@@ -0,0 +1,60 @@
BEGIN;
CREATE TABLE daily_checklist_empty_kandangs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
daily_checklist_id bigint NOT NULL,
kandang_id bigint NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
created_by bigint,
deleted_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT fk_dcek_daily_checklist
FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_kandang
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
CONSTRAINT fk_dcek_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_dcek_deleted_by
FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT ck_dcek_range CHECK (end_date >= start_date)
);
CREATE INDEX idx_dcek_kandang_range
ON daily_checklist_empty_kandangs (kandang_id, start_date, end_date)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_dcek_daily_checklist_unique
ON daily_checklist_empty_kandangs (daily_checklist_id)
WHERE deleted_at IS NULL;
INSERT INTO daily_checklist_empty_kandangs (
daily_checklist_id, kandang_id, start_date, end_date, created_by, created_at, updated_at
)
SELECT
dc.id,
dc.kandang_id,
dc.date AS start_date,
COALESCE(
(SELECT (next_dc.date - INTERVAL '1 day')::date
FROM daily_checklists next_dc
WHERE next_dc.kandang_id = dc.kandang_id
AND next_dc.date > dc.date
AND next_dc.category <> 'empty_kandang'
AND (next_dc.status IS NULL OR next_dc.status <> 'REJECTED')
AND next_dc.deleted_at IS NULL
ORDER BY next_dc.date ASC
LIMIT 1),
dc.date
) AS end_date,
dc.created_by,
dc.created_at,
dc.updated_at
FROM daily_checklists dc
WHERE dc.category = 'empty_kandang'
AND dc.deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE customers DROP COLUMN bank_name;
ALTER TABLE suppliers DROP COLUMN bank_name;
@@ -0,0 +1,2 @@
ALTER TABLE customers ADD COLUMN bank_name VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE suppliers ADD COLUMN bank_name VARCHAR(100);
+1
View File
@@ -15,6 +15,7 @@ type Customer struct {
Phone string `gorm:"not null;size:20"` Phone string `gorm:"not null;size:20"`
Email string `gorm:"type:varchar(50);not null"` Email string `gorm:"type:varchar(50);not null"`
AccountNumber string `gorm:"not null;size:50"` AccountNumber string `gorm:"not null;size:50"`
BankName string `gorm:"not null;size:100;default:''"`
Balance float64 `gorm:"default:0"` Balance float64 `gorm:"default:0"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
@@ -0,0 +1,27 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandang struct {
Id uint `gorm:"primaryKey"`
DailyChecklistId uint `gorm:"not null"`
KandangId uint `gorm:"not null"`
StartDate time.Time `gorm:"type:date;not null"`
EndDate time.Time `gorm:"type:date;not null"`
CreatedBy *uint
DeletedBy *uint
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"`
Kandang *KandangGroup `gorm:"foreignKey:KandangId;references:Id"`
}
func (DailyChecklistEmptyKandang) TableName() string {
return "daily_checklist_empty_kandangs"
}
+1
View File
@@ -28,6 +28,7 @@ type DailyChecklist struct {
Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` Creator *User `gorm:"foreignKey:CreatedBy;references:Id"`
Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"` Deleter *User `gorm:"foreignKey:DeletedBy;references:Id"`
Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"`
EmptyKandang *DailyChecklistEmptyKandang `gorm:"foreignKey:DailyChecklistId;references:Id"`
} }
type DailyChecklistPhase struct { type DailyChecklistPhase struct {
+1
View File
@@ -17,6 +17,7 @@ type Expense struct {
RealizationDate time.Time `gorm:"type:date;column:realization_date"` RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"` TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
IsPaid bool `gorm:"column:is_paid;not null;default:false"`
CreatedBy uint64 `gorm:""` CreatedBy uint64 `gorm:""`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+1
View File
@@ -43,6 +43,7 @@ type Recording struct {
StandardEggMass *float64 `gorm:"-"` StandardEggMass *float64 `gorm:"-"`
StandardEggWeight *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"`
StandardFcr *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"`
StandardWeek *int `gorm:"-"`
PopulationCanChange *bool `gorm:"-"` PopulationCanChange *bool `gorm:"-"`
TransferExecuted *bool `gorm:"-"` TransferExecuted *bool `gorm:"-"`
IsTransition *bool `gorm:"-"` IsTransition *bool `gorm:"-"`
+1
View File
@@ -19,6 +19,7 @@ type Supplier struct {
Address string `gorm:"not null"` Address string `gorm:"not null"`
Npwp *string `gorm:"size:50"` Npwp *string `gorm:"size:50"`
AccountNumber *string `gorm:"size:50"` AccountNumber *string `gorm:"size:50"`
BankName *string `gorm:"size:100"`
Balance float64 `gorm:"type:numeric(15,3);default:0"` Balance float64 `gorm:"type:numeric(15,3);default:0"`
DueDate int `gorm:"not null"` DueDate int `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
+2
View File
@@ -66,6 +66,7 @@ const (
P_ProductStockGetOne = "lti.inventory.product_stock.detail" P_ProductStockGetOne = "lti.inventory.product_stock.detail"
P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list" P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list"
P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail" P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail"
P_StockLogGetAll = "lti.inventory.stock_log.list"
) )
const ( const (
P_ClosingGetAll = "lti.closing.list" P_ClosingGetAll = "lti.closing.list"
@@ -207,6 +208,7 @@ const (
P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete" P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete"
) )
const ( const (
P_ChickinsGetAll = "lti.production.chickins.list"
P_ChickinsCreateOne = "lti.production.chickins.create" P_ChickinsCreateOne = "lti.production.chickins.create"
P_ChickinsGetOne = "lti.production.chickins.detail" P_ChickinsGetOne = "lti.production.chickins.detail"
P_ChickinsApproval = "lti.production.chickins.approve" P_ChickinsApproval = "lti.production.chickins.approve"
@@ -238,17 +238,17 @@ func (u *DailyChecklistController) GetReport(c *fiber.Ctx) error {
} }
result, totalResults, err := u.DailyChecklistService.GetReport(c, query) result, totalResults, err := u.DailyChecklistService.GetReport(c, query)
withoutActivities := func(src map[string]int) map[string]int {
if src == nil {
return map[string]int{}
}
return src
}
if err != nil { if err != nil {
return err return err
} }
withoutActivities := func(src map[string]any) map[string]any {
if src == nil {
return map[string]any{}
}
return src
}
responseData := make([]dto.DailyChecklistReportDTO, len(result)) responseData := make([]dto.DailyChecklistReportDTO, len(result))
for i, item := range result { for i, item := range result {
responseData[i] = dto.DailyChecklistReportDTO{ responseData[i] = dto.DailyChecklistReportDTO{
@@ -412,6 +412,33 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
}) })
} }
func (u *DailyChecklistController) UpdateByPut(c *fiber.Ctx) error {
req := new(validation.Create)
param := c.Params("idDailyChecklist")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.DailyChecklistService.UpdateByPut(c, req, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update dailyChecklist successfully",
Data: dto.ToDailyChecklistListDTO(*result),
})
}
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error { func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files { for _, file := range files {
@@ -42,6 +42,13 @@ type DailyChecklistDetailDTO struct {
TotalActivity int `json:"total_activity"` TotalActivity int `json:"total_activity"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"` DocumentURLs []DailyChecklistDocumentDTO `json:"document_urls"`
EmptyKandang *DailyChecklistEmptyKandangDTO `json:"empty_kandang,omitempty"`
}
type DailyChecklistEmptyKandangDTO struct {
Id uint `json:"id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
} }
type DailyChecklistDocumentDTO struct { type DailyChecklistDocumentDTO struct {
@@ -72,13 +79,14 @@ type DailyChecklistPerformanceOverviewDTO struct {
ActivityLeft int `json:"activity_left"` ActivityLeft int `json:"activity_left"`
} }
type DailyChecklistReportDTO struct { type DailyChecklistReportDTO struct {
Area DailyChecklistReportEntityDTO `json:"area"` Area DailyChecklistReportEntityDTO `json:"area"`
Farm DailyChecklistReportEntityDTO `json:"farm"` Farm DailyChecklistReportEntityDTO `json:"farm"`
Kandang DailyChecklistReportEntityDTO `json:"kandang"` Kandang DailyChecklistReportEntityDTO `json:"kandang"`
ABK DailyChecklistReportEntityDTO `json:"abk"` ABK DailyChecklistReportEntityDTO `json:"abk"`
Phase string `json:"phase"` Phase string `json:"phase"`
DailyActivities map[string]int `json:"daily_activities"` DailyActivities map[string]any `json:"daily_activities"`
Summary DailyChecklistReportSummaryDTO `json:"summary"` Summary DailyChecklistReportSummaryDTO `json:"summary"`
} }
@@ -179,6 +187,17 @@ func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO {
} }
} }
func ToDailyChecklistEmptyKandangDTO(e *entity.DailyChecklistEmptyKandang) *DailyChecklistEmptyKandangDTO {
if e == nil || e.Id == 0 {
return nil
}
return &DailyChecklistEmptyKandangDTO{
Id: e.Id,
StartDate: e.StartDate,
EndDate: e.EndDate,
}
}
func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO { func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.DailyChecklistPhase, tasks []entity.DailyChecklistActivityTask, assignedEmployees []entity.Employee, totalActivities int, progress float64, documentURLs []DailyChecklistDocumentDTO) DailyChecklistDetailDTO {
phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases)) phaseDTOs := make([]DailyChecklistPhaseDTO, 0, len(phases))
for _, phase := range phases { for _, phase := range phases {
@@ -240,5 +259,6 @@ func ToDailyChecklistDetailDTO(checklist entity.DailyChecklist, phases []entity.
TotalActivity: totalActivities, TotalActivity: totalActivities,
Progress: progress, Progress: progress,
DocumentURLs: documentURLs, DocumentURLs: documentURLs,
EmptyKandang: ToDailyChecklistEmptyKandangDTO(checklist.EmptyKandang),
} }
} }
+2 -1
View File
@@ -22,6 +22,7 @@ type DailyChecklistModule struct{}
func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db)
emptyKandangRepo := rDailyChecklist.NewDailyChecklistEmptyKandangRepository(db)
phasesRepo := rPhases.NewPhasesRepository(db) phasesRepo := rPhases.NewPhasesRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db) documentRepo := commonRepo.NewDocumentRepository(db)
@@ -30,7 +31,7 @@ func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
panic(fmt.Sprintf("failed to create document service: %v", err)) panic(fmt.Sprintf("failed to create document service: %v", err))
} }
dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate, documentSvc) dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, emptyKandangRepo, phasesRepo, validate, documentSvc)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
DailyChecklistRoutes(router, userService, dailyChecklistService) DailyChecklistRoutes(router, userService, dailyChecklistService)
@@ -0,0 +1,98 @@
package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DailyChecklistEmptyKandangRepository interface {
repository.BaseRepository[entity.DailyChecklistEmptyKandang]
FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error)
FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error)
FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error)
SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error
}
type DailyChecklistEmptyKandangRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.DailyChecklistEmptyKandang]
}
func NewDailyChecklistEmptyKandangRepository(db *gorm.DB) DailyChecklistEmptyKandangRepository {
return &DailyChecklistEmptyKandangRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklistEmptyKandang](db),
}
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindByDailyChecklistID(ctx context.Context, dailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("daily_checklist_id = ?", dailyChecklistID).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlapping(ctx context.Context, kandangID uint, startDate, endDate time.Time, excludeDailyChecklistID uint) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
query := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDailyChecklistID > 0 {
query = query.Where("daily_checklist_id <> ?", excludeDailyChecklistID)
}
if err := query.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindActiveCoveringDate(ctx context.Context, kandangID uint, date time.Time) (*entity.DailyChecklistEmptyKandang, error) {
var rec entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date).
First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) FindOverlappingInRange(ctx context.Context, kandangIDs []uint, rangeStart, rangeEnd time.Time) ([]entity.DailyChecklistEmptyKandang, error) {
if len(kandangIDs) == 0 {
return []entity.DailyChecklistEmptyKandang{}, nil
}
var recs []entity.DailyChecklistEmptyKandang
if err := r.DB().WithContext(ctx).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?", kandangIDs, rangeEnd, rangeStart).
Find(&recs).Error; err != nil {
return nil, err
}
return recs, nil
}
func (r *DailyChecklistEmptyKandangRepositoryImpl) SoftDeleteByDailyChecklistID(ctx context.Context, dailyChecklistID uint, actorID *uint) error {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID != nil {
updates["deleted_by"] = *actorID
}
return r.DB().WithContext(ctx).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", dailyChecklistID).
Updates(updates).Error
}
@@ -59,6 +59,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment) route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate) route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate)
route.Put("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateByPut)
route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne) route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne) route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
} }
@@ -29,6 +29,7 @@ type DailyChecklistService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error)
UpdateByPut(ctx *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error)
BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error) BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error
@@ -46,6 +47,7 @@ type dailyChecklistService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.DailyChecklistRepository Repository repository.DailyChecklistRepository
EmptyKandangRepo repository.DailyChecklistEmptyKandangRepository
PhaseRepo phaseRepo.PhasesRepository PhaseRepo phaseRepo.PhasesRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
} }
@@ -104,7 +106,7 @@ type DailyChecklistReportItem struct {
EmployeeID uint EmployeeID uint
EmployeeName string EmployeeName string
PhaseName string PhaseName string
DailyActivities map[string]int DailyActivities map[string]any
Summary DailyChecklistReportSummary Summary DailyChecklistReportSummary
} }
@@ -127,22 +129,26 @@ const (
dailyChecklistCategoryEmptyKandang = "empty_kandang" dailyChecklistCategoryEmptyKandang = "empty_kandang"
dailyChecklistStatusRejected = "REJECTED" dailyChecklistStatusRejected = "REJECTED"
dailyChecklistStatusDraft = "DRAFT" dailyChecklistStatusDraft = "DRAFT"
dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range"
dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist"
dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date"
dailyChecklistErrEmptyKandangRangeOverlap = "Empty kandang range overlaps with an existing empty kandang period for this kandang"
dailyChecklistErrDateInsideEmptyKandang = "Tanggal berada dalam periode kandang kosong untuk kandang ini"
dailyChecklistErrEmptyKandangEndDateInvalid = "empty_kandang_end_date harus >= date"
) )
func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { func NewDailyChecklistService(repo repository.DailyChecklistRepository, emptyKandangRepo repository.DailyChecklistEmptyKandangRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService {
return &dailyChecklistService{ return &dailyChecklistService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
EmptyKandangRepo: emptyKandangRepo,
PhaseRepo: phaseRepo, PhaseRepo: phaseRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
} }
} }
func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang") return db.Preload("Kandang").Preload("EmptyKandang")
} }
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error { func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
@@ -276,7 +282,11 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
normalizedSearch := re.ReplaceAllString(params.Search, "") normalizedSearch := re.ReplaceAllString(params.Search, "")
if normalizedSearch != "" { if normalizedSearch != "" {
like := "%" + normalizedSearch + "%" like := "%" + normalizedSearch + "%"
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", like, like) db = db.Where(`(
regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR
(dc.category = 'empty_kandang' AND regexp_replace('Kandang Kosong', '[^a-zA-Z0-9]', '', 'g') ILIKE ?)
)`, like, like, like)
} }
} }
@@ -519,22 +529,26 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
status := req.Status status := req.Status
category := req.Category category := req.Category
endDate := date
if req.EmptyKandang { if req.EmptyKandang {
if strings.TrimSpace(req.EmptyKandangEndDate) == "" { category = dailyChecklistCategoryEmptyKandang
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date is required when empty_kandang is true")
} }
endDate, err = time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.EmptyKandangEndDate)) var emptyEndDate time.Time
if err != nil { if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
} }
if endDate.Before(date) { if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, "empty_kandang_end_date must be greater than or equal to date") return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
} }
category = dailyChecklistCategoryEmptyKandang
} }
targetID := uint(0) targetID := uint(0)
@@ -544,18 +558,40 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
return err return err
} }
if req.EmptyKandang { if category == dailyChecklistCategoryEmptyKandang {
if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil { if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoExistingEmptyKandangInRange(tx, req.KandangId, date, emptyEndDate, 0); err != nil {
return err
}
if err := s.validateNoDeletedNonEmptyKandangForDate(tx, req.KandangId, date); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, 0); err != nil {
return err
}
if err := s.validateDateNotInExistingEmptyKandangChecklist(tx, req.KandangId, date, 0); err != nil {
return err return err
} }
return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID)
} }
if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil { if err := s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID); err != nil {
return err return err
} }
return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) if category == dailyChecklistCategoryEmptyKandang {
actorID, _ := m.ActorIDFromContext(c)
if err := s.upsertEmptyKandangRange(tx, targetID, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
}
return nil
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err) s.Log.Errorf("Failed to create/upsert dailyChecklist: %+v", err)
@@ -585,31 +621,131 @@ func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kan
return nil return nil
} }
func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { func (s *dailyChecklistService) validateNoNormalChecklistInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category <> ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64 var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}). if err := q.Count(&conflictCount).Error; err != nil {
Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate).
Count(&conflictCount).Error; err != nil {
return err return err
} }
if conflictCount > 0 { if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist) return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist)
} }
return nil return nil
} }
func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { func (s *dailyChecklistService) validateNoEmptyKandangRangeOverlap(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, endDate, startDate)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var overlapCount int64
if err := q.Count(&overlapCount).Error; err != nil {
return err
}
if overlapCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInEmptyKandangRange(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date <= ? AND end_date >= ?", kandangID, date, date)
if excludeDCID > 0 {
q = q.Where("daily_checklist_id <> ?", excludeDCID)
}
var rec entity.DailyChecklistEmptyKandang
if err := q.First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
func (s *dailyChecklistService) validateNoExistingEmptyKandangInRange(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL",
kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangRangeOverlap)
}
return nil
}
func (s *dailyChecklistService) validateDateNotInExistingEmptyKandangChecklist(tx *gorm.DB, kandangID uint, date time.Time, excludeDCID uint) error {
q := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL",
kandangID, date, dailyChecklistCategoryEmptyKandang)
if excludeDCID > 0 {
q = q.Where("id <> ?", excludeDCID)
}
var conflictCount int64
if err := q.Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateInsideEmptyKandang)
}
return nil
}
func (s *dailyChecklistService) upsertEmptyKandangRange(tx *gorm.DB, dailyChecklistID, kandangID uint, startDate, endDate time.Time, actorID uint) error {
var existing entity.DailyChecklistEmptyKandang
err := tx.Where("daily_checklist_id = ?", dailyChecklistID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
return tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("id = ?", existing.Id).
Updates(map[string]any{
"kandang_id": kandangID,
"start_date": startDate,
"end_date": endDate,
"updated_at": time.Now(),
}).Error
}
record := &entity.DailyChecklistEmptyKandang{
DailyChecklistId: dailyChecklistID,
KandangId: kandangID,
StartDate: startDate,
EndDate: endDate,
}
if actorID > 0 {
actor := actorID
record.CreatedBy = &actor
}
return tx.Create(record).Error
}
func (s *dailyChecklistService) validateNoDeletedNonEmptyKandangForDate(tx *gorm.DB, kandangID uint, date time.Time) error {
var conflictCount int64 var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}). if err := tx.Model(&entity.DailyChecklist{}).
Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). Unscoped().
Where("kandang_id = ? AND date = ? AND deleted_at IS NULL AND category != ?", kandangID, date, dailyChecklistCategoryEmptyKandang).
Count(&conflictCount).Error; err != nil { Count(&conflictCount).Error; err != nil {
return err return err
} }
if conflictCount > 0 { if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist) return fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrDeletedNonEmptyKandangExists)
} }
return nil return nil
@@ -856,6 +992,157 @@ func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStat
return updated, nil return updated, nil
} }
func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD")
}
category := req.Category
if req.EmptyKandang {
category = dailyChecklistCategoryEmptyKandang
}
status := req.Status
var emptyEndDate time.Time
if category == dailyChecklistCategoryEmptyKandang {
trimmedEnd := strings.TrimSpace(req.EmptyKandangEndDate)
if trimmedEnd == "" {
emptyEndDate = date
} else {
parsedEnd, parseErr := time.Parse(dailyChecklistDateLayout, trimmedEnd)
if parseErr != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid empty_kandang_end_date format, use YYYY-MM-DD")
}
if parsedEnd.Before(date) {
return nil, fiber.NewError(fiber.StatusBadRequest, dailyChecklistErrEmptyKandangEndDateInvalid)
}
emptyEndDate = parsedEnd
}
}
var wasBranchC bool // non-empty_kandang → empty_kandang transition
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil {
return err
}
var existing entity.DailyChecklist
if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return err
}
existingIsEmpty := existing.Category == dailyChecklistCategoryEmptyKandang
newIsEmpty := category == dailyChecklistCategoryEmptyKandang
if newIsEmpty {
if err := s.validateNoNormalChecklistInRange(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
if err := s.validateNoEmptyKandangRangeOverlap(tx, req.KandangId, date, emptyEndDate, id); err != nil {
return err
}
} else {
if err := s.validateDateNotInEmptyKandangRange(tx, req.KandangId, date, id); err != nil {
return err
}
}
var conflictCount int64
if err := tx.Model(&entity.DailyChecklist{}).
Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL",
id, date, req.KandangId, category).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists with same date, kandang, and category")
}
result := tx.Model(&entity.DailyChecklist{}).Where("id = ?", id).Updates(map[string]any{
"date": date,
"kandang_id": req.KandangId,
"category": category,
"status": status,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
actorID, _ := m.ActorIDFromContext(c)
if newIsEmpty {
if err := s.upsertEmptyKandangRange(tx, id, req.KandangId, date, emptyEndDate, actorID); err != nil {
return err
}
// Branch C: non-empty → empty_kandang, hard-delete task/progress data
if !existingIsEmpty {
wasBranchC = true
if err := tx.Exec(`
DELETE FROM daily_checklist_activity_task_assignments
WHERE task_id IN (
SELECT id FROM daily_checklist_activity_tasks WHERE checklist_id = ?
)`, id).Error; err != nil {
return err
}
if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil {
return err
}
if err := tx.Where("daily_checklist_id = ?", id).Delete(&entity.DailyChecklistTask{}).Error; err != nil {
return err
}
}
} else if existingIsEmpty {
updates := map[string]any{
"deleted_at": time.Now(),
}
if actorID > 0 {
updates["deleted_by"] = actorID
}
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(updates).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
// Branch C: delete DC documents outside transaction (storage is external)
if wasBranchC && s.DocumentSvc != nil {
docs, docErr := s.DocumentSvc.ListByTarget(c.Context(), string(utils.DocumentTypeDailyChecklist), uint64(id))
if docErr == nil && len(docs) > 0 {
docIDs := make([]uint, 0, len(docs))
for _, doc := range docs {
docIDs = append(docIDs, doc.Id)
}
if delErr := s.DocumentSvc.DeleteDocuments(c.Context(), docIDs, true); delErr != nil {
s.Log.Errorf("Failed to delete documents for DC %d during empty_kandang conversion: %+v", id, delErr)
}
}
}
return s.GetOne(c, id)
}
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil { if err := s.ensureChecklistAccess(c, id); err != nil {
return err return err
@@ -887,6 +1174,15 @@ func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
if err := tx.Model(&entity.DailyChecklistEmptyKandang{}).
Where("daily_checklist_id = ? AND deleted_at IS NULL", id).
Updates(map[string]any{
"deleted_at": time.Now(),
"deleted_by": actorID,
}).Error; err != nil {
return err
}
return nil return nil
}); err != nil { }); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1449,11 +1745,12 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name") Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name")
} }
var total int64 // --- Count approved rows ---
var approvedTotal int64
groupedForCount := buildGroupedQuery() groupedForCount := buildGroupedQuery()
if err := s.Repository.DB().WithContext(c.Context()). if err := s.Repository.DB().WithContext(c.Context()).
Table("(?) AS grouped", groupedForCount). Table("(?) AS grouped", groupedForCount).
Count(&total).Error; err != nil { Count(&approvedTotal).Error; err != nil {
s.Log.Errorf("Failed to count report data: %+v", err) s.Log.Errorf("Failed to count report data: %+v", err)
return nil, 0, err return nil, 0, err
} }
@@ -1473,19 +1770,197 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
TotalAssignments int64 TotalAssignments int64
} }
rows := make([]reportRow, 0) type fallbackRowType struct {
if err := buildGroupedQuery(). AreaID uint
Order("a.name, loc.name, k.name, e.name"). AreaName string
Offset(offset). LocationID uint
Limit(params.Limit). LocationName string
Scan(&rows).Error; err != nil { KandangID uint
s.Log.Errorf("Failed to fetch report data: %+v", err) KandangName string
EmployeeID uint
EmployeeName string
}
// buildFallbackQ returns employees in kandangs that have NO approved checklist data
// for the filtered period. Applies the same scope/area/location/kandang/employee filters.
buildFallbackQ := func() *gorm.DB {
approvedKandangSubQ := buildBase().Select("DISTINCT dc.kandang_id")
q := s.Repository.DB().WithContext(c.Context()).
Table("employee_kandangs ek").
Joins("JOIN employees e ON e.id = ek.employee_id AND e.deleted_at IS NULL").
Joins("JOIN kandang_groups k ON k.id = ek.kandang_id AND k.deleted_at IS NULL").
Joins("JOIN locations loc ON loc.id = k.location_id AND loc.deleted_at IS NULL").
Joins("JOIN areas a ON a.id = loc.area_id AND a.deleted_at IS NULL").
Where("ek.kandang_id NOT IN (?)", approvedKandangSubQ).
Select("e.id AS employee_id, e.name AS employee_name, k.id AS kandang_id, k.name AS kandang_name, loc.id AS location_id, loc.name AS location_name, a.id AS area_id, a.name AS area_name")
q = m.ApplyScopeFilter(q, locationScope, "loc.id")
q = m.ApplyScopeFilter(q, areaScope, "a.id")
if params.AreaID != nil {
q = q.Where("a.id = ?", *params.AreaID)
}
if params.LocationID != nil {
q = q.Where("loc.id = ?", *params.LocationID)
}
if params.KandangID != nil {
q = q.Where("ek.kandang_id = ?", *params.KandangID)
}
if params.EmployeeID != nil {
q = q.Where("ek.employee_id = ?", *params.EmployeeID)
}
// PhaseID not applied: fallback rows have no phase data
return q
}
// --- Count fallback rows ---
var fallbackTotal int64
if err := s.Repository.DB().WithContext(c.Context()).
Table("(?) AS fb", buildFallbackQ()).
Count(&fallbackTotal).Error; err != nil {
s.Log.Errorf("Failed to count fallback report data: %+v", err)
return nil, 0, err return nil, 0, err
} }
if len(rows) == 0 { total := approvedTotal + fallbackTotal
// --- Fetch ALL approved rows (pagination done in Go after merging with fallback) ---
allApprovedRows := make([]reportRow, 0)
if approvedTotal > 0 {
if err := buildGroupedQuery().
Order("a.name, loc.name, k.name, e.name").
Scan(&allApprovedRows).Error; err != nil {
s.Log.Errorf("Failed to fetch report data: %+v", err)
return nil, 0, err
}
}
// --- Fetch ALL fallback rows ---
allFallbackRows := make([]fallbackRowType, 0)
if fallbackTotal > 0 {
if err := buildFallbackQ().
Order("a.name, loc.name, k.name, e.name").
Scan(&allFallbackRows).Error; err != nil {
s.Log.Errorf("Failed to fetch fallback report data: %+v", err)
return nil, 0, err
}
}
// --- Merge approved + fallback and sort consistently ---
type mergedEntry struct {
AreaName string
LocationName string
KandangName string
EmployeeName string
IsApproved bool
Idx int
}
merged := make([]mergedEntry, 0, len(allApprovedRows)+len(allFallbackRows))
for i, r := range allApprovedRows {
merged = append(merged, mergedEntry{
AreaName: r.AreaName, LocationName: r.LocationName,
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
IsApproved: true, Idx: i,
})
}
for i, r := range allFallbackRows {
merged = append(merged, mergedEntry{
AreaName: r.AreaName, LocationName: r.LocationName,
KandangName: r.KandangName, EmployeeName: r.EmployeeName,
IsApproved: false, Idx: i,
})
}
sort.Slice(merged, func(i, j int) bool {
a, b := merged[i], merged[j]
if a.AreaName != b.AreaName {
return a.AreaName < b.AreaName
}
if a.LocationName != b.LocationName {
return a.LocationName < b.LocationName
}
if a.KandangName != b.KandangName {
return a.KandangName < b.KandangName
}
return a.EmployeeName < b.EmployeeName
})
// --- Apply Go-level pagination ---
end := offset + params.Limit
if end > len(merged) {
end = len(merged)
}
if offset >= len(merged) {
return []DailyChecklistReportItem{}, total, nil return []DailyChecklistReportItem{}, total, nil
} }
pageData := merged[offset:end]
// --- Split page into approved vs fallback rows ---
pageApproved := make([]reportRow, 0)
pageFallback := make([]fallbackRowType, 0)
for _, entry := range pageData {
if entry.IsApproved {
pageApproved = append(pageApproved, allApprovedRows[entry.Idx])
} else {
pageFallback = append(pageFallback, allFallbackRows[entry.Idx])
}
}
applyEmptyKandangFlags := func(items []DailyChecklistReportItem, kandangIDs []uint) error {
if len(kandangIDs) == 0 {
return nil
}
firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1)
type emptyRangeRec struct {
KandangID uint
StartDate time.Time
EndDate time.Time
}
var rangeRecs []emptyRangeRec
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id IN ? AND start_date <= ? AND end_date >= ?",
kandangIDs, lastDay, firstDay).
Select("kandang_id, start_date, end_date").
Scan(&rangeRecs).Error; err != nil {
s.Log.Errorf("Failed to get empty kandang ranges for report: %+v", err)
return err
}
emptyDaysByKandang := make(map[uint]map[int]struct{})
for _, rec := range rangeRecs {
effectiveStart := rec.StartDate
if effectiveStart.Before(firstDay) {
effectiveStart = firstDay
}
effectiveEnd := rec.EndDate
if effectiveEnd.After(lastDay) {
effectiveEnd = lastDay
}
if effectiveStart.After(effectiveEnd) {
continue
}
if _, ok := emptyDaysByKandang[rec.KandangID]; !ok {
emptyDaysByKandang[rec.KandangID] = make(map[int]struct{})
}
for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) {
emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{}
}
}
for i, item := range items {
daySet := emptyDaysByKandang[item.KandangID]
for day := range daySet {
key := strconv.Itoa(day)
if _, exists := items[i].DailyActivities[key]; !exists {
items[i].DailyActivities[key] = "Kandang kosong"
}
}
}
return nil
}
type comboKey struct { type comboKey struct {
EmployeeID uint EmployeeID uint
@@ -1507,7 +1982,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
kandangSet := make(map[uint]struct{}) kandangSet := make(map[uint]struct{})
phaseSet := make(map[uint]struct{}) phaseSet := make(map[uint]struct{})
for _, row := range rows { for _, row := range pageApproved {
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
comboSet[key] = struct{}{} comboSet[key] = struct{}{}
if _, ok := employeeSet[row.EmployeeID]; !ok { if _, ok := employeeSet[row.EmployeeID]; !ok {
@@ -1648,8 +2123,9 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return selected return selected
} }
items := make([]DailyChecklistReportItem, len(rows)) // --- Build approved items (existing logic) ---
for i, row := range rows { approvedItems := make([]DailyChecklistReportItem, len(pageApproved))
for i, row := range pageApproved {
key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID}
activities := dailyActivityMap[key] activities := dailyActivityMap[key]
@@ -1659,7 +2135,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
totalChecklist := 0 totalChecklist := 0
categoryCounts := DailyChecklistReportCategory{} categoryCounts := DailyChecklistReportCategory{}
activityOutput := make(map[string]int, len(activities)) activityOutput := make(map[string]any, len(activities))
for day, stat := range activities { for day, stat := range activities {
activityOutput[day] = stat.Completed activityOutput[day] = stat.Completed
@@ -1696,7 +2172,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100)) kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100))
} }
items[i] = DailyChecklistReportItem{ approvedItems[i] = DailyChecklistReportItem{
AreaID: row.AreaID, AreaID: row.AreaID,
AreaName: row.AreaName, AreaName: row.AreaName,
LocationID: row.LocationID, LocationID: row.LocationID,
@@ -1717,5 +2193,55 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
} }
} }
return items, total, nil // --- Build fallback items (kandangs with no approved data) ---
fallbackItems := make([]DailyChecklistReportItem, len(pageFallback))
for i, fb := range pageFallback {
fallbackItems[i] = DailyChecklistReportItem{
AreaID: fb.AreaID,
AreaName: fb.AreaName,
LocationID: fb.LocationID,
LocationName: fb.LocationName,
KandangID: fb.KandangID,
KandangName: fb.KandangName,
EmployeeID: fb.EmployeeID,
EmployeeName: fb.EmployeeName,
PhaseName: "",
DailyActivities: map[string]any{},
Summary: DailyChecklistReportSummary{},
}
}
// --- Reconstruct allItems in the sorted pageData order ---
allItems := make([]DailyChecklistReportItem, len(pageData))
approvedIdx := 0
fallbackIdx := 0
for i, entry := range pageData {
if entry.IsApproved {
allItems[i] = approvedItems[approvedIdx]
approvedIdx++
} else {
allItems[i] = fallbackItems[fallbackIdx]
fallbackIdx++
}
}
// --- Collect all kandangIDs on this page (approved + fallback) for empty_kandang flags ---
allKandangSet := make(map[uint]struct{})
for _, id := range kandangIDs {
allKandangSet[id] = struct{}{}
}
for _, fb := range pageFallback {
allKandangSet[fb.KandangID] = struct{}{}
}
allKandangIDs := make([]uint, 0, len(allKandangSet))
for id := range allKandangSet {
allKandangIDs = append(allKandangIDs, id)
}
// --- Flag empty kandang days within this report month ---
if err := applyEmptyKandangFlags(allItems, allKandangIDs); err != nil {
return nil, 0, err
}
return allItems, total, nil
} }
@@ -208,8 +208,18 @@ func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *
Count(&activeInRange).Error; err != nil { Count(&activeInRange).Error; err != nil {
t.Fatalf("failed counting checklists in range: %v", err) t.Fatalf("failed counting checklists in range: %v", err)
} }
if activeInRange != 5 { if activeInRange != 1 {
t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange) t.Fatalf("expected 1 active empty_kandang checklist created for range, got %d", activeInRange)
}
var emptyRangeCount int64
if err := db.Model(&entity.DailyChecklistEmptyKandang{}).
Where("kandang_id = ? AND start_date = ? AND end_date = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")).
Count(&emptyRangeCount).Error; err != nil {
t.Fatalf("failed counting empty kandang ranges: %v", err)
}
if emptyRangeCount != 1 {
t.Fatalf("expected 1 empty kandang range record for [2026-01-01, 2026-01-05], got %d", emptyRangeCount)
} }
} }
@@ -304,6 +314,18 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
updated_at DATETIME NULL, updated_at DATETIME NULL,
deleted_at DATETIME NULL deleted_at DATETIME NULL
)`, )`,
`CREATE TABLE daily_checklist_empty_kandangs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
daily_checklist_id INTEGER NOT NULL,
kandang_id INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_by INTEGER NULL,
deleted_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)`,
`INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
`INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, `INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`,
@@ -316,7 +338,8 @@ func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.
} }
repo := repository.NewDailyChecklistRepository(db) repo := repository.NewDailyChecklistRepository(db)
svc := NewDailyChecklistService(repo, nil, validator.New(), nil) emptyRepo := repository.NewDailyChecklistEmptyKandangRepository(db)
svc := NewDailyChecklistService(repo, emptyRepo, nil, validator.New(), nil)
return svc, db return svc, db
} }
@@ -10,7 +10,7 @@ type Create struct {
Category string `json:"category" validate:"required"` Category string `json:"category" validate:"required"`
Status string `json:"status" validate:"required"` Status string `json:"status" validate:"required"`
EmptyKandang bool `json:"empty_kandang"` EmptyKandang bool `json:"empty_kandang"`
EmptyKandangEndDate string `json:"empty_kandang_end_date"` EmptyKandangEndDate string `json:"empty_kandang_end_date" validate:"omitempty"`
} }
type Update struct { type Update struct {
@@ -119,9 +119,9 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
var rows []RecordingWeeklyMetric var rows []RecordingWeeklyMetric
weekExpr := `CASE weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1 WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1) ELSE (r.day / 7 + 1)
END` END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -503,9 +503,9 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
var rows []ComparisonWeeklyMetric var rows []ComparisonWeeklyMetric
weekExpr := `CASE weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1 WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1) ELSE (r.day / 7 + 1)
END` END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -574,9 +574,9 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
var rows []EggQualityWeeklyMetric var rows []EggQualityWeeklyMetric
weekExpr := `CASE weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1 WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1) ELSE (r.day / 7 + 1)
END` END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -616,9 +616,9 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
var rows []WeeklyEggWeightMetric var rows []WeeklyEggWeightMetric
weekExpr := `CASE weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1 WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1) ELSE (r.day / 7 + 1)
END` END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -647,9 +647,9 @@ func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, s
var rows []WeeklyFeedUsageMetric var rows []WeeklyFeedUsageMetric
weekExpr := `CASE weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1 WHEN r.day IS NULL OR r.day < 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 WHEN UPPER(pf.category) = 'LAYING' THEN (r.day / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1) ELSE (r.day / 7 + 1)
END` END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
@@ -65,6 +65,8 @@ func (u *ExpenseController) GetAll(c *fiber.Ctx) error {
RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")), RealizationStatus: strings.TrimSpace(c.Query("realization_status", "")),
ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint64(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint64(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
} }
if isAllExpenseExcelExportRequest(c) { if isAllExpenseExcelExportRequest(c) {
@@ -481,6 +483,27 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error {
}) })
} }
func (u *ExpenseController) Pay(c *fiber.Ctx) error {
expenseID := c.Params("id")
id, err := strconv.Atoi(expenseID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID")
}
expense, err := u.ExpenseService.Pay(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Pay expense successfully",
Data: expense,
})
}
func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error { func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error {
requiredPerms := []string{} requiredPerms := []string{}
@@ -29,6 +29,7 @@ type ExpenseBaseDTO struct {
RealizationDate *time.Time `json:"realization_date,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"`
TransactionDate time.Time `json:"transaction_date"` TransactionDate time.Time `json:"transaction_date"`
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
IsPaid bool `json:"is_paid"`
} }
type ExpenseListDTO struct { type ExpenseListDTO struct {
@@ -86,6 +87,7 @@ type KandangGroupDTO struct {
type DocumentDTO struct { type DocumentDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Path string `json:"path"` Path string `json:"path"`
Name string `json:"name"`
} }
// === MAPPERS === // === MAPPERS ===
@@ -126,6 +128,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO {
RealizationDate: realizationDate, RealizationDate: realizationDate,
TransactionDate: e.TransactionDate, TransactionDate: e.TransactionDate,
Location: location, Location: location,
IsPaid: e.IsPaid,
} }
} }
@@ -184,6 +187,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
documents = append(documents, DocumentDTO{ documents = append(documents, DocumentDTO{
ID: uint64(doc.Id), ID: uint64(doc.Id),
Path: doc.Path, Path: doc.Path,
Name: doc.Name,
}) })
} }
@@ -191,6 +195,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
realizationDocs = append(realizationDocs, DocumentDTO{ realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id), ID: uint64(doc.Id),
Path: doc.Path, Path: doc.Path,
Name: doc.Name,
}) })
} }
@@ -98,6 +98,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return db. return db.
Preload("Expense"). Preload("Expense").
Preload("Expense.Supplier"). Preload("Expense.Supplier").
Preload("Expense.Location").
Preload("Kandang"). Preload("Kandang").
Preload("Kandang.Location"). Preload("Kandang.Location").
Preload("Nonstock"). Preload("Nonstock").
@@ -177,10 +178,48 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
return nil, 0, err return nil, 0, err
} }
sortExpr := "expense_realizations.created_at"
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
switch filters.SortBy {
case "po_number":
sortExpr = "expenses.po_number"
case "reference_number":
sortExpr = "expenses.reference_number"
case "realization_date":
sortExpr = "expenses.realization_date"
case "transaction_date":
sortExpr = "expenses.transaction_date"
case "category":
sortExpr = "expenses.category"
case "product":
sortExpr = "(SELECT name FROM nonstocks WHERE id = expense_nonstocks.nonstock_id)"
case "supplier":
sortExpr = "suppliers.name"
case "location":
sortExpr = "(SELECT l.name FROM kandangs k JOIN locations l ON l.id = k.location_id WHERE k.id = expense_nonstocks.kandang_id)"
case "kandang":
sortExpr = "(SELECT name FROM kandangs WHERE id = expense_nonstocks.kandang_id)"
case "qty_pengajuan":
sortExpr = "expense_nonstocks.qty"
case "price_pengajuan":
sortExpr = "expense_nonstocks.price"
case "total_pengajuan":
sortExpr = "expense_nonstocks.qty * expense_nonstocks.price"
case "qty_realisasi":
sortExpr = "expense_realizations.qty"
case "price_realisasi":
sortExpr = "expense_realizations.price"
case "total_realisasi":
sortExpr = "expense_realizations.qty * expense_realizations.price"
}
if err := db. if err := db.
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).
Order("expense_realizations.created_at DESC"). Order(sortExpr + " " + order).
Find(&realizations).Error; err != nil { Find(&realizations).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
+1
View File
@@ -36,6 +36,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService
route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization)
route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization)
route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense)
route.Patch("/:id/pay", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.Pay)
route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument)
route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument)
} }
@@ -36,6 +36,7 @@ type ExpenseService interface {
DeleteOne(ctx *fiber.Ctx, id uint64) error DeleteOne(ctx *fiber.Ctx, id uint64) error
CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
Pay(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
@@ -288,7 +289,40 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
like, like,
) )
} }
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "reference_number":
return db.Order("expenses.reference_number " + sortOrder)
case "transaction_date":
return db.Order("expenses.transaction_date " + sortOrder)
case "realization_date":
return db.Order("expenses.realization_date " + sortOrder)
case "location":
return db.Order("(SELECT COALESCE(name,'') FROM locations WHERE id = expenses.location_id) " + sortOrder)
case "created_user":
return db.Order("(SELECT COALESCE(name,'') FROM users WHERE id = expenses.created_by) " + sortOrder)
case "supplier":
return db.Order("(SELECT COALESCE(name,'') FROM suppliers WHERE id = expenses.supplier_id) " + sortOrder)
case "grand_total":
return db.Order(`(SELECT COALESCE(
(SELECT SUM(er.qty * er.price) FROM expense_realizations er
JOIN expense_nonstocks en ON en.id = er.expense_nonstock_id
WHERE en.expense_id = expenses.id),
(SELECT SUM(en2.qty * en2.price) FROM expense_nonstocks en2
WHERE en2.expense_id = expenses.id),
0)) ` + sortOrder)
case "is_paid":
return db.Order("expenses.is_paid " + sortOrder)
case "created_at":
return db.Order("expenses.created_at " + sortOrder)
default:
return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC") return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
}
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -342,6 +376,18 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail
expense.LatestApproval = approval expense.LatestApproval = approval
responseDTO := expenseDto.ToExpenseDetailDTO(expense) responseDTO := expenseDto.ToExpenseDetailDTO(expense)
for i := range responseDTO.Documents {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.Documents[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.Documents[i].Path = url
}
}
for i := range responseDTO.RealizationDocs {
if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.RealizationDocs[i].Path, 15*time.Minute); err == nil && url != "" {
responseDTO.RealizationDocs[i].Path = url
}
}
return &responseDTO, nil return &responseDTO, nil
} }
@@ -1298,6 +1344,41 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
return responseDTO, nil return responseDTO, nil
} }
func (s *expenseService) Pay(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
); err != nil {
return nil, err
}
expense, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
}
if expense.IsPaid {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense is already paid")
}
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB {
return db.Where("action = ?", entity.ApprovalActionApproved)
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
}
if latestApproval == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if latestApproval.StepNumber < uint16(utils.ExpenseStepFinance) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment")
}
if err := s.Repository.PatchOne(c.Context(), id, map[string]any{"is_paid": true}, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update payment status")
}
return s.GetOne(c, id)
}
func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
@@ -54,6 +54,8 @@ type Query struct {
RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"` RealizationStatus string `query:"realization_status" validate:"omitempty,max=100"`
ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockID uint64 `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint64 `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=reference_number transaction_date realization_date location created_user supplier grand_total is_paid created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
} }
type CreateRealization struct { type CreateRealization struct {
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
@@ -13,6 +14,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const transactionExcelExportFetchLimit = 99999999
type TransactionController struct { type TransactionController struct {
TransactionService service.TransactionService TransactionService service.TransactionService
} }
@@ -97,6 +100,8 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
CustomerIDs: customerIDs, CustomerIDs: customerIDs,
SupplierIDs: supplierIDs, SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""), SortDate: c.Query("sort_date", ""),
SortBy: c.Query("sort_by", ""),
SortOrder: c.Query("sort_order", ""),
StartDate: c.Query("start_date", ""), StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""), EndDate: c.Query("end_date", ""),
} }
@@ -105,6 +110,14 @@ func (u *TransactionController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
if isTransactionExcelExportRequest(c) {
results, err := u.getAllTransactionsForExcel(c, query)
if err != nil {
return err
}
return exportTransactionListExcel(c, results)
}
result, totalResults, err := u.TransactionService.GetAll(c, query) result, totalResults, err := u.TransactionService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -147,6 +160,32 @@ func (u *TransactionController) GetOne(c *fiber.Ctx) error {
}) })
} }
func isTransactionExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func (u *TransactionController) getAllTransactionsForExcel(c *fiber.Ctx, baseQuery *validation.Query) ([]entity.Payment, error) {
query := *baseQuery
query.Page = 1
query.Limit = transactionExcelExportFetchLimit
results := make([]entity.Payment, 0)
for {
pageResults, total, err := u.TransactionService.GetAll(c, &query)
if err != nil {
return nil, err
}
if len(pageResults) == 0 || total == 0 {
break
}
results = append(results, pageResults...)
if int64(len(results)) >= total {
break
}
query.Page++
}
return results, nil
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -0,0 +1,307 @@
package controller
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
const transactionExportSheetName = "Transaksi"
func exportTransactionListExcel(c *fiber.Ctx, payments []entity.Payment) error {
content, err := buildTransactionExportWorkbook(payments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("transaksi_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildTransactionExportWorkbook(payments []entity.Payment) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != transactionExportSheetName {
if err := file.SetSheetName(defaultSheet, transactionExportSheetName); err != nil {
return nil, err
}
}
if err := setTransactionExportColumns(file); err != nil {
return nil, err
}
if err := setTransactionExportHeaders(file); err != nil {
return nil, err
}
if err := setTransactionExportRows(file, payments); err != nil {
return nil, err
}
if err := file.SetPanes(transactionExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func setTransactionExportColumns(file *excelize.File) error {
columnWidths := map[string]float64{
"A": 20,
"B": 22,
"C": 18,
"D": 25,
"E": 14,
"F": 16,
"G": 16,
"H": 22,
"I": 22,
"J": 18,
"K": 18,
"L": 18,
"M": 30,
"N": 22,
"O": 20,
}
sheet := transactionExportSheetName
for col, width := range columnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setTransactionExportHeaders(file *excelize.File) error {
sheet := transactionExportSheetName
headers := []string{
"Kode Pembayaran",
"No. Referensi",
"Tipe Transaksi",
"Pihak",
"Tipe Pihak",
"Tanggal Bayar",
"Metode Bayar",
"Bank",
"No. Rekening Bank",
"Pemasukan",
"Pengeluaran",
"Nominal",
"Catatan",
"Dibuat Oleh",
"Status",
}
for i, header := range headers {
colName, err := excelize.ColumnNumberToName(i + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+"1", header); err != nil {
return err
}
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "A1", "O1", headerStyle)
}
func setTransactionExportRows(file *excelize.File, payments []entity.Payment) error {
if len(payments) == 0 {
return nil
}
sheet := transactionExportSheetName
for i, p := range payments {
row := strconv.Itoa(i + 2)
if err := writeTransactionExportRow(file, sheet, row, p); err != nil {
return err
}
}
lastRow := strconv.Itoa(len(payments) + 1)
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "O"+lastRow, dataStyle); err != nil {
return err
}
numericStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "J2", "L"+lastRow, numericStyle)
}
func writeTransactionExportRow(file *excelize.File, sheet, row string, p entity.Payment) error {
incomeAmount, expenseAmount := txAmounts(p.Direction, p.Nominal)
values := []interface{}{
safeTxText(p.PaymentCode),
safeTxRefNumber(p.ReferenceNumber),
safeTxText(txTransactionType(p)),
safeTxText(txPartyName(p)),
safeTxText(p.PartyType),
formatTxDate(p.PaymentDate),
safeTxText(p.PaymentMethod),
safeTxBank(p),
safeTxBankAccount(p),
incomeAmount,
expenseAmount,
p.Nominal,
safeTxText(p.Notes),
safeTxText(txCreatedBy(p)),
formatTxStatus(p),
}
for colIdx, val := range values {
colName, err := excelize.ColumnNumberToName(colIdx + 1)
if err != nil {
return err
}
if err := file.SetCellValue(sheet, colName+row, val); err != nil {
return err
}
}
return nil
}
func safeTxText(s string) string {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return "-"
}
return trimmed
}
func safeTxRefNumber(s *string) string {
if s == nil {
return "-"
}
return safeTxText(*s)
}
func safeTxBank(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.Name)
}
func safeTxBankAccount(p entity.Payment) string {
if p.BankWarehouse.Id == 0 {
return "-"
}
return safeTxText(p.BankWarehouse.AccountNumber)
}
func formatTxDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return t.Format("02-01-2006")
}
func formatTxStatus(p entity.Payment) string {
if p.LatestApproval == nil {
return "-"
}
return safeTxText(p.LatestApproval.StepName)
}
func txTransactionType(p entity.Payment) string {
if p.TransactionType != "" {
return p.TransactionType
}
return p.Direction
}
func txPartyName(p entity.Payment) string {
switch p.PartyType {
case "CUSTOMER":
if p.Customer != nil && p.Customer.Id != 0 {
return p.Customer.Name
}
case "SUPPLIER":
if p.Supplier != nil && p.Supplier.Id != 0 {
return p.Supplier.Name
}
}
return ""
}
func txCreatedBy(p entity.Payment) string {
if p.CreatedUser.Id == 0 {
return ""
}
return p.CreatedUser.Name
}
func txAmounts(direction string, nominal float64) (income, expense float64) {
switch strings.ToUpper(direction) {
case "IN":
return nominal, 0
case "OUT":
return 0, nominal
default:
return 0, 0
}
}
@@ -72,27 +72,36 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { needsPartyJoin := params.Search != "" || params.SortBy == "customer_name"
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" needsBankJoin := params.Search != "" || params.SortBy == "bank"
if needsPartyJoin {
db = db.Joins( db = db.Joins(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL", "LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer), string(utils.PaymentPartyCustomer),
).Joins( ).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL", "LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier), string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
) )
}
if needsBankJoin {
db = db.Joins("LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL")
}
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where( db = db.Where(
`LOWER(payment_code) LIKE ? OR `(LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ? OR LOWER(COALESCE(notes, '')) LIKE ? OR
LOWER(COALESCE(customers.name, '')) LIKE ? OR LOWER(COALESCE(customers.name, '')) LIKE ? OR
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ?`, LOWER(COALESCE(banks.name, '')) LIKE ? OR
like, like, like, like, like, like, like, like, CAST(payments.nominal AS TEXT) LIKE ? OR
TO_CHAR(payments.payment_date, 'YYYY-MM-DD') LIKE ?)`,
like, like, like, like, like, like, like, like, like, like,
) )
} }
@@ -136,7 +145,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
db = db.Where("payment_date < ?", *endDate) db = db.Where("payment_date < ?", *endDate)
} }
return applyTransactionSort(db, params.SortDate) return applyTransactionSort(db, params.SortBy, params.SortOrder, params.SortDate)
}) })
if err != nil { if err != nil {
@@ -268,13 +277,39 @@ func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Tim
return startPtr, endPtr, nil return startPtr, endPtr, nil
} }
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB { func applyTransactionSort(db *gorm.DB, sortBy, sortOrder, sortDate string) *gorm.DB {
order := "DESC"
if strings.ToUpper(strings.TrimSpace(sortOrder)) == "ASC" {
order = "ASC"
}
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "payment_code":
return db.Order("payments.payment_code " + order)
case "reference_number":
return db.Order("payments.reference_number " + order)
case "transaction_type":
return db.Order("payments.transaction_type " + order)
case "customer_name":
return db.Order("COALESCE(customers.name, suppliers.name) " + order)
case "payment_date":
return db.Order("payments.payment_date " + order)
case "created_at":
return db.Order("payments.created_at " + order)
case "payment_method":
return db.Order("payments.payment_method " + order)
case "bank":
return db.Order("banks.account_number " + order)
case "expense_amount":
return db.Order("CASE WHEN payments.direction = 'OUT' THEN payments.nominal ELSE 0 END " + order)
case "income_amount":
return db.Order("CASE WHEN payments.direction = 'IN' THEN payments.nominal ELSE 0 END " + order)
}
switch strings.ToLower(strings.TrimSpace(sortDate)) { switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at": case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC") return db.Order("payments.created_at DESC").Order("payments.payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
default: default:
return db.Order("payment_date DESC").Order("created_at DESC") return db.Order("payments.payment_date DESC").Order("payments.created_at DESC")
} }
} }
@@ -10,13 +10,15 @@ type Update struct {
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"` TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"` BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"` SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=payment_code reference_number transaction_type customer_name payment_date created_at payment_method bank expense_amount income_amount"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
} }
@@ -7,7 +7,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -166,42 +165,16 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
return db return db
} }
fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags) return db.
Where(`
db = db.
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id").
Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id")
actualFlagFilter := `
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM flags f_flag FROM flags f_flag
WHERE f_flag.flagable_id = p_flag.id WHERE f_flag.flagable_id = product_warehouses.product_id
AND f_flag.flagable_type = ? AND f_flag.flagable_type = ?
AND f_flag.name IN ? AND f_flag.name IN ?
) )
` `, entity.FlagableTypeProduct, flags).
if len(fallbackCategoryCodes) == 0 {
return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct()
}
return db.
Where(
`(`+actualFlagFilter+`) OR (
NOT EXISTS (
SELECT 1
FROM flags f_any
WHERE f_any.flagable_id = p_flag.id
AND f_any.flagable_type = ?
)
AND pc_flag.code IN ?
)`,
entity.FlagableTypeProduct,
flags,
entity.FlagableTypeProduct,
fallbackCategoryCodes,
).
Distinct() Distinct()
} }
@@ -117,7 +117,7 @@ func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) {
} }
} }
func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) { func TestApplyFlagsFilterOnlyIncludesFlaggedProducts(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t) db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db) repo := NewProductWarehouseRepository(db)
ctx := context.Background() ctx := context.Background()
@@ -131,12 +131,14 @@ func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 { // Only PW 1 (product 10, flagged PAKAN) should match.
t.Fatalf("expected flagged and legacy RAW rows to match, got %v", ids) // PW 2 (product 20, no flags, RAW category) must not appear — legacy fallback removed.
if len(ids) != 1 || ids[0] != 1 {
t.Fatalf("expected only flagged row to match, got %v", ids)
} }
} }
func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *testing.T) { func TestApplyFlagsFilterExcludesWrongFlaggedProducts(t *testing.T) {
db := setupProductWarehouseFlagFilterTestDB(t) db := setupProductWarehouseFlagFilterTestDB(t)
repo := NewProductWarehouseRepository(db) repo := NewProductWarehouseRepository(db)
ctx := context.Background() ctx := context.Background()
@@ -150,8 +152,9 @@ func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *t
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
// PW 3 belongs to an OVK-flagged product — must not appear when filtering for PAKAN.
if len(ids) != 0 { if len(ids) != 0 {
t.Fatalf("expected OVK-flagged product not to match PAKAN fallback, got %v", ids) t.Fatalf("expected OVK-flagged product not to match PAKAN filter, got %v", ids)
} }
} }
+2
View File
@@ -10,6 +10,7 @@ import (
adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments"
productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks" productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks"
productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses"
stockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs"
transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers"
// MODULE IMPORTS // MODULE IMPORTS
) )
@@ -23,6 +24,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida
adjustments.AdjustmentModule{}, adjustments.AdjustmentModule{},
transfers.TransferModule{}, transfers.TransferModule{},
productStocks.ProductStockModule{}, productStocks.ProductStockModule{},
stockLogs.StockLogModule{},
// MODULE REGISTRY // MODULE REGISTRY
} }
@@ -0,0 +1,66 @@
package controller
import (
"math"
"strings"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations"
stockLogDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/dto"
stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type StockLogController struct {
StockLogService stockLogService.StockLogService
}
func NewStockLogController(s stockLogService.StockLogService) *StockLogController {
return &StockLogController{
StockLogService: s,
}
}
func (u *StockLogController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
ProductWarehouseID: uint(c.QueryInt("product_warehouse_id", 0)),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
// Export to Excel
if strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") {
if query.ProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "product_warehouse_id is required for export")
}
results, err := u.StockLogService.GetAllForExport(c, query.ProductWarehouseID)
if err != nil {
return err
}
return exportStockLogListExcel(c, results)
}
result, totalResults, err := u.StockLogService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[stockLogDTO.StockLogListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all stock logs successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: stockLogDTO.ToStockLogListDTOs(result),
})
}
@@ -0,0 +1,118 @@
package controller
import (
"fmt"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
)
func exportStockLogListExcel(c *fiber.Ctx, stockLogs []entity.StockLog) error {
file := excelize.NewFile()
defer file.Close()
sheet := "Stock Logs"
file.SetSheetName("Sheet1", sheet)
headers := []string{
"ID",
"Tanggal",
"Gudang",
"Stok Akhir",
"Peningkatan",
"Penurunan",
"Jenis Transaksi",
"Catatan",
"Oleh",
}
// Column widths
colWidths := map[string]float64{
"A": 8,
"B": 20,
"C": 25,
"D": 14,
"E": 14,
"F": 14,
"G": 20,
"H": 30,
"I": 20,
}
for col, width := range colWidths {
file.SetColWidth(sheet, col, col, width)
}
// Header style
headerStyle, _ := file.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 11,
},
Fill: excelize.Fill{
Type: "pattern",
Pattern: 1,
Color: []string{"D9E1F2"},
},
Border: []excelize.Border{
{Type: "bottom", Style: 1, Color: "000000"},
},
})
// Write header row
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
file.SetCellValue(sheet, cell, h)
file.SetCellStyle(sheet, cell, cell, headerStyle)
}
// Freeze header row
file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
})
// Write data rows
for i, log := range stockLogs {
row := i + 2
warehouseName := ""
if log.ProductWarehouse != nil {
warehouseName = log.ProductWarehouse.Warehouse.Name
}
userName := ""
if log.CreatedUser != nil {
userName = log.CreatedUser.Name
}
notes := ""
if log.Notes != "" {
notes = log.Notes
}
file.SetCellInt(sheet, fmt.Sprintf("A%d", row), int(log.Id))
file.SetCellValue(sheet, fmt.Sprintf("B%d", row), log.CreatedAt.Format("2006-01-02 15:04:05"))
file.SetCellValue(sheet, fmt.Sprintf("C%d", row), warehouseName)
file.SetCellFloat(sheet, fmt.Sprintf("D%d", row), log.Stock, 3, 64)
file.SetCellFloat(sheet, fmt.Sprintf("E%d", row), log.Increase, 3, 64)
file.SetCellFloat(sheet, fmt.Sprintf("F%d", row), log.Decrease, 3, 64)
file.SetCellValue(sheet, fmt.Sprintf("G%d", row), log.LoggableType)
file.SetCellValue(sheet, fmt.Sprintf("H%d", row), notes)
file.SetCellValue(sheet, fmt.Sprintf("I%d", row), userName)
}
buffer, err := file.WriteToBuffer()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("stock_logs_%s.xlsx", time.Now().Format("20060102_150405"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(buffer.Bytes())
}
@@ -0,0 +1,61 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
type StockLogListDTO struct {
Id uint `json:"id"`
ProductWarehouseId uint `json:"product_warehouse_id"`
Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"`
CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func ToStockLogListDTO(e entity.StockLog) StockLogListDTO {
var notes *string
if e.Notes != "" {
n := e.Notes
notes = &n
}
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(*e.CreatedUser)
createdUser = &mapped
}
return StockLogListDTO{
Id: e.Id,
ProductWarehouseId: e.ProductWarehouseId,
Increase: e.Increase,
Decrease: e.Decrease,
Stock: e.Stock,
LoggableType: e.LoggableType,
LoggableId: e.LoggableId,
Notes: notes,
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
CreatedAt: e.CreatedAt,
}
}
func ToStockLogListDTOs(e []entity.StockLog) []StockLogListDTO {
if len(e) == 0 {
return []StockLogListDTO{}
}
result := make([]StockLogListDTO, len(e))
for i, log := range e {
result[i] = ToStockLogListDTO(log)
}
return result
}
@@ -0,0 +1,24 @@
package stockLogs
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type StockLogModule struct{}
func (StockLogModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
userRepo := rUser.NewUserRepository(db)
userService := sUser.NewUserService(userRepo, validate)
stockLogRepo := stockLogRepo.NewStockLogRepository(db)
service := stockLogService.NewStockLogService(stockLogRepo, validate)
StockLogRoutes(router, userService, service)
}
@@ -0,0 +1,19 @@
package stockLogs
import (
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/controllers"
stockLog "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func StockLogRoutes(v1 fiber.Router, u user.UserService, s stockLog.StockLogService) {
ctrl := controller.NewStockLogController(s)
route := v1.Group("/stock-logs")
route.Use(m.Auth(u))
route.Get("/", m.RequirePermissions(m.P_StockLogGetAll), ctrl.GetAll)
}
@@ -0,0 +1,125 @@
package service
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations"
stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type StockLogService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error)
GetAllForExport(ctx *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error)
}
type stockLogService struct {
Log *logrus.Logger
Validate *validator.Validate
StockLogRepo stockLogRepo.StockLogRepository
}
func NewStockLogService(
stockLogRepo stockLogRepo.StockLogRepository,
validate *validator.Validate,
) StockLogService {
return &stockLogService{
Log: utils.Log,
Validate: validate,
StockLogRepo: stockLogRepo,
}
}
func (s *stockLogService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
stockLogs, total, err := s.StockLogRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = db.Where("product_warehouse_id = ?", params.ProductWarehouseID)
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
}
db = db.
Preload("CreatedUser").
Order("stock_logs.created_at DESC")
return db
})
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return nil, 0, err
}
if total == 0 {
return []entity.StockLog{}, 0, nil
}
return stockLogs, total, nil
}
func (s *stockLogService) GetAllForExport(c *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error) {
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB())
if err != nil {
return nil, err
}
stockLogs, _, err := s.StockLogRepo.GetAll(c.Context(), 0, -1, func(db *gorm.DB) *gorm.DB {
db = db.Where("product_warehouse_id = ?", productWarehouseID)
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
}
db = db.
Preload("CreatedUser").
Preload("ProductWarehouse").
Preload("ProductWarehouse.Warehouse").
Order("stock_logs.created_at ASC")
return db
})
if err != nil {
s.Log.Errorf("Failed to get stock logs for export: %+v", err)
return nil, err
}
return stockLogs, nil
}
@@ -0,0 +1,7 @@
package validation
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
ProductWarehouseID uint `query:"product_warehouse_id" validate:"required,gt=0"`
}
@@ -56,6 +56,12 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids") return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
} }
sortBy := strings.TrimSpace(c.Query("sort_by", ""))
sortOrder := strings.TrimSpace(c.Query("sort_order", ""))
if sortOrder == "" {
sortOrder = "asc"
}
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
@@ -66,6 +72,8 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
SortBy: sortBy,
SortOrder: sortOrder,
} }
if isAllExcelExportRequest(c) { if isAllExcelExportRequest(c) {
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -153,7 +152,7 @@ func setMarketingExportRows(file *excelize.File, sheet string, items []dto.Marke
if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil { if err := file.SetCellValue(sheet, "D"+strconv.Itoa(rowNumber), safeMarketingExportText(item.Customer.Name)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), formatMarketingRupiah(sumMarketingGrandTotal(item.SalesOrder))); err != nil { if err := file.SetCellValue(sheet, "E"+strconv.Itoa(rowNumber), sumMarketingGrandTotal(item.SalesOrder)); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil { if err := file.SetCellValue(sheet, "F"+strconv.Itoa(rowNumber), formatMarketingProducts(item.SalesOrder)); err != nil {
@@ -266,40 +265,6 @@ func sumMarketingGrandTotal(items []dto.DeliveryMarketingProductDTO) float64 {
return total return total
} }
func formatMarketingRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
func safeMarketingExportText(value string) string { func safeMarketingExportText(value string) string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
@@ -292,7 +292,29 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
if params.MarketingId != 0 { if params.MarketingId != 0 {
return db.Where("id = ?", params.MarketingId) return db.Where("id = ?", params.MarketingId)
} }
orderDir := "DESC"
if params.SortOrder != "" {
orderDir = strings.ToUpper(params.SortOrder)
}
switch strings.TrimSpace(params.SortBy) {
case "so_number":
return db.Order("marketings.so_number " + orderDir)
case "so_date":
return db.Order("marketings.so_date " + orderDir)
case "status":
statusSQL := "(SELECT step_name FROM approvals WHERE approvable_type = '" + utils.ApprovalWorkflowMarketing.String() + "' AND approvable_id = marketings.id ORDER BY action_at DESC, id DESC LIMIT 1) " + orderDir
return db.Order(statusSQL)
case "customer":
return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir)
case "grand_total":
return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir)
case "created_at":
return db.Order("marketings.created_at " + orderDir)
default:
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -520,9 +542,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err return nil, err
} }
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, id, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status")
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction)
marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil)
@@ -608,6 +636,23 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
} }
} }
if latestApproval != nil && latestApproval.StepNumber == uint16(utils.MarketingDeliveryOrder) {
action := entity.ApprovalActionUpdated
_, err := approvalSvcTx.CreateApproval(
c.Context(),
utils.ApprovalWorkflowMarketing,
id,
utils.MarketingStepSalesOrder,
&action,
actorID,
nil)
if err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval to Sales Order")
}
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -516,7 +516,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
c.Context(), c.Context(),
utils.ApprovalWorkflowMarketing, utils.ApprovalWorkflowMarketing,
id, id,
approvalutils.ApprovalStep(latestApproval.StepNumber), utils.MarketingStepPengajuan,
&action, &action,
actorID, actorID,
nil) nil)
@@ -770,7 +770,12 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
totalWeight, totalPrice := s.calculatePriceByMarketingType( var totalWeight, totalPrice float64
if rp.TotalPrice != nil {
totalWeight = math.Round(rp.Qty*rp.AvgWeight*100) / 100
totalPrice = *rp.TotalPrice
} else {
totalWeight, totalPrice = s.calculatePriceByMarketingType(
marketingType, marketingType,
rp.Qty, rp.Qty,
rp.AvgWeight, rp.AvgWeight,
@@ -779,6 +784,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
rp.ConvertionUnit, rp.ConvertionUnit,
rp.WeightPerConvertion, rp.WeightPerConvertion,
) )
}
marketingProduct := &entity.MarketingProduct{ marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId, MarketingId: marketingId,
@@ -821,7 +827,7 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
totalPrice = math.Round(qty*unitPrice*100) / 100 totalPrice = math.Round(qty*unitPrice*100) / 100
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
totalWeight = math.Round(qty*avgWeight*100) / 100 totalWeight = math.Round(qty*avgWeight*100) / 100
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100 totalPrice = math.Round(totalWeight*unitPrice*100) / 100
} else { } else {
totalWeight = math.Round(qty*avgWeight*100) / 100 totalWeight = math.Round(qty*avgWeight*100) / 100
@@ -31,6 +31,8 @@ type DeliveryOrderQuery struct {
MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"`
ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"`
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total created_at"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
type DeliveryOrderApprove struct { type DeliveryOrderApprove struct {
@@ -26,6 +26,7 @@ type CreateMarketingProduct struct {
UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"`
Qty float64 `json:"qty" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"`
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"`
TotalPrice *float64 `json:"total_price" validate:"omitempty,gt=0"`
} }
type Update struct { type Update struct {
@@ -14,6 +14,7 @@ type CustomerRelationDTO struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
@@ -28,6 +29,7 @@ type CustomerListDTO struct {
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email"` Email string `json:"email"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
BankName string `json:"bank_name"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Pic userDTO.UserRelationDTO `json:"pic"` Pic userDTO.UserRelationDTO `json:"pic"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedUser userDTO.UserRelationDTO `json:"created_user"`
@@ -53,6 +55,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO {
Name: e.Name, Name: e.Name,
Type: e.Type, Type: e.Type,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Address: e.Address, Address: e.Address,
Balance: e.Balance, Balance: e.Balance,
Pic: pic, Pic: pic,
@@ -81,6 +84,7 @@ func ToCustomerListDTO(e entity.Customer) CustomerListDTO {
Phone: e.Phone, Phone: e.Phone,
Email: e.Email, Email: e.Email,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Pic: pic, Pic: pic,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
@@ -133,6 +133,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Phone: req.Phone, Phone: req.Phone,
Email: req.Email, Email: req.Email,
AccountNumber: req.AccountNumber, AccountNumber: req.AccountNumber,
BankName: req.BankName,
CreatedBy: actorID, CreatedBy: actorID,
} }
@@ -193,6 +194,10 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber updateBody["account_number"] = *req.AccountNumber
} }
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if len(updateBody) == 0 { if len(updateBody) == 0 {
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -8,6 +8,7 @@ type Create struct {
Phone string `json:"phone" validate:"required_strict,max=20"` Phone string `json:"phone" validate:"required_strict,max=20"`
Email string `json:"email" validate:"required_strict,email,max=50"` Email string `json:"email" validate:"required_strict,email,max=50"`
AccountNumber string `json:"account_number" validate:"required_strict,max=50"` AccountNumber string `json:"account_number" validate:"required_strict,max=50"`
BankName string `json:"bank_name" validate:"required_strict,max=100"`
} }
type Update struct { type Update struct {
@@ -18,6 +19,7 @@ type Update struct {
Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"`
Email *string `json:"email,omitempty" validate:"omitempty,max=50"` Email *string `json:"email,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
} }
type Query struct { type Query struct {
@@ -27,6 +27,8 @@ func (u *EmployeesController) GetAll(c *fiber.Ctx) error {
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "updated_at"),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"fmt"
"strings" "strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -126,11 +127,18 @@ func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if params.IsActive != nil { if params.IsActive != nil {
db = db.Where("employees.is_active = ?", *params.IsActive) db = db.Where("employees.is_active = ?", *params.IsActive)
} }
return db.
db = db.
Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). Select("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at").
Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at"). Group("employees.id, employees.name, employees.is_active, employees.created_at, employees.updated_at")
Order("employees.created_at DESC").
Order("employees.updated_at DESC") if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("employees.%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("employees.%s ASC", params.SortBy))
}
return db
}) })
if err != nil { if err != nil {
@@ -18,4 +18,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
KandangId *uint `query:"kandang_id" validate:"omitempty"` KandangId *uint `query:"kandang_id" validate:"omitempty"`
IsActive *bool `query:"is_active" validate:"omitempty"` IsActive *bool `query:"is_active" validate:"omitempty"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
} }
@@ -29,6 +29,8 @@ func (u *KandangGroupController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
LocationId: c.QueryInt("location_id", 0), LocationId: c.QueryInt("location_id", 0),
PicId: c.QueryInt("pic_id", 0), PicId: c.QueryInt("pic_id", 0),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "updated_at"),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -70,7 +70,14 @@ func (s kandangGroupService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
if params.PicId != 0 { if params.PicId != 0 {
db = db.Where("kandang_groups.pic_id = ?", params.PicId) db = db.Where("kandang_groups.pic_id = ?", params.PicId)
} }
return db.Order("kandang_groups.created_at DESC").Order("kandang_groups.updated_at DESC")
if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("kandang_groups.%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("kandang_groups.%s ASC", params.SortBy))
}
return db
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -20,4 +20,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
} }
@@ -29,6 +29,8 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
LocationId: c.QueryInt("location_id", 0), LocationId: c.QueryInt("location_id", 0),
PicId: c.QueryInt("pic_id", 0), PicId: c.QueryInt("pic_id", 0),
OrderBy: c.Query("order_by", "desc"),
SortBy: c.Query("sort_by", "created_at"),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -66,7 +66,14 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
if params.PicId != 0 { if params.PicId != 0 {
db = db.Where("pic_id = ?", params.PicId) db = db.Where("pic_id = ?", params.PicId)
} }
return db.Order("created_at DESC").Order("updated_at DESC")
if params.OrderBy == "desc" || params.OrderBy == "" {
db = db.Order(fmt.Sprintf("%s DESC", params.SortBy))
} else {
db = db.Order(fmt.Sprintf("%s ASC", params.SortBy))
}
return db
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -26,4 +26,6 @@ type Query struct {
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"` LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
PicId int `query:"pic_id" validate:"omitempty,number,gt=0"` PicId int `query:"pic_id" validate:"omitempty,number,gt=0"`
SortBy string `query:"sort_by" validate:"omitempty,max=50,oneof=name created_at updated_at" default:"updated_at"`
OrderBy string `query:"order_by" validate:"omitempty,oneof=asc desc" default:"desc"`
} }
@@ -26,6 +26,7 @@ type SupplierListDTO struct {
Address string `json:"address"` Address string `json:"address"`
Npwp *string `json:"npwp,omitempty"` Npwp *string `json:"npwp,omitempty"`
AccountNumber *string `json:"account_number,omitempty"` AccountNumber *string `json:"account_number,omitempty"`
BankName *string `json:"bank_name,omitempty"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
DueDate int `json:"due_date"` DueDate int `json:"due_date"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
@@ -66,6 +67,7 @@ func ToSupplierListDTO(e entity.Supplier) SupplierListDTO {
Address: e.Address, Address: e.Address,
Npwp: e.Npwp, Npwp: e.Npwp,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
BankName: e.BankName,
Balance: e.Balance, Balance: e.Balance,
DueDate: e.DueDate, DueDate: e.DueDate,
SupplierRelationDTO: ToSupplierRelationDTO(e), SupplierRelationDTO: ToSupplierRelationDTO(e),
@@ -160,6 +160,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
Address: req.Address, Address: req.Address,
Npwp: req.Npwp, Npwp: req.Npwp,
AccountNumber: req.AccountNumber, AccountNumber: req.AccountNumber,
BankName: req.BankName,
DueDate: req.DueDate, DueDate: req.DueDate,
CreatedBy: actorID, CreatedBy: actorID,
} }
@@ -243,6 +244,10 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["account_number"] = *req.AccountNumber updateBody["account_number"] = *req.AccountNumber
} }
if req.BankName != nil {
updateBody["bank_name"] = *req.BankName
}
if req.DueDate != nil { if req.DueDate != nil {
updateBody["due_date"] = *req.DueDate updateBody["due_date"] = *req.DueDate
} }
@@ -12,6 +12,7 @@ type Create struct {
Address string `json:"address" validate:"required_strict"` Address string `json:"address" validate:"required_strict"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate int `json:"due_date" validate:"required_strict,number,gt=0"` DueDate int `json:"due_date" validate:"required_strict,number,gt=0"`
} }
@@ -27,6 +28,7 @@ type Update struct {
Address *string `json:"address,omitempty" validate:"omitempty"` Address *string `json:"address,omitempty" validate:"omitempty"`
Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"`
AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"`
BankName *string `json:"bank_name,omitempty" validate:"omitempty,max=100"`
DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"` DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"`
} }
@@ -1,6 +1,7 @@
package controller package controller
import ( import (
"math"
"strconv" "strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto"
@@ -21,32 +22,32 @@ func NewChickinController(chickinService service.ChickinService) *ChickinControl
} }
} }
// func (u *ChickinController) GetAll(c *fiber.Ctx) error { func (u *ChickinController) GetAll(c *fiber.Ctx) error {
// query := &validation.Query{ query := &validation.Query{
// Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
// Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
// ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)),
// } }
// result, totalResults, err := u.ChickinService.GetAll(c, query) result, totalResults, err := u.ChickinService.GetAll(c, query)
// if err != nil { if err != nil {
// return err return err
// } }
// return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
// JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{ JSON(response.SuccessWithPaginate[dto.ChickinListDTO]{
// Code: fiber.StatusOK, Code: fiber.StatusOK,
// Status: "success", Status: "success",
// Message: "Get all chickins successfully", Message: "Get all chickins successfully",
// Meta: response.Meta{ Meta: response.Meta{
// Page: query.Page, Page: query.Page,
// Limit: query.Limit, Limit: query.Limit,
// TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
// TotalResults: totalResults, TotalResults: totalResults,
// }, },
// Data: dto.ToChickinListDTOs(result), Data: dto.ToChickinListDTOs(result),
// }) })
// } }
// func (u *ChickinController) GetOne(c *fiber.Ctx) error { // func (u *ChickinController) GetOne(c *fiber.Ctx) error {
// param := c.Params("id") // param := c.Params("id")
@@ -15,8 +15,8 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService
route := v1.Group("/chickins") route := v1.Group("/chickins")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
// route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_ChickinsGetAll), ctrl.GetAll)
route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne)
// route.Patch("/:id", ctrl.UpdateOne) // route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
@@ -77,6 +77,8 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
"Z": 22, "Z": 22,
"AA": 16, "AA": 16,
"AB": 18, "AB": 18,
"AC": 24,
"AD": 18,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -96,7 +98,7 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error {
} }
func setRecordingExportHeaders(file *excelize.File, sheet string) error { func setRecordingExportHeaders(file *excelize.File, sheet string) error {
verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"} verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB", "AC", "AD"}
for _, col := range verticalHeaderCols { for _, col := range verticalHeaderCols {
if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil {
return err return err
@@ -117,6 +119,8 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
"Z1": "Catatan Approval", "Z1": "Catatan Approval",
"AA1": "Dibuat Oleh", "AA1": "Dibuat Oleh",
"AB1": "Tanggal Submit", "AB1": "Tanggal Submit",
"AC1": "Nama Sapronak",
"AD1": "Jumlah Input Sapronak",
} }
for cell, value := range headerValues { for cell, value := range headerValues {
if err := file.SetCellValue(sheet, cell, value); err != nil { if err := file.SetCellValue(sheet, cell, value); err != nil {
@@ -230,7 +234,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "AB2", headerStyle) return file.SetCellStyle(sheet, "A1", "AD2", headerStyle)
} }
func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error {
@@ -241,11 +245,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
columns := []string{ columns := []string{
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
"AC", "AD",
} }
for i, item := range items { currentRow := 3
rowNumber := i + 3 type rowRange struct{ start, end int }
itemRanges := make([]rowRange, 0, len(items))
for i, item := range items {
fcrStd := 0.0 fcrStd := 0.0
if item.ProjectFlock.Fcr != nil { if item.ProjectFlock.Fcr != nil {
fcrStd = item.ProjectFlock.Fcr.FcrStd fcrStd = item.ProjectFlock.Fcr.FcrStd
@@ -283,46 +290,79 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
createdBy = safeExportText(item.Approval.ActionBy.Name) createdBy = safeExportText(item.Approval.ActionBy.Name)
} }
// Expand recordings into one row per sapronak
type sapronakRow struct {
name string
input string
}
sapronaks := make([]sapronakRow, 0)
if len(item.FeedUsage) > 0 {
for _, fu := range item.FeedUsage {
sapronaks = append(sapronaks, sapronakRow{
name: safeExportText(fu.ProductName),
input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true),
})
}
} else {
sapronaks = append(sapronaks, sapronakRow{name: "-", input: "-"})
}
groupStart := currentRow
for sIdx, s := range sapronaks {
if sIdx == 0 {
rowValues := []interface{}{ rowValues := []interface{}{
i + 1, i + 1, // A
locationName, locationName, // B
safeExportText(item.ProjectFlock.FlockName), safeExportText(item.ProjectFlock.FlockName), // C
kandangName, kandangName, // D
item.ProjectFlock.Period, item.ProjectFlock.Period, // E
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F
formatAgeLabel(item), formatAgeLabel(item), // G
formatDateIndonesian(item.RecordDatetime), formatDateIndonesian(item.RecordDatetime), // H
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I
formatNumberID(item.FcrValue, 2, true), formatNumberID(item.FcrValue, 2, true), // J
formatNumberID(fcrStd, 2, true), formatNumberID(fcrStd, 2, true), // K
formatNumberID(item.FeedIntake, 2, true), formatNumberID(item.FeedIntake, 2, true), // L
formatNumberID(feedIntakeStd, 2, true), formatNumberID(feedIntakeStd, 2, true), // M
formatPercentID(item.CumDepletionRate, 2), formatPercentID(item.CumDepletionRate, 2), // N
formatPercentID(maxDepletionStd, 2), formatPercentID(maxDepletionStd, 2), // O
formatNumberID(item.TotalDepletionQty, 2, true), formatNumberID(item.TotalDepletionQty, 2, true), // P
formatNumberID(item.EggMass, 2, true), formatNumberID(item.EggMass, 2, true), // Q
formatNumberID(eggMassStd, 2, true), formatNumberID(eggMassStd, 2, true), // R
formatNumberID(item.EggWeight, 2, true), formatNumberID(item.EggWeight, 2, true), // S
formatNumberID(eggWeightStd, 2, true), formatNumberID(eggWeightStd, 2, true), // T
formatPercentID(item.HenDay, 2), formatPercentID(item.HenDay, 2), // U
formatPercentID(henDayStd, 2), formatPercentID(henDayStd, 2), // V
formatPercentID(item.HenHouse, 2), formatPercentID(item.HenHouse, 2), // W
formatPercentID(henHouseStd, 2), formatPercentID(henHouseStd, 2), // X
formatApprovalStatus(item), formatApprovalStatus(item), // Y
safeExportText(pointerString(item.Approval.Notes)), safeExportText(pointerString(item.Approval.Notes)), // Z
createdBy, createdBy, // AA
formatDateIndonesian(item.CreatedAt), formatDateIndonesian(item.CreatedAt), // AB
s.name, // AC
s.input, // AD
} }
for idx, col := range columns { for idx, col := range columns {
cell := fmt.Sprintf("%s%d", col, rowNumber) cell := fmt.Sprintf("%s%d", col, currentRow)
if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil { if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil {
return err return err
} }
} }
} else {
file.SetCellValue(sheet, fmt.Sprintf("AC%d", currentRow), s.name)
file.SetCellValue(sheet, fmt.Sprintf("AD%d", currentRow), s.input)
} }
lastRow := len(items) + 2 currentRow++
}
itemRanges = append(itemRanges, rowRange{groupStart, currentRow - 1})
}
lastRow := currentRow - 1
dataCenterStyle, err := file.NewStyle(&excelize.Style{ dataCenterStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{
Horizontal: "center", Horizontal: "center",
@@ -339,7 +379,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil { if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil {
return err return err
} }
@@ -360,13 +400,62 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor
return err return err
} }
leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"} leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB", "AC"}
for _, col := range leftColumns { for _, col := range leftColumns {
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil { if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil {
return err return err
} }
} }
// Apply bottom border on the last sapronak row of each recording group
// Separate styles to preserve alignment (AC=left, AD=center) and thin borders
borderBottomLeftStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "999999", Style: 2},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
borderBottomCenterStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "E6E6E6", Style: 1},
{Type: "top", Color: "E6E6E6", Style: 1},
{Type: "bottom", Color: "999999", Style: 2},
{Type: "right", Color: "E6E6E6", Style: 1},
},
})
if err != nil {
return err
}
mergeCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
}
for _, rng := range itemRanges {
if rng.end > rng.start {
for _, col := range mergeCols {
file.MergeCell(sheet, fmt.Sprintf("%s%d", col, rng.start), fmt.Sprintf("%s%d", col, rng.end))
}
}
file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle)
file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle)
}
return nil return nil
} }
@@ -74,6 +74,8 @@ type RecordingRelationDTO struct {
ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` ProjectFlock RecordingProjectFlockDTO `json:"project_flock"`
RecordDatetime time.Time `json:"record_datetime"` RecordDatetime time.Time `json:"record_datetime"`
Day int `json:"day"` Day int `json:"day"`
Week int `json:"week"`
ExcessDays int `json:"excess_days"`
TotalDepletionQty float64 `json:"total_depletion_qty"` TotalDepletionQty float64 `json:"total_depletion_qty"`
TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"`
CumDepletionRate float64 `json:"cum_depletion_rate"` CumDepletionRate float64 `json:"cum_depletion_rate"`
@@ -92,6 +94,12 @@ type RecordingRelationDTO struct {
Approval approvalDTO.ApprovalRelationDTO `json:"approval"` Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
} }
type RecordingFeedUsageDTO struct {
ProductName string `json:"product_name"`
UsageAmount float64 `json:"usage_amount"`
PendingQty float64 `json:"pending_qty"`
}
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingRelationDTO RecordingRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -99,6 +107,7 @@ type RecordingListDTO struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Kandang *RecordingKandangDTO `json:"kandang,omitempty"` Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"` Location *RecordingLocationDTO `json:"location,omitempty"`
FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
@@ -192,6 +201,36 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
return result return result
} }
func ToRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO {
return toRecordingFeedUsageDTOs(stocks)
}
func toRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO {
result := make([]RecordingFeedUsageDTO, 0, len(stocks))
for _, s := range stocks {
productName := ""
if s.ProductWarehouse.Product.Id != 0 {
productName = s.ProductWarehouse.Product.Name
}
var usageAmount float64
if s.UsageQty != nil {
usageAmount = *s.UsageQty
}
var pendingQty float64
if s.PendingQty != nil {
pendingQty = *s.PendingQty
}
result = append(result, RecordingFeedUsageDTO{
ProductName: productName,
UsageAmount: usageAmount,
PendingQty: pendingQty,
})
}
return result
}
func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO {
result := make([]RecordingEggDTO, len(eggs)) result := make([]RecordingEggDTO, len(eggs))
for i, egg := range eggs { for i, egg := range eggs {
@@ -222,6 +261,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO {
CreatedUser: createdUser, CreatedUser: createdUser,
Kandang: recordingKandangDTO(e), Kandang: recordingKandangDTO(e),
Location: recordingKandangLocationDTO(e), Location: recordingKandangLocationDTO(e),
FeedUsage: toRecordingFeedUsageDTOs(e.Stocks),
} }
} }
@@ -232,11 +272,15 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
latestApproval = snapshot latestApproval = snapshot
} }
day := intValue(e.Day)
return RecordingRelationDTO{ return RecordingRelationDTO{
Id: e.Id, Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e), ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day), Day: day,
Week: day / 7,
ExcessDays: day % 7,
TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionQty: floatValue(e.TotalDepletionQty),
TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty),
CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2),
@@ -276,9 +320,13 @@ func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO {
result.Period = pfk.Period result.Period = pfk.Period
if pfk.ProjectFlock.ProductionStandard.Id != 0 { if pfk.ProjectFlock.ProductionStandard.Id != 0 {
week := recordingWeekValue(e)
if e.StandardWeek != nil && *e.StandardWeek > 0 {
week = *e.StandardWeek
}
result.ProductionStandart = &RecordingProductionStandardDTO{ result.ProductionStandart = &RecordingProductionStandardDTO{
Id: pfk.ProjectFlock.ProductionStandard.Id, Id: pfk.ProjectFlock.ProductionStandard.Id,
Week: recordingWeekValue(e), Week: week,
Name: pfk.ProjectFlock.ProductionStandard.Name, Name: pfk.ProjectFlock.ProductionStandard.Name,
HenDayStd: floatValue(e.StandardHenDay), HenDayStd: floatValue(e.StandardHenDay),
HenHouseStd: floatValue(e.StandardHenHouse), HenHouseStd: floatValue(e.StandardHenHouse),
@@ -49,6 +49,7 @@ type RecordingRepository interface {
DeleteEggs(tx *gorm.DB, recordingID uint) error DeleteEggs(tx *gorm.DB, recordingID uint) error
ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error)
UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error
UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
@@ -146,7 +147,10 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("Stocks").
Preload("Stocks.ProductWarehouse").
Preload("Stocks.ProductWarehouse.Product")
} }
func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB { func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB {
@@ -543,6 +547,12 @@ func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, tot
Update("total_qty", totalQty).Error Update("total_qty", totalQty).Error
} }
func (r *RecordingRepositoryImpl) UpdateEggWeight(tx *gorm.DB, eggID uint, weight *float64) error {
return tx.Model(&entity.RecordingEgg{}).
Where("id = ?", eggID).
Update("weight", weight).Error
}
func (r *RecordingRepositoryImpl) GetRecordingEggByID( func (r *RecordingRepositoryImpl) GetRecordingEggByID(
ctx context.Context, ctx context.Context,
id uint, id uint,
@@ -173,6 +173,37 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
// Pre-fetch transfer maps by category to avoid N+1 per-recording queries.
growingPFKIDs := make([]uint, 0, len(pfkIDs))
layingPFKIDs := make([]uint, 0, len(pfkIDs))
seenCat := make(map[uint]bool, len(pfkIDs))
for i := range recordings {
pfkID := recordings[i].ProjectFlockKandangId
if pfkID == 0 || seenCat[pfkID] {
continue
}
seenCat[pfkID] = true
cat := ""
if recordings[i].ProjectFlockKandang != nil && recordings[i].ProjectFlockKandang.ProjectFlock.Id != 0 {
cat = strings.ToUpper(strings.TrimSpace(recordings[i].ProjectFlockKandang.ProjectFlock.Category))
}
switch cat {
case string(utils.ProjectFlockCategoryGrowing):
growingPFKIDs = append(growingPFKIDs, pfkID)
case string(utils.ProjectFlockCategoryLaying):
layingPFKIDs = append(layingPFKIDs, pfkID)
}
}
sourceTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandangs(c.Context(), growingPFKIDs)
if err != nil {
return nil, 0, err
}
targetTransferByPFK, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandangs(c.Context(), layingPFKIDs)
if err != nil {
return nil, 0, err
}
hasTargetRecordingCache := make(map[uint]bool)
cutOverChickinAvailability := make(map[uint]bool) cutOverChickinAvailability := make(map[uint]bool)
for i := range recordings { for i := range recordings {
if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() { if recordings[i].ProjectFlockKandangId != 0 && !recordings[i].RecordDatetime.IsZero() {
@@ -192,7 +223,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
rate := recordingutil.ComputeDepletionRate(prev, current, totalChick) rate := recordingutil.ComputeDepletionRate(prev, current, totalChick)
recordings[i].DepletionRate = &rate recordings[i].DepletionRate = &rate
populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationState(c.Context(), &recordings[i]) populationCanChange, transferExecuted, isTransition, isLaying, _, _, stateErr := s.evaluatePopulationMutationStateFromCaches(c.Context(), &recordings[i], sourceTransferByPFK, targetTransferByPFK, hasTargetRecordingCache)
if stateErr != nil { if stateErr != nil {
return nil, 0, stateErr return nil, 0, stateErr
} }
@@ -768,6 +799,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals) match := recordingutil.EggTotalsEqual(existingTotals, incomingTotals)
if match { if match {
hasEggChanges = false hasEggChanges = false
} else if recordingutil.EggQtyByWarehouseEqual(existingTotals, incomingTotals) {
// Weight-only change: update weight fields directly without touching FIFO
if err := s.updateEggWeightsOnly(tx, existingEggs, req.Eggs); err != nil {
return err
}
// hasEggChanges stays true so metrics are recomputed
} else { } else {
category := "" category := ""
if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 {
@@ -785,7 +822,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil { if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
return err return err
} }
if err := ensureRecordingEggsUnused(existingEggs); err != nil { if err := ensureRecordingEggQtyChangeSafe(existingEggs, req.Eggs); err != nil {
return err return err
} }
if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil {
@@ -1308,6 +1345,82 @@ func (s *recordingService) evaluatePopulationMutationState(ctx context.Context,
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
} }
// evaluatePopulationMutationStateFromCaches is identical to evaluatePopulationMutationState
// but uses pre-fetched transfer maps to avoid N+1 queries in list endpoints.
func (s *recordingService) evaluatePopulationMutationStateFromCaches(
ctx context.Context,
recording *entity.Recording,
sourceTransferByPFK map[uint]*entity.LayingTransfer,
targetTransferByPFK map[uint]*entity.LayingTransfer,
hasTargetRecordingCache map[uint]bool,
) (bool, bool, bool, bool, *entity.LayingTransfer, time.Time, error) {
if recording == nil || recording.ProjectFlockKandangId == 0 || s.TransferLayingRepo == nil {
return true, false, false, false, nil, time.Time{}, nil
}
category, err := s.resolveRecordingCategory(ctx, recording)
if err != nil {
s.Log.Errorf("Failed to resolve recording category for population mutation check (recording=%d): %+v", recording.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi perubahan populasi recording")
}
var transfer *entity.LayingTransfer
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer = sourceTransferByPFK[recording.ProjectFlockKandangId]
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer = targetTransferByPFK[recording.ProjectFlockKandangId]
default:
return true, false, false, false, nil, time.Time{}, nil
}
if transfer == nil {
return true, false, false, false, nil, time.Time{}, nil
}
transferDate := transferPhysicalMoveDate(transfer)
if transferDate.IsZero() {
return true, false, false, false, transfer, transferDate, nil
}
transferExecuted := transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero()
recordDate := normalizeDateOnlyUTC(recording.RecordDatetime)
_, economicCutoffDate := transferRecordingWindow(transfer)
isTransition := !recordDate.Before(transferDate) && recordDate.Before(economicCutoffDate)
isLaying := !recordDate.Before(economicCutoffDate)
populationCanChange := true
if category == strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)) {
populationCanChange = !(transferExecuted && !recordDate.Before(transferDate))
if transferExecuted && !recordDate.Before(transferDate) {
var hasTargetLayingRecording bool
if cached, ok := hasTargetRecordingCache[transfer.Id]; ok {
hasTargetLayingRecording = cached
} else {
hasTargetLayingRecording, err = s.hasAnyRecordingOnTransferTargets(ctx, transfer)
if err != nil {
s.Log.Errorf("Failed to resolve target laying recording state for transfer %d: %+v", transfer.Id, err)
return true, false, false, false, nil, time.Time{}, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi status transisi recording")
}
hasTargetRecordingCache[transfer.Id] = hasTargetLayingRecording
}
if hasTargetLayingRecording {
isTransition = false
isLaying = true
} else {
today := normalizeDateOnlyUTC(time.Now().UTC())
if !today.Before(economicCutoffDate) {
isTransition = true
isLaying = false
}
}
}
}
return populationCanChange, transferExecuted, isTransition, isLaying, transfer, transferDate, nil
}
func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) { func (s *recordingService) hasAnyRecordingOnTransferTargets(ctx context.Context, transfer *entity.LayingTransfer) (bool, error) {
if transfer == nil || transfer.Id == 0 { if transfer == nil || transfer.Id == 0 {
return false, nil return false, nil
@@ -2321,21 +2434,24 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
} }
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) // If this PFK is a laying transfer target, use source growing PFK's chick_in_date
if err != nil { sourcePFKIDs := s.getLayingTransferSourcePFKIDs(ctx, projectFlockKandangID)
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
var chickinDate time.Time var chickinDate time.Time
for _, pop := range populations { if len(sourcePFKIDs) > 0 {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { for _, pfkID := range sourcePFKIDs {
continue cd := s.getEarliestChickInDate(ctx, pfkID)
} if !cd.IsZero() && (chickinDate.IsZero() || cd.Before(chickinDate)) {
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) { chickinDate = cd
chickinDate = pop.ProjectChickin.ChickInDate
} }
} }
}
// Fallback: use current PFK's own chick_in_date (cut-over or non-laying)
if chickinDate.IsZero() {
chickinDate = s.getEarliestChickInDate(ctx, projectFlockKandangID)
}
if chickinDate.IsZero() { if chickinDate.IsZero() {
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan") return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
} }
@@ -2347,7 +2463,56 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in") return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
} }
return diff + 1, nil return diff, nil
}
func (s *recordingService) getLayingTransferSourcePFKIDs(ctx context.Context, targetPFKID uint) []uint {
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, targetPFKID)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to check laying transfer for pfk=%d: %+v", targetPFKID, err)
}
return nil
}
if transfer == nil {
return nil
}
ids := make([]uint, 0)
if transfer.SourceProjectFlockKandangId != nil {
ids = append(ids, *transfer.SourceProjectFlockKandangId)
}
// Check multi-source transfers
var sources []entity.LayingTransferSource
if err := s.Repository.DB().WithContext(ctx).
Where("laying_transfer_id = ?", transfer.Id).
Find(&sources).Error; err == nil {
for _, src := range sources {
ids = append(ids, src.SourceProjectFlockKandangId)
}
}
return ids
}
func (s *recordingService) getEarliestChickInDate(ctx context.Context, pfkID uint) time.Time {
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, pfkID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for pfk=%d: %+v", pfkID, err)
return time.Time{}
}
var chickinDate time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) {
chickinDate = pop.ProjectChickin.ChickInDate
}
}
return chickinDate
} }
func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error {
@@ -2508,8 +2673,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
if isGrowing { if isGrowing {
week := 0 week := 0
if recording.Day != nil && *recording.Day > 0 { if recording.Day != nil && *recording.Day >= 0 {
week = (*recording.Day-1)/7 + 1 week = *recording.Day/7 + 1
} }
if week > 0 && s.Repository != nil { if week > 0 && s.Repository != nil {
meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week) meanBw, ok, err := s.Repository.GetUniformityMeanBwByWeek(tx, recording.ProjectFlockKandangId, week)
@@ -3008,6 +3173,12 @@ func (s *recordingService) reflowSyncRecordingStocks(
existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock)
} }
shouldWriteLog := shouldWriteRecordingStockLog(note, actorID)
if shouldWriteLog && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
resetLogState := newRecordingStockLogState()
stocksToApply := make([]entity.RecordingStock, 0, len(incoming)) stocksToApply := make([]entity.RecordingStock, 0, len(incoming))
for _, item := range incoming { for _, item := range incoming {
list := existingByWarehouse[item.ProductWarehouseId] list := existingByWarehouse[item.ProductWarehouseId]
@@ -3015,6 +3186,25 @@ func (s *recordingService) reflowSyncRecordingStocks(
if len(list) > 0 { if len(list) > 0 {
stock = list[0] stock = list[0]
existingByWarehouse[item.ProductWarehouseId] = list[1:] existingByWarehouse[item.ProductWarehouseId] = list[1:]
// Write reset (increase) stock_log for the OLD consumption BEFORE overwriting UsageQty.
// FIFO internally does Rollback+Reallocate inside reflowApplyRecordingStocks, but the
// corresponding +increase stock_log for the rollback step was previously missing, causing
// stock_log.stock to drift below the true FIFO qty on every in-place edit.
rollbackQty := recordingStockRollbackQty(stock)
if rollbackQty > 1e-6 && shouldWriteLog {
resetLog := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: rollbackQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.appendRecordingStockLog(ctx, tx, resetLogState, resetLog); err != nil {
return err
}
}
} else { } else {
zero := 0.0 zero := 0.0
stock = entity.RecordingStock{ stock = entity.RecordingStock{
@@ -3672,6 +3862,44 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
return nil return nil
} }
func ensureRecordingEggQtyChangeSafe(existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
usedByWarehouse := make(map[uint]float64)
for _, egg := range existingEggs {
usedByWarehouse[egg.ProductWarehouseId] += egg.TotalUsed
}
newQtyByWarehouse := make(map[uint]int)
for _, egg := range reqEggs {
newQtyByWarehouse[egg.ProductWarehouseId] += egg.Qty
}
for warehouseID, used := range usedByWarehouse {
if used <= 0 {
continue
}
if float64(newQtyByWarehouse[warehouseID]) < used {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Jumlah telur tidak dapat dikurangi di bawah jumlah yang sudah terjual (%.0f butir)", used))
}
}
return nil
}
func (s *recordingService) updateEggWeightsOnly(tx *gorm.DB, existingEggs []entity.RecordingEgg, reqEggs []validation.Egg) error {
weightByWarehouse := make(map[uint]*float64)
for i := range reqEggs {
weightByWarehouse[reqEggs[i].ProductWarehouseId] = reqEggs[i].Weight
}
for _, egg := range existingEggs {
newWeight, ok := weightByWarehouse[egg.ProductWarehouseId]
if !ok {
continue
}
if err := s.Repository.UpdateEggWeight(tx, egg.Id, newWeight); err != nil {
return err
}
}
return nil
}
func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error {
if tx == nil || projectFlockKandangId == 0 || from.IsZero() { if tx == nil || projectFlockKandangId == 0 || from.IsZero() {
return nil return nil
@@ -17,6 +17,8 @@ type TransferLayingRepository interface {
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error)
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -242,3 +244,121 @@ func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx cont
} }
return &transfer, nil return &transfer, nil
} }
type pfkTransferIDRow struct {
SourcePFKID uint `gorm:"column:source_pfk_id"`
TransferID uint `gorm:"column:transfer_id"`
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
result := make(map[uint]*entity.LayingTransfer)
if len(pfkIDs) == 0 {
return result, nil
}
var rows []pfkTransferIDRow
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
FROM (
SELECT id AS transfer_id, source_project_flock_kandang_id AS source_pfk_id
FROM laying_transfers
WHERE source_project_flock_kandang_id IN ?
AND deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = id
ORDER BY a.id DESC LIMIT 1
) = ?
UNION ALL
SELECT lts.laying_transfer_id AS transfer_id, lts.source_project_flock_kandang_id AS source_pfk_id
FROM laying_transfer_sources lts
JOIN laying_transfers t ON t.id = lts.laying_transfer_id AND t.deleted_at IS NULL
WHERE lts.source_project_flock_kandang_id IN ?
AND lts.deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = t.id
ORDER BY a.id DESC LIMIT 1
) = ?
) combined
ORDER BY source_pfk_id, transfer_id DESC
`,
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return result, nil
}
transferIDs := make([]uint, 0, len(rows))
pfkByTransfer := make(map[uint]uint, len(rows))
for _, row := range rows {
transferIDs = append(transferIDs, row.TransferID)
pfkByTransfer[row.TransferID] = row.SourcePFKID
}
var transfers []entity.LayingTransfer
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
return nil, err
}
for i := range transfers {
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
result[pfkID] = &transfers[i]
}
}
return result, nil
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandangs(ctx context.Context, pfkIDs []uint) (map[uint]*entity.LayingTransfer, error) {
result := make(map[uint]*entity.LayingTransfer)
if len(pfkIDs) == 0 {
return result, nil
}
var rows []pfkTransferIDRow
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT ON (source_pfk_id) source_pfk_id, transfer_id
FROM (
SELECT ltt.laying_transfer_id AS transfer_id, ltt.target_project_flock_kandang_id AS source_pfk_id
FROM laying_transfer_targets ltt
JOIN laying_transfers t ON t.id = ltt.laying_transfer_id AND t.deleted_at IS NULL
WHERE ltt.target_project_flock_kandang_id IN ?
AND ltt.deleted_at IS NULL
AND (
SELECT a.action FROM approvals a
WHERE a.approvable_type = ? AND a.approvable_id = t.id
ORDER BY a.id DESC LIMIT 1
) = ?
) combined
ORDER BY source_pfk_id, transfer_id DESC
`,
pfkIDs, string(utils.ApprovalWorkflowTransferToLaying), string(entity.ApprovalActionApproved),
).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return result, nil
}
transferIDs := make([]uint, 0, len(rows))
pfkByTransfer := make(map[uint]uint, len(rows))
for _, row := range rows {
transferIDs = append(transferIDs, row.TransferID)
pfkByTransfer[row.TransferID] = row.SourcePFKID
}
var transfers []entity.LayingTransfer
if err := r.db.WithContext(ctx).Where("id IN ? AND deleted_at IS NULL", transferIDs).Find(&transfers).Error; err != nil {
return nil, err
}
for i := range transfers {
if pfkID := pfkByTransfer[transfers[i].Id]; pfkID != 0 {
result[pfkID] = &transfers[i]
}
}
return result, nil
}
@@ -91,17 +91,17 @@ func buildPurchaseQuery(c *fiber.Ctx) *validation.Query {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search")), Search: strings.TrimSpace(c.Query("search")),
ApprovalStatus: strings.TrimSpace(c.Query("approval_status")), ApprovalStatus: strings.TrimSpace(c.Query("approval_status")),
PoDate: strings.TrimSpace(c.Query("po_date")), StartDate: strings.TrimSpace(c.Query("start_date")),
PoDateFrom: strings.TrimSpace(c.Query("po_date_from")), EndDate: strings.TrimSpace(c.Query("end_date")),
PoDateTo: strings.TrimSpace(c.Query("po_date_to")), FilterBy: strings.TrimSpace(c.Query("filter_by")),
CreatedFrom: strings.TrimSpace(c.Query("created_from")),
CreatedTo: strings.TrimSpace(c.Query("created_to")),
SupplierID: uint(c.QueryInt("supplier_id", 0)), SupplierID: uint(c.QueryInt("supplier_id", 0)),
AreaID: uint(c.QueryInt("area_id", 0)), AreaID: uint(c.QueryInt("area_id", 0)),
LocationID: uint(c.QueryInt("location_id", 0)), LocationID: uint(c.QueryInt("location_id", 0)),
ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)),
ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)),
ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")), ProductCategoryID: strings.TrimSpace(c.Query("product_category_id")),
SortBy: strings.TrimSpace(c.Query("sort_by", "")),
SortOrder: strings.TrimSpace(c.Query("sort_order", "")),
} }
} }
@@ -2,14 +2,11 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
@@ -45,16 +42,13 @@ func buildPurchaseExportWorkbook(purchases []entity.Purchase) ([]byte, error) {
} }
} }
listItems := dto.ToPurchaseListDTOs(purchases)
grandTotals := buildPurchaseGrandTotalMap(purchases)
if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportColumns(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil { if err := setPurchaseExportHeaders(file, purchaseExportSheetName); err != nil {
return nil, err return nil, err
} }
if err := setPurchaseExportRows(file, purchaseExportSheetName, listItems, grandTotals); err != nil { if err := setPurchaseExportRows(file, purchaseExportSheetName, purchases); err != nil {
return nil, err return nil, err
} }
if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{ if err := file.SetPanes(purchaseExportSheetName, &excelize.Panes{
@@ -81,10 +75,19 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
"D": 14, "D": 14,
"E": 22, "E": 22,
"F": 22, "F": 22,
"G": 18, "G": 22,
"H": 18, "H": 32,
"I": 52, "I": 10,
"J": 24, "J": 12,
"K": 16,
"L": 16,
"M": 22,
"N": 12,
"O": 16,
"P": 16,
"Q": 18,
"R": 18,
"S": 24,
} }
for col, width := range columnWidths { for col, width := range columnWidths {
@@ -101,16 +104,25 @@ func setPurchaseExportColumns(file *excelize.File, sheet string) error {
func setPurchaseExportHeaders(file *excelize.File, sheet string) error { func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
headers := []string{ headers := []string{
"PR Number", "PR Number", // A
"PO Number", "PO Number", // B
"Tanggal PO", "Tanggal PO", // C
"Tanggal Terima", "Tanggal Terima", // D
"Supplier", "Supplier", // E
"Lokasi", "Lokasi", // F
"Status", "Gudang", // G
"Grand Total", "Product", // H
"Products", "Qty", // I
"Notes", "Satuan", // J
"Price", // K
"Total Produk", // L
"Vendor Ekspedisi",// M
"Qty Ekspedisi", // N
"Price Ekspedisi", // O
"Total Ekspedisi", // P
"Grand Total All", // Q
"Status", // R
"Notes", // S
} }
for i, header := range headers { for i, header := range headers {
@@ -138,49 +150,36 @@ func setPurchaseExportHeaders(file *excelize.File, sheet string) error {
return err return err
} }
return file.SetCellStyle(sheet, "A1", "J1", headerStyle) return file.SetCellStyle(sheet, "A1", "S1", headerStyle)
} }
func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.PurchaseListDTO, grandTotals map[uint]float64) error { func setPurchaseExportRows(file *excelize.File, sheet string, purchases []entity.Purchase) error {
if len(items) == 0 { if len(purchases) == 0 {
return nil return nil
} }
for i, item := range items { var sumL, sumP, sumQ float64
row := strconv.Itoa(i + 2)
if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(item.PrNumber)); err != nil { rowIdx := 2
for p := range purchases {
purchase := &purchases[p]
if len(purchase.Items) == 0 {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, nil, &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(item.PoNumber)); err != nil { rowIdx++
continue
}
for it := range purchase.Items {
if err := writePurchaseExportRow(file, sheet, rowIdx, purchase, &purchase.Items[it], &sumL, &sumP, &sumQ); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(item.PoDate)); err != nil { rowIdx++
return err
}
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, safePurchaseSupplierName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safePurchaseLocationName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, formatPurchaseExportStatus(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, formatPurchaseRupiah(grandTotals[item.Id])); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, formatPurchaseProducts(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseExportPointerText(item.Notes)); err != nil {
return err
} }
} }
lastRow := len(items) + 1 lastDataRow := rowIdx - 1
dataStyle, err := file.NewStyle(&excelize.Style{ dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{ Alignment: &excelize.Alignment{
Horizontal: "left", Horizontal: "left",
@@ -197,7 +196,7 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "A2", "J"+strconv.Itoa(lastRow), dataStyle); err != nil { if err := file.SetCellStyle(sheet, "A2", "S"+strconv.Itoa(lastDataRow), dataStyle); err != nil {
return err return err
} }
@@ -216,47 +215,224 @@ func setPurchaseExportRows(file *excelize.File, sheet string, items []dto.Purcha
if err != nil { if err != nil {
return err return err
} }
if err := file.SetCellStyle(sheet, "K2", "Q"+strconv.Itoa(lastDataRow), moneyStyle); err != nil {
return err
}
return file.SetCellStyle(sheet, "H2", "H"+strconv.Itoa(lastRow), moneyStyle) return addPurchaseExportSumRow(file, sheet, rowIdx, sumL, sumP, sumQ)
} }
func buildPurchaseGrandTotalMap(items []entity.Purchase) map[uint]float64 { func writePurchaseExportRow(file *excelize.File, sheet string, rowIdx int, purchase *entity.Purchase, item *entity.PurchaseItem, sumL, sumP, sumQ *float64) error {
result := make(map[uint]float64, len(items)) row := strconv.Itoa(rowIdx)
for i := range items {
total := 0.0 // Purchase-level columns (repeat for every item row of the same purchase)
for j := range items[i].Items { if err := file.SetCellValue(sheet, "A"+row, safePurchaseExportText(purchase.PrNumber)); err != nil {
total += items[i].Items[j].TotalPrice return err
} }
result[items[i].Id] = total if err := file.SetCellValue(sheet, "B"+row, safePurchaseExportPointerText(purchase.PoNumber)); err != nil {
return err
} }
return result if err := file.SetCellValue(sheet, "C"+row, formatPurchaseExportDate(purchase.PoDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "E"+row, safePurchaseExportEntitySupplierName(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+row, formatPurchaseExportEntityStatus(purchase)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "S"+row, safePurchaseExportPointerText(purchase.Notes)); err != nil {
return err
}
if item == nil {
for _, col := range []string{"D", "F", "G", "H", "J", "M"} {
if err := file.SetCellValue(sheet, col+row, "-"); err != nil {
return err
}
}
for _, col := range []string{"I", "K", "L", "N", "O", "P", "Q"} {
if err := file.SetCellValue(sheet, col+row, 0); err != nil {
return err
}
}
return nil
}
// Item-level columns
var expeditionQty, expeditionPrice, expeditionTotal float64
if item.ExpenseNonstock != nil {
expeditionQty = item.ExpenseNonstock.Qty
expeditionPrice = item.ExpenseNonstock.Price
expeditionTotal = expeditionQty * expeditionPrice
}
itemGrandTotal := item.TotalPrice + expeditionTotal
*sumL += item.TotalPrice
*sumP += expeditionTotal
*sumQ += itemGrandTotal
if err := file.SetCellValue(sheet, "D"+row, formatPurchaseExportDate(item.ReceivedDate)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "F"+row, safePurchaseItemLocationName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "G"+row, safePurchaseWarehouseName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "H"+row, safePurchaseItemProductName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "I"+row, item.TotalQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "J"+row, safePurchaseItemUomName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "K"+row, item.Price); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, item.TotalPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "M"+row, safePurchaseItemExpeditionVendorName(item)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+row, expeditionQty); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+row, expeditionPrice); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, expeditionTotal); err != nil {
return err
}
if err := file.SetCellValue(sheet, "Q"+row, itemGrandTotal); err != nil {
return err
}
return nil
} }
func safePurchaseSupplierName(item dto.PurchaseListDTO) string { func addPurchaseExportSumRow(file *excelize.File, sheet string, rowIdx int, sumL, sumP, sumQ float64) error {
if item.Supplier == nil { row := strconv.Itoa(rowIdx)
sumStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
sumMoneyStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FEF3C7"}},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 2},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+row, "S"+row, sumStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L"+row, "L"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "P"+row, "Q"+row, sumMoneyStyle); err != nil {
return err
}
if err := file.SetCellValue(sheet, "A"+row, "TOTAL"); err != nil {
return err
}
if err := file.SetCellValue(sheet, "L"+row, sumL); err != nil {
return err
}
if err := file.SetCellValue(sheet, "P"+row, sumP); err != nil {
return err
}
return file.SetCellValue(sheet, "Q"+row, sumQ)
}
func safePurchaseExportEntitySupplierName(purchase *entity.Purchase) string {
if purchase.Supplier.Id == 0 {
return "-" return "-"
} }
return safePurchaseExportText(item.Supplier.Name) return safePurchaseExportText(purchase.Supplier.Name)
} }
func safePurchaseLocationName(item dto.PurchaseListDTO) string { func safePurchaseWarehouseName(item *entity.PurchaseItem) string {
if item.Location == nil { if item.Warehouse == nil {
return "-" return "-"
} }
return safePurchaseExportText(item.Location.Name) return safePurchaseExportText(item.Warehouse.Name)
} }
func formatPurchaseExportStatus(item dto.PurchaseListDTO) string { func safePurchaseItemLocationName(item *entity.PurchaseItem) string {
if item.LatestApproval == nil { if item.Warehouse == nil || item.Warehouse.Location == nil {
return "-"
}
return safePurchaseExportText(item.Warehouse.Location.Name)
}
func safePurchaseItemProductName(item *entity.PurchaseItem) string {
if item.Product == nil {
return "-"
}
return safePurchaseExportText(item.Product.Name)
}
func safePurchaseItemUomName(item *entity.PurchaseItem) string {
if item.Product == nil || item.Product.Uom.Id == 0 {
return "-"
}
return safePurchaseExportText(item.Product.Uom.Name)
}
func safePurchaseItemExpeditionVendorName(item *entity.PurchaseItem) string {
if item.ExpenseNonstock == nil || item.ExpenseNonstock.Expense == nil {
return "-"
}
exp := item.ExpenseNonstock.Expense
if exp.Supplier == nil || exp.Supplier.Id == 0 {
return "-"
}
return safePurchaseExportText(exp.Supplier.Name)
}
func formatPurchaseExportEntityStatus(purchase *entity.Purchase) string {
if purchase.LatestApproval == nil {
return "-" return "-"
} }
if item.LatestApproval.Action != nil && if purchase.LatestApproval.Action != nil &&
strings.EqualFold(strings.TrimSpace(*item.LatestApproval.Action), string(entity.ApprovalActionRejected)) { strings.EqualFold(strings.TrimSpace(string(*purchase.LatestApproval.Action)), string(entity.ApprovalActionRejected)) {
return "Ditolak" return "Ditolak"
} }
return safePurchaseExportText(item.LatestApproval.StepName) return safePurchaseExportText(purchase.LatestApproval.StepName)
} }
func formatPurchaseExportDate(value *time.Time) string { func formatPurchaseExportDate(value *time.Time) string {
@@ -273,33 +449,6 @@ func formatPurchaseExportDate(value *time.Time) string {
return t.Format("02-01-2006") return t.Format("02-01-2006")
} }
func formatPurchaseProducts(item dto.PurchaseListDTO) string {
if len(item.Products) == 0 {
return "-"
}
seen := make(map[string]struct{})
names := make([]string, 0, len(item.Products))
for i := range item.Products {
name := strings.TrimSpace(item.Products[i].Name)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return "-"
}
sort.Strings(names)
return strings.Join(names, ", ")
}
func safePurchaseExportPointerText(value *string) string { func safePurchaseExportPointerText(value *string) string {
if value == nil { if value == nil {
return "-" return "-"
@@ -315,37 +464,3 @@ func safePurchaseExportText(value string) string {
return trimmed return trimmed
} }
func formatPurchaseRupiah(value float64) string {
if math.IsNaN(value) || math.IsInf(value, 0) {
return "Rp 0"
}
rounded := int64(math.Round(value))
sign := ""
if rounded < 0 {
sign = "-"
rounded = -rounded
}
raw := strconv.FormatInt(rounded, 10)
if raw == "" {
raw = "0"
}
var grouped strings.Builder
rem := len(raw) % 3
if rem > 0 {
grouped.WriteString(raw[:rem])
if len(raw) > rem {
grouped.WriteString(".")
}
}
for i := rem; i < len(raw); i += 3 {
grouped.WriteString(raw[i : i+3])
if i+3 < len(raw) {
grouped.WriteString(".")
}
}
return "Rp " + sign + grouped.String()
}
@@ -22,9 +22,8 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
nil, nil,
"catatan", "catatan",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(11, "Pakan Starter", 1000000, "Location A"), buildPurchaseItemForExportTest(11, "Pakan Starter", 500, 2, 1000000, "Location A", "kg"),
buildPurchaseItemForExportTest(12, "Vitamin A", 350000, "Location B"), buildPurchaseItemForExportTest(12, "Vitamin A", 350, 1, 350000, "Location B", "botol"),
buildPurchaseItemForExportTest(11, "Pakan Starter", 0, ""),
}, },
), ),
buildPurchaseForExportTest( buildPurchaseForExportTest(
@@ -37,7 +36,7 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
ptrApprovalAction(entity.ApprovalActionRejected), ptrApprovalAction(entity.ApprovalActionRejected),
"", "",
[]entity.PurchaseItem{ []entity.PurchaseItem{
buildPurchaseItemForExportTest(21, "Obat X", 75000, ""), buildPurchaseItemForExportTest(21, "Obat X", 75000, 1, 75000, "", ""),
}, },
), ),
}) })
@@ -51,16 +50,27 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
defer file.Close() defer file.Close()
// Verify all 19 headers
expectedHeaders := map[string]string{ expectedHeaders := map[string]string{
"A1": "PR Number", "A1": "PR Number",
"B1": "PO Number", "B1": "PO Number",
"C1": "Tanggal PO", "C1": "Tanggal PO",
"D1": "Supplier", "D1": "Tanggal Terima",
"E1": "Lokasi", "E1": "Supplier",
"F1": "Status", "F1": "Lokasi",
"G1": "Grand Total", "G1": "Gudang",
"H1": "Products", "H1": "Product",
"I1": "Notes", "I1": "Qty",
"J1": "Satuan",
"K1": "Price",
"L1": "Total Produk",
"M1": "Vendor Ekspedisi",
"N1": "Qty Ekspedisi",
"O1": "Price Ekspedisi",
"P1": "Total Ekspedisi",
"Q1": "Grand Total All",
"R1": "Status",
"S1": "Notes",
} }
for cell, expected := range expectedHeaders { for cell, expected := range expectedHeaders {
got, err := file.GetCellValue(purchaseExportSheetName, cell) got, err := file.GetCellValue(purchaseExportSheetName, cell)
@@ -72,24 +82,46 @@ func TestBuildPurchaseExportWorkbookHeadersAndRows(t *testing.T) {
} }
} }
// Row 2: Purchase 1, Item 1 (Pakan Starter)
assertPurchaseCellEquals(t, file, "A2", "PR-00011") assertPurchaseCellEquals(t, file, "A2", "PR-00011")
assertPurchaseCellEquals(t, file, "B2", "PO-00011") assertPurchaseCellEquals(t, file, "B2", "PO-00011")
assertPurchaseCellEquals(t, file, "C2", "22-04-2026") assertPurchaseCellEquals(t, file, "C2", "22-04-2026")
assertPurchaseCellEquals(t, file, "D2", "Supplier A") assertPurchaseCellEquals(t, file, "E2", "Supplier A")
assertPurchaseCellEquals(t, file, "E2", "Location A") assertPurchaseCellEquals(t, file, "F2", "Location A")
assertPurchaseCellEquals(t, file, "F2", "Manager Purchase") assertPurchaseCellEquals(t, file, "H2", "Pakan Starter")
assertPurchaseCellEquals(t, file, "G2", "Rp 1.350.000") assertPurchaseCellEquals(t, file, "J2", "kg")
assertPurchaseCellEquals(t, file, "H2", "Pakan Starter, Vitamin A") assertPurchaseCellEquals(t, file, "K2", "500")
assertPurchaseCellEquals(t, file, "I2", "catatan") assertPurchaseCellEquals(t, file, "L2", "1000000")
assertPurchaseCellEquals(t, file, "M2", "-")
assertPurchaseCellEquals(t, file, "P2", "0")
assertPurchaseCellEquals(t, file, "Q2", "1000000")
assertPurchaseCellEquals(t, file, "R2", "Manager Purchase")
assertPurchaseCellEquals(t, file, "S2", "catatan")
assertPurchaseCellEquals(t, file, "A3", "PR-00012") // Row 3: Purchase 1, Item 2 (Vitamin A)
assertPurchaseCellEquals(t, file, "B3", "-") assertPurchaseCellEquals(t, file, "A3", "PR-00011")
assertPurchaseCellEquals(t, file, "C3", "-") assertPurchaseCellEquals(t, file, "H3", "Vitamin A")
assertPurchaseCellEquals(t, file, "E3", "-") assertPurchaseCellEquals(t, file, "J3", "botol")
assertPurchaseCellEquals(t, file, "F3", "Ditolak") assertPurchaseCellEquals(t, file, "L3", "350000")
assertPurchaseCellEquals(t, file, "G3", "Rp 75.000") assertPurchaseCellEquals(t, file, "Q3", "350000")
assertPurchaseCellEquals(t, file, "H3", "Obat X")
assertPurchaseCellEquals(t, file, "I3", "-") // Row 4: Purchase 2, Item 1 (Obat X) — no location, rejected
assertPurchaseCellEquals(t, file, "A4", "PR-00012")
assertPurchaseCellEquals(t, file, "B4", "-")
assertPurchaseCellEquals(t, file, "C4", "-")
assertPurchaseCellEquals(t, file, "F4", "-")
assertPurchaseCellEquals(t, file, "H4", "Obat X")
assertPurchaseCellEquals(t, file, "J4", "-")
assertPurchaseCellEquals(t, file, "L4", "75000")
assertPurchaseCellEquals(t, file, "Q4", "75000")
assertPurchaseCellEquals(t, file, "R4", "Ditolak")
assertPurchaseCellEquals(t, file, "S4", "-")
// Row 5: SUM row — total produk=1425000, ekspedisi=0, grand total all=1425000
assertPurchaseCellEquals(t, file, "A5", "TOTAL")
assertPurchaseCellEquals(t, file, "L5", "1425000")
assertPurchaseCellEquals(t, file, "P5", "0")
assertPurchaseCellEquals(t, file, "Q5", "1425000")
} }
func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) { func assertPurchaseCellEquals(t *testing.T, file *excelize.File, cell, expected string) {
@@ -144,13 +176,20 @@ func buildPurchaseForExportTest(
} }
} }
func buildPurchaseItemForExportTest(productID uint, productName string, totalPrice float64, locationName string) entity.PurchaseItem { func buildPurchaseItemForExportTest(productID uint, productName string, price, totalQty, totalPrice float64, locationName, uomName string) entity.PurchaseItem {
uomID := uint(0)
if uomName != "" {
uomID = productID + 2000
}
item := entity.PurchaseItem{ item := entity.PurchaseItem{
ProductId: productID, ProductId: productID,
Price: price,
TotalQty: totalQty,
TotalPrice: totalPrice, TotalPrice: totalPrice,
Product: &entity.Product{ Product: &entity.Product{
Id: productID, Id: productID,
Name: productName, Name: productName,
Uom: entity.Uom{Id: uomID, Name: uomName},
}, },
} }
@@ -31,12 +31,16 @@ type PurchaseListDTO struct {
CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
RequesterName string `json:"requester_name"` RequesterName string `json:"requester_name"`
PoExpedition []PoExpeditionDTO `json:"po_expedition"` PoExpedition []PoExpeditionDTO `json:"po_expedition"`
Items []PurchaseItemDTO `json:"items"`
Products []productDTO.ProductRelationDTO `json:"products"` Products []productDTO.ProductRelationDTO `json:"products"`
Location *locationDTO.LocationRelationDTO `json:"location"` Location *locationDTO.LocationRelationDTO `json:"location"`
Area *areaDTO.AreaRelationDTO `json:"area"` Area *areaDTO.AreaRelationDTO `json:"area"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
ProductsTotal float64 `json:"products_total"`
ExpeditionTotal float64 `json:"expedition_total"`
GrandTotalAll float64 `json:"grand_total_all"`
} }
type PurchaseDetailDTO struct { type PurchaseDetailDTO struct {
@@ -68,6 +72,8 @@ type PurchaseItemDTO struct {
VehicleNumber *string `json:"vehicle_number"` VehicleNumber *string `json:"vehicle_number"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` TransportPerItem *float64 `json:"transport_per_item,omitempty"`
ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"`
ExpeditionQty float64 `json:"expedition_qty"`
ExpeditionTotal float64 `json:"expedition_total"`
HasChickin bool `json:"has_chickin"` HasChickin bool `json:"has_chickin"`
} }
@@ -126,6 +132,8 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO {
if item.ExpenseNonstock != nil { if item.ExpenseNonstock != nil {
priceCopy := item.ExpenseNonstock.Price priceCopy := item.ExpenseNonstock.Price
dto.TransportPerItem = &priceCopy dto.TransportPerItem = &priceCopy
dto.ExpeditionQty = item.ExpenseNonstock.Qty
dto.ExpeditionTotal = item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
if item.ExpenseNonstock.Expense != nil { if item.ExpenseNonstock.Expense != nil {
exp := item.ExpenseNonstock.Expense exp := item.ExpenseNonstock.Expense
@@ -176,11 +184,17 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
location *locationDTO.LocationRelationDTO location *locationDTO.LocationRelationDTO
area *areaDTO.AreaRelationDTO area *areaDTO.AreaRelationDTO
receivedDate *time.Time receivedDate *time.Time
productsTotal float64
expeditionTotal float64
) )
productMap := make(map[uint]productDTO.ProductRelationDTO) productMap := make(map[uint]productDTO.ProductRelationDTO)
expeditionRefSet := make(map[uint64]struct{}) expeditionRefSet := make(map[uint64]struct{})
for i := range p.Items { for i := range p.Items {
item := p.Items[i] item := p.Items[i]
productsTotal += item.TotalPrice
if item.ExpenseNonstock != nil {
expeditionTotal += item.ExpenseNonstock.Qty * item.ExpenseNonstock.Price
}
if item.Product != nil && item.Product.Id != 0 { if item.Product != nil && item.Product.Id != 0 {
if _, exists := productMap[item.Product.Id]; !exists { if _, exists := productMap[item.Product.Id]; !exists {
productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product) productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product)
@@ -227,12 +241,16 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO {
CreatedUser: createdUser, CreatedUser: createdUser,
RequesterName: requesterName, RequesterName: requesterName,
PoExpedition: poExpedition, PoExpedition: poExpedition,
Items: ToPurchaseItemDTOs(p.Items),
Products: products, Products: products,
Location: location, Location: location,
Area: area, Area: area,
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
LatestApproval: latestApproval, LatestApproval: latestApproval,
ProductsTotal: productsTotal,
ExpeditionTotal: expeditionTotal,
GrandTotalAll: productsTotal + expeditionTotal,
} }
} }
@@ -145,33 +145,16 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id") productCategoryIDs, err := parseUintCSVFilter(params.ProductCategoryID, "product_category_id")
if err != nil { if err != nil {
return nil, 0, utils.BadRequest(err.Error()) return nil, 0, utils.BadRequest(err.Error())
} }
var poDateStart *time.Time dateStart, dateEnd, err := parsePurchaseDateRangeForQuery(params.StartDate, params.EndDate, "date")
var poDateEnd *time.Time
if strings.TrimSpace(params.PoDate) != "" {
poDate, parseErr := utils.ParseDateString(strings.TrimSpace(params.PoDate))
if parseErr != nil {
return nil, 0, utils.BadRequest("po_date must use format YYYY-MM-DD")
}
poDateStart = &poDate
poDateEndValue := poDate.AddDate(0, 0, 1)
poDateEnd = &poDateEndValue
} else {
poDateStart, poDateEnd, err = parsePoDateRangeForQuery(params.PoDateFrom, params.PoDateTo)
if err != nil { if err != nil {
return nil, 0, utils.BadRequest(err.Error()) return nil, 0, utils.BadRequest(err.Error())
} }
} filterBy := strings.TrimSpace(params.FilterBy)
search := strings.ToLower(strings.TrimSpace(params.Search)) search := strings.ToLower(strings.TrimSpace(params.Search))
approvalStatuses := parseStringCSVFilter(params.ApprovalStatus) approvalStatuses := parseStringCSVFilter(params.ApprovalStatus)
@@ -187,23 +170,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = db.Where("supplier_id = ?", params.SupplierID) db = db.Where("supplier_id = ?", params.SupplierID)
} }
if createdFrom != nil { switch filterBy {
db = db.Where("created_at >= ?", *createdFrom) case "po_date":
if dateStart != nil {
db = db.Where("purchases.po_date >= ?", *dateStart)
} }
if dateEnd != nil {
if createdTo != nil { db = db.Where("purchases.po_date < ?", *dateEnd)
db = db.Where("created_at < ?", *createdTo)
} }
if poDateStart != nil { case "due_date":
db = db.Where("purchases.po_date >= ?", *poDateStart) if dateStart != nil {
db = db.Where("purchases.due_date >= ?", *dateStart)
} }
if dateEnd != nil {
if poDateStart != nil { db = db.Where("purchases.due_date < ?", *dateEnd)
db = db.Where("purchases.po_date >= ?", *poDateStart) }
case "received_date":
if dateStart != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date >= ?)`,
*dateStart,
)
}
if dateEnd != nil {
db = db.Where(
`EXISTS (SELECT 1 FROM purchase_items pi WHERE pi.purchase_id = purchases.id AND pi.received_date < ?)`,
*dateEnd,
)
}
default:
if dateStart != nil {
db = db.Where("purchases.created_at >= ?", *dateStart)
}
if dateEnd != nil {
db = db.Where("purchases.created_at < ?", *dateEnd)
} }
if poDateEnd != nil {
db = db.Where("purchases.po_date < ?", *poDateEnd)
} }
if scope.Restrict { if scope.Restrict {
@@ -261,7 +262,48 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
db = applyPurchaseApprovalStatusFilter(db, approvalStatuses) db = applyPurchaseApprovalStatusFilter(db, approvalStatuses)
db = applyPurchaseSearchFilter(db, search) db = applyPurchaseSearchFilter(db, search)
sortBy := strings.TrimSpace(params.SortBy)
sortOrder := strings.ToUpper(strings.TrimSpace(params.SortOrder))
if sortOrder == "" {
sortOrder = "DESC"
}
switch sortBy {
case "po_expedition":
return db.Order(`(SELECT MIN(e.reference_number) FROM purchase_items pi
LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
LEFT JOIN expenses e ON e.id = en.expense_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder + " NULLS LAST")
case "supplier":
return db.Order(`(SELECT COALESCE(s.name, '') FROM suppliers s WHERE s.id = purchases.supplier_id) ` + sortOrder)
case "requester_name":
return db.Order(`(SELECT COALESCE(u.name, '') FROM users u WHERE u.id = purchases.created_by) ` + sortOrder)
case "products":
return db.Order(`(SELECT MIN(COALESCE(p.name, '')) FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "location":
return db.Order(`(SELECT MIN(COALESCE(l.name, '')) FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
JOIN locations l ON l.id = w.location_id
WHERE pi.purchase_id = purchases.id) ` + sortOrder)
case "po_date":
return db.Order("purchases.po_date " + sortOrder)
case "po_number":
return db.Order("COALESCE(purchases.po_number, purchases.pr_number) " + sortOrder)
case "received_date":
return db.Order(`(SELECT MIN(pi2.received_date) FROM purchase_items pi2 WHERE pi2.purchase_id = purchases.id) ` + sortOrder)
case "due_date":
return db.Order("purchases.due_date " + sortOrder)
case "status":
return db.Order(`(SELECT COALESCE(a.step_name, '') FROM approvals a
WHERE a.approvable_type = 'PURCHASES' AND a.approvable_id = purchases.id
ORDER BY a.action_at DESC, a.id DESC LIMIT 1) ` + sortOrder)
case "created_at":
return db.Order("purchases.created_at " + sortOrder)
default:
return db.Order("created_at DESC").Order("purchases.id DESC") return db.Order("created_at DESC").Order("purchases.id DESC")
}
}) })
if err != nil { if err != nil {
@@ -2197,30 +2239,29 @@ func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []ent
return nil return nil
} }
func parsePoDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { func parsePurchaseDateRangeForQuery(fromStr, toStr, fieldName string) (*time.Time, *time.Time, error) {
var fromPtr *time.Time var fromPtr *time.Time
var toPtr *time.Time var toPtr *time.Time
if strings.TrimSpace(fromStr) != "" { if strings.TrimSpace(fromStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(fromStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_from must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_from must use format YYYY-MM-DD")
} }
fromValue := parsed fromPtr = &parsed
fromPtr = &fromValue
} }
if strings.TrimSpace(toStr) != "" { if strings.TrimSpace(toStr) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(toStr)) parsed, err := utils.ParseDateString(strings.TrimSpace(toStr))
if err != nil { if err != nil {
return nil, nil, errors.New("po_date_to must use format YYYY-MM-DD") return nil, nil, errors.New(fieldName + "_to must use format YYYY-MM-DD")
} }
nextDay := parsed.AddDate(0, 0, 1) nextDay := parsed.AddDate(0, 0, 1)
toPtr = &nextDay toPtr = &nextDay
} }
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, errors.New("po_date_from must be earlier than po_date_to") return nil, nil, errors.New(fieldName + "_from must be earlier than " + fieldName + "_to")
} }
return fromPtr, toPtr, nil return fromPtr, toPtr, nil
@@ -75,10 +75,10 @@ type Query struct {
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"`
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"` ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"` ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=po_date due_date received_date created_at"`
Search string `query:"search" validate:"omitempty,max=100"` Search string `query:"search" validate:"omitempty,max=100"`
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=po_expedition supplier requester_name products location po_date received_date due_date status created_at po_number"`
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc ASC DESC"`
} }
@@ -51,6 +51,8 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
AreaId: int64(ctx.QueryInt("area_id", 0)), AreaId: int64(ctx.QueryInt("area_id", 0)),
LocationId: int64(ctx.QueryInt("location_id", 0)), LocationId: int64(ctx.QueryInt("location_id", 0)),
RealizationDate: ctx.Query("realization_date", ""), RealizationDate: ctx.Query("realization_date", ""),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
} }
locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB())
@@ -362,6 +364,7 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error {
StartDate: ctx.Query("start_date", ""), StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""), EndDate: ctx.Query("end_date", ""),
FilterBy: ctx.Query("filter_by", ""), FilterBy: ctx.Query("filter_by", ""),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""), SortOrder: ctx.Query("sort_order", ""),
} }
@@ -389,6 +392,13 @@ func (c *RepportController) GetDebtSupplier(ctx *fiber.Ctx) error {
return err return err
} }
if isDebtSupplierExcelExportRequest(ctx) {
return exportDebtSupplierExcel(ctx, result)
}
if isDebtSupplierExcelAllExportRequest(ctx) {
return exportDebtSupplierExcelAll(ctx, result)
}
supplierIDs = query.SupplierIDs supplierIDs = query.SupplierIDs
if supplierIDs == nil { if supplierIDs == nil {
supplierIDs = []int64{} supplierIDs = []int64{}
@@ -459,6 +469,8 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
Limit: ctx.QueryInt("limit", 10), Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs, CustomerIDs: customerIDs,
FilterBy: strings.ToUpper(ctx.Query("filter_by", "")), FilterBy: strings.ToUpper(ctx.Query("filter_by", "")),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
StartDate: ctx.Query("start_date", ""), StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""), EndDate: ctx.Query("end_date", ""),
} }
@@ -473,6 +485,13 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
return err return err
} }
if isCustomerPaymentExcelExportRequest(ctx) {
return exportCustomerPaymentExcel(ctx, result)
}
if isCustomerPaymentExcelAllExportRequest(ctx) {
return exportCustomerPaymentExcelAll(ctx, result)
}
// If single customer mode (only 1 customer ID), return without pagination // If single customer mode (only 1 customer ID), return without pagination
if len(customerIDs) == 1 { if len(customerIDs) == 1 {
return ctx.Status(fiber.StatusOK). return ctx.Status(fiber.StatusOK).
@@ -500,6 +519,83 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
}) })
} }
type BalanceMonitoringResponse struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta response.Meta `json:"meta"`
Data []dto.BalanceMonitoringRowDTO `json:"data"`
Totals dto.BalanceMonitoringTotalsDTO `json:"totals"`
}
func (c *RepportController) GetBalanceMonitoring(ctx *fiber.Ctx) error {
customerIDs, err := parseUintCSV(ctx.Query("customer_ids"))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "customer_ids must be comma separated positive integers")
}
salesIDs, err := parseUintCSV(ctx.Query("sales_ids"))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "sales_ids must be comma separated positive integers")
}
query := &validation.BalanceMonitoringQuery{
Page: ctx.QueryInt("page", 1),
Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs,
SalesIDs: salesIDs,
FilterBy: strings.ToLower(ctx.Query("filter_by", "")),
SortBy: ctx.Query("sort_by", ""),
SortOrder: ctx.Query("sort_order", ""),
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
}
result, totals, totalResults, err := c.RepportService.GetBalanceMonitoring(ctx, query)
if err != nil {
return err
}
limit := query.Limit
if limit < 1 {
limit = 10
}
return ctx.Status(fiber.StatusOK).JSON(BalanceMonitoringResponse{
Code: fiber.StatusOK,
Status: "success",
Message: "Get balance monitoring report successfully",
Meta: response.Meta{
Page: query.Page,
Limit: limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))),
TotalResults: totalResults,
},
Data: result,
Totals: totals,
})
}
func parseUintCSV(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseUint(part, 10, 32)
if err != nil || id == 0 {
return nil, fmt.Errorf("invalid id: %s", part)
}
result = append(result, uint(id))
}
return result, nil
}
func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
idParam := ctx.Params("idProjectFlockKandang") idParam := ctx.Params("idProjectFlockKandang")
if idParam == "" { if idParam == "" {
@@ -0,0 +1,576 @@
package controller
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isCustomerPaymentExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func isCustomerPaymentExcelAllExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all")
}
func exportCustomerPaymentExcel(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error {
content, err := buildCustomerPaymentWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func exportCustomerPaymentExcelAll(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error {
content, err := buildCustomerPaymentAllWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-all-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func buildCustomerPaymentWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if len(items) == 0 {
if err := writeCustomerPaymentSheet(file, defaultSheet, dto.CustomerPaymentReportItem{}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
for idx, item := range items {
sheetName := sanitizeCustomerPaymentSheetName(customerPaymentName(item))
if sheetName == "" {
sheetName = fmt.Sprintf("Customer %d", idx+1)
}
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeCustomerPaymentSheet(file, sheetName, item); err != nil {
return nil, err
}
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func buildCustomerPaymentAllWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Kontrol Pembayaran Customer"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setCustomerPaymentAllColumns(file, sheet); err != nil {
return nil, err
}
if err := setCustomerPaymentAllHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeCustomerPaymentAllRows(file, sheet, items); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var cpSheetHeaders = []string{
"No",
"Tanggal DO/Bayar",
"Tanggal Realisasi",
"Aging",
"Referensi",
"Nomor Polisi",
"Ekor/Qty",
"Berat (Kg)",
"AVG",
"Harga/Unit (Rp)",
"Harga Akhir (Rp)",
"Total (Rp)",
"Pembayaran (Rp)",
"Saldo Piutang (Rp)",
"Keterangan",
"Pengambilan",
"Sales/Marketing",
}
var cpAllSheetHeaders = append([]string{"Customer"}, cpSheetHeaders...)
var cpSheetColumnWidths = map[string]float64{
"A": 5,
"B": 15,
"C": 12,
"D": 8,
"E": 12,
"F": 15,
"G": 10,
"H": 12,
"I": 10,
"J": 15,
"K": 15,
"L": 15,
"M": 15,
"N": 15,
"O": 20,
"P": 15,
"Q": 20,
}
var cpAllSheetColumnWidths = map[string]float64{
"A": 22,
"B": 6,
"C": 15,
"D": 15,
"E": 8,
"F": 12,
"G": 15,
"H": 10,
"I": 12,
"J": 10,
"K": 15,
"L": 15,
"M": 15,
"N": 15,
"O": 15,
"P": 20,
"Q": 15,
"R": 20,
}
func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.CustomerPaymentReportItem) error {
for col, width := range cpSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
// Row 1: headers
for i, h := range cpSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
redStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000"},
})
if err != nil {
return err
}
// Row 2: saldo awal
if err := file.SetCellValue(sheet, "N2", item.InitialBalance); err != nil {
return err
}
if item.InitialBalance < 0 {
if err := file.SetCellStyle(sheet, "N2", "N2", redStyle); err != nil {
return err
}
}
// Rows 3+: data rows
for i, row := range item.Rows {
rowNum := i + 3
rowStr := fmt.Sprintf("%d", rowNum)
cells := customerPaymentRowCells(row, i+1)
for colIdx, val := range cells {
col, _ := excelize.ColumnNumberToName(colIdx + 1)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if row.AccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redStyle); err != nil {
return err
}
}
}
// Total row
totalRowNum := len(item.Rows) + 3
totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]interface{}{
"A": "Total",
"G": formatCPIDInteger(item.Summary.TotalQty),
"H": formatCPIDInteger(item.Summary.TotalWeight),
"K": item.Summary.TotalFinalAmount,
"L": item.Summary.TotalGrandAmount,
"M": item.Summary.TotalPayment,
"N": item.Summary.TotalAccountsReceivable,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if item.Summary.TotalAccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redStyle); err != nil {
return err
}
}
return nil
}
func setCustomerPaymentAllColumns(file *excelize.File, sheet string) error {
for col, width := range cpAllSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
return file.SetRowHeight(sheet, 1, 24)
}
func setCustomerPaymentAllHeaders(file *excelize.File, sheet string) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: borderStyle,
})
if err != nil {
return err
}
for i, h := range cpAllSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
lastCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders))
return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle)
}
func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.CustomerPaymentReportItem) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redDataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
redTotalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
lastHeaderCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders))
currentRow := 2
for _, item := range items {
name := customerPaymentName(item)
// Saldo awal row
saldoStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil {
return err
}
if err := file.SetCellValue(sheet, "O"+saldoStr, item.InitialBalance); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil {
return err
}
if item.InitialBalance < 0 {
if err := file.SetCellStyle(sheet, "O"+saldoStr, "O"+saldoStr, redDataStyle); err != nil {
return err
}
}
currentRow++
// Data rows
for seq, row := range item.Rows {
rowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+rowStr, name); err != nil {
return err
}
cells := customerPaymentRowCells(row, seq+1)
for colIdx, val := range cells {
col, _ := excelize.ColumnNumberToName(colIdx + 2)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil {
return err
}
if row.AccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "O"+rowStr, "O"+rowStr, redDataStyle); err != nil {
return err
}
}
currentRow++
}
// Total row
totalStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]interface{}{
"A": name,
"B": "Total",
"H": formatCPIDInteger(item.Summary.TotalQty),
"I": formatCPIDInteger(item.Summary.TotalWeight),
"L": item.Summary.TotalFinalAmount,
"M": item.Summary.TotalGrandAmount,
"N": item.Summary.TotalPayment,
"O": item.Summary.TotalAccountsReceivable,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalStr, lastHeaderCol+totalStr, totalStyle); err != nil {
return err
}
if item.Summary.TotalAccountsReceivable < 0 {
if err := file.SetCellStyle(sheet, "O"+totalStr, "O"+totalStr, redTotalStyle); err != nil {
return err
}
}
currentRow++
// Empty separator row
currentRow++
}
return nil
}
// customerPaymentRowCells returns 17 cell values for cols A..Q.
func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interface{} {
return []interface{}{
seq,
formatCPDate(row.TransDate),
formatCPOptionalDate(row.DeliveryDate),
formatCPAging(row.AgingDay),
safeCPText(row.Reference),
joinCPStrings(row.VehicleNumbers),
formatCPIDInteger(row.Qty),
formatCPIDInteger(row.Weight),
formatCPAvg(row.AverageWeight),
row.UnitPrice,
row.FinalPrice,
row.TotalPrice,
row.PaymentAmount,
row.AccountsReceivable,
safeCPText(row.Status),
joinCPStrings(row.PickupInfo),
safeCPText(row.SalesPerson),
}
}
func customerPaymentName(item dto.CustomerPaymentReportItem) string {
name := strings.TrimSpace(item.Customer.Name)
if name == "" {
return "Customer"
}
return name
}
func sanitizeCustomerPaymentSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ", "\\", " ", "/", " ",
"?", " ", "*", " ", "[", " ", "]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
return "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
return string(runes[:31])
}
return sanitized
}
var cpIndonesianMonths = [12]string{
"Jan", "Feb", "Mar", "Apr", "Mei", "Jun",
"Jul", "Agu", "Sep", "Okt", "Nov", "Des",
}
func formatCPDate(t time.Time) string {
if t.IsZero() {
return "-"
}
loc, err := time.LoadLocation("Asia/Jakarta")
if err == nil {
t = t.In(loc)
}
return fmt.Sprintf("%02d %s %d", t.Day(), cpIndonesianMonths[t.Month()-1], t.Year())
}
func formatCPOptionalDate(t *time.Time) string {
if t == nil || t.IsZero() {
return "-"
}
return formatCPDate(*t)
}
func formatCPAging(v *int) string {
if v == nil {
return "-"
}
return strconv.Itoa(*v)
}
func formatCPIDInteger(v float64) string {
n := int64(math.Round(v))
if n == 0 {
return "0"
}
negative := n < 0
abs := n
if negative {
abs = -n
}
s := strconv.FormatInt(abs, 10)
// insert dots as thousand separators
var b strings.Builder
start := len(s) % 3
if start == 0 {
start = 3
}
b.WriteString(s[:start])
for i := start; i < len(s); i += 3 {
b.WriteByte('.')
b.WriteString(s[i : i+3])
}
if negative {
return "-" + b.String()
}
return b.String()
}
func formatCPAvg(v float64) string {
if v == 0 {
return "0"
}
s := strconv.FormatFloat(v, 'f', 2, 64)
return strings.ReplaceAll(s, ".", ",")
}
func safeCPText(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return "-"
}
return t
}
func joinCPStrings(ss []string) string {
var parts []string
for _, s := range ss {
s = strings.TrimSpace(s)
if s != "" {
parts = append(parts, s)
}
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "\n")
}
@@ -0,0 +1,452 @@
package controller
import (
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/xuri/excelize/v2"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
)
func isDebtSupplierExcelExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel")
}
func isDebtSupplierExcelAllExportRequest(c *fiber.Ctx) bool {
return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all")
}
func exportDebtSupplierExcel(c *fiber.Ctx, items []dto.DebtSupplierDTO) error {
content, err := buildDebtSupplierWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-hutang-supplier-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
func exportDebtSupplierExcelAll(c *fiber.Ctx, items []dto.DebtSupplierDTO) error {
content, err := buildDebtSupplierAllWorkbook(items)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
}
filename := fmt.Sprintf("laporan-hutang-supplier-all-%s.xlsx", time.Now().Format("2006-01-02-1504"))
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
return c.Status(fiber.StatusOK).Send(content)
}
// buildDebtSupplierWorkbook creates a workbook with one sheet per supplier.
func buildDebtSupplierWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if len(items) == 0 {
if err := writeDebtSupplierSheet(file, defaultSheet, dto.DebtSupplierDTO{}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
for idx, item := range items {
sheetName := sanitizeDebtSupplierSheetName(debtSupplierName(item))
if sheetName == "" {
sheetName = fmt.Sprintf("Supplier %d", idx+1)
}
if idx == 0 {
if defaultSheet != sheetName {
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
return nil, err
}
}
} else {
if _, err := file.NewSheet(sheetName); err != nil {
return nil, err
}
}
if err := writeDebtSupplierSheet(file, sheetName, item); err != nil {
return nil, err
}
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildDebtSupplierAllWorkbook creates a single-sheet workbook with purchase-supplier styling.
func buildDebtSupplierAllWorkbook(items []dto.DebtSupplierDTO) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
const sheet = "Rekap Hutang Supplier"
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
if err := setDebtSupplierAllColumns(file, sheet); err != nil {
return nil, err
}
if err := setDebtSupplierAllHeaders(file, sheet); err != nil {
return nil, err
}
if err := writeDebtSupplierAllRows(file, sheet, items); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buf, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
var debtSupplierSheetHeaders = []string{
"No",
"Nomor PR",
"Nomor PO",
"Tanggal Terima/Bayar",
"Tanggal PO",
"Aging (Hari)",
"Area",
"Gudang",
"Jatuh Tempo",
"Status Jatuh Tempo",
"Nominal Pembelian (Rp)",
"Pembayaran (Rp)",
"Sisa Saldo Hutang (Rp)",
"Status",
"Nomor Perjalanan",
}
var debtSupplierAllSheetHeaders = append([]string{"Supplier"}, debtSupplierSheetHeaders...)
var debtSupplierSheetColumnWidths = map[string]float64{
"A": 5,
"B": 14,
"C": 12,
"D": 20,
"E": 10,
"F": 12,
"G": 15,
"H": 20,
"I": 12,
"J": 20,
"K": 20,
"L": 15,
"M": 20,
"N": 12,
"O": 15,
}
var debtSupplierAllSheetColumnWidths = map[string]float64{
"A": 24,
"B": 6,
"C": 14,
"D": 14,
"E": 20,
"F": 12,
"G": 10,
"H": 16,
"I": 22,
"J": 12,
"K": 22,
"L": 20,
"M": 18,
"N": 22,
"O": 14,
"P": 18,
}
func writeDebtSupplierSheet(file *excelize.File, sheet string, item dto.DebtSupplierDTO) error {
for col, width := range debtSupplierSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
// Row 1: headers
for i, h := range debtSupplierSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
// Row 2: saldo awal
if err := file.SetCellValue(sheet, "M2", item.InitialBalance); err != nil {
return err
}
// Rows 3+: data
redStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "FF0000"},
})
if err != nil {
return err
}
for i, row := range item.Rows {
rowNum := i + 3
rowStr := fmt.Sprintf("%d", rowNum)
values := debtSupplierRowCells(row, i+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 1)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if row.DebtPrice < 0 {
if err := file.SetCellStyle(sheet, "M"+rowStr, "M"+rowStr, redStyle); err != nil {
return err
}
}
}
// Total row
totalRowNum := len(item.Rows) + 3
totalRowStr := fmt.Sprintf("%d", totalRowNum)
totalCells := map[string]interface{}{
"A": "Total",
"F": item.Total.Aging,
"K": item.Total.TotalPrice,
"L": item.Total.PaymentPrice,
"M": item.Total.DebtPrice,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if item.Total.DebtPrice < 0 {
if err := file.SetCellStyle(sheet, "M"+totalRowStr, "M"+totalRowStr, redStyle); err != nil {
return err
}
}
return nil
}
func setDebtSupplierAllColumns(file *excelize.File, sheet string) error {
for col, width := range debtSupplierAllSheetColumnWidths {
if err := file.SetColWidth(sheet, col, col, width); err != nil {
return err
}
}
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil
}
func setDebtSupplierAllHeaders(file *excelize.File, sheet string) error {
headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
Border: []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
},
})
if err != nil {
return err
}
for i, h := range debtSupplierAllSheetHeaders {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := file.SetCellValue(sheet, col+"1", h); err != nil {
return err
}
}
lastCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders))
return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle)
}
func writeDebtSupplierAllRows(file *excelize.File, sheet string, items []dto.DebtSupplierDTO) error {
borderStyle := []excelize.Border{
{Type: "left", Color: "000000", Style: 1},
{Type: "top", Color: "000000", Style: 1},
{Type: "bottom", Color: "000000", Style: 1},
{Type: "right", Color: "000000", Style: 1},
}
dataStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
totalStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}},
Alignment: &excelize.Alignment{Vertical: "center", WrapText: true},
Border: borderStyle,
})
if err != nil {
return err
}
lastHeaderCol, _ := excelize.ColumnNumberToName(len(debtSupplierAllSheetHeaders))
currentRow := 2
for _, item := range items {
supplierName := debtSupplierName(item)
// Saldo awal row
saldoRowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+saldoRowStr, supplierName); err != nil {
return err
}
if err := file.SetCellValue(sheet, "N"+saldoRowStr, item.InitialBalance); err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+saldoRowStr, lastHeaderCol+saldoRowStr, dataStyle); err != nil {
return err
}
currentRow++
// Data rows
for seq, row := range item.Rows {
rowStr := fmt.Sprintf("%d", currentRow)
if err := file.SetCellValue(sheet, "A"+rowStr, supplierName); err != nil {
return err
}
values := debtSupplierRowCells(row, seq+1)
for colIdx, val := range values {
col, _ := excelize.ColumnNumberToName(colIdx + 2)
if err := file.SetCellValue(sheet, col+rowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil {
return err
}
currentRow++
}
// Total row
totalRowStr := fmt.Sprintf("%d", currentRow)
totalCells := map[string]interface{}{
"A": supplierName,
"B": "Total",
"L": item.Total.TotalPrice,
"M": item.Total.PaymentPrice,
"N": item.Total.DebtPrice,
}
for col, val := range totalCells {
if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil {
return err
}
}
if err := file.SetCellStyle(sheet, "A"+totalRowStr, lastHeaderCol+totalRowStr, totalStyle); err != nil {
return err
}
currentRow++
// Empty separator row
currentRow++
}
return nil
}
// debtSupplierRowCells returns cell values for one data row (columns: No, PR, PO, ReceivedDate, PoDate, Aging, Area, Warehouse, DueDate, DueStatus, TotalPrice, PaymentPrice, DebtPrice, Status, TravelNumber).
func debtSupplierRowCells(row dto.DebtSupplierRowDTO, seq int) []interface{} {
areaName := "-"
if row.Area != nil && strings.TrimSpace(row.Area.Name) != "" {
areaName = row.Area.Name
}
warehouseName := "-"
if row.Warehouse != nil && strings.TrimSpace(row.Warehouse.Name) != "" {
warehouseName = row.Warehouse.Name
}
return []interface{}{
seq,
safeDebtSupplierText(row.PrNumber),
safeDebtSupplierText(row.PoNumber),
safeDebtSupplierText(row.ReceivedDate),
safeDebtSupplierText(row.PoDate),
row.Aging,
areaName,
warehouseName,
safeDebtSupplierText(row.DueDate),
safeDebtSupplierText(row.DueStatus),
row.TotalPrice,
row.PaymentPrice,
row.DebtPrice,
safeDebtSupplierText(row.Status),
safeDebtSupplierText(row.TravelNumber),
}
}
func debtSupplierName(item dto.DebtSupplierDTO) string {
if item.Supplier != nil && strings.TrimSpace(item.Supplier.Name) != "" {
return item.Supplier.Name
}
return "Supplier"
}
func sanitizeDebtSupplierSheetName(name string) string {
replacer := strings.NewReplacer(
":", " ", "\\", " ", "/", " ",
"?", " ", "*", " ", "[", " ", "]", " ",
)
sanitized := strings.TrimSpace(replacer.Replace(name))
if sanitized == "" {
return "Sheet"
}
runes := []rune(sanitized)
if len(runes) > 31 {
return string(runes[:31])
}
return sanitized
}
func safeDebtSupplierText(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return "-"
}
return t
}
@@ -2,7 +2,6 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -50,6 +49,14 @@ func buildMarketingReportWorkbook(items []dto.RepportMarketingItemDTO) ([]byte,
if err := setMarketingReportRows(file, items); err != nil { if err := setMarketingReportRows(file, items); err != nil {
return nil, err return nil, err
} }
if err := file.SetPanes(marketingReportExportSheetName, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer() buffer, err := file.WriteToBuffer()
if err != nil { if err != nil {
@@ -88,6 +95,10 @@ func setMarketingReportColumns(file *excelize.File) error {
} }
} }
if err := file.SetRowHeight(sheet, 1, 24); err != nil {
return err
}
return nil return nil
} }
@@ -110,7 +121,6 @@ func setMarketingReportHeaders(file *excelize.File) error {
"Bobot Total (Kg)", "Bobot Total (Kg)",
"Harga Jual (Rp)", "Harga Jual (Rp)",
"HPP (Rp)", "HPP (Rp)",
"HPP Amount (Rp)",
"Total (Rp)", "Total (Rp)",
} }
@@ -124,7 +134,22 @@ func setMarketingReportHeaders(file *excelize.File) error {
} }
} }
return nil headerStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "A1", "Q1", headerStyle)
} }
func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error { func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error {
@@ -165,16 +190,15 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
customerName, customerName,
safeMarketingExportText(item.DoNumber), safeMarketingExportText(item.DoNumber),
salesName, salesName,
safeMarketingExportText(item.VehicleNumber), safeMarketingExportText(formatMarketingVehicleNumber(item.VehicleNumber)),
safeMarketingExportText(item.MarketingType), safeMarketingExportText(item.MarketingType),
productName, productName,
item.Qty, item.Qty,
item.AverageWeightKg, item.AverageWeightKg,
item.TotalWeightKg, item.TotalWeightKg,
formatMarketingRupiah(item.SalesPricePerKg), item.SalesPricePerKg,
formatMarketingRupiah(item.HppPricePerKg), item.HppPricePerKg,
formatMarketingRupiah(item.HppAmount), item.SalesAmount,
formatMarketingRupiah(item.SalesAmount),
} }
for colIdx, val := range values { for colIdx, val := range values {
@@ -204,21 +228,87 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte
if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil { if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "O"+totalRow, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil { if err := file.SetCellValue(sheet, "O"+totalRow, summary.AverageSalesPrice); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil { if err := file.SetCellValue(sheet, "P"+totalRow, summary.TotalHppPricePerKg); err != nil {
return err return err
} }
if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalHppAmount))); err != nil { if err := file.SetCellValue(sheet, "Q"+totalRow, float64(summary.TotalSalesAmount)); err != nil {
return err
}
if err := file.SetCellValue(sheet, "R"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil {
return err return err
} }
} }
return nil if len(items) > 0 {
lastDataRow := strconv.Itoa(len(items) + 1)
dataStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A2", "Q"+lastDataRow, dataStyle); err != nil {
return err
}
numericStyle, err := file.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "L2", "Q"+lastDataRow, numericStyle); err != nil {
return err
}
}
totalTextStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}},
Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
if err := file.SetCellStyle(sheet, "A"+totalRow, "Q"+totalRow, totalTextStyle); err != nil {
return err
}
totalNumericStyle, err := file.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "1F2937"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}},
Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "D1D5DB", Style: 1},
{Type: "top", Color: "D1D5DB", Style: 1},
{Type: "bottom", Color: "D1D5DB", Style: 1},
{Type: "right", Color: "D1D5DB", Style: 1},
},
})
if err != nil {
return err
}
return file.SetCellStyle(sheet, "L"+totalRow, "Q"+totalRow, totalNumericStyle)
} }
func formatMarketingDate(t time.Time) string { func formatMarketingDate(t time.Time) string {
@@ -242,30 +332,3 @@ func safeMarketingExportText(value string) string {
return trimmed return trimmed
} }
// formatMarketingRupiah formats a float64 as Indonesian Rupiah string.
// e.g. 1000000 → "Rp 1.000.000"
func formatMarketingRupiah(value float64) string {
rounded := int64(math.Round(value))
negative := rounded < 0
abs := rounded
if negative {
abs = -rounded
}
numStr := strconv.FormatInt(abs, 10)
n := len(numStr)
var b strings.Builder
for i, c := range numStr {
if i > 0 && (n-i)%3 == 0 {
b.WriteByte('.')
}
b.WriteRune(c)
}
if negative {
return "Rp -" + b.String()
}
return "Rp " + b.String()
}
@@ -55,23 +55,22 @@ type pdfColumn struct {
var marketingPdfColumns = []pdfColumn{ var marketingPdfColumns = []pdfColumn{
{"No", 6, "C"}, {"No", 6, "C"},
{"Tanggal Sales Order", 16, "C"}, {"Tanggal\nJual", 16, "C"},
{"Tanggal Delivery Order", 16, "C"}, {"Tanggal\nRealisasi", 20, "C"},
{"Aging\n(Hari)", 9, "C"}, {"Aging\n(Hari)", 9, "C"},
{"Gudang Fisik", 20, "L"}, {"Gudang\nFisik", 20, "L"},
{"Pelanggan", 20, "L"}, {"Pelanggan", 20, "L"},
{"No. DO", 18, "L"},
{"Sales", 18, "L"}, {"Sales", 18, "L"},
{"No. Polisi", 18, "L"},
{"Tipe\nMarketing", 16, "C"},
{"Produk", 16, "L"}, {"Produk", 16, "L"},
{"Nomor DO", 14, "C"}, {"Kuantitas", 13, "R"},
{"Nomor Polisi", 14, "C"}, {"Bobot\nRata-Rata (Kg)", 18, "R"},
{"Tipe\nMarketing", 14, "C"}, {"Bobot\nTotal Berat (Kg)", 18, "R"},
{"Quantity", 13, "R"},
{"Rata-Rata\n(Kg)", 13, "R"},
{"Total Berat\n(Kg)", 14, "R"},
{"Harga Jual\n(Rp)", 17, "R"}, {"Harga Jual\n(Rp)", 17, "R"},
{"HPP\n(Rp)", 17, "R"}, {"HPP\n(Rp)", 17, "R"},
{"Total Jual\n(Rp)", 18, "R"}, {"Total (Rp)", 18, "R"},
{"Total HPP\n(Rp)", 18, "R"},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -184,50 +183,76 @@ func writeMarketingPdfRows(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO)
pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1) pdf.SetLineWidth(0.1)
rowH := 6.0 lineH := 5.0
for idx, item := range items { for idx, item := range items {
// page break check values := marketingPdfRowValues(idx+1, item)
rowH := calcMarketingRowHeight(pdf, values, lineH)
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage() pdf.AddPage()
writeMarketingPdfHeader(pdf) writeMarketingPdfHeader(pdf)
pdf.SetFont("Helvetica", "", 6) pdf.SetFont("Helvetica", "", 6)
} }
// alternating bg var fillR, fillG, fillB int
if idx%2 == 1 { if idx%2 == 1 {
pdf.SetFillColor(rowAltR, rowAltG, rowAltB) fillR, fillG, fillB = rowAltR, rowAltG, rowAltB
} else { } else {
pdf.SetFillColor(255, 255, 255) fillR, fillG, fillB = 255, 255, 255
} }
pdf.SetTextColor(40, 40, 40) pdf.SetTextColor(40, 40, 40)
y := pdf.GetY() y := pdf.GetY()
writeMarketingPdfRow(pdf, idx+1, item, rowH, y) writeMarketingPdfRow(pdf, item, values, lineH, rowH, y, fillR, fillG, fillB)
} }
} }
func writeMarketingPdfRow(pdf *fpdf.Fpdf, no int, item dto.RepportMarketingItemDTO, h, y float64) { func calcMarketingRowHeight(pdf *fpdf.Fpdf, values []string, lineH float64) float64 {
fill := true // use the fill colour already set margin := pdf.GetCellMargin()
cols := marketingPdfColumns cols := marketingPdfColumns
x := 10.0 // left margin maxLines := 1
for i, col := range cols {
if i >= len(values) || i == 9 {
continue
}
usableW := col.width - 2*margin
if usableW <= 0 {
continue
}
lines := pdf.SplitLines([]byte(values[i]), usableW)
n := len(lines)
if n == 0 {
n = 1
}
if n > maxLines {
maxLines = n
}
}
return float64(maxLines) * lineH
}
values := marketingPdfRowValues(no, item) func writeMarketingPdfRow(pdf *fpdf.Fpdf, item dto.RepportMarketingItemDTO, values []string, lineH, rowH, y float64, fillR, fillG, fillB int) {
cols := marketingPdfColumns
x := 10.0
for i, col := range cols { for i, col := range cols {
pdf.SetXY(x, y) if i == 9 {
drawMarketingTypeBadge(pdf, x, y, col.width, rowH, item.MarketingType)
if i == 10 { // Tipe Marketing → badge pdf.SetDrawColor(borderR, borderG, borderB)
drawMarketingTypeBadge(pdf, x, y, col.width, h, item.MarketingType) pdf.SetTextColor(40, 40, 40)
} else { } else {
pdf.CellFormat(col.width, h, values[i], "1", 0, col.align, fill, 0, "") pdf.SetFillColor(fillR, fillG, fillB)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.Rect(x, y, col.width, rowH, "FD")
pdf.SetTextColor(40, 40, 40)
pdf.SetXY(x, y)
pdf.MultiCell(col.width, lineH, values[i], "", col.align, false)
} }
x += col.width x += col.width
} }
pdf.SetXY(10, y+h) pdf.SetXY(10, y+rowH)
} }
func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string {
@@ -255,18 +280,17 @@ func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string {
strconv.Itoa(item.AgingDays), strconv.Itoa(item.AgingDays),
warehouse, warehouse,
customer, customer,
sales,
product,
safeMarketingExportText(item.DoNumber), safeMarketingExportText(item.DoNumber),
safeMarketingExportText(item.VehicleNumber), sales,
safeMarketingExportText(item.MarketingType), // index 10, overridden by badge safeMarketingExportText(formatMarketingVehicleNumber(item.VehicleNumber)),
safeMarketingExportText(item.MarketingType), // index 9, overridden by badge
product,
formatMarketingPdfNumber(item.Qty), formatMarketingPdfNumber(item.Qty),
formatMarketingPdfDecimal(item.AverageWeightKg), formatMarketingPdfDecimal(item.AverageWeightKg),
formatMarketingPdfDecimal(item.TotalWeightKg), formatMarketingPdfDecimal(item.TotalWeightKg),
formatMarketingPdfRupiah(item.SalesPricePerKg), formatMarketingPdfRupiah(item.SalesPricePerKg),
formatMarketingPdfRupiah(item.HppPricePerKg), formatMarketingPdfRupiah(item.HppPricePerKg),
formatMarketingPdfRupiah(item.SalesAmount), formatMarketingPdfRupiah(item.SalesAmount),
formatMarketingPdfRupiah(item.HppAmount),
} }
} }
@@ -280,30 +304,9 @@ func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO)
return return
} }
rowH := 6.5
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage()
writeMarketingPdfHeader(pdf)
}
pdf.SetFont("Helvetica", "B", 6) pdf.SetFont("Helvetica", "B", 6)
pdf.SetFillColor(220, 230, 245)
pdf.SetTextColor(30, 64, 120)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1)
y := pdf.GetY() lineH := 5.0
x := 10.0
// merge first 11 cols (No … Tipe Marketing) into "TOTAL" label
mergedWidth := 0.0
for i := 0; i < 11; i++ {
mergedWidth += marketingPdfColumns[i].width
}
pdf.SetXY(x, y)
pdf.CellFormat(mergedWidth, rowH, "TOTAL", "1", 0, "R", true, 0, "")
x += mergedWidth
totals := []string{ totals := []string{
formatMarketingPdfNumber(float64(summary.TotalQty)), formatMarketingPdfNumber(float64(summary.TotalQty)),
@@ -312,13 +315,58 @@ func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO)
formatMarketingPdfRupiah(summary.AverageSalesPrice), formatMarketingPdfRupiah(summary.AverageSalesPrice),
formatMarketingPdfRupiah(summary.TotalHppPricePerKg), formatMarketingPdfRupiah(summary.TotalHppPricePerKg),
formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)), formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)),
formatMarketingPdfRupiah(float64(summary.TotalHppAmount)),
} }
margin := pdf.GetCellMargin()
maxLines := 1
for i, val := range totals {
col := marketingPdfColumns[11+i]
usableW := col.width - 2*margin
if usableW <= 0 {
continue
}
lines := pdf.SplitLines([]byte(val), usableW)
n := len(lines)
if n == 0 {
n = 1
}
if n > maxLines {
maxLines = n
}
}
rowH := float64(maxLines) * lineH
if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 {
pdf.AddPage()
writeMarketingPdfHeader(pdf)
pdf.SetFont("Helvetica", "B", 6)
}
pdf.SetTextColor(30, 64, 120)
pdf.SetDrawColor(borderR, borderG, borderB)
pdf.SetLineWidth(0.1)
y := pdf.GetY()
x := 10.0
const totalFillR, totalFillG, totalFillB = 220, 230, 245
mergedWidth := 0.0
for i := range 11 {
mergedWidth += marketingPdfColumns[i].width
}
pdf.SetFillColor(totalFillR, totalFillG, totalFillB)
pdf.Rect(x, y, mergedWidth, rowH, "FD")
pdf.SetXY(x, y)
pdf.MultiCell(mergedWidth, lineH, "TOTAL", "", "R", false)
x += mergedWidth
for i, val := range totals { for i, val := range totals {
col := marketingPdfColumns[11+i] col := marketingPdfColumns[11+i]
pdf.SetFillColor(totalFillR, totalFillG, totalFillB)
pdf.Rect(x, y, col.width, rowH, "FD")
pdf.SetXY(x, y) pdf.SetXY(x, y)
pdf.CellFormat(col.width, rowH, val, "1", 0, "R", true, 0, "") pdf.MultiCell(col.width, lineH, val, "", "R", false)
x += col.width x += col.width
} }
@@ -484,6 +532,27 @@ func marketingPdfPageHeight(pdf *fpdf.Fpdf) float64 {
return h return h
} }
// formatMarketingVehicleNumber spaces out Indonesian plate segments: D1234MBU → D 1234 MBU.
// Returns s unchanged if it doesn't match the [letters][digits][letters] pattern.
func formatMarketingVehicleNumber(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
i := 0
for i < len(s) && (s[i] >= 'A' && s[i] <= 'Z' || s[i] >= 'a' && s[i] <= 'z') {
i++
}
j := i
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
j++
}
if i == 0 || j == i || j == len(s) {
return s
}
return s[:i] + " " + s[i:j] + " " + s[j:]
}
// formatMarketingPdfThousands inserts period every 3 digits. // formatMarketingPdfThousands inserts period every 3 digits.
func formatMarketingPdfThousands(v int64) string { func formatMarketingPdfThousands(v int64) string {
negative := v < 0 negative := v < 0
@@ -0,0 +1,71 @@
package dto
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
)
type BalanceMonitoringAyamDTO struct {
Ekor float64 `json:"ekor"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringTelurDTO struct {
Butir float64 `json:"butir"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringTradingDTO struct {
Qty float64 `json:"qty"`
Kg float64 `json:"kg"`
Nominal float64 `json:"nominal"`
}
type BalanceMonitoringRowDTO struct {
Customer customerDTO.CustomerRelationDTO `json:"customer"`
SaldoAwal float64 `json:"saldo_awal"`
PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"`
PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"`
PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"`
Pembayaran float64 `json:"pembayaran"`
Aging int `json:"aging"`
AgingRataRata float64 `json:"aging_rata_rata"`
SaldoAkhir float64 `json:"saldo_akhir"`
}
type BalanceMonitoringTotalsDTO struct {
SaldoAwal float64 `json:"saldo_awal"`
PenjualanAyam BalanceMonitoringAyamDTO `json:"penjualan_ayam"`
PenjualanTelur BalanceMonitoringTelurDTO `json:"penjualan_telur"`
PenjualanTrading BalanceMonitoringTradingDTO `json:"penjualan_trading"`
Pembayaran float64 `json:"pembayaran"`
Aging int `json:"aging"`
AgingRataRata float64 `json:"aging_rata_rata"`
SaldoAkhir float64 `json:"saldo_akhir"`
}
func ToBalanceMonitoringRowDTO(
customer entity.Customer,
saldoAwal float64,
ayam BalanceMonitoringAyamDTO,
telur BalanceMonitoringTelurDTO,
trading BalanceMonitoringTradingDTO,
pembayaran float64,
aging int,
agingRataRata float64,
) BalanceMonitoringRowDTO {
saldoAkhir := saldoAwal + pembayaran - (ayam.Nominal + telur.Nominal + trading.Nominal)
return BalanceMonitoringRowDTO{
Customer: customerDTO.ToCustomerRelationDTO(customer),
SaldoAwal: saldoAwal,
PenjualanAyam: ayam,
PenjualanTelur: telur,
PenjualanTrading: trading,
Pembayaran: pembayaran,
Aging: aging,
AgingRataRata: agingRataRata,
SaldoAkhir: saldoAkhir,
}
}
@@ -6,6 +6,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto"
supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto"
) )
@@ -48,6 +49,7 @@ type RepportExpenseRealisasiDTO struct {
type RepportExpenseListDTO struct { type RepportExpenseListDTO struct {
RepportExpenseBaseDTO RepportExpenseBaseDTO
Location *locationDTO.LocationRelationDTO `json:"location,omitempty"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"` Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"`
Realisasi RepportExpenseRealisasiDTO `json:"realisasi"` Realisasi RepportExpenseRealisasiDTO `json:"realisasi"`
@@ -133,6 +135,15 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo
totalRealisasi = ns.Realization.Qty * ns.Realization.Price totalRealisasi = ns.Realization.Qty * ns.Realization.Price
} }
var location *locationDTO.LocationRelationDTO
if ns.Expense != nil && ns.Expense.Location != nil && ns.Expense.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(*ns.Expense.Location)
location = &mapped
} else if ns.Kandang != nil && ns.Kandang.Location.Id != 0 {
mapped := locationDTO.ToLocationRelationDTO(ns.Kandang.Location)
location = &mapped
}
// Get kandang data at the main level // Get kandang data at the main level
var kandang *kandangDTO.KandangRelationDTO var kandang *kandangDTO.KandangRelationDTO
if ns.Kandang != nil && ns.Kandang.Id != 0 { if ns.Kandang != nil && ns.Kandang.Id != 0 {
@@ -142,6 +153,7 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo
return RepportExpenseListDTO{ return RepportExpenseListDTO{
RepportExpenseBaseDTO: baseDTO, RepportExpenseBaseDTO: baseDTO,
Location: location,
Kandang: kandang, Kandang: kandang,
Pengajuan: ToRepportExpensePengajuanDTO(ns), Pengajuan: ToRepportExpensePengajuanDTO(ns),
Realisasi: realisasi, Realisasi: realisasi,

Some files were not shown because too many files have changed in this diff Show More