diff --git a/go.mod b/go.mod index 6d37a691..355f8e5c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 github.com/bytedance/sonic v1.12.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 @@ -45,8 +46,10 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -70,6 +73,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -94,4 +98,8 @@ require ( golang.org/x/text v0.22.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index 71f9378c..188b0dae 100644 --- a/go.sum +++ b/go.sum @@ -65,12 +65,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -88,6 +94,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -184,6 +192,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -344,4 +355,12 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql new file mode 100644 index 00000000..294d5e40 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql @@ -0,0 +1,33 @@ +BEGIN; + +-- Remove grading details from recording_eggs +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS weight; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0); + +-- Restore grading_eggs table for rollback scenarios +CREATE TABLE grading_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_egg_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + grade VARCHAR, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_grading_eggs_recording_egg + FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE, + CONSTRAINT fk_grading_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_grading_eggs_recording_egg + ON grading_eggs (recording_egg_id); + +COMMIT; diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql new file mode 100644 index 00000000..4da8c647 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Remove separate grading table and move grading details into recording_eggs +DROP INDEX IF EXISTS idx_grading_eggs_recording_egg; +DROP TABLE IF EXISTS grading_eggs; + +ALTER TABLE recording_eggs + ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND (weight IS NULL OR weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql new file mode 100644 index 00000000..022e3a36 --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql @@ -0,0 +1,38 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_expense_nonstock; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_project_flock_kandang; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id; +DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id; + +ALTER TABLE purchase_items + DROP COLUMN IF EXISTS expense_nonstock_id, + DROP COLUMN IF EXISTS project_flock_kandang_id, + ALTER COLUMN vehicle_number DROP NOT NULL, + ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number; + +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR USING pr_number, + ALTER COLUMN po_number TYPE VARCHAR USING po_number, + ALTER COLUMN created_at DROP DEFAULT, + ALTER COLUMN updated_at DROP DEFAULT; + +ALTER TABLE purchases + ADD COLUMN credit_term INT NOT NULL DEFAULT 0, + ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT, + ALTER COLUMN grand_total DROP DEFAULT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql new file mode 100644 index 00000000..c8d5748f --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql @@ -0,0 +1,57 @@ +-- Adjust purchases table to new purchasing schema +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50), + ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50), + ALTER COLUMN created_at SET DEFAULT now(), + ALTER COLUMN updated_at SET DEFAULT now(); + +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term, + DROP COLUMN IF EXISTS grand_total; + +-- Bring purchase_items in line with new requirements +ALTER TABLE purchase_items + ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT, + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; + +UPDATE purchase_items +SET vehicle_number = '' +WHERE vehicle_number IS NULL; + +ALTER TABLE purchase_items + ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10), + ALTER COLUMN vehicle_number SET NOT NULL; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_expense_nonstock + FOREIGN KEY (expense_nonstock_id) + REFERENCES expense_nonstocks(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id + ON purchase_items (expense_nonstock_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id + ON purchase_items (project_flock_kandang_id); diff --git a/internal/entities/product_warehouse.go b/internal/entities/product_warehouse.go index 0837cc45..8e1ece25 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -8,7 +8,8 @@ type ProductWarehouse struct { Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` // Relations - Product Product `gorm:"foreignKey:ProductId;references:Id"` - Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` - StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` + Product Product `gorm:"foreignKey:ProductId;references:Id"` + Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/entities/project_budget.go b/internal/entities/project_budget.go index 23c521ac..c74455b6 100644 --- a/internal/entities/project_budget.go +++ b/internal/entities/project_budget.go @@ -5,11 +5,13 @@ import ( ) type ProjectBudget struct { - Id uint `gorm:"primaryKey"` - Qty float64 `gorm:"type:numeric(15,3);not null"` - Price float64 `gorm:"type:numeric(15,3);not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null"` + NonstockId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` - Nonstock *Nonstock `gorm:"foreignKey:Id;references:Id"` - ProjectFlock *ProjectFlock `gorm:"foreignKey:Id;references:Id"` + Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` } diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index e8745455..0a92b54b 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -24,6 +24,7 @@ type ProjectFlock struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` + Budgets []ProjectBudget `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 47ac15c8..fe9b7100 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -10,9 +10,7 @@ type Purchase struct { PoNumber *string PoDate *time.Time SupplierId uint `gorm:"not null"` - CreditTerm *int DueDate *time.Time - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` Notes *string CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index e5b45bad..22cb62ed 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,20 +5,22 @@ import ( ) type PurchaseItem struct { - Id uint `gorm:"primaryKey;autoIncrement"` - PurchaseId uint `gorm:"not null"` - ProductId uint `gorm:"not null"` - WarehouseId uint `gorm:"not null"` - ProductWarehouseId *uint - ReceivedDate *time.Time - TravelNumber *string - TravelNumberDocs *string - VehicleNumber *string - SubQty float64 `gorm:"type:numeric(15,3);not null"` - TotalQty float64 `gorm:"type:numeric(15,3);default:0"` - TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` - Price float64 `gorm:"type:numeric(15,3);default:0"` - TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + Id uint `gorm:"primaryKey;autoIncrement"` + PurchaseId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + WarehouseId uint `gorm:"not null"` + ProductWarehouseId *uint + ProjectFlockKandangId *uint + ReceivedDate *time.Time + TravelNumber *string + TravelNumberDocs *string + VehicleNumber *string + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` + Price float64 `gorm:"type:numeric(15,3);default:0"` + TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + ExpenseNonstockId *uint64 // Relations Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 28eafeb7..775d15dc 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,24 +7,11 @@ type RecordingEgg struct { RecordingId uint `gorm:"column:recording_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` Qty int `gorm:"column:qty;not null"` + Weight *float64 `gorm:"column:weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } - -type GradingEgg struct { - Id uint `gorm:"primaryKey"` - RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"` - Qty float64 `gorm:"column:qty;not null"` - Grade string `gorm:"column:grade;type:varchar(50)"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - - RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` -} diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index d025aa45..60af9e2a 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -54,6 +54,28 @@ func (u *ClosingController) GetAll(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid id") + } + + result, err := u.ClosingService.GetProjectFlockByID(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing information successfully", + Data: result, + }) +} + func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { param := c.Params("projectFlockId") @@ -76,6 +98,33 @@ func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID)) + if err != nil { + return err + } + + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing penjualan successfully", + Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), + }) +} + func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { param := c.Params("projectFlockId") diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go new file mode 100644 index 00000000..4c47a7e0 --- /dev/null +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -0,0 +1,109 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" +) + +// === Response DTO === + +type SalesDTO struct { + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + PaymentStatus string `json:"payment_status"` +} + +type PenjualanRealisasiResponseDTO struct { + ProjectType string `json:"project_type"` + FlockId uint `json:"flock_id"` + Period int `json:"period"` + Sales []SalesDTO `json:"sales"` +} + +// === Mapper Functions === + +func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + + // todo: usia ayam masih dummy + age := 0 + + var product *productDTO.ProductRelationDTO + if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { + mapped := productDTO.ToProductRelationDTO(e.MarketingProduct.ProductWarehouse.Product) + product = &mapped + } + + var customer *customerDTO.CustomerRelationDTO + if e.MarketingProduct.Marketing.Customer.Id != 0 { + mapped := customerDTO.ToCustomerRelationDTO(e.MarketingProduct.Marketing.Customer) + customer = &mapped + } + + var kandang *kandangDTO.KandangRelationDTO + if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang) + kandang = &mapped + } + + doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) + + return SalesDTO{ + Id: e.Id, + RealizationDate: *e.DeliveryDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.Qty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + Price: e.UnitPrice, + TotalPrice: e.TotalPrice, + Kandang: kandang, + PaymentStatus: "Paid", + } +} + +func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { + result := make([]SalesDTO, len(e)) + for i, r := range e { + result[i] = ToSalesDTO(r) + } + return result +} + +func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { + period := extractPeriodFromRealisasi(e) + return PenjualanRealisasiResponseDTO{ + ProjectType: projectType, + FlockId: projectFlockID, + Period: period, + Sales: ToSalesDTOs(e), + } +} + +func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { + if len(realisasi) > 0 { + for _, item := range realisasi { + if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period + } + } + } + return 0 +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index 248c7945..77941256 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -9,6 +9,9 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) @@ -18,11 +21,13 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) userRepo := rUser.NewUserRepository(db) - + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + marketingRepo := rMarketings.NewMarketingRepository(db) + marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, approvalService, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ClosingRoutes(router, userService, closingService) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index bea32155..f04c14c4 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -21,6 +21,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", ctrl.GetAll) + route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan) route.Get("/:projectFlockId", ctrl.GetClosingSummary) route.Get("/:projectFlockId/sapronak", ctrl.GetClosingSapronak) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index a689a2ea..d3ab26e6 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -10,6 +10,9 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -21,23 +24,31 @@ import ( type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.SapronakQuery) (*dto.ClosingSapronakDTO, int64, error) } type closingService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ClosingRepository - ApprovalSvc commonSvc.ApprovalService + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + MarketingRepo marketingRepository.MarketingRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ApprovalSvc commonSvc.ApprovalService } -func NewClosingService(repo repository.ClosingRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService { return &closingService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - ApprovalSvc: approvalSvc, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockRepo: projectFlockRepo, + MarketingRepo: marketingRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ApprovalSvc: approvalSvc, } } @@ -73,6 +84,43 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return closings, total, nil } +func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + if err != nil { + return nil, err + } + return projectFlock, nil +} + +func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) { + + realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + }) + if err != nil { + return nil, err + } + if len(realisasi) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found") + } + return realisasi, nil +} + func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 25806ebb..55114ec8 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -151,12 +151,16 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { } req.Documents = form.File["documents"] - if transactionDate := c.FormValue("transaction_date"); transactionDate != "" { + + transactionDate := c.FormValue("transaction_date") + if transactionDate != "" { req.TransactionDate = &transactionDate } categoryVal := c.FormValue("category") - req.Category = &categoryVal + if categoryVal != "" { + req.Category = &categoryVal + } supplierIDVal := c.FormValue("supplier_id") if supplierIDVal != "" { @@ -312,13 +316,18 @@ func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error { req.Documents = form.File["documents"] - req.RealizationDate = c.FormValue("realization_date") + realizationDate := c.FormValue("realization_date") + if realizationDate != "" { + req.RealizationDate = &realizationDate + } realizationsJSON := c.FormValue("realizations") if realizationsJSON != "" { - if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { + var realizations []validation.RealizationItem + if err := json.Unmarshal([]byte(realizationsJSON), &realizations); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err)) } + req.Realizations = &realizations } expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 5a8b66fc..1fc5c07a 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -14,6 +14,8 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route := v1.Group("/expenses") route.Use(m.Auth(u)) + + // route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 2bd00a0f..7de05689 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -183,7 +183,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) - referenceNumber, err := s.generateReferenceNumber(dbTransaction) + referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } @@ -732,10 +732,10 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va expenseRepoTx := repository.NewExpenseRepository(tx) // Check if only updating documents - updateDataOnly := len(req.Realizations) == 0 && len(req.Documents) > 0 + updateDataOnly := req.Realizations == nil && len(req.Documents) > 0 - if len(req.Realizations) > 0 { - for _, realizationItem := range req.Realizations { + if req.Realizations != nil { + for _, realizationItem := range *req.Realizations { expenseNonstockID := realizationItem.ExpenseNonstockID @@ -770,6 +770,12 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } + if req.RealizationDate != nil { + if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") + } + } + if len(req.Documents) > 0 { if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { return err @@ -1050,17 +1056,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return results, nil } -func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) { - - sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context) - if err != nil { - return "", err - } - refNum := fmt.Sprintf("BOP-LTI-%05d", sequence) - - return refNum, nil -} - func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) diff --git a/internal/modules/expenses/services/number_helper.go b/internal/modules/expenses/services/number_helper.go new file mode 100644 index 00000000..2d1be912 --- /dev/null +++ b/internal/modules/expenses/services/number_helper.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + "fmt" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" +) + +// GenerateExpenseReferenceNumber builds a new reference number using the expense sequence. +func GenerateExpenseReferenceNumber(ctx context.Context, repo expenseRepo.ExpenseRepository) (string, error) { + sequence, err := repo.GetNextSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("BOP-LTI-%05d", sequence), nil +} diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index abe6198c..9dc2b07b 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -5,11 +5,11 @@ import ( ) type Create struct { - PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` - TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` - Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` - SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` + SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } @@ -26,11 +26,11 @@ type CostItem struct { } type Update struct { - TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` - Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` - SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` + Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` } type Query struct { @@ -46,9 +46,9 @@ type CreateRealization struct { } type UpdateRealization struct { - RealizationDate string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` + RealizationDate *string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` - Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` + Realizations *[]RealizationItem `form:"realizations" json:"realizations" validate:"omitempty,min=1,dive"` } type RealizationItem struct { diff --git a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go b/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go deleted file mode 100644 index 512a5786..00000000 --- a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go +++ /dev/null @@ -1,51 +0,0 @@ -package repository - -import ( - "context" - - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gorm.io/gorm" -) - -type MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] - GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) - GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) -} - -type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] -} - -func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { - return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), - } -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { - var deliveryProduct entity.MarketingDeliveryProduct - if err := r.DB().WithContext(ctx).Where("marketing_product_id = ?", marketingProductID).First(&deliveryProduct).Error; err != nil { - return nil, err - } - return &deliveryProduct, nil -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - // Raw query untuk mengambil delivery products berdasarkan marketing ID dengan preload MarketingProduct - // Filter: hanya ambil yang sudah memiliki delivery_date (delivery date tidak null) - if err := r.DB().WithContext(ctx). - Preload("MarketingProduct"). - Joins("INNER JOIN marketing_products mp ON marketing_delivery_products.marketing_product_id = mp.id"). - Where("mp.marketing_id = ?", marketingId). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Order("marketing_delivery_products.id ASC"). - Find(&deliveryProducts).Error; err != nil { - return nil, err - } - - return deliveryProducts, nil -} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index cc7d5b85..f690b2a2 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("CreatedUser") + Preload("ProjectFlockKandang") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -104,7 +104,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = s.Repository.ApplyFlagsFilter(db, cleanFlags) - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("product_warehouses.id DESC") }) if err != nil { diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go index d2f29fe9..69037499 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go @@ -319,15 +319,20 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri }) for i := range groups { - if groups[i].DeliveryDate != nil { - dateStr := groups[i].DeliveryDate.Format("20060102") - groups[i].DoNumber = fmt.Sprintf("%s-%s-%d", soNumber, dateStr, groups[i].Warehouse.Id) - } + groups[i].DoNumber = GenerateDeliveryOrderNumber(soNumber, groups[i].DeliveryDate, groups[i].Warehouse.Id) } return groups } +func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { + dateStr := "" + if deliveryDate != nil { + dateStr = deliveryDate.Format("20060102") + } + return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId) +} + func getVehicleNumber(e entity.MarketingProduct) string { if e.DeliveryProduct != nil && e.DeliveryProduct.VehicleNumber != "" { return e.DeliveryProduct.VehicleNumber diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go index 99bd8396..efe3737d 100644 --- a/internal/modules/marketing/delivery-orderss/module.go +++ b/internal/modules/marketing/delivery-orderss/module.go @@ -9,7 +9,6 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -22,7 +21,7 @@ type DeliveryOrdersModule struct{} func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { marketingRepo := rMarketing.NewMarketingRepository(db) marketingProductRepo := rMarketing.NewMarketingProductRepository(db) - marketingDeliveryProductRepo := rMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(db) + marketingDeliveryProductRepo := rMarketing.NewMarketingDeliveryProductRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go index 09e48f29..c83330da 100644 --- a/internal/modules/marketing/delivery-orderss/route.go +++ b/internal/modules/marketing/delivery-orderss/route.go @@ -1,7 +1,7 @@ package delivery_orderss import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -17,6 +17,7 @@ func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders. // Sisanya di group /delivery-orders route := v1.Group("/delivery-orders") + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go index 92809f19..52ced7d7 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go @@ -8,9 +8,8 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - marketingDeliveryProductRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" @@ -35,14 +34,14 @@ type deliveryOrdersService struct { Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository - MarketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository + MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService } func NewDeliveryOrdersService( marketingRepo marketingRepo.MarketingRepository, marketingProductRepo marketingRepo.MarketingProductRepository, - marketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository, + marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) DeliveryOrdersService { @@ -200,7 +199,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) @@ -259,7 +258,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) } - approvalAction := entity.ApprovalActionApproved if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -301,7 +299,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go index 95e9b3bb..a3c2af88 100644 --- a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go +++ b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,9 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] + GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) } type MarketingDeliveryProductRepositoryImpl struct { @@ -19,3 +24,53 @@ func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProduct BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), } } + +func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // JOIN digunakan untuk filter WHERE clause ke ProjectFlockID yang berada 3 level relasi atas + // Entity relations digunakan di Preload (callback) untuk load data, bukan untuk filter + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Distinct("marketing_delivery_products.*") + + if callback != nil { + db = callback(db) + } + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // JOIN untuk filter by marketing_id yang ada di related table + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Where("marketing_products.marketing_id = ?", marketingId) + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { + var deliveryProduct entity.MarketingDeliveryProduct + + if err := r.DB().WithContext(ctx). + Where("marketing_product_id = ?", marketingProductID). + First(&deliveryProduct).Error; err != nil { + return nil, err + } + + return &deliveryProduct, nil +} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go index ae6d7a81..f87cea66 100644 --- a/internal/modules/marketing/sales-orders/route.go +++ b/internal/modules/marketing/sales-orders/route.go @@ -1,7 +1,7 @@ package sales_orders import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -14,6 +14,7 @@ func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesO v1.Delete("/:id", ctrl.DeleteOne) route := v1.Group("/sales-orders") + route.Use(m.Auth(u)) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/sales-orders/services/sales-orders.service.go index 8acef29d..061ffaf7 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/sales-orders/services/sales-orders.service.go @@ -10,7 +10,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" @@ -125,7 +124,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) marketing = &entity.Marketing{ @@ -220,7 +219,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) updateBody := make(map[string]any) if req.CustomerId != 0 { @@ -527,7 +526,7 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return updated, nil } -func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo rInvMarketingDeliveryProduct.MarketingDeliveryProductRepository) error { +func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index afa90660..a7cfac94 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -24,10 +24,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous func (u *WarehouseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 4c15b94c..79c41284 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -53,11 +53,28 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.ActiveProjectFlockOnly { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM kandangs k + JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE k.id = warehouses.kandang_id + AND LOWER(latest_approval.step_name) = LOWER(?) + ) + `, "Aktif") + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 6046defe..1e305520 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -17,8 +17,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + ActiveProjectFlockOnly bool `query:"active_project_flock"` } diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 6d78520e..52d53be5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -329,3 +329,29 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { Message: "Get projectflock kandang successfully", Data: dtoResult}) } + +func (u *ProjectflockController) Resubmit(c *fiber.Ctx) error { + param := c.Params("id") + req := new(validation.Resubmit) + + 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.ProjectflockService.Resubmit(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Resubmit projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 8324dd71..0922b160 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -9,6 +9,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/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" // pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" @@ -24,15 +25,16 @@ type ProjectFlockRelationDTO struct { type ProjectFlockListDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` + ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type KandangWithProjectFlockIdDTO struct { @@ -51,6 +53,13 @@ type KandangPeriodSummaryDTO struct { Period int `json:"period"` } +type ProjectBudgetDTO struct { + Id uint `json:"id"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` +} + func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectFlockListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser.Id != 0 { @@ -110,6 +119,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF ProjectFlockRelationDTO: createProjectFlockRelationDTO(e, period), Area: areaSummary, Kandangs: kandangSummaries, + ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), Category: e.Category, Fcr: fcrSummary, Location: locationSummary, @@ -184,3 +194,26 @@ func createProjectFlockRelationDTO(e entity.ProjectFlock, period int) ProjectFlo FlockName: e.FlockName, } } + +func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO { + var nonstockRef *nonstockDTO.NonstockRelationDTO + if e.Nonstock != nil && e.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*e.Nonstock) + nonstockRef = &mapped + } + + return ProjectBudgetDTO{ + Id: e.Id, + Qty: e.Qty, + Price: e.Price, + Nonstock: nonstockRef, + } +} + +func ToProjectBudgetDTOs(e []entity.ProjectBudget) []ProjectBudgetDTO { + result := make([]ProjectBudgetDTO, len(e)) + for i, r := range e { + result[i] = ToProjectBudgetDTO(r) + } + return result +} diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 4fd932a4..acd77338 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -12,7 +12,9 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,10 +29,12 @@ type ProjectflockModule struct{} func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { flockRepo := rFlock.NewFlockRepository(db) kandangRepo := rKandang.NewKandangRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -39,7 +43,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index eede3638..15afaf59 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -54,7 +54,10 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm Preload("Location"). Preload("Kandangs"). Preload("KandangHistory"). - Preload("KandangHistory.Kandang") + Preload("KandangHistory.Kandang"). + Preload("Budgets"). + Preload("Budgets.Nonstock"). + Preload("Budgets.Nonstock.Uom") } } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index c1e37cd5..710f5225 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -23,5 +23,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) route.Get("/locations/:location_id/periods", ctrl.GetPeriodSummary) + route.Put("/:id/resubmit", ctrl.Resubmit) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 827e5b19..1a7fc6f2 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -15,7 +15,9 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + nonstockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectBudgetRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -39,6 +41,7 @@ type ProjectflockService interface { GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) + Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) } type projectflockService struct { @@ -47,8 +50,10 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository + NonstockRepo nonstockRepository.NonstockRepository WarehouseRepo warehouseRepository.WarehouseRepository ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + ProjectBudgetRepo projectBudgetRepository.ProjectBudgetRepository PivotRepo repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey @@ -67,8 +72,11 @@ func NewProjectflockService( pivotRepo repository.ProjectFlockKandangRepository, warehouseRepo warehouseRepository.WarehouseRepository, productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, + projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository, + nonstockRepo nonstockRepository.NonstockRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, + ) ProjectflockService { return &projectflockService{ Log: utils.Log, @@ -76,6 +84,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + NonstockRepo: nonstockRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PivotRepo: pivotRepo, @@ -289,7 +298,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - // Generate unique flock name (sequential per base name, starting from 1) generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, 1, nil) if err != nil { return err @@ -300,7 +308,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - // Compute period per kandang so every kandang maintains its own cycle history. periods, err := projectRepo.GetNextPeriodsForKandangs(c.Context(), kandangIDs) if err != nil { return err @@ -309,6 +316,10 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, createBody.Id, req.ProjectBudgets); err != nil { + return err + } + action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( @@ -1044,3 +1055,139 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka } return kandangRepository.NewKandangRepository(s.Repository.DB()) } + +func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + + kandangIDs := uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data kandang") + } + if len(kandangs) != len(kandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + + for _, pb := range req.ProjectBudgets { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &pb.NonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return nil, err + } + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + var period int = 1 + if len(existing.KandangHistory) > 0 { + period = existing.KandangHistory[0].Period + } + + periods := make(map[uint]int, len(kandangIDs)) + for _, kandangID := range kandangIDs { + periods[kandangID] = period + } + + if err := s.attachKandangs(c.Context(), dbTransaction, existing.Id, kandangIDs, periods); err != nil { + return err + } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, existing.Id, req.ProjectBudgets); err != nil { + return err + } + + action := entity.ApprovalActionUpdated + _, err = approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + existing.Id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ) + return err + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengajukan ulang project flock") + } + + return s.getOneEntityOnly(c, id) +} + +func (s projectflockService) UpsertProjectBudget(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, budgets []validation.ProjectBudget) error { + + if len(budgets) == 0 { + return nil + } + budgetRepo := projectBudgetRepository.NewProjectBudgetRepository(dbTransaction) + + nonstockMap := make(map[uint]bool) + relationChecks := make([]commonSvc.RelationCheck, 0, len(budgets)) + for _, b := range budgets { + if nonstockMap[b.NonstockId] { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate nonstock_id: %d", b.NonstockId)) + } + nonstockMap[b.NonstockId] = true + nonstockID := b.NonstockId + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Nonstock", + ID: &nonstockID, + Exists: s.NonstockRepo.IdExists, + }) + } + + if err := commonSvc.EnsureRelations(ctx, relationChecks...); err != nil { + return err + } + + if err := budgetRepo.DeleteMany(ctx, func(q *gorm.DB) *gorm.DB { + return q.Where("project_flock_id = ?", projectFlockID) + }); err != nil && err != gorm.ErrRecordNotFound { + return err + } + + records := make([]*entity.ProjectBudget, 0, len(budgets)) + for _, b := range budgets { + records = append(records, &entity.ProjectBudget{ + ProjectFlockId: projectFlockID, + NonstockId: b.NonstockId, + Price: b.Price, + Qty: b.Qty, + }) + } + + if err := budgetRepo.CreateMany(ctx, records, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to save project budgets") + } + + return nil + +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 33f20725..00b01456 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,12 +1,13 @@ package validation type Create struct { - FlockName string `json:"flock_name" validate:"required_strict"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Update struct { @@ -36,3 +37,14 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type ProjectBudget struct { + NonstockId uint `json:"nonstock_id" validate:"required_strict,number,gt=0"` + Price float64 `json:"price" validate:"required_strict,number,gt=0"` + Qty float64 `json:"qty" validate:"required_strict,number,gt=0"` +} + +type Resubmit struct { + KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"` +} diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index c348a454..c0f1737b 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -146,27 +146,6 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { }) } -func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error { - req := new(validation.SubmitGrading) - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.RecordingService.SubmitGrading(c, req) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Submit grading eggs successfully", - Data: dto.ToRecordingDetailDTO(*result), - }) -} - func (u *RecordingController) Approve(c *fiber.Ctx) error { req := new(validation.Approve) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index f7cc4ee2..51fba8a4 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,7 +1,6 @@ package dto import ( - "math" "strings" "time" @@ -16,22 +15,19 @@ import ( // === DTO Structs === type RecordingRelationDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - Day int `json:"day"` - ProjectFlockCategory string `json:"project_flock_category"` - TotalDepletionQty float64 `json:"total_depletion_qty"` - CumDepletionRate float64 `json:"cum_depletion_rate"` - DailyGain float64 `json:"daily_gain"` - AvgDailyGain float64 `json:"avg_daily_gain"` - CumIntake int `json:"cum_intake"` - FcrValue float64 `json:"fcr_value"` - TotalChickQty float64 `json:"total_chick_qty"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` - EggGradingStatus *string `json:"egg_grading_status"` - EggGradingPendingQty *int `json:"egg_grading_pending_qty"` - EggGradingCompletedQty *int `json:"egg_grading_completed_qty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day int `json:"day"` + ProjectFlockCategory string `json:"project_flock_category"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + DailyGain float64 `json:"daily_gain"` + AvgDailyGain float64 `json:"avg_daily_gain"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + TotalChickQty float64 `json:"total_chick_qty"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type RecordingListDTO struct { @@ -72,8 +68,8 @@ type RecordingEggDTO struct { Id uint `json:"id"` ProductWarehouseId uint `json:"product_warehouse_id"` Qty int `json:"qty"` + Weight *float64 `json:"weight,omitempty"` ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` - Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` } type RecordingProductWarehouseDTO struct { @@ -84,11 +80,6 @@ type RecordingProductWarehouseDTO struct { WarehouseName string `json:"warehouse_name"` } -type RecordingEggGradingDTO struct { - Grade string `json:"grade,omitempty"` - Qty float64 `json:"qty"` -} - // === Mapper Functions === func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { @@ -140,25 +131,20 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { latestApproval = snapshot } - gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e) - return RecordingRelationDTO{ - Id: e.Id, - ProjectFlockKandangId: e.ProjectFlockKandangId, - RecordDatetime: e.RecordDatetime, - Day: day, - ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: totalDepletionQty, - CumDepletionRate: cumDepletionRate, - DailyGain: dailyGain, - AvgDailyGain: avgDailyGain, - CumIntake: cumIntake, - FcrValue: fcrValue, - TotalChickQty: totalChickQty, - Approval: latestApproval, - EggGradingStatus: gradingStatus, - EggGradingPendingQty: gradingPending, - EggGradingCompletedQty: gradingCompleted, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + Day: day, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: totalDepletionQty, + CumDepletionRate: cumDepletionRate, + DailyGain: dailyGain, + AvgDailyGain: avgDailyGain, + CumIntake: cumIntake, + FcrValue: fcrValue, + TotalChickQty: totalChickQty, + Approval: latestApproval, } } @@ -253,29 +239,13 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { Id: egg.Id, ProductWarehouseId: egg.ProductWarehouseId, Qty: egg.Qty, + Weight: egg.Weight, ProductWarehouse: mapProductWarehouseDTO(&egg.ProductWarehouse), - Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), } } return result } -func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO { - if len(gradings) == 0 { - return nil - } - - result := make([]RecordingEggGradingDTO, len(gradings)) - for i, grading := range gradings { - result[i] = RecordingEggGradingDTO{ - Grade: grading.Grade, - Qty: grading.Qty, - } - } - - return result -} - func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { if pw == nil { return productWarehouseDTO.ProductWarehouseDTO{} @@ -289,61 +259,6 @@ func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.Pro return *mapped } -const goodEggProductWarehouseID uint = 5 - -func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) { - goodEggs := filterGoodEggs(e.Eggs) - if len(goodEggs) == 0 { - return nil, nil, nil - } - - totalEggs := 0 - totalGraded := 0.0 - for _, egg := range goodEggs { - totalEggs += egg.Qty - for _, grading := range egg.GradingEggs { - totalGraded += grading.Qty - } - } - - if totalEggs == 0 { - return nil, nil, nil - } - - pendingFloat := float64(totalEggs) - totalGraded - if pendingFloat < 0 { - pendingFloat = 0 - } - pendingInt := int(math.Round(pendingFloat)) - completedInt := int(math.Round(totalGraded)) - if completedInt < 0 { - completedInt = 0 - } - - if pendingInt > 0 { - status := "GRADING_TELUR" - return &status, &pendingInt, &completedInt - } - - status := "GRADING_SELESAI" - zero := 0 - return &status, &zero, &completedInt -} - -func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg { - if len(eggs) == 0 { - return nil - } - - result := make([]entity.RecordingEgg, 0, len(eggs)) - for _, egg := range eggs { - if egg.ProductWarehouseId == goodEggProductWarehouseID { - result = append(result, egg) - } - } - return result -} - func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 341031e1..a19faa33 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -39,7 +39,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "created_at", + CreatedAt: "id", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 5feb8d6b..60457074 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -35,8 +35,6 @@ type RecordingRepository interface { DeleteEggs(tx *gorm.DB, recordingID uint) error ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) - CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error - DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) @@ -76,8 +74,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). - Preload("Eggs.ProductWarehouse.Warehouse"). - Preload("Eggs.GradingEggs") + Preload("Eggs.ProductWarehouse.Warehouse") } func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { @@ -188,7 +185,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( Preload("Recording.ProjectFlockKandang"). Preload("Recording.ProjectFlockKandang.ProjectFlock"). Preload("ProductWarehouse"). - Preload("GradingEggs"). Where("id = ?", id) if err := query.First(&egg).Error; err != nil { @@ -197,17 +193,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( return &egg, nil } -func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error { - if len(gradings) == 0 { - return nil - } - return tx.Create(&gradings).Error -} - -func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error { - return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error -} - func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { if projectFlockKandangId == 0 { return false, nil diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index c492c39f..83b426db 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -18,7 +18,6 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS route.Get("/", ctrl.GetAll) route.Get("/next-day", ctrl.GetNextDay) route.Post("/", ctrl.CreateOne) - route.Post("/gradings", ctrl.SubmitGrading) route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Post("/approvals", ctrl.Approve) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 82f60433..a83c1128 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -33,7 +33,6 @@ type RecordingService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error - SubmitGrading(ctx *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } @@ -273,7 +272,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } action := entity.ApprovalActionCreated - if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepGradingTelur, action, createdRecording.CreatedBy, nil); err != nil { + if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { s.Log.Errorf("Failed to create recording approval for %d: %+v", createdRecording.Id, err) return err } @@ -347,16 +346,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - hasExistingGradings := false - for _, egg := range recordingEntity.Eggs { - if len(egg.GradingEggs) > 0 { - hasExistingGradings = true - break - } - } - - hasEggsAfterUpdate := len(recordingEntity.Eggs) > 0 - if hasBodyChanges { if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear body weights: %+v", err) @@ -441,9 +430,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } - - hasExistingGradings = false - hasEggsAfterUpdate = len(req.Eggs) > 0 } if hasBodyChanges || hasStockChanges || hasDepletionChanges { @@ -459,20 +445,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } - var step approvalutils.ApprovalStep - if isLaying { - if !hasEggsAfterUpdate { - step = utils.RecordingStepGradingTelur - } else if hasEggChanges { - step = utils.RecordingStepGradingTelur - } else if hasExistingGradings { - step = utils.RecordingStepPengajuan - } else { - step = utils.RecordingStepGradingTelur - } - } else { - step = utils.RecordingStepPengajuan - } + step := utils.RecordingStepPengajuan latestApproval := recordingEntity.LatestApproval if latestApproval == nil { @@ -517,109 +490,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return s.GetOne(c, id) } -func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - if len(req.EggsGrading) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") - } - - recordingEggID := req.EggsGrading[0].RecordingEggId - for _, grading := range req.EggsGrading[1:] { - if grading.RecordingEggId != recordingEggID { - return nil, fiber.NewError(fiber.StatusBadRequest, "semua grading harus untuk recording egg yang sama") - } - } - - ctx := c.Context() - var recordingID uint - transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, recordingEggID, func(db *gorm.DB) *gorm.DB { - return tx - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Recording egg not found") - } - if err != nil { - s.Log.Errorf("Failed to get recording egg %d: %+v", recordingEggID, err) - return err - } - - var category string - if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { - return fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") - } - - totalGradingQty := 0.0 - for _, grading := range req.EggsGrading { - totalGradingQty += grading.Qty - } - - availableRecorded := float64(recordingEgg.Qty) - if totalGradingQty > availableRecorded { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), - ) - } - - if recordingEgg.ProductWarehouse.Id != 0 { - availableWarehouse := recordingEgg.ProductWarehouse.Quantity - if totalGradingQty > availableWarehouse { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), - ) - } - } - - if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { - s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - - gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) - createdBy := recordingEgg.CreatedBy - if createdBy == 0 { - createdBy = recordingEgg.Recording.CreatedBy - } - for _, item := range req.EggsGrading { - gradings = append(gradings, entity.GradingEgg{ - RecordingEggId: recordingEgg.Id, - Grade: strings.TrimSpace(item.Grade), - Qty: item.Qty, - CreatedBy: createdBy, - }) - } - - if len(gradings) > 0 { - if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { - s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - } - - action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { - s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) - return err - } - - recordingID = recordingEgg.RecordingId - return nil - }) - if transactionErr != nil { - return nil, transactionErr - } - - return s.GetOne(c, recordingID) -} - func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -934,14 +804,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getFeedUsageInGrams: %w", err) } - fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) - if err != nil { - return fmt.Errorf("getFcrID: %w", err) - } - currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) + prevAvgKg := recordingutil.GramsToKg(prevAvgGrams) currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -951,9 +817,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } recording.TotalDepletionQty = &cumDepletionQty + var remainingChick float64 if totalChick > 0 { totalChickFloat := float64(totalChick) - remainingChick := totalChickFloat - cumDepletionQty + remainingChick = totalChickFloat - cumDepletionQty if remainingChick < 0 { remainingChick = 0 } @@ -978,24 +845,19 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm updates["daily_gain"] = dailyGainKg recording.DailyGain = &dailyGainKg } else { - updates["daily_gain"] = gorm.Expr("NULL") - recording.DailyGain = nil + dailyGainKg := 0.0 + updates["daily_gain"] = dailyGainKg + recording.DailyGain = &dailyGainKg } - if fcrId != 0 && currentAvgKg > 0 && day > 0 { - if fcrWeightKg, ok, err := s.Repository.GetFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { - return fmt.Errorf("getFcrStandardWeightKg: %w", err) - } else if ok { - avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) - updates["avg_daily_gain"] = avgDailyGain - recording.AvgDailyGain = &avgDailyGain - } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil - } + if currentAvgKg > 0 && remainingChick > 0 { + avgDailyGain := (currentAvgKg - prevAvgKg) / remainingChick + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil + avgDailyGain := 0.0 + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } if usageInGrams > 0 && totalChick > 0 { diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 28ea8a9f..28c38ff5 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -19,8 +19,9 @@ type ( } Egg struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty int `json:"qty" validate:"required,number,min=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty int `json:"qty" validate:"required,number,min=0"` + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gte=0"` } ) @@ -45,16 +46,6 @@ type Query struct { ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } -type EggGrading struct { - RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` - Grade string `json:"grade" validate:"required"` - Qty float64 `json:"qty" validate:"required,gte=0"` -} - -type SubmitGrading struct { - EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` -} - type Approve struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index 4a29d860..d6114952 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -21,13 +21,10 @@ type PurchaseRelationDTO struct { Notes *string `json:"notes"` } - type PurchaseListDTO struct { PurchaseRelationDTO Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` DueDate *time.Time `json:"due_date"` - GrandTotal float64 `json:"grand_total"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -37,9 +34,7 @@ type PurchaseListDTO struct { type PurchaseDetailDTO struct { PurchaseRelationDTO Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` DueDate *time.Time `json:"due_date"` - GrandTotal float64 `json:"grand_total"` Items []PurchaseItemDTO `json:"items"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` @@ -145,9 +140,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { return PurchaseListDTO{ PurchaseRelationDTO: ToPurchaseRelationDTO(&p), Supplier: supplier, - CreditTerm: p.CreditTerm, DueDate: p.DueDate, - GrandTotal: p.GrandTotal, CreatedUser: createdUser, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, @@ -188,13 +181,11 @@ func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { return PurchaseDetailDTO{ PurchaseRelationDTO: ToPurchaseRelationDTO(&p), Supplier: supplier, - CreditTerm: p.CreditTerm, DueDate: p.DueDate, - GrandTotal: p.GrandTotal, Items: ToPurchaseItemDTOs(p.Items), CreatedUser: createdUser, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, LatestApproval: latestApproval, } -} \ No newline at end of file +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 56dd5932..ec1b24f7 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -8,15 +8,20 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -28,13 +33,49 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + expenseRepository := expenseRepo.NewExpenseRepository(db) + expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } - expenseBridge := service.NewNoopPurchaseExpenseBridge() + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) + } + expenseServiceInstance := expenseService.NewExpenseService( + expenseRepository, + supplierRepo, + nonstockRepo, + approvalService, + expenseRealizationRepo, + projectFlockKandangRepository, + validate, + ) + expenseBridge := service.NewExpenseBridge( + db, + purchaseRepo, + projectFlockKandangRepository, + expenseServiceInstance, + ) + + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + _ = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_ITEMS"), + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "id", + }, + OrderBy: []string{"id ASC"}, + }) purchaseService := service.NewPurchaseService( validate, @@ -43,8 +84,10 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, + projectFlockKandangRepository, approvalService, expenseBridge, + fifoService, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 49bb07e9..bcb35e85 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -19,12 +19,12 @@ type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error - UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64) error + UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate) error UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error - UpdateGrandTotal(ctx context.Context, purchaseID uint, grandTotal float64) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) + BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error } type PurchaseRepositoryImpl struct { @@ -59,6 +59,34 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } +func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error { + if purchaseID == 0 { + return nil + } + + query := ` +WITH latest_pfk AS ( + SELECT pfk.id, pfk.kandang_id + FROM project_flock_kandangs pfk + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE LOWER(latest_approval.step_name) = LOWER('Aktif') +) +UPDATE purchase_items pi +SET project_flock_kandang_id = lp.id +FROM warehouses w +JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id +WHERE pi.purchase_id = ? + AND pi.project_flock_kandang_id IS NULL + AND pi.warehouse_id = w.id; +` + return r.DB().WithContext(ctx).Exec(query, purchaseID).Error +} + func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil @@ -99,7 +127,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, - grandTotal float64, ) error { if len(updates) == 0 { return errors.New("pricing updates cannot be empty") @@ -133,14 +160,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( } } - if err := db.Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - }).Error; err != nil { - return err - } - return nil } @@ -201,20 +220,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return nil } -func (r *PurchaseRepositoryImpl) UpdateGrandTotal( - ctx context.Context, - purchaseID uint, - grandTotal float64, -) error { - return r.DB().WithContext(ctx). - Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - "updated_at": gorm.Expr("NOW()"), - }).Error -} - func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 3e857d35..1f42872c 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -2,42 +2,757 @@ package service import ( "context" + "errors" + "fmt" + "strconv" + "strings" "time" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" + expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) -// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. type PurchaseExpenseBridge interface { - OnItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error - OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error - OnItemsReceived(ctx context.Context, purchaseID uint, updates []ExpenseReceivingPayload) error + OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error + OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error } -// ExpenseReceivingPayload captures the minimum data expense integration will need once available. type ExpenseReceivingPayload struct { - PurchaseItemID uint - ProductID uint - WarehouseID uint - ReceivedQty float64 - ReceivedDate *time.Time + PurchaseItemID uint + ProductID uint + WarehouseID uint + SupplierID uint + TransportPerItem *float64 + ReceivedQty float64 + ReceivedDate *time.Time } -// noopPurchaseExpenseBridge is the default implementation until the expense module is ready. -type noopPurchaseExpenseBridge struct{} - -func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { - return &noopPurchaseExpenseBridge{} +type groupedItem struct { + item *entity.PurchaseItem + payload ExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 } -func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint, _ []entity.PurchaseItem) error { +func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { + return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) +} + +type expenseBridge struct { + db *gorm.DB + purchaseRepo rPurchase.PurchaseRepository + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + expenseSvc expenseSvc.ExpenseService +} + +func NewExpenseBridge( + db *gorm.DB, + purchaseRepo rPurchase.PurchaseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + expenseSvc expenseSvc.ExpenseService, +) PurchaseExpenseBridge { + return &expenseBridge{ + db: db, + purchaseRepo: purchaseRepo, + projectFlockKandangRepo: projectFlockKandangRepo, + expenseSvc: expenseSvc, + } +} + +func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) error { + if len(items) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + for _, item := range items { + if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) + } + } + if len(expenseNonstockIDs) > 0 { + for _, nsID := range expenseNonstockIDs { + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", nsID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + } + if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { + return err + } + } + + var links []struct { + ItemID uint + ExpenseNonstockID *uint64 + } + if err := tx. + Model(&entity.PurchaseItem{}). + Select("id as item_id, expense_nonstock_id"). + Where("id IN ?", extractIDs(items)). + Scan(&links).Error; err != nil { + return err + } + + for _, link := range links { + if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 { + continue + } + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", *link.ExpenseNonstockID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).Error; err != nil { + return err + } + } + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (b *expenseBridge) cleanupExistingNonstocks(ctx context.Context, updates []ExpenseReceivingPayload) error { + if len(updates) == 0 { + return nil + } + + itemIDs := make([]uint, 0, len(updates)) + for _, upd := range updates { + if upd.PurchaseItemID != 0 { + itemIDs = append(itemIDs, upd.PurchaseItemID) + } + } + if len(itemIDs) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var links []struct { + ItemID uint + ExpenseNonstockID *uint64 + } + if err := tx.Model(&entity.PurchaseItem{}). + Select("id as item_id, expense_nonstock_id"). + Where("id IN ?", itemIDs). + Scan(&links).Error; err != nil { + return err + } + + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + for _, link := range links { + if link.ExpenseNonstockID != nil && *link.ExpenseNonstockID != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *link.ExpenseNonstockID) + } + } + + if len(expenseNonstockIDs) == 0 { + return nil + } + + for _, nsID := range expenseNonstockIDs { + var expenseID uint64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", nsID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + } + + if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { + return err + } + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (b *expenseBridge) cleanupEmptyExpenses(ctx context.Context, expenseIDs []uint64) error { + if len(expenseIDs) == 0 { + return nil + } + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for _, id := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", id). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(id)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, id).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { + if len(expenseIDs) == 0 { + return nil + } + if actorID == 0 { + actorID = 1 + } + svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + action := entity.ApprovalActionUpdated + for id := range expenseIDs { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } return nil } -func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint, _ []uint) error { +func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { + if purchaseID == 0 || len(updates) == 0 { + return nil + } + + ctx := c.Context() + + // Load current links to decide whether to update in place or recreate. + type itemLink struct { + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + } + + purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("Items"). + Preload("Items.Warehouse"). + Preload("Items.Warehouse.Kandang") + }) + if err != nil { + return err + } + + itemLinks := make(map[uint]itemLink) + updatedExpenses := make(map[uint64]struct{}) + if len(updates) > 0 { + ids := make([]uint, 0, len(updates)) + for _, upd := range updates { + if upd.PurchaseItemID != 0 { + ids = append(ids, upd.PurchaseItemID) + } + } + if len(ids) > 0 { + rows := make([]struct { + ItemID uint + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + }, 0) + if err := b.db.WithContext(ctx). + Table("purchase_items pi"). + Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price"). + Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id"). + Joins("LEFT JOIN expenses e ON e.id = en.expense_id"). + Where("pi.id IN ?", ids). + Scan(&rows).Error; err != nil { + return err + } + // Build quick lookup per item and per group key for existing expenses. + for _, row := range rows { + itemLinks[row.ItemID] = itemLink{ + ExpenseNonstockID: row.ExpenseNonstockID, + ExpenseID: row.ExpenseID, + SupplierID: row.SupplierID, + TransactionDate: row.TransactionDate, + Qty: row.Qty, + Price: row.Price, + } + } + } + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + itemMap[purchase.Items[i].Id] = &purchase.Items[i] + } + + groups := make(map[string][]groupedItem) + + for _, payload := range updates { + if payload.ReceivedDate == nil { + return fiber.NewError(fiber.StatusBadRequest, "received_date is required") + } + item := itemMap[payload.PurchaseItemID] + if item == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + } + if payload.ReceivedQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID)) + } + + receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour) + supplierID := payload.SupplierID + if supplierID == 0 { + supplierID = purchase.SupplierId + } + + // Decide whether to update existing expense_nonstock or create new. + link, hasLink := itemLinks[payload.PurchaseItemID] + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour) + newDate := receivedDate + oldSupplier := link.SupplierID + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + + // If supplier/date unchanged, update nonstock in place. + if oldSupplier == supplierID && oldDate.Equal(newDate) { + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + }).Error; err != nil { + return err + } + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + continue + } + + // Supplier/date changed: if the linked expense has only this nonstock, update it in place. + if link.ExpenseID != 0 { + var cnt int64 + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", link.ExpenseID). + Count(&cnt).Error; err != nil { + return err + } + if cnt == 1 { + if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.Expense{}). + Where("id = ?", link.ExpenseID). + Updates(map[string]interface{}{ + "transaction_date": newDate, + "supplier_id": supplierID, + }).Error; err != nil { + return err + } + updateBody := map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + "kandang_id": uint64(*item.Warehouse.KandangId), + } + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + updatedExpenses[link.ExpenseID] = struct{}{} + continue + } + + // Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it. + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + gItem := groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + } + + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID) + if err != nil { + return err + } + + var createdNonstockID uint64 + if expenseDetail != nil { + noteMap := mapExpenseNotes(expenseDetail) + createdNonstockID = noteMap[payload.PurchaseItemID] + } + + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + updateBody := map[string]interface{}{ + "expense_id": expenseDetail.Id, + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + } + if kandangID != nil { + updateBody["kandang_id"] = uint64(*kandangID) + } + if projectFK != nil { + updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } + + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + + if createdNonstockID != 0 { + if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil { + return err + } + } + + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + continue + } + + // Otherwise create new expense/nonstock in grouping flow. + } + + baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) + key := baseKey + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID) + } + + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + groups[key] = append(groups[key], groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + }) + } + + for key, items := range groups { + if len(items) == 0 { + continue + } + parts := strings.Split(key, ":") + if len(parts) < 3 { + return errors.New("invalid expense grouping key") + } + expenseDate, err := utils.ParseDateString(parts[1]) + if err != nil { + return err + } + + supplierID, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return err + } + + expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID)) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID)) + if err != nil { + return err + } + if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil { + return err + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + } + + if len(updatedExpenses) > 0 { + if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil { + return err + } + } + return nil } -func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint, _ []ExpenseReceivingPayload) error { +func (b *expenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) { + var id uint64 + err := b.db.WithContext(ctx). + Table("nonstocks AS ns"). + Select("ns.id"). + Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id"). + Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))). + Where("nss.supplier_id = ?", supplierID). + Order("ns.id"). + Limit(1). + Scan(&id).Error + if err != nil { + return 0, err + } + if id == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi") + } + return id, nil +} + +func extractIDs(items []entity.PurchaseItem) []uint { + result := make([]uint, 0, len(items)) + for _, item := range items { + if item.Id != 0 { + result = append(result, item.Id) + } + } + return result +} + +func (b *expenseBridge) createExpenseViaService( + c *fiber.Ctx, + purchase *entity.Purchase, + items []groupedItem, + expenseDate time.Time, + expeditionNonstockID uint64, + poNumber *string, + supplierID uint, +) (*expenseDto.ExpenseDetailDTO, error) { + ctx := c.Context() + if b.expenseSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available") + } + if len(items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense") + } + + kandangID := items[0].kandangID + if kandangID == nil || *kandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + + costItems := make([]expenseValidation.CostItem, 0, len(items)) + for _, gi := range items { + note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) + price := gi.item.Price + if gi.payload.TransportPerItem != nil { + price = *gi.payload.TransportPerItem + } + costItems = append(costItems, expenseValidation.CostItem{ + NonstockID: expeditionNonstockID, + Quantity: gi.payload.ReceivedQty, + Price: price, + Notes: note, + }) + } + + req := &expenseValidation.Create{ + PoNumber: "", + TransactionDate: utils.FormatDate(expenseDate), + Category: "BOP", + SupplierID: uint64(supplierID), + ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ + KandangID: uint64(*kandangID), + CostItems: costItems, + }}, + } + if poNumber != nil { + req.PoNumber = *poNumber + } + + detail, err := b.expenseSvc.CreateOne(c, req) + if err != nil { + return nil, err + } + + // Mark approvals up to Finance so latest is Manager Finance + action := entity.ApprovalActionApproved + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + return nil, err + } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return nil, err + } + + return detail, nil +} + +func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error { + if detail == nil || len(items) == 0 { + return nil + } + + noteToExpenseNonstock := mapExpenseNotes(detail) + + if len(noteToExpenseNonstock) == 0 { + return nil + } + + for _, gi := range items { + expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID] + if !ok { + continue + } + if err := b.db.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("id = ?", gi.payload.PurchaseItemID). + Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { + return err + } + } + return nil } + +func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { + result := make(map[uint]uint64) + if detail == nil { + return result + } + for _, kandang := range detail.Kandangs { + for _, pengajuan := range kandang.Pengajuans { + note := strings.TrimSpace(pengajuan.Notes) + if note == "" { + continue + } + const prefix = "purchase_item:" + if !strings.HasPrefix(note, prefix) { + continue + } + idStr := strings.TrimPrefix(note, prefix) + var itemID uint + if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { + continue + } + result[itemID] = pengajuan.Id + } + } + return result +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 60a65960..bbaa1b40 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -16,10 +16,12 @@ import ( rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -39,26 +41,28 @@ type PurchaseService interface { } const ( - priceTolerance = 0.0001 + priceTolerance = 0.0001 + purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS") ) type purchaseService struct { - Log *logrus.Logger - Validate *validator.Validate - PurchaseRepo rPurchase.PurchaseRepository - ProductRepo rProduct.ProductRepository - WarehouseRepo rWarehouse.WarehouseRepository - SupplierRepo rSupplier.SupplierRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ApprovalSvc commonSvc.ApprovalService - ExpenseBridge PurchaseExpenseBridge - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductRepo rProduct.ProductRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge + FifoSvc commonSvc.FifoService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem - GrandTotal float64 } func NewPurchaseService( @@ -68,23 +72,24 @@ func NewPurchaseService( warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, + fifoSvc commonSvc.FifoService, ) PurchaseService { - if expenseBridge == nil { - expenseBridge = NewNoopPurchaseExpenseBridge() - } return &purchaseService{ - Log: utils.Log, - Validate: validate, - PurchaseRepo: purchaseRepo, - ProductRepo: productRepo, - WarehouseRepo: warehouseRepo, - SupplierRepo: supplierRepo, - ProductWarehouseRepo: productWarehouseRepo, - ApprovalSvc: approvalSvc, - ExpenseBridge: expenseBridge, - approvalWorkflow: utils.ApprovalWorkflowPurchase, + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductRepo: productRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, + FifoSvc: fifoSvc, + approvalWorkflow: utils.ApprovalWorkflowPurchase, } } func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { @@ -114,9 +119,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti offset := (params.Page - 1) * params.Limit - createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { - return nil, 0, err + return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -225,6 +230,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId uint warehouseId uint subQty float64 + pfkID *uint } if len(req.Items) == 0 { @@ -233,31 +239,47 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) - getWarehouse := func(id uint) (*entity.Warehouse, error) { + getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) { if warehouse, ok := warehouseCache[id]; ok { - return warehouse, nil + return warehouse, nil, nil } warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return db.Preload("Area").Preload("location") + return db.Preload("Area").Preload("Location") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + return nil, nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + } + var pfkID *uint + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + idCopy := uint(pfk.Id) + pfkID = &idCopy + } else if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d has no active project flock", id)) + } else if err != nil { + s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } } warehouseCache[id] = warehouse - return warehouse, nil + return warehouse, pfkID, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { - if _, err := getWarehouse(item.WarehouseID); err != nil { + _, pfkID, err := getWarehouse(item.WarehouseID) + if err != nil { return nil, err } @@ -286,35 +308,42 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId: productId, warehouseId: warehouseId, subQty: item.Quantity, + pfkID: pfkID, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } - creditTermValue := req.CreditTerm - creditTerm := &creditTermValue - dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) - dueDate := &dueDateValue + var dueDate *time.Time + if req.DueDate != nil && strings.TrimSpace(*req.DueDate) != "" { + parsed, err := utils.ParseDateString(strings.TrimSpace(*req.DueDate)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid due_date, expected YYYY-MM-DD") + } + parsed = parsed.UTC() + dueDate = &parsed + } purchase := &entity.Purchase{ SupplierId: uint(req.SupplierID), - CreditTerm: creditTerm, DueDate: dueDate, - GrandTotal: 0, Notes: req.Notes, CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) + emptyVehicle := "" for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ - ProductId: item.productId, - WarehouseId: item.warehouseId, - SubQty: item.subQty, - TotalQty: 0, - TotalUsed: 0, - Price: 0, - TotalPrice: 0, + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProjectFlockKandangId: item.pfkID, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + VehicleNumber: &emptyVehicle, }) } @@ -331,6 +360,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return err } + if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil { + return err + } + actorID := uint(purchase.CreatedBy) if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil { return err @@ -361,6 +394,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, err } + ctx := c.Context() + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -371,7 +406,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, err } - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -379,7 +414,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { + if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -418,12 +453,10 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { - if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil { return err } - grandTotalUpdated = true } if len(payload.NewItems) > 0 { @@ -432,12 +465,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid } } - if !grandTotalUpdated { - if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil { - return err - } - } - if isInitialApproval { if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { return err @@ -481,17 +508,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } - if len(payload.NewItems) > 0 { - newItems := make([]entity.PurchaseItem, len(payload.NewItems)) - for i, item := range payload.NewItems { - if item == nil { - continue - } - newItems[i] = *item - } - s.notifyExpenseItemsCreated(c.Context(), purchase.Id, newItems) - } - return updated, nil } @@ -611,6 +627,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } + ctx := c.Context() + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -621,7 +639,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -647,14 +665,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } if action == entity.ApprovalActionRejected { - if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { + if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { return nil, err } - updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } - if err := s.attachLatestApproval(c.Context(), updated); err != nil { + if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } return updated, nil @@ -670,6 +688,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation payload validation.ReceivePurchaseItemRequest receivedDate time.Time warehouseID uint + supplierID uint + transportPerItem *float64 overrideWarehouse bool receivedQty float64 } @@ -682,7 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } - receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) + receivedDate, err := utils.ParseDateString(payload.ReceivedDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } @@ -697,6 +717,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if warehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) } + if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving does not allow changing warehouse") + } var receivedQty float64 if payload.ReceivedQty != nil { @@ -716,11 +739,27 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } visitedItems[payload.PurchaseItemID] = struct{}{} + supplierID := purchase.SupplierId + if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { + supplierID = *payload.ExpeditionVendorID + } + + var transportPerItem *float64 + if payload.TransportPerItem != nil { + if *payload.TransportPerItem < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) + } + val := *payload.TransportPerItem + transportPerItem = &val + } + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, receivedDate: receivedDate, warehouseID: warehouseID, + supplierID: supplierID, + transportPerItem: transportPerItem, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -737,7 +776,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation approvalSvc := commonSvc.NewApprovalService( commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()), ) - + if approvalSvc != nil { filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { @@ -767,6 +806,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + fifoAdds := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -780,21 +824,29 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation var newPWID *uint clearPW := false + // Always ensure PW when qty > 0 so stockable has target. if prep.receivedQty > 0 { pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) if err != nil { return err } newPWID = &pwID - deltas[pwID] += prep.receivedQty - affected[pwID] = struct{}{} - } else { + } else if oldPWID != nil { + newPWID = oldPWID clearPW = true } - if oldPWID != nil { - deltas[*oldPWID] -= item.TotalQty - affected[*oldPWID] = struct{}{} + deltaQty := prep.receivedQty - item.TotalQty + switch { + case deltaQty > 0 && newPWID != nil: + fifoAdds = append(fifoAdds, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + case deltaQty < 0 && newPWID != nil: + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} } dateCopy := prep.receivedDate @@ -830,12 +882,21 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { - return err - } - - if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { - return err + if s.FifoSvc != nil { + for _, adj := range fifoAdds { + if adj.pwID == 0 || adj.qty <= 0 { + continue + } + if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } } return nil @@ -860,15 +921,31 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation for _, prep := range prepared { date := prep.receivedDate payload := ExpenseReceivingPayload{ - PurchaseItemID: prep.item.Id, - ProductID: prep.item.ProductId, - WarehouseID: uint(prep.warehouseID), - ReceivedQty: prep.receivedQty, - ReceivedDate: &date, + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint(prep.warehouseID), + SupplierID: prep.supplierID, + TransportPerItem: prep.transportPerItem, + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } - s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads) + if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil { + s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + } + + // Create approvals only after expense sync succeeds + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + return nil, err + } + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { + return nil, err + } return updated, nil } @@ -918,6 +995,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") } + toDeleteSet := make(map[uint]struct{}, len(toDelete)) + for _, id := range toDelete { + toDeleteSet[id] = struct{}{} + } + itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete)) + for _, item := range purchase.Items { + if _, ok := toDeleteSet[item.Id]; ok { + itemsToDelete = append(itemsToDelete, item) + } + } + if len(purchase.Items)-len(toDelete) <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") } @@ -929,10 +1017,6 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del return err } - if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil { - return err - } - return nil }) if transactionErr != nil { @@ -942,8 +1026,14 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") } - if len(toDelete) > 0 { - s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + } } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) @@ -971,9 +1061,9 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - itemIDs := make([]uint, 0, len(purchase.Items)) - for _, item := range purchase.Items { - itemIDs = append(itemIDs, item.Id) + itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items)) + for i, item := range purchase.Items { + itemsToDelete[i] = item } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -995,38 +1085,87 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") } - if len(itemIDs) > 0 { - s.notifyExpenseItemsDeleted(ctx, uint(id), itemIDs) + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err) + if fe, ok := err.(*fiber.Error); ok { + return fe + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + } } return nil } -func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { - return +func (s *purchaseService) createPurchaseApproval( + ctx context.Context, + db *gorm.DB, + purchaseID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, + allowDuplicate bool, +) error { + if purchaseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") } - if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { - s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) + if actorID == 0 { + actorID = 1 } + + svc := s.approvalServiceForDB(db) + if svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") + } + + modifier := func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + + latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) + if err != nil { + return err + } + + if !allowDuplicate && latest != nil && + latest.Action != nil && + *latest.Action == action { + return nil + } + + actionCopy := action + _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) + return err } -func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) { +func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { + if db != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } + if s.ApprovalSvc != nil { + return s.ApprovalSvc + } + if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) + } + return nil +} + +func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil { - s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err) + return nil } + return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads) } -func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil { - s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err) +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return nil } + return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items) + } func (s *purchaseService) buildStaffAdjustmentPayload( @@ -1054,7 +1193,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) - var grandTotal float64 existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) for _, item := range purchase.Items { @@ -1120,7 +1258,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } updates = append(updates, update) - grandTotal += totalPrice delete(requestItems, item.Id) } if len(requestItems) > 0 { @@ -1129,6 +1266,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) + emptyVehicle := "" for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { @@ -1175,18 +1313,18 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } newItem := &entity.PurchaseItem{ - PurchaseId: purchase.Id, - ProductId: payload.ProductID, - WarehouseId: payload.WarehouseID, - SubQty: qty, - TotalQty: 0, - TotalUsed: 0, - Price: payload.Price, - TotalPrice: totalPrice, + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, + VehicleNumber: &emptyVehicle, } newItems = append(newItems, newItem) existingCombos[key] = struct{}{} - grandTotal += totalPrice } if len(updates) == 0 && len(newItems) == 0 { @@ -1196,7 +1334,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return &staffAdjustmentPayload{ PricingUpdates: updates, NewItems: newItems, - GrandTotal: grandTotal, }, nil } @@ -1239,36 +1376,6 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity return nil } -func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { - var fromPtr *time.Time - var toPtr *time.Time - const queryDateLayout = "2006-01-02" - - if strings.TrimSpace(fromStr) != "" { - parsed, err := time.Parse(queryDateLayout, fromStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD") - } - fromValue := parsed - fromPtr = &fromValue - } - - if strings.TrimSpace(toStr) != "" { - parsed, err := time.Parse(queryDateLayout, toStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD") - } - toValue := parsed.AddDate(0, 0, 1) - toPtr = &toValue - } - - if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to") - } - - return fromPtr, toPtr, nil -} - func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { value := strings.ToUpper(strings.TrimSpace(raw)) switch value { @@ -1302,53 +1409,3 @@ func (s *purchaseService) rejectAndReload( } return updated, nil } - -func (s *purchaseService) createPurchaseApproval( - ctx context.Context, - db *gorm.DB, - purchaseID uint, - step approvalutils.ApprovalStep, - action entity.ApprovalAction, - actorID uint, - notes *string, - allowDuplicate bool, -) error { - if purchaseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") - } - if actorID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval") - } - - var svc commonSvc.ApprovalService - switch { - case db != nil: - svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - case s.ApprovalSvc != nil: - svc = s.ApprovalSvc - case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil: - svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) - } - if svc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") - } - - modifier := func(db *gorm.DB) *gorm.DB { - return db.Where("step_number = ?", uint16(step)) - } - - latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) - if err != nil { - return err - } - - if !allowDuplicate && latest != nil && - latest.Action != nil && - *latest.Action == action { - return nil - } - - actionCopy := action - _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) - return err -} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 420b6c63..6bbe9ddc 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -8,7 +8,7 @@ type PurchaseItemPayload struct { type CreatePurchaseRequest struct { SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - CreditTerm int `json:"credit_term" validate:"required,gte=0"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Notes *string `json:"notes" validate:"omitempty,max=500"` Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` } @@ -38,6 +38,8 @@ type ReceivePurchaseItemRequest struct { PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"` WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` + ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` diff --git a/internal/utils/constant.go b/internal/utils/constant.go index e9d0d60d..6594ac6b 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -154,12 +154,14 @@ const ( ApprovalWorkflowProjectFlock approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCKS") ProjectFlockStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockStepAktif approvalutils.ApprovalStep = 2 + ProjectFlockStepSelesai approvalutils.ApprovalStep = 3 ) // projectFlockApprovalSteps keeps the workflow step definitions for project flock approvals. var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepPengajuan: "Pengajuan", ProjectFlockStepAktif: "Aktif", + ProjectFlockStepSelesai: "Selesai", } // ------------------------------------------------------------------- @@ -198,13 +200,11 @@ var TransferToLayingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS") - RecordingStepGradingTelur approvalutils.ApprovalStep = 1 - RecordingStepPengajuan approvalutils.ApprovalStep = 2 - RecordingStepDisetujui approvalutils.ApprovalStep = 3 + RecordingStepPengajuan approvalutils.ApprovalStep = 1 + RecordingStepDisetujui approvalutils.ApprovalStep = 2 ) var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ - RecordingStepGradingTelur: "Grading-Telur", RecordingStepPengajuan: "Pengajuan", RecordingStepDisetujui: "Disetujui", } diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 8f0fe81f..f10926dc 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -80,6 +80,7 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, Qty: item.Qty, + Weight: item.Weight, CreatedBy: createdBy, }) } diff --git a/internal/utils/time.go b/internal/utils/time.go index f57a3bb3..5f34923e 100644 --- a/internal/utils/time.go +++ b/internal/utils/time.go @@ -1,8 +1,9 @@ package utils import ( - "time" "errors" + "strings" + "time" ) // ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time @@ -23,3 +24,35 @@ func ParseDateString(dateStr string) (time.Time, error) { func FormatDate(t time.Time) string { return t.Format("2006-01-02") } + +// ParseDateRangeForQuery parses optional YYYY-MM-DD from/to strings for list filters. +// It returns a start pointer (inclusive) and an end pointer advanced by one day +// so callers can safely use "< end" to achieve an inclusive upper bound. +func ParseDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + if strings.TrimSpace(fromStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(fromStr)) + if err != nil { + return nil, nil, errors.New("created_from must use format YYYY-MM-DD") + } + fromValue := parsed + fromPtr = &fromValue + } + + if strings.TrimSpace(toStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(toStr)) + if err != nil { + return nil, nil, errors.New("created_to must use format YYYY-MM-DD") + } + nextDay := parsed.AddDate(0, 0, 1) + toPtr = &nextDay + } + + if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { + return nil, nil, errors.New("created_from must be earlier than created_to") + } + + return fromPtr, toPtr, nil +}