Files
fm_be/internal/repository/mysql/base_repo.go
2026-07-16 22:16:45 +07:00

643 lines
17 KiB
Go

package mysql
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"wucher/internal/domain/base"
"wucher/internal/shared/pkg/sortkey"
"wucher/internal/shared/pkg/uuidv7"
)
type BaseRepository struct {
db *gorm.DB
}
type binaryValue struct {
data []byte
}
func (b binaryValue) Value() (driver.Value, error) {
if len(b.data) == 0 {
return nil, nil
}
return append([]byte(nil), b.data...), nil
}
func NewBaseRepository(db *gorm.DB) *BaseRepository {
return &BaseRepository{db: db}
}
func (r *BaseRepository) CreateBase(ctx context.Context, row *base.Base, categoryType string) error {
categoryID, err := r.categoryIDByType(ctx, categoryType)
if err != nil {
return err
}
row.BaseCategoryID = categoryID
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
row.CreatedBy = actor
row.UpdatedBy = actor
}
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart)
row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd)
requestedIsActive := row.IsActive
operationalShiftTimes := row.OperationalShiftTimes
row.OperationalShiftTimes = nil
db := r.db.WithContext(ctx)
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Omit(clause.Associations).Create(row).Error; err != nil {
return err
}
if err := tx.Model(&base.Base{}).Where("id = ?", row.ID).UpdateColumn("is_active", requestedIsActive).Error; err != nil {
return err
}
if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil {
return err
}
return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, operationalShiftTimes)
})
}
func (r *BaseRepository) UpdateBase(ctx context.Context, row *base.Base, categoryType string) error {
categoryID, err := r.categoryIDByType(ctx, categoryType)
if err != nil {
return err
}
row.BaseCategoryID = categoryID
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
row.UpdatedBy = actor
}
row.DefaultStartTimeType = normalizeBaseShiftTimeType(row.DefaultStartTimeType)
row.DefaultEndTimeType = normalizeBaseShiftTimeType(row.DefaultEndTimeType)
row.DefaultShiftStart = baseClockStorageValue(row.DefaultShiftStart)
row.DefaultShiftEnd = baseClockStorageValue(row.DefaultShiftEnd)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Use an explicit UPDATE statement so the base shift columns are stored
// in the same datetime shape that exists in the local schema while still
// accepting clock-only inputs from the API layer.
if err := tx.Exec(
fmt.Sprintf(`UPDATE bases
SET base = ?,
base_category_id = ?,
foto_attachment_id = ?,
base_abbreviation = ?,
address = ?,
latitude = ?,
longitude = ?,
landline_number = ?,
mobile_number = ?,
email = ?,
sortkey = ?,
sms_alert = ?,
checklist = ?,
leg_time = ?,
default_start_time_type = ?,
default_end_time_type = ?,
default_shift_start = ?,
default_shift_end = ?,
utc = ?,
dry = ?,
control_center = ?,
dul = ?,
notes = ?,
is_active = ?,
updated_by = ?,
updated_at = ?
WHERE id = ? AND deleted_at IS NULL`),
row.BaseName,
binaryValue{data: row.BaseCategoryID},
binaryValue{data: row.FotoAttachmentID},
row.BaseAbbreviation,
row.Address,
row.Latitude,
row.Longitude,
row.LandlineNumber,
row.MobileNumber,
row.Email,
row.SortKey,
row.SMSAlert,
row.Checklist,
row.LegTime,
row.DefaultStartTimeType,
row.DefaultEndTimeType,
row.DefaultShiftStart,
row.DefaultShiftEnd,
row.UTC,
row.Dry,
row.ControlCenter,
row.DUL,
row.Notes,
row.IsActive,
binaryValue{data: row.UpdatedBy},
time.Now().UTC(),
binaryValue{data: row.ID},
).Error; err != nil {
return err
}
if err := r.replaceBaseContactRolesTx(ctx, tx, row.ID, row.HEMSEDCContactIDs, row.MedPaxContactIDs, row.ResponsiblePilotContactIDs); err != nil {
return err
}
if row.OperationalShiftTimes == nil {
return nil
}
return r.replaceBaseOperationalShiftTimesTx(ctx, tx, row.ID, row.OperationalShiftTimes)
})
}
func (r *BaseRepository) DeleteBase(ctx context.Context, id []byte, categoryType string) error {
categoryID, err := r.categoryIDByType(ctx, categoryType)
if err != nil {
return err
}
if err := ensureNoReferenceBeforeDelete(ctx, r.db, "bases", id); err != nil {
return err
}
now := time.Now().UTC()
updates := map[string]any{
"deleted_at": now,
}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
updates["deleted_by"] = actor
updates["updated_by"] = actor
}
return mapDeleteConstraintError(r.db.WithContext(ctx).
Model(&base.Base{}).
Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID).
Updates(updates).Error)
}
func (r *BaseRepository) GetBaseByID(ctx context.Context, id []byte, categoryType string) (*base.Base, error) {
categoryID, err := r.categoryIDByType(ctx, categoryType)
if err != nil {
return nil, err
}
var row base.Base
err = r.db.WithContext(ctx).
Preload("FotoAttachment").
Preload("FotoAttachment.File").
Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes).
Where("id = ? AND base_category_id = ? AND deleted_at IS NULL", id, categoryID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err == nil {
if loadErr := r.loadBaseContactRoles(ctx, []*base.Base{&row}); loadErr != nil {
return nil, loadErr
}
}
return &row, err
}
func (r *BaseRepository) ListBases(ctx context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) {
var rows []base.Base
var total int64
baseQuery := r.db.WithContext(ctx).
Model(&base.Base{}).
Where("deleted_at IS NULL")
if strings.TrimSpace(categoryType) != "" {
categoryID, err := r.categoryIDByType(ctx, categoryType)
if err != nil {
return nil, 0, err
}
baseQuery = baseQuery.Where("base_category_id = ?", categoryID)
}
if filter != "" {
like := "%" + filter + "%"
baseQuery = baseQuery.Where(
"base LIKE ? OR base_abbreviation LIKE ? OR address LIKE ? OR email LIKE ? OR landline_number LIKE ? OR mobile_number LIKE ?",
like,
like,
like,
like,
like,
like,
)
}
if dul != nil {
baseQuery = baseQuery.Where("dul = ?", *dul)
}
query := baseQuery
if sort != "" {
query = query.Order(sort)
} else {
for _, clause := range sortkey.ActivePositiveSortClauses("bases", "is_active", "sortkey", "base", false) {
query = query.Order(clause)
}
}
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
if limit > 0 {
query = query.Limit(limit).Offset(offset)
}
query = query.Preload("BaseCategory")
query = query.Preload("FotoAttachment").Preload("FotoAttachment.File")
query = query.Preload("OperationalShiftTimes", preloadBaseOperationalShiftTimes)
if err := query.Find(&rows).Error; err != nil {
return nil, 0, err
}
ptrs := make([]*base.Base, 0, len(rows))
for i := range rows {
ptrs = append(ptrs, &rows[i])
}
if err := r.loadBaseContactRoles(ctx, ptrs); err != nil {
return nil, 0, err
}
return rows, total, nil
}
func preloadBaseOperationalShiftTimes(db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
q := db.Where("deleted_at IS NULL").Order("date_start ASC, date_end ASC")
if db.Dialector != nil && strings.EqualFold(db.Dialector.Name(), "mysql") {
q = q.Select(`
id,
base_id,
date_start,
date_end,
start_time_type,
end_time_type,
CASE WHEN shift_start IS NULL THEN '' ELSE TIME_FORMAT(shift_start, '%H:%i:%s') END AS shift_start,
CASE WHEN shift_end IS NULL THEN '' ELSE TIME_FORMAT(shift_end, '%H:%i:%s') END AS shift_end,
created_at,
created_by,
updated_at,
updated_by,
deleted_at,
deleted_by
`)
}
return q
}
func (r *BaseRepository) replaceBaseContactRolesTx(
ctx context.Context,
tx *gorm.DB,
baseID []byte,
hemsEDCIDs [][]byte,
medPaxIDs [][]byte,
pilotIDs [][]byte,
) error {
if len(baseID) != 16 {
return errors.New("invalid base id")
}
all := append(copyIDs(hemsEDCIDs), medPaxIDs...)
all = append(all, pilotIDs...)
if err := ensureContactsExistTx(tx, all); err != nil {
return err
}
if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseContactRole{}).Error; err != nil {
return err
}
rows := make([]base.BaseContactRole, 0, len(hemsEDCIDs)+len(medPaxIDs)+len(pilotIDs))
actor := actorUserIDFromContext(ctx)
for _, contactID := range hemsEDCIDs {
if len(contactID) != 16 {
return fmt.Errorf("invalid hems_edc contact id")
}
rows = append(rows, base.BaseContactRole{
BaseID: baseID,
ContactID: contactID,
RoleCode: base.BaseContactRoleHEMSEDC,
CreatedBy: actor,
UpdatedBy: actor,
})
}
for _, contactID := range medPaxIDs {
if len(contactID) != 16 {
return fmt.Errorf("invalid med_pax contact id")
}
rows = append(rows, base.BaseContactRole{
BaseID: baseID,
ContactID: contactID,
RoleCode: base.BaseContactRoleMedPax,
CreatedBy: actor,
UpdatedBy: actor,
})
}
for _, contactID := range pilotIDs {
if len(contactID) != 16 {
return fmt.Errorf("invalid responsible_pilot contact id")
}
rows = append(rows, base.BaseContactRole{
BaseID: baseID,
ContactID: contactID,
RoleCode: base.BaseContactRoleResponsiblePilot,
CreatedBy: actor,
UpdatedBy: actor,
})
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
}
func (r *BaseRepository) replaceBaseOperationalShiftTimesTx(ctx context.Context, tx *gorm.DB, baseID []byte, items []base.BaseOperationalShiftTime) error {
if len(baseID) != 16 {
return errors.New("invalid base id")
}
if items == nil {
return nil
}
actor := actorUserIDFromContext(ctx)
if err := tx.Where("base_id = ?", baseID).Delete(&base.BaseOperationalShiftTime{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
now := time.Now().UTC()
for i := range items {
row := items[i]
if row.DateStart == nil || row.DateEnd == nil || row.DateStart.IsZero() || row.DateEnd.IsZero() {
return errors.New("invalid operational shift date range")
}
if row.DateEnd.Before(*row.DateStart) {
return errors.New("operational shift date_end must be >= date_start")
}
if len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
dateStart := row.DateStart.UTC().Format("2006-01-02")
dateEnd := row.DateEnd.UTC().Format("2006-01-02")
var createdBy any
var updatedBy any
if len(actor) > 0 {
createdBy = binaryValue{data: actor}
updatedBy = binaryValue{data: actor}
}
shiftStart := shiftTimeValue(row.ShiftStart)
shiftEnd := shiftTimeValue(row.ShiftEnd)
if err := tx.Exec(
`INSERT INTO base_operational_shift_times
(id, base_id, date_start, date_end, start_time_type, end_time_type, shift_start, shift_end, created_at, created_by, updated_at, updated_by, deleted_at, deleted_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
binaryValue{data: row.ID},
binaryValue{data: baseID},
dateStart,
dateEnd,
row.StartTimeType,
row.EndTimeType,
shiftStart,
shiftEnd,
now,
createdBy,
now,
updatedBy,
nil,
nil,
).Error; err != nil {
return err
}
}
return nil
}
func shiftTimeValue(raw string) any {
clock, ok := extractClockValue(raw)
if !ok {
return nil
}
return clock
}
func baseClockStorageValue(raw string) string {
clock, ok := extractClockValue(raw)
if !ok {
return strings.TrimSpace(raw)
}
return "2000-01-01 " + clock
}
func normalizeBaseShiftTimeType(raw string) string {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", base.ShiftTimeTypeFixed:
return base.ShiftTimeTypeFixed
case base.ShiftTimeTypeBMCT:
return base.ShiftTimeTypeBMCT
case base.ShiftTimeTypeECET:
return base.ShiftTimeTypeECET
default:
return strings.ToUpper(strings.TrimSpace(raw))
}
}
func extractClockValue(raw string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" {
return "", false
}
layouts := []string{
"15:04:05",
"15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
time.RFC3339,
time.RFC3339Nano,
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, s); err == nil {
return parsed.UTC().Format("15:04:05"), true
}
}
return "", false
}
func ensureContactsExistTx(tx *gorm.DB, ids [][]byte) error {
valid := make([][]byte, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
if len(id) != 16 {
return errors.New("invalid contact id")
}
key := string(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
valid = append(valid, id)
}
if len(valid) == 0 {
return nil
}
var count int64
if err := tx.Table("users").Where("id IN ?", valid).Count(&count).Error; err != nil {
return err
}
if count != int64(len(valid)) {
return errors.New("one or more contact ids not found")
}
return nil
}
func (r *BaseRepository) loadBaseContactRoles(ctx context.Context, bases []*base.Base) error {
if len(bases) == 0 {
return nil
}
baseIDs := make([][]byte, 0, len(bases))
index := make(map[string]*base.Base, len(bases))
for _, b := range bases {
if b == nil || len(b.ID) != 16 {
continue
}
baseIDs = append(baseIDs, b.ID)
index[string(b.ID)] = b
b.HEMSEDCContactIDs = nil
b.MedPaxContactIDs = nil
b.ResponsiblePilotContactIDs = nil
b.HEMSEDCs = nil
b.MedPax = nil
b.ResponsiblePilots = nil
}
if len(baseIDs) == 0 {
return nil
}
var rels []base.BaseContactRole
if err := r.db.WithContext(ctx).
Where("base_id IN ?", baseIDs).
Order("created_at ASC").
Find(&rels).Error; err != nil {
return err
}
for i := range rels {
b := index[string(rels[i].BaseID)]
if b == nil {
continue
}
switch rels[i].RoleCode {
case base.BaseContactRoleHEMSEDC:
b.HEMSEDCContactIDs = append(b.HEMSEDCContactIDs, append([]byte(nil), rels[i].ContactID...))
case base.BaseContactRoleMedPax:
b.MedPaxContactIDs = append(b.MedPaxContactIDs, append([]byte(nil), rels[i].ContactID...))
case base.BaseContactRoleResponsiblePilot:
b.ResponsiblePilotContactIDs = append(b.ResponsiblePilotContactIDs, append([]byte(nil), rels[i].ContactID...))
}
}
var hemsEDCContactIDs [][]byte
var medPaxContactIDs [][]byte
var pilotContactIDs [][]byte
for _, b := range bases {
for i := range b.HEMSEDCContactIDs {
hemsEDCContactIDs = append(hemsEDCContactIDs, b.HEMSEDCContactIDs[i])
}
for i := range b.MedPaxContactIDs {
medPaxContactIDs = append(medPaxContactIDs, b.MedPaxContactIDs[i])
}
for i := range b.ResponsiblePilotContactIDs {
pilotContactIDs = append(pilotContactIDs, b.ResponsiblePilotContactIDs[i])
}
}
allContactIDs := append(append(hemsEDCContactIDs, medPaxContactIDs...), pilotContactIDs...)
contactMap, err := r.contactNameMap(ctx, allContactIDs)
if err != nil {
return err
}
for _, b := range bases {
for i := range b.HEMSEDCContactIDs {
id := b.HEMSEDCContactIDs[i]
p := contactMap[string(id)]
p.ContactID = append([]byte(nil), id...)
b.HEMSEDCs = append(b.HEMSEDCs, p)
}
for i := range b.MedPaxContactIDs {
id := b.MedPaxContactIDs[i]
p := contactMap[string(id)]
p.ContactID = append([]byte(nil), id...)
b.MedPax = append(b.MedPax, p)
}
for i := range b.ResponsiblePilotContactIDs {
id := b.ResponsiblePilotContactIDs[i]
p := contactMap[string(id)]
p.ContactID = append([]byte(nil), id...)
b.ResponsiblePilots = append(b.ResponsiblePilots, p)
}
}
return nil
}
func (r *BaseRepository) contactNameMap(ctx context.Context, contactIDs [][]byte) (map[string]base.BaseContactPerson, error) {
uniq := make([][]byte, 0, len(contactIDs))
seen := map[string]struct{}{}
for _, id := range contactIDs {
if len(id) != 16 {
continue
}
key := string(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return map[string]base.BaseContactPerson{}, nil
}
var rows []struct {
ID []byte `gorm:"column:id"`
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
}
if err := r.db.WithContext(ctx).
Table("users").
Select("id, first_name, last_name").
Where("id IN ?", uniq).
Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]base.BaseContactPerson, len(rows))
for i := range rows {
out[string(rows[i].ID)] = base.BaseContactPerson{
FirstName: rows[i].FirstName,
LastName: rows[i].LastName,
}
}
return out, nil
}
func copyIDs(src [][]byte) [][]byte {
out := make([][]byte, 0, len(src))
for _, id := range src {
out = append(out, id)
}
return out
}
func (r *BaseRepository) categoryIDByType(ctx context.Context, categoryType string) ([]byte, error) {
normalized, ok := base.NormalizeCategoryType(categoryType)
if !ok {
return nil, errors.New("invalid base category type")
}
return r.categoryIDByKey(ctx, normalized)
}
func (r *BaseRepository) categoryIDByKey(ctx context.Context, categoryKey string) ([]byte, error) {
var row struct {
ID []byte `gorm:"column:id"`
}
if err := r.db.WithContext(ctx).
Table("base_categories").
Select("id").
Where("`key` = ?", strings.ToLower(strings.TrimSpace(categoryKey))).
Take(&row).Error; err != nil {
return nil, err
}
return append([]byte(nil), row.ID...), nil
}