From 1c875a916b321f1cf3a77ea7e45b6449ca02c4d1 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 27 Dec 2025 14:30:03 +0700 Subject: [PATCH 01/14] feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity --- .../repository/common.approval.repository..go | 3 +- .../repository/common.exists.repository.go | 35 +- ...251224033033_create_payment_table.down.sql | 3 + ...20251224033033_create_payment_table.up.sql | 22 ++ ...43019_add_soft_delete_fk_triggers.down.sql | 18 + ...4043019_add_soft_delete_fk_triggers.up.sql | 126 ++++++ ...0000_create_payment_code_sequence.down.sql | 1 + ...130000_create_payment_code_sequence.up.sql | 1 + internal/entities/initial.go | 30 ++ internal/entities/payment.go | 32 ++ internal/entities/transaction.go | 18 + internal/middleware/permissions.go | 9 + .../controllers/initial.controller.go | 92 +++++ .../finance/initials/dto/initial.dto.go | 163 ++++++++ internal/modules/finance/initials/module.go | 36 ++ .../repositories/initial.repository.go | 51 +++ internal/modules/finance/initials/route.go | 21 + .../initials/services/initial.service.go | 336 ++++++++++++++++ .../validations/initial.validation.go | 27 ++ .../controllers/injection.controller.go | 92 +++++ .../finance/injections/dto/injection.dto.go | 102 +++++ internal/modules/finance/injections/module.go | 36 ++ .../repositories/injection.repository.go | 41 ++ internal/modules/finance/injections/route.go | 21 + .../injections/services/injection.service.go | 230 +++++++++++ .../validations/injection.validation.go | 21 + internal/modules/finance/module.go | 13 + .../controllers/payment.controller.go | 92 +++++ .../finance/payments/dto/payment.dto.go | 189 +++++++++ internal/modules/finance/payments/module.go | 36 ++ .../repositories/payment.repository.go | 62 +++ internal/modules/finance/payments/route.go | 21 + .../payments/services/payment.service.go | 362 ++++++++++++++++++ .../validations/payment.validation.go | 29 ++ internal/modules/finance/route.go | 31 ++ .../controllers/transaction.controller.go | 96 +++++ .../transactions/dto/transaction.dto.go | 189 +++++++++ .../modules/finance/transactions/module.go | 42 ++ .../repositories/transaction.repository.go | 21 + .../modules/finance/transactions/route.go | 21 + .../services/transaction.service.go | 175 +++++++++ .../validations/transaction.validation.go | 15 + .../master/kandangs/dto/kandang.dto.go | 1 + internal/route/route.go | 2 + internal/utils/constant.go | 108 ++++++ tools/templates/route.tmpl | 19 +- 46 files changed, 3068 insertions(+), 23 deletions(-) create mode 100644 internal/database/migrations/20251224033033_create_payment_table.down.sql create mode 100644 internal/database/migrations/20251224033033_create_payment_table.up.sql create mode 100644 internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql create mode 100644 internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql create mode 100644 internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql create mode 100644 internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql create mode 100644 internal/entities/initial.go create mode 100644 internal/entities/payment.go create mode 100644 internal/entities/transaction.go create mode 100644 internal/modules/finance/initials/controllers/initial.controller.go create mode 100644 internal/modules/finance/initials/dto/initial.dto.go create mode 100644 internal/modules/finance/initials/module.go create mode 100644 internal/modules/finance/initials/repositories/initial.repository.go create mode 100644 internal/modules/finance/initials/route.go create mode 100644 internal/modules/finance/initials/services/initial.service.go create mode 100644 internal/modules/finance/initials/validations/initial.validation.go create mode 100644 internal/modules/finance/injections/controllers/injection.controller.go create mode 100644 internal/modules/finance/injections/dto/injection.dto.go create mode 100644 internal/modules/finance/injections/module.go create mode 100644 internal/modules/finance/injections/repositories/injection.repository.go create mode 100644 internal/modules/finance/injections/route.go create mode 100644 internal/modules/finance/injections/services/injection.service.go create mode 100644 internal/modules/finance/injections/validations/injection.validation.go create mode 100644 internal/modules/finance/module.go create mode 100644 internal/modules/finance/payments/controllers/payment.controller.go create mode 100644 internal/modules/finance/payments/dto/payment.dto.go create mode 100644 internal/modules/finance/payments/module.go create mode 100644 internal/modules/finance/payments/repositories/payment.repository.go create mode 100644 internal/modules/finance/payments/route.go create mode 100644 internal/modules/finance/payments/services/payment.service.go create mode 100644 internal/modules/finance/payments/validations/payment.validation.go create mode 100644 internal/modules/finance/route.go create mode 100644 internal/modules/finance/transactions/controllers/transaction.controller.go create mode 100644 internal/modules/finance/transactions/dto/transaction.dto.go create mode 100644 internal/modules/finance/transactions/module.go create mode 100644 internal/modules/finance/transactions/repositories/transaction.repository.go create mode 100644 internal/modules/finance/transactions/route.go create mode 100644 internal/modules/finance/transactions/services/transaction.service.go create mode 100644 internal/modules/finance/transactions/validations/transaction.validation.go diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index dc517f21..8c045084 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets( result := make(map[uint]entity.Approval, len(approvableIDs)) q := r.DB().WithContext(ctx). + Select("DISTINCT ON (approvable_id) *"). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). - Order("action_at DESC") + Order("approvable_id, action_at DESC") if modifier != nil { q = modifier(q) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index c6bc11f0..b8206eb9 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -9,45 +10,59 @@ import ( // Exists reports whether a record with the given ID exists for type T. func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { - var count int64 - if err := db.WithContext(ctx). + var marker int + err := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("id = ?", id). - Count(&count).Error; err != nil { + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { return false, err } - return count > 0, nil + return true, nil } func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("name = ?", name). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { if field == "" { return false, fmt.Errorf("field is required") } - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where(fmt.Sprintf("%s = ?", field), value). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } diff --git a/internal/database/migrations/20251224033033_create_payment_table.down.sql b/internal/database/migrations/20251224033033_create_payment_table.down.sql new file mode 100644 index 00000000..14bc4ca1 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_payments_bank_id; +DROP INDEX IF EXISTS payments_party_polymorphic; +DROP TABLE IF EXISTS payments; diff --git a/internal/database/migrations/20251224033033_create_payment_table.up.sql b/internal/database/migrations/20251224033033_create_payment_table.up.sql new file mode 100644 index 00000000..d27c55f4 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + payment_code VARCHAR(50) NOT NULL, + reference_number VARCHAR(100) NULL, + transaction_type VARCHAR(50), + party_type VARCHAR(50) NOT NULL, + party_id BIGINT NOT NULL, + payment_date TIMESTAMPTZ NOT NULL, + payment_method VARCHAR(20) NOT NULL, + bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE, + direction VARCHAR(5) NOT NULL, + nominal NUMERIC(15, 3) NOT NULL, + notes TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Indexes +CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id); +CREATE INDEX idx_payments_bank_id ON payments (bank_id); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql new file mode 100644 index 00000000..1d55147b --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql @@ -0,0 +1,18 @@ +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + END LOOP; +END $$; + +DROP FUNCTION IF EXISTS soft_delete_handle_fk(); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql new file mode 100644 index 00000000..50996e8f --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql @@ -0,0 +1 @@ +DROP SEQUENCE IF EXISTS payments_code_seq; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql new file mode 100644 index 00000000..875b0697 --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql @@ -0,0 +1 @@ +CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1; diff --git a/internal/entities/initial.go b/internal/entities/initial.go new file mode 100644 index 00000000..c562d748 --- /dev/null +++ b/internal/entities/initial.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Initial struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(100);not null"` + TransactionType string `gorm:"type:varchar(50);not null"` + InitialBalanceType string `gorm:"type:varchar(20);not null"` + PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"` + BankId *uint `gorm:"index"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedBy uint `gorm:"index" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Bank Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/payment.go b/internal/entities/payment.go new file mode 100644 index 00000000..e48800fb --- /dev/null +++ b/internal/entities/payment.go @@ -0,0 +1,32 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Payment struct { + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` + + BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/transaction.go b/internal/entities/transaction.go new file mode 100644 index 00000000..b099bd08 --- /dev/null +++ b/internal/entities/transaction.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f46c25a9..02145930 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -192,6 +192,15 @@ const ( P_PurchaseApprovalManager = "lti.Purchase.approve.manager" ) +const ( + P_FinanceGetAll = "lti.finance.list" + P_FinanceGetOne = "lti.finance.detail" + P_FinanceCreateOne = "lti.finance.create" + P_FinanceUpdateOne = "lti.finance.update" + P_FinanceDeleteOne = "lti.finance.delete" + P_FinanceApproval = "lti.finance.approve" +) + const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" diff --git a/internal/modules/finance/initials/controllers/initial.controller.go b/internal/modules/finance/initials/controllers/initial.controller.go new file mode 100644 index 00000000..4aef677a --- /dev/null +++ b/internal/modules/finance/initials/controllers/initial.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InitialController struct { + InitialService service.InitialService +} + +func NewInitialController(initialService service.InitialService) *InitialController { + return &InitialController{ + InitialService: initialService, + } +} + +func (u *InitialController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InitialService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + 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.InitialService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go new file mode 100644 index 00000000..5eb76e9c --- /dev/null +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -0,0 +1,163 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InitialRelationDTO struct { + Id uint `json:"id"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + InitialBalanceType string `json:"initial_balance_type"` + InitialBalanceTypeLabel string `json:"initial_balance_type_label"` + Party Party `json:"party"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InitialListDTO struct { + InitialRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InitialDetailDTO struct { + InitialListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO { + reference := "" + if e.ReferenceNumber != nil { + reference = *e.ReferenceNumber + } + + initialBalanceType := initialBalanceTypeFromPayment(e) + return InitialRelationDTO{ + Id: e.Id, + ReferenceNumber: reference, + TransactionType: transactionTypeLabel(e.TransactionType), + InitialBalanceType: initialBalanceType, + InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType), + Party: partyFromInitial(e), + Bank: bankFromInitial(e), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInitialListDTO(e entity.Payment) InitialListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InitialListDTO{ + InitialRelationDTO: ToInitialRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInitial(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInitialListDTOs(e []entity.Payment) []InitialListDTO { + result := make([]InitialListDTO, len(e)) + for i, r := range e { + result[i] = ToInitialListDTO(r) + } + return result +} + +func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { + return InitialDetailDTO{ + InitialListDTO: ToInitialListDTO(e), + } +} + +func partyFromInitial(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) { + return "Saldo Awal" + } + return transactionType +} + +func initialBalanceLabel(balanceType string) string { + switch strings.ToUpper(strings.TrimSpace(balanceType)) { + case "NEGATIVE": + return "Saldo Awal Negatif" + case "POSITIVE": + return "Saldo Awal Positif" + default: + return balanceType + } +} + +func initialBalanceTypeFromPayment(e entity.Payment) string { + if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} diff --git a/internal/modules/finance/initials/module.go b/internal/modules/finance/initials/module.go new file mode 100644 index 00000000..051c8d3f --- /dev/null +++ b/internal/modules/finance/initials/module.go @@ -0,0 +1,36 @@ +package initials + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InitialModule struct{} + +func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + initialRepo := rInitial.NewInitialRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + + initialService := sInitial.NewInitialService(initialRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InitialRoutes(router, userService, initialService) +} diff --git a/internal/modules/finance/initials/repositories/initial.repository.go b/internal/modules/finance/initials/repositories/initial.repository.go new file mode 100644 index 00000000..9c285c5c --- /dev/null +++ b/internal/modules/finance/initials/repositories/initial.repository.go @@ -0,0 +1,51 @@ +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 InitialRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InitialRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInitialRepository(db *gorm.DB) InitialRepository { + return &InitialRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/initials/route.go b/internal/modules/finance/initials/route.go new file mode 100644 index 00000000..21232493 --- /dev/null +++ b/internal/modules/finance/initials/route.go @@ -0,0 +1,21 @@ +package initials + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers" + initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) { + ctrl := controller.NewInitialController(s) + + route := v1.Group("/initial-balances") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go new file mode 100644 index 00000000..2eb15d3b --- /dev/null +++ b/internal/modules/finance/initials/services/initial.service.go @@ -0,0 +1,336 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InitialService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type initialService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InitialRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInitialService( + repo repository.InitialRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InitialService { + return &initialService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInitial, + } +} + +func (s initialService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(initial.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err) + } else { + initial.LatestApproval = approval + } + } + return initial, nil +} + +func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInitialCode(c.Context()) + if err != nil { + return nil, err + } + + reference := req.ReferenceNumber + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: &reference, + TransactionType: string(utils.TransactionTypeSaldoAwal), + PartyType: party, + PartyId: req.PartyId, + PaymentDate: time.Now(), + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: directionForInitialType(balanceType), + Nominal: signedNominal(balanceType, req.Nominal), + Notes: req.Note, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + initialRepoTx := repository.NewInitialRepository(dbTransaction) + if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InitialStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create initial: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.Note != nil { + updateBody["notes"] = *req.Note + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + + requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil + requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil + var existing *entity.Payment + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + existing = current + } + + if req.PartyType != nil || req.PartyId != nil { + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + } + + if req.InitialBalanceType != nil || req.Nominal != nil { + balanceType := balanceTypeFromPayment(existing) + if req.InitialBalanceType != nil { + normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType) + if err != nil { + return nil, err + } + balanceType = normalized + } + + nominal := math.Abs(existing.Nominal) + if req.Nominal != nil { + nominal = *req.Nominal + } + + updateBody["direction"] = directionForInitialType(balanceType) + updateBody["nominal"] = signedNominal(balanceType, nominal) + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + s.Log.Errorf("Failed to update initial: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInitialTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) +} + +func balanceTypeFromPayment(payment *entity.Payment) string { + if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizeInitialBalanceType(balanceType string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(balanceType)) + switch normalized { + case "NEGATIVE", "POSITIVE": + return normalized, nil + default: + return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`") + } +} + +func directionForInitialType(balanceType string) string { + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" +} + +func signedNominal(balanceType string, nominal float64) float64 { + normalized := math.Abs(nominal) + if strings.EqualFold(balanceType, "NEGATIVE") { + return -normalized + } + return normalized +} + +func (s initialService) generateInitialCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INIT-%05d", sequence), nil +} + +func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go new file mode 100644 index 00000000..27df2eea --- /dev/null +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` + InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Note string `json:"note" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"` + InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Note *string `json:"note,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/injections/controllers/injection.controller.go b/internal/modules/finance/injections/controllers/injection.controller.go new file mode 100644 index 00000000..8f6c6b6d --- /dev/null +++ b/internal/modules/finance/injections/controllers/injection.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InjectionController struct { + InjectionService service.InjectionService +} + +func NewInjectionController(injectionService service.InjectionService) *InjectionController { + return &InjectionController{ + InjectionService: injectionService, + } +} + +func (u *InjectionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InjectionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Balance injection created successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + 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.InjectionService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} diff --git a/internal/modules/finance/injections/dto/injection.dto.go b/internal/modules/finance/injections/dto/injection.dto.go new file mode 100644 index 00000000..d0be7f3f --- /dev/null +++ b/internal/modules/finance/injections/dto/injection.dto.go @@ -0,0 +1,102 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InjectionRelationDTO struct { + Id uint `json:"id"` + TransactionType string `json:"transaction_type"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + AdjustmentDate string `json:"adjustment_date"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InjectionListDTO struct { + InjectionRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InjectionDetailDTO struct { + InjectionListDTO +} + +// === Mapper Functions === + +func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO { + return InjectionRelationDTO{ + Id: e.Id, + TransactionType: transactionTypeLabel(e.TransactionType), + Bank: bankFromInjection(e), + AdjustmentDate: utils.FormatDate(e.PaymentDate), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInjectionListDTO(e entity.Payment) InjectionListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InjectionListDTO{ + InjectionRelationDTO: ToInjectionRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInjection(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO { + result := make([]InjectionListDTO, len(e)) + for i, r := range e { + result[i] = ToInjectionListDTO(r) + } + return result +} + +func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO { + return InjectionDetailDTO{ + InjectionListDTO: ToInjectionListDTO(e), + } +} + +func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInjection(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) { + return "Injection" + } + return transactionType +} diff --git a/internal/modules/finance/injections/module.go b/internal/modules/finance/injections/module.go new file mode 100644 index 00000000..0c4517e6 --- /dev/null +++ b/internal/modules/finance/injections/module.go @@ -0,0 +1,36 @@ +package injections + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InjectionModule struct{} + +func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + injectionRepo := rInjection.NewInjectionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InjectionRoutes(router, userService, injectionService) +} diff --git a/internal/modules/finance/injections/repositories/injection.repository.go b/internal/modules/finance/injections/repositories/injection.repository.go new file mode 100644 index 00000000..2e6869b7 --- /dev/null +++ b/internal/modules/finance/injections/repositories/injection.repository.go @@ -0,0 +1,41 @@ +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 InjectionRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InjectionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInjectionRepository(db *gorm.DB) InjectionRepository { + return &InjectionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/injections/route.go b/internal/modules/finance/injections/route.go new file mode 100644 index 00000000..cb66ccb7 --- /dev/null +++ b/internal/modules/finance/injections/route.go @@ -0,0 +1,21 @@ +package injections + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers" + injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) { + ctrl := controller.NewInjectionController(s) + + route := v1.Group("/injections") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go new file mode 100644 index 00000000..1b1062b4 --- /dev/null +++ b/internal/modules/finance/injections/services/injection.service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InjectionService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type injectionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InjectionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInjectionService( + repo repository.InjectionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InjectionService { + return &injectionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInjection, + } +} + +func (s injectionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse") +} + +func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(injection.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err) + } else { + injection.LatestApproval = approval + } + } + return injection, nil +} + +func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInjectionCode(c.Context()) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + TransactionType: string(utils.TransactionTypeInjection), + PartyType: string(utils.PaymentPartyCustomer), + PartyId: 0, + PaymentDate: adjustmentDate, + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: "IN", + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + injectionRepoTx := repository.NewInjectionRepository(dbTransaction) + if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InjectionStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create injection: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + } + + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.AdjustmentDate != nil { + parsedDate, err := utils.ParseDateString(*req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + s.Log.Errorf("Failed to update injection: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInjectionTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) +} + +func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INJ-%05d", sequence), nil +} + +func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go new file mode 100644 index 00000000..eb324525 --- /dev/null +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/module.go b/internal/modules/finance/module.go new file mode 100644 index 00000000..ded5fbae --- /dev/null +++ b/internal/modules/finance/module.go @@ -0,0 +1,13 @@ +package finance + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type FinanceModule struct{} + +func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/finance/payments/controllers/payment.controller.go b/internal/modules/finance/payments/controllers/payment.controller.go new file mode 100644 index 00000000..5bccecf4 --- /dev/null +++ b/internal/modules/finance/payments/controllers/payment.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PaymentController struct { + PaymentService service.PaymentService +} + +func NewPaymentController(paymentService service.PaymentService) *PaymentController { + return &PaymentController{ + PaymentService: paymentService, + } +} + +func (u *PaymentController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PaymentService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + 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.PaymentService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go new file mode 100644 index 00000000..23005e2d --- /dev/null +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type PaymentRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type PaymentListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type PaymentDetailDTO struct { + PaymentListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return PaymentRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToPaymentListDTO(e entity.Payment) PaymentListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return PaymentListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO { + result := make([]PaymentListDTO, len(e)) + for i, r := range e { + result[i] = ToPaymentListDTO(r) + } + return result +} + +func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { + return PaymentDetailDTO{ + PaymentListDTO: ToPaymentListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go new file mode 100644 index 00000000..fdc0ce47 --- /dev/null +++ b/internal/modules/finance/payments/module.go @@ -0,0 +1,36 @@ +package payments + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gorm.io/gorm" + + rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PaymentModule struct{} + +func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + paymentRepo := rPayment.NewPaymentRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + PaymentRoutes(router, userService, paymentService) +} diff --git a/internal/modules/finance/payments/repositories/payment.repository.go b/internal/modules/finance/payments/repositories/payment.repository.go new file mode 100644 index 00000000..b16f8881 --- /dev/null +++ b/internal/modules/finance/payments/repositories/payment.repository.go @@ -0,0 +1,62 @@ +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 PaymentRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + SupplierCategory(ctx context.Context, supplierId uint) (string, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type PaymentRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) PaymentRepository { + return &PaymentRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) { + var supplier entity.Supplier + if err := r.db.WithContext(ctx). + Select("id", "category"). + First(&supplier, supplierId).Error; err != nil { + return "", err + } + return supplier.Category, nil +} + +func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go new file mode 100644 index 00000000..4b0e8bd2 --- /dev/null +++ b/internal/modules/finance/payments/route.go @@ -0,0 +1,21 @@ +package payments + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers" + payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) { + ctrl := controller.NewPaymentController(s) + + route := v1.Group("/payments") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go new file mode 100644 index 00000000..356288f1 --- /dev/null +++ b/internal/modules/finance/payments/services/payment.service.go @@ -0,0 +1,362 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PaymentService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type paymentService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PaymentRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewPaymentService( + repo repository.PaymentRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) PaymentService { + return &paymentService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowPayment, + } +} + +func (s paymentService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err) + } else { + payment.LatestApproval = approval + } + } + return payment, nil +} + +func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + //! CHECK PARTY TYPE + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + //! CHECK EXISTS + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + //? NORMALIZE + paymentDate, err := utils.ParseDateString(req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + method, err := normalizePaymentMethod(req.PaymentMethod) + if err != nil { + return nil, err + } + transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId) + if err != nil { + return nil, err + } + + //? GET CREATED BY + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generatePaymentCode(c.Context(), party) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(dbTransaction) + if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.PaymentStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create payment: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.PaymentDate != nil { + parsedDate, err := utils.ParseDateString(*req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.PaymentMethod != nil { + method, err := normalizePaymentMethod(*req.PaymentMethod) + if err != nil { + return nil, err + } + updateBody["payment_method"] = method + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if req.PartyType != nil || req.PartyId != nil { + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + updateBody["direction"] = directionForParty(partyType) + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + + transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId) + if err != nil { + return nil, err + } + updateBody["transaction_type"] = transactionType + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + s.Log.Errorf("Failed to update payment: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizePaymentMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if !utils.IsValidPaymentMethod(normalized) { + return "", utils.BadRequest("Invalid payment_method") + } + return normalized, nil +} + +func directionForParty(partyType string) string { + if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer { + return "IN" + } + return "OUT" +} + +func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return string(utils.TransactionTypePenjualan), nil + case utils.PaymentPartySupplier: + category, err := s.getSupplierCategory(ctx, partyId) + if err != nil { + return "", err + } + if isSupplierCategoryBiaya(category) { + return string(utils.TransactionTypeBiaya), nil + } + return string(utils.TransactionTypePembelian), nil + default: + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) { + prefix := "PAY" + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + prefix = "PAY-IN" + case utils.PaymentPartySupplier: + prefix = "PAY-OUT" + } + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%05d", prefix, sequence), nil +} + +func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} + +func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) { + category, err := s.Repository.SupplierCategory(ctx, supplierId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", utils.NotFound("Supplier not found") + } + return "", err + } + return strings.ToUpper(strings.TrimSpace(category)), nil +} + +func isSupplierCategoryBiaya(category string) bool { + switch strings.ToUpper(strings.TrimSpace(category)) { + case string(utils.SupplierCategoryBOP), "BIAYA": + return true + default: + return false + } +} diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go new file mode 100644 index 00000000..14c8f151 --- /dev/null +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -0,0 +1,29 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/route.go b/internal/modules/finance/route.go new file mode 100644 index 00000000..bc99bf7e --- /dev/null +++ b/internal/modules/finance/route.go @@ -0,0 +1,31 @@ +package finance + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments" + initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials" + injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections" + transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/finance") + + allModules := []modules.Module{ + payments.PaymentModule{}, + initials.InitialModule{}, + injections.InjectionModule{}, + transactions.TransactionModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go new file mode 100644 index 00000000..fa3e1369 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransactionController struct { + TransactionService service.TransactionService +} + +func NewTransactionController(transactionService service.TransactionService) *TransactionController { + return &TransactionController{ + TransactionService: transactionService, + } +} + +func (u *TransactionController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.TransactionService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transactions successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransactionListDTOs(result), + }) +} + +func (u *TransactionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransactionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transaction successfully", + Data: dto.ToTransactionListDTO(*result), + }) +} + +func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transaction successfully", + }) +} diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go new file mode 100644 index 00000000..25740344 --- /dev/null +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type TransactionRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type TransactionListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type TransactionDetailDTO struct { + TransactionListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return TransactionRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToTransactionListDTO(e entity.Payment) TransactionListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return TransactionListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO { + result := make([]TransactionListDTO, len(e)) + for i, r := range e { + result[i] = ToTransactionListDTO(r) + } + return result +} + +func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { + return TransactionDetailDTO{ + TransactionListDTO: ToTransactionListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/transactions/module.go b/internal/modules/finance/transactions/module.go new file mode 100644 index 00000000..c98931a3 --- /dev/null +++ b/internal/modules/finance/transactions/module.go @@ -0,0 +1,42 @@ +package transactions + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransactionModule struct{} + +func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transactionRepo := rTransaction.NewTransactionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + TransactionRoutes(router, userService, transactionService) +} diff --git a/internal/modules/finance/transactions/repositories/transaction.repository.go b/internal/modules/finance/transactions/repositories/transaction.repository.go new file mode 100644 index 00000000..d1629e8b --- /dev/null +++ b/internal/modules/finance/transactions/repositories/transaction.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type TransactionRepository interface { + repository.BaseRepository[entity.Payment] +} + +type TransactionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] +} + +func NewTransactionRepository(db *gorm.DB) TransactionRepository { + return &TransactionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + } +} diff --git a/internal/modules/finance/transactions/route.go b/internal/modules/finance/transactions/route.go new file mode 100644 index 00000000..d21f5441 --- /dev/null +++ b/internal/modules/finance/transactions/route.go @@ -0,0 +1,21 @@ +package transactions + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers" + transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) { + ctrl := controller.NewTransactionController(s) + + route := v1.Group("/transactions") + // route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go new file mode 100644 index 00000000..f7398d43 --- /dev/null +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransactionService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type transactionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.TransactionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey +} + +func NewTransactionService( + repo repository.TransactionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) TransactionService { + return &transactionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ + string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, + string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, + }, + } +} + +func (s transactionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + return db.Where( + `LOWER(payment_code) LIKE ? OR + LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(transaction_type, '')) LIKE ? OR + LOWER(COALESCE(notes, '')) LIKE ?`, + like, like, like, like, + ) + } + return db.Order("payment_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get transactions: %+v", err) + return nil, 0, err + } + s.attachApprovals(c.Context(), transactions) + return transactions, total, nil +} + +func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + if err != nil { + s.Log.Errorf("Failed get transaction by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget( + c.Context(), + s.workflowForTransaction(transaction), + id, + s.approvalQueryModifier(), + ) + if err != nil { + s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err) + } else { + transaction.LatestApproval = approval + } + } + return transaction, nil +} + +func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + s.Log.Errorf("Failed to delete transaction: %+v", err) + return err + } + return nil +} + +func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) { + if s.ApprovalSvc == nil || len(transactions) == 0 { + return + } + + workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{} + for _, transaction := range transactions { + workflow := s.workflowForTransaction(&transaction) + workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id) + } + + approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs)) + for workflow, ids := range workflowIDs { + if len(ids) == 0 { + continue + } + approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err) + continue + } + approvalByWorkflow[workflow] = approvals + } + + for i := range transactions { + workflow := s.workflowForTransaction(&transactions[i]) + if approvals, ok := approvalByWorkflow[workflow]; ok { + transactions[i].LatestApproval = approvals[transactions[i].Id] + } + } +} + +func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey { + if transaction == nil { + return utils.ApprovalWorkflowPayment + } + transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType)) + if transactionType == "" { + return utils.ApprovalWorkflowPayment + } + if workflow, ok := s.approvalWorkflows[transactionType]; ok { + return workflow + } + return utils.ApprovalWorkflowPayment +} + +func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 1584b07f..baea9523 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { Name: e.Name, Status: e.Status, Location: location, + Capacity: e.Capacity, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/route/route.go b/internal/route/route.go index 294fc900..aa538b0c 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -20,6 +20,7 @@ import ( ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" // MODULE IMPORTS ) @@ -44,6 +45,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, closings.ClosingModule{}, repports.RepportModule{}, + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b33f9b..7caa637e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -146,6 +146,45 @@ const ( ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ) +// ------------------------------------------------------------------- +// Payment Method +// ------------------------------------------------------------------- + +type PaymentMethod string + +const ( + PaymentMethodTransfer PaymentMethod = "TRANSFER" + PaymentMethodCash PaymentMethod = "CASH" + PaymentMethodCard PaymentMethod = "CARD" + PaymentMethodCheque PaymentMethod = "CHEQUE" + PaymentMethodSaldo PaymentMethod = "SALDO" +) + +// ------------------------------------------------------------------- +// Trasaction Type +// ------------------------------------------------------------------- + +type TransactionType string + +const ( + TransactionTypePenjualan TransactionType = "PENJUALAN" + TransactionTypePembelian TransactionType = "PEMBELIAN" + TransactionTypeBiaya TransactionType = "BIAYA" + TransactionTypeInjection TransactionType = "INJECTION" + TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" +) + +// ------------------------------------------------------------------- +// Payment Party +// ------------------------------------------------------------------- + +type PaymentParty string + +const ( + PaymentPartyCustomer PaymentParty = "CUSTOMER" + PaymentPartySupplier PaymentParty = "SUPPLIER" +) + // ------------------------------------------------------------------- // Kandang Status // ------------------------------------------------------------------- @@ -314,6 +353,51 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -448,6 +532,30 @@ func IsValidExpenseCategory(v string) bool { return false } +func IsValidPaymentMethod(v string) bool { + switch PaymentMethod(v) { + case PaymentMethodTransfer, PaymentMethodCash, PaymentMethodCard, PaymentMethodCheque, PaymentMethodSaldo: + return true + } + return false +} + +func IsValidTransactionType(v string) bool { + switch TransactionType(v) { + case TransactionTypePenjualan, TransactionTypePembelian, TransactionTypeBiaya, TransactionTypeInjection, TransactionTypeSaldoAwal: + return true + } + return false +} + +func IsValidPaymentParty(v string) bool { + switch PaymentParty(v) { + case PaymentPartyCustomer, PaymentPartySupplier: + return true + } + return false +} + // example use // Recording helper diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl index 26958deb..9dea2530 100644 --- a/tools/templates/route.tmpl +++ b/tools/templates/route.tmpl @@ -1,7 +1,7 @@ {{define "route"}}package {{Kebab .Entity}}s 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/{{Kebab .FeatName}}s/controllers" {{Camel .Entity}} "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,17 +13,12 @@ func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .En ctrl := controller.New{{Pascal .Entity}}Controller(s) route := v1.Group("/{{Kebab .Entity}}s") + 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) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) } {{end}} From 9ee3b7582c889d0a70376913986ffa47e39f7a65 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 22 Dec 2025 13:51:27 +0700 Subject: [PATCH 02/14] Feat[BE]: on chickin laying covert Pullet to Layer --- .../chickins/services/chickin.service.go | 20 +++---------------- .../services/project_flock_kandang.service.go | 12 +++-------- .../repports/services/repport.service.go | 1 - 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b8eefa49..0c513e88 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -188,7 +188,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") @@ -199,19 +198,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } - } - var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated @@ -472,9 +458,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -549,7 +535,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { existingPW := &products[0] - // Update project_flock_kandang_id if not already set + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { existingPW.ProjectFlockKandangId = projectFlockKandangId if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index cf2d87ee..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -555,6 +555,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -568,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -584,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index fbca69b7..f9642bd2 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -138,7 +138,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { - s.Log.Warnf("HPP calculation: No cost found for project flock ID %d. Check if purchase items are linked to project_flock_kandang_id", projectFlockID) return 0 } From 306cf11feec27e5a0657a13fa562843caaa93db5 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 12:26:35 +0700 Subject: [PATCH 03/14] Feat[BE]: integrate FIFO service for chickin stock management --- .../modules/production/chickins/module.go | 32 ++- .../chickins/services/chickin.service.go | 249 ++++++++++-------- internal/utils/fifo/constants.go | 1 + 3 files changed, 169 insertions(+), 113 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f4e91056..df0ebd26 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -36,16 +38,44 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) userRepo := rUser.NewUserRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsablekeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "id", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0c513e88..fe78080b 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -23,6 +25,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsablekeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -43,9 +47,10 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -57,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, } } @@ -124,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -152,20 +156,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: 0, - PendingUsageQty: availableQty, + UsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, @@ -193,6 +193,25 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } + for _, chickin := range newChikins { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + return err + } + + if chickin.PendingUsageQty > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) + } + } + + warehouseDeltas := make(map[uint]float64) + for _, chickin := range newChikins { + warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty + } + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -287,6 +306,27 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) + return err + } + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -297,54 +337,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -373,11 +365,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -400,7 +391,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -477,27 +467,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -558,7 +538,6 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId WarehouseId: warehouseId, ProjectFlockKandangId: projectFlockKandangId, Quantity: 0, - // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -574,10 +553,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -590,34 +569,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "qty": gorm.Expr("qty + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -630,7 +582,80 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var desired float64 = chickin.UsageQty + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c47d3cd7..c1a79444 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,4 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 7dc5c9e9a5b373ef2b16da5ba425c58aaf4cc13c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 14:10:08 +0700 Subject: [PATCH 04/14] Feat[BE]: add document handling to stock transfer process --- internal/entities/stock-transfer.go | 1 + internal/entities/stock_transfer_delivery.go | 34 ++++----- .../controllers/transfer.controller.go | 7 +- .../inventory/transfers/dto/transfer.dto.go | 25 ++++++- .../modules/inventory/transfers/module.go | 12 ++- .../transfers/services/transfer.service.go | 73 +++++++++++-------- 6 files changed, 95 insertions(+), 57 deletions(-) diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..69324b65 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,20 @@ import "time" // DETAIL EKSPEDISI type StockTransferDelivery struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - SupplierId uint64 - VehiclePlate string - DriverName string - DocumentNumber string - DocumentPath string - ShippingCostItem float64 - ShippingCostTotal float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Supplier *Supplier `gorm:"foreignKey:SupplierId"` - Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..c21e5286 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -80,15 +80,14 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - // ambil file form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } - _ = form.File["documents"] - // todo: tunggu ada aws baru proses - result, err := u.TransferService.CreateOne(c, &req) + files := form.File["documents"] + + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index fe97ce0f..d38fb78d 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -43,6 +43,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -57,6 +65,7 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` + Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -79,7 +88,6 @@ type TransferDeliveryDTO struct { VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` - DocumentPath string `json:"document_path"` ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` @@ -174,6 +182,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +192,22 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, }) } + var documents []DocumentDTO + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -196,6 +215,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, + Documents: documents, } } @@ -232,7 +252,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, }) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 19a0ded6..9389f9f4 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,10 +1,14 @@ package transfers import ( + "context" + "github.com/go-playground/validator/v10" "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" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" @@ -29,8 +33,14 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index f94295f6..33ca77ff 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "mime/multipart" "strings" "github.com/go-playground/validator/v10" @@ -27,7 +28,7 @@ import ( type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) - CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -42,9 +43,10 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -57,6 +59,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +75,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", "STOCK_TRANSFER") + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -94,31 +100,31 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer + s.Log.Infof("Attempting to get StockTransfer with ID: %d", id) transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { + s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") } - s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } - s.Log.Infof("Retrieved transfer: %+v", transfer) + if transferPtr != nil { + s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents)) + } return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { pwIDs := make([]uint, 0, len(req.Products)) @@ -180,7 +186,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { - s.Log.Errorf("Failed to get next movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) @@ -198,10 +203,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) var details []*entity.StockTransferDetail for _, product := range req.Products { @@ -212,10 +215,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer details: %+v", err) return err } - s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { @@ -224,13 +225,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) } if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } @@ -256,27 +255,46 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) return err } - s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + + actorIDCopy := actorID + if s.DocumentSvc != nil && len(files) > 0 { + s.Log.Infof("Starting document upload for %d files", len(files)) + documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + for idx, file := range files { + docIndex := idx + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: "STOCK_TRANSFER_DOCUMENT", + Index: &docIndex, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "STOCK_TRANSFER", + DocumentableID: entityTransfer.Id, + CreatedBy: &actorIDCopy, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") + } + s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) + } for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) if err != nil { - s.Log.Errorf("Failed to get source product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") } if sourcePW.Quantity < product.ProductQty { - s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) } sourcePW.Quantity -= product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - s.Log.Errorf("Failed to update source product warehouse: %+v", err) return err } - s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, @@ -287,7 +305,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) return err } @@ -295,7 +312,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { @@ -311,18 +327,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - s.Log.Errorf("Failed to create destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") } - s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - s.Log.Errorf("Failed to update destination product warehouse: %+v", err) return err } - s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) increaseLog := &entity.StockLog{ Increase: product.ProductQty, @@ -333,7 +345,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } } @@ -343,7 +354,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -359,7 +370,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } @@ -372,7 +382,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } - s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } From ebf0f8c5ab686de1a83548884abb047615d4e400 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 17:51:42 +0700 Subject: [PATCH 05/14] Feat[BE]: refactor document handling in transfer service and introduce document type constants --- internal/entities/stock_transfer_delivery.go | 1 + .../controllers/transfer.controller.go | 9 ++- .../inventory/transfers/dto/transfer.dto.go | 62 ++++++++++--------- .../transfers/services/transfer.service.go | 39 ++++++------ internal/utils/constant.go | 13 ++++ 5 files changed, 72 insertions(+), 52 deletions(-) diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 69324b65..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -20,4 +20,5 @@ type StockTransferDelivery struct { StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index c21e5286..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -87,6 +87,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err @@ -97,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index d38fb78d..14ca04d2 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,7 +62,6 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` - Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -74,14 +70,12 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` @@ -91,6 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Documents []DocumentDTO `json:"documents"` } type TransferDeliveryItemDTO struct { @@ -99,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -148,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -158,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - // Map delivery items var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -183,6 +174,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -195,16 +197,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - }) - } - var documents []DocumentDTO - for _, doc := range e.Documents { - documents = append(documents, DocumentDTO{ - Id: doc.Id, - Path: doc.Path, - Name: doc.Name, - Ext: doc.Ext, - Size: doc.Size, + Documents: documents, }) } @@ -215,7 +208,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, - Documents: documents, } } @@ -228,21 +220,31 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { } func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { - // Map details var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -254,8 +256,10 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Documents: documents, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 33ca77ff..89e7b271 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -76,8 +76,8 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details.Product"). Preload("Deliveries.Items"). Preload("Deliveries.Supplier"). - Preload("Documents", func(db *gorm.DB) *gorm.DB { - return db.Where("documentable_type = ?", "STOCK_TRANSFER") + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) }) } @@ -258,29 +258,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - actorIDCopy := actorID if s.DocumentSvc != nil && len(files) > 0 { - s.Log.Infof("Starting document upload for %d files", len(files)) - documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + // Upload documents for each delivery for idx, file := range files { - docIndex := idx - documentFiles = append(documentFiles, commonSvc.DocumentFile{ - File: file, - Type: "STOCK_TRANSFER_DOCUMENT", - Index: &docIndex, + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1)) + } } - _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ - DocumentableType: "STOCK_TRANSFER", - DocumentableID: entityTransfer.Id, - CreatedBy: &actorIDCopy, - Files: documentFiles, - }) - if err != nil { - s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") - } - s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) } for _, product := range req.Products { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 7caa637e..20e0ab6a 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -398,6 +398,19 @@ var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ InjectionStepDisetujui: "Disetujui", } +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- From 67ddd8e667cf51ee773a07fea9561fe542fc60db Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 09:24:32 +0700 Subject: [PATCH 06/14] Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies --- .../modules/production/chickins/module.go | 8 ++-- .../chickins/services/chickin.service.go | 38 +++++++++---------- internal/route/route.go | 8 ++-- internal/utils/fifo/constants.go | 2 +- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index df0ebd26..2cd0ad7e 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -39,19 +39,19 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsablekeyProjectChickin, + Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_usage_qty", - CreatedAt: "id", + CreatedAt: "created_at", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index fe78080b..965e39ba 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -25,7 +25,7 @@ import ( "gorm.io/gorm" ) -var chickinUsableKey = fifo.UsablekeyProjectChickin +var chickinUsableKey = fifo.UsableKeyProjectChickin type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -135,8 +135,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -164,7 +165,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: availableQty, + UsageQty: 0, PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, @@ -172,6 +173,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -193,24 +195,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - for _, chickin := range newChikins { - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { return err } - - if chickin.PendingUsageQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) - } } - warehouseDeltas := make(map[uint]float64) - for _, chickin := range newChikins { - warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty - } - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) - return err - } + // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { @@ -599,19 +591,20 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { if chickin == nil || s.FifoSvc == nil { return nil } - var desired float64 = chickin.UsageQty + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desired, - AllowPending: false, + Quantity: desiredQty, + AllowPending: true, Tx: tx, }) if err != nil { @@ -619,6 +612,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ "usage_qty": result.UsageQuantity, "pending_usage_qty": result.PendingQuantity, diff --git a/internal/route/route.go b/internal/route/route.go index aa538b0c..877ec875 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,15 +12,15 @@ import ( closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" - finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" // MODULE IMPORTS ) @@ -44,8 +44,8 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, - repports.RepportModule{}, - finance.FinanceModule{}, + repports.RepportModule{}, + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c1a79444..fd0bca06 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,5 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 20f8a45823e1531e2edbda394041bd0c59548a76 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 10:42:27 +0700 Subject: [PATCH 07/14] Feat[BE]: update update dto for transfer document --- .../inventory/transfers/dto/transfer.dto.go | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 14ca04d2..f1286595 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -85,7 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` - Documents []DocumentDTO `json:"documents"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -174,15 +174,16 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -197,7 +198,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - Documents: documents, + Document: document, }) } @@ -234,15 +235,16 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -256,7 +258,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Documents: documents, + Document: document, }) } From c7ae836cf01996e37f66c5ee16610a465de0dbb8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 09:19:39 +0700 Subject: [PATCH 08/14] Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers --- .../common/service/common.fifo.service.go | 25 ++++++-- internal/entities/stock_log.go | 10 ---- .../repositories/closing.repository.go | 4 +- .../services/adjustment.service.go | 12 ++-- .../transfers/services/transfer.service.go | 6 +- .../modules/production/chickins/module.go | 1 - .../chickins/services/chickin.service.go | 59 ++++++++++++++++--- internal/utils/constant.go | 2 + 8 files changed, 83 insertions(+), 36 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index e3b80268..bf97f831 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa var lots []stockLot for key, cfg := range configs { - selectStmt := fmt.Sprintf( - "%s AS id, %s AS available_qty, %s AS created_at", - cfg.Columns.ID, - fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), - cfg.Columns.CreatedAt, - ) + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } var rows []struct { ID uint diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 310d8cf8..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -2,16 +2,6 @@ package entities import "time" -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) - type StockLog struct { Id uint `gorm:"primaryKey;column:id"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6a59c5f9..cf49826a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -783,7 +783,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) if err != nil { return nil, nil, err } @@ -792,7 +792,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) if err != nil { return nil, nil, err } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7bcbca7e..5a634382 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -70,7 +70,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -97,7 +97,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } @@ -154,14 +154,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - // TransactionType: transactionType, - LoggableType: entity.LogTypeAdjustment, + + LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, CreatedBy: actorID, // TODO: should Get from auth middleware } - if transactionType == entity.TransactionTypeIncrease { + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity } else { @@ -248,7 +248,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 89e7b271..a8a8996e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -259,7 +259,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if s.DocumentSvc != nil && len(files) > 0 { - // Upload documents for each delivery + for idx, file := range files { documentFiles := []commonSvc.DocumentFile{ { @@ -296,7 +296,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, Notes: "", - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, @@ -335,7 +335,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques increaseLog := &entity.StockLog{ Increase: product.ProductQty, - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 2cd0ad7e..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -42,7 +42,6 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 965e39ba..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -16,6 +16,7 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -48,6 +49,7 @@ type chickinService struct { ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { @@ -63,6 +65,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -135,7 +138,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) - chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index + chickinQtyMap := make(map[uint]float64) for idx, chickinReq := range req.ChickinRequests { @@ -197,13 +200,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } - // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -306,8 +307,13 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + if chickin.UsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } @@ -461,7 +467,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { - if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } @@ -591,7 +597,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } @@ -622,14 +628,35 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + return nil } -func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -646,6 +673,22 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + return nil } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 20e0ab6a..db5598e5 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -111,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- From c3302397ccd3a736946b609c803032318844c7b1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:20:57 +0700 Subject: [PATCH 09/14] Feat[BE]: integrate document service into expense module and update related DTOs for document handling --- internal/entities/expense.go | 13 +- internal/modules/expenses/dto/expense.dto.go | 21 +- internal/modules/expenses/module.go | 8 +- .../expenses/services/expense.service.go | 252 ++++++++---------- internal/modules/purchases/module.go | 7 + internal/utils/constant.go | 8 +- 6 files changed, 150 insertions(+), 159 deletions(-) diff --git a/internal/entities/expense.go b/internal/entities/expense.go index e6ab1d77..83a6031b 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,8 +12,6 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -23,8 +20,10 @@ type Expense struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index c55dba2c..4bb9ebe1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -179,12 +178,20 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + // Map documents from Document service + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + // Map realization documents from Document service + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 6d276b5d..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 24ba4f2e..728c689f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,11 +2,8 @@ package service import ( "context" - "database/sql" - "encoding/json" "errors" "fmt" - "mime/multipart" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -49,9 +46,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +59,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +71,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -269,9 +274,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -527,9 +546,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -658,9 +691,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va 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 + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -833,9 +881,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -870,79 +933,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), @@ -951,62 +941,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index ec1b24f7..6daf2a39 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -41,6 +42,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } @@ -54,6 +60,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseRealizationRepo, projectFlockKandangRepository, + documentSvc, validate, ) expenseBridge := service.NewExpenseBridge( diff --git a/internal/utils/constant.go b/internal/utils/constant.go index db5598e5..85b0cc91 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -408,9 +408,13 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) // ------------------------------------------------------------------- From 96c29178348361de82e2f20db70c070cc586fa19 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:21:23 +0700 Subject: [PATCH 10/14] Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration --- ...20251226031727_alter_table_expense_delete_document.down.sql | 3 +++ .../20251226031727_alter_table_expense_delete_document.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; From ac8536a4a1e9466dbe2ccc47cc18d91362a4e101 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 19:02:50 +0700 Subject: [PATCH 11/14] Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs --- ...ds_to_marketing_delivery_products.down.sql | 28 +++++ ...elds_to_marketing_delivery_products.up.sql | 58 +++++++++ .../migrations/20251226114218_add.down.sql | 7 ++ .../migrations/20251226114218_add.up.sql | 19 +++ .../entities/marketing_delivery_product.go | 23 ++-- internal/middleware/permissions.go | 1 + .../closings/dto/closingMarketing.dto.go | 2 +- .../closings/services/closing.service.go | 6 +- .../marketing/dto/deliveryorder.dto.go | 2 +- internal/modules/marketing/module.go | 33 ++++- .../salesorder_delivery_product.repository.go | 33 +++++ internal/modules/marketing/route.go | 15 ++- .../services/deliveryorder.service.go | 113 ++++++++++++------ .../marketing/services/salesorder.service.go | 9 +- .../repports/dto/repportMarketing.dto.go | 8 +- internal/utils/fifo/constants.go | 5 +- 16 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql create mode 100644 internal/database/migrations/20251226114218_add.down.sql create mode 100644 internal/database/migrations/20251226114218_add.up.sql diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 02145930..7a21262c 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -77,6 +77,7 @@ const ( P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderCreateOne = "lti.marketing.sales_order.create" diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 8c904561..4c7b4d35 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.Qty, + Qty: e.UsageQty, // Show allocated quantity from FIFO Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 48728195..ab8e6f7b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -712,13 +712,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo ) for _, product := range deliveryProducts { - if product.Qty == 0 { + if product.UsageQty == 0 { continue } projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.Qty - totalQty += product.Qty + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty } if totalQty == 0 { diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index b2bb70d7..a6eea180 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 586e7961..d8c8fc6a 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,6 +2,7 @@ package marketing import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,11 +14,12 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "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" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} @@ -31,6 +33,28 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + // Initialize FIFO service + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + // Register marketing_delivery_products as FIFO Usable + // Note: ProductWarehouseID comes from marketing_products table via preload + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -42,9 +66,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) // Register routes diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index ba2c1133..04051009 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface { GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error } type MarketingDeliveryProductRepositoryImpl struct { @@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool { joinSQL := statement.SQL.String() return strings.Contains(joinSQL, "JOIN "+tableName) } + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 139d1ee9..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route := router.Group("/marketing") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) - route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) - route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) - route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) - route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 793ed716..e864a778 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,10 +15,10 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -30,12 +30,12 @@ type DeliveryOrdersService interface { } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( @@ -43,15 +43,16 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -247,7 +247,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -256,7 +256,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -354,8 +355,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -363,14 +365,18 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -393,50 +399,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 02cd2e42..bef2a477 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -307,15 +307,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -340,7 +342,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -602,13 +604,14 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 9c026590..90c2fe50 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -61,7 +61,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - totalWeightKg := mdp.Qty * mdp.AvgWeight + totalWeightKg := mdp.UsageQty * mdp.AvgWeight salesAmount := totalWeightKg * mdp.UnitPrice var hpp float64 @@ -78,7 +78,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK AgingDays: agingDays, DoNumber: doNumber, MarketingType: getMarketingType(mdp), - Qty: mdp.Qty, + Qty: mdp.UsageQty, AverageWeightKg: mdp.AvgWeight, TotalWeightKg: totalWeightKg, SalesPricePerKg: mdp.UnitPrice, @@ -194,8 +194,8 @@ func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, ca totalHppAmount := int64(0) for _, mdp := range mdps { - calculatedTotalWeight := mdp.Qty * mdp.AvgWeight - totalQty += int(mdp.Qty) + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) totalWeightKg += calculatedTotalWeight totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index fd0bca06..ea6f96c0 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,6 +1,7 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" ) From dbb13da7c4e0667c73cf6c2cc2b419fc8764e47c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 23:36:53 +0700 Subject: [PATCH 12/14] Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes --- ...d_to_marketing_delivery_products.down.sql} | 0 ..._id_to_marketing_delivery_products.up.sql} | 0 ...reate_production_standards_tables.down.sql | 10 ++++ ..._create_production_standards_tables.up.sql | 54 +++++++++++++++++++ 4 files changed, 64 insertions(+) rename internal/database/migrations/{20251226114218_add.down.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql} (100%) rename internal/database/migrations/{20251226114218_add.up.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql} (100%) create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.down.sql create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.up.sql diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.down.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.up.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..61aa3071 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,54 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw INT, + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT NOT NULL, + CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); From bb76d27f2514a559c7a9a262e746593992ada53a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 27 Dec 2025 09:02:16 +0700 Subject: [PATCH 13/14] feat[BE#US386]: add production standards module with CRUD operations - Created database migration for production standards and related tables. - Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail. - Developed controller for handling production standard requests. - Added DTOs for data transfer between layers. - Implemented service layer for business logic related to production standards. - Created repository interfaces and implementations for data access. - Added validation for production standard requests. - Registered routes for production standards in the main application. --- ..._create_production_standards_tables.up.sql | 60 +++- internal/entities/production_standard.go | 19 ++ .../entities/production_standard_detail.go | 19 ++ internal/entities/standard_growth_detail.go | 19 ++ .../production-standard.controller.go | 145 +++++++++ .../dto/production-standard.dto.go | 155 +++++++++ .../master/production-standards/module.go | 33 ++ .../production_standard.repository.go | 103 ++++++ .../production_standard_detail.repository.go | 63 ++++ .../standard_growth_detail.repository.go | 63 ++++ .../master/production-standards/route.go | 23 ++ .../services/production-standard.service.go | 302 ++++++++++++++++++ .../production-standard.validation.go | 41 +++ internal/modules/master/route.go | 2 + 14 files changed, 1038 insertions(+), 9 deletions(-) create mode 100644 internal/entities/production_standard.go create mode 100644 internal/entities/production_standard_detail.go create mode 100644 internal/entities/standard_growth_detail.go create mode 100644 internal/modules/master/production-standards/controllers/production-standard.controller.go create mode 100644 internal/modules/master/production-standards/dto/production-standard.dto.go create mode 100644 internal/modules/master/production-standards/module.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard.repository.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard_detail.repository.go create mode 100644 internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go create mode 100644 internal/modules/master/production-standards/route.go create mode 100644 internal/modules/master/production-standards/services/production-standard.service.go create mode 100644 internal/modules/master/production-standards/validations/production-standard.validation.go diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql index 61aa3071..2af43d20 100644 --- a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -6,12 +6,25 @@ CREATE TABLE IF NOT EXISTS production_standards ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL + created_by BIGINT ); -- Create index for deleted_at (soft delete) CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + -- Create production_standard_details table CREATE TABLE IF NOT EXISTS production_standard_details ( id BIGSERIAL PRIMARY KEY, @@ -22,11 +35,19 @@ CREATE TABLE IF NOT EXISTS production_standard_details ( target_egg_weight NUMERIC(15, 3), target_egg_mass NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + updated_at TIMESTAMPTZ DEFAULT NOW() ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_production_standard_details_standard_week ON production_standard_details(production_standard_id, week); @@ -35,20 +56,41 @@ CREATE UNIQUE INDEX idx_production_standard_details_standard_week CREATE TABLE IF NOT EXISTS standard_growth_details ( id BIGSERIAL PRIMARY KEY, production_standard_id BIGINT NOT NULL, - target_mean_bw INT, + target_mean_bw NUMERIC(15, 3), max_depletion NUMERIC(15, 3), min_uniformity NUMERIC(15, 3) NOT NULL, week INT NOT NULL, - feed_intake INT, + feed_intake NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - created_by BIGINT NOT NULL, - CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + created_by BIGINT ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_standard_growth_details_standard_week ON standard_growth_details(production_standard_id, week); +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + -- Create index for project_category CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..cd50a572 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..1635385d --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + 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.ProductionStandardService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..9544732a --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardListDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardListDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO { + result := make([]ProductionStandardListDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardListDTO: ToProductionStandardListDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +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 ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +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 ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +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 StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..d2035bea --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..b81faf8b --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + s.Log.Errorf("Failed get productionStandard by id: %+v", err) + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + s.Log.Errorf("Failed to check existing production standard: %+v", err) + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to update production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + s.Log.Errorf("Failed to delete productionStandard: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..51aeecc7 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,41 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..26ae28ee 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -20,6 +20,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -40,6 +41,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, // MODULE REGISTRY } From e30ef5ef10b3de0db068b18d680b13922d8ce136 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 27 Dec 2025 14:30:03 +0700 Subject: [PATCH 14/14] feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity --- internal/utils/constant.go | 53 +++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b0cc91..1fb156d2 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -408,15 +408,60 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" - DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" - DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // -------------------------------------------------------------------