1206 lines
42 KiB
Go
1206 lines
42 KiB
Go
package repositories
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"nearle/models"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
type PartnerRepository interface {
|
||
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
|
||
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
|
||
GetRiderShifts(aid int) ([]models.Ridershifts, error)
|
||
CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error)
|
||
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
|
||
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
|
||
GetRiderInfo(userid int) (models.RiderInfo, error)
|
||
GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error)
|
||
CreateRider(rider models.NewRider) (int, error)
|
||
UpdateRider(rider models.NewRider) error
|
||
GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error)
|
||
CreatePartner(input models.NewPartner) (int, error)
|
||
UpdatePartner(input models.NewPartner) error
|
||
GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error)
|
||
EnsureRegion(district string) (int, error)
|
||
}
|
||
|
||
type partnerRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewPartnerRepository(db *gorm.DB) PartnerRepository {
|
||
return &partnerRepository{db: db}
|
||
}
|
||
|
||
func (r *partnerRepository) GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error) {
|
||
var data []models.RiderInfo
|
||
var q1 string
|
||
|
||
const riders = `SELECT DISTINCT b.poolid, a.userid, a.firstname, a.lastname,CONCAT(a.firstname, ' ', a.lastname) AS fullname,a.contactno, a.userfcmtoken, a.partnerid, a.applocationid,
|
||
c.identificationno, c.registrationno, c.licenseno, c.vehiclename, c.vehicleno,d.shiftid, d.starttime, d.endtime, d.shifthours, d.basefare, d.fuelcharge,e.logdate,
|
||
e.login, e.logout, e.workhours, e.shorthours, e.logstatus, a.status,f.locationname AS applocation
|
||
FROM app_users a
|
||
INNER JOIN app_userpools b ON a.userid = b.userid
|
||
INNER JOIN ridersettings c ON a.userid = c.userid
|
||
INNER JOIN ridershifts d ON c.shiftid = d.shiftid
|
||
INNER JOIN (
|
||
SELECT r1.*
|
||
FROM riderlogs r1
|
||
INNER JOIN (
|
||
SELECT userid, MAX(logdate) AS max_logdate
|
||
FROM riderlogs
|
||
WHERE logdate::date = CURRENT_DATE
|
||
GROUP BY userid
|
||
) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate
|
||
) e ON a.userid = e.userid
|
||
INNER JOIN app_location f ON a.applocationid = f.applocationid
|
||
INNER JOIN app_locationconfig g ON g.applocationid = f.applocationid`
|
||
|
||
if aid != 0 {
|
||
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
|
||
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.applocationid = ` + strconv.Itoa(aid)
|
||
} else if partnerid != 0 {
|
||
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
|
||
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.partnerid = ` + strconv.Itoa(partnerid)
|
||
} else if tid != 0 {
|
||
q1 = riders + ` WHERE a.configid = 6 AND a.status = 'Active' AND b.onduty = 1
|
||
AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND a.tenantid = ` + strconv.Itoa(tid)
|
||
} else {
|
||
q1 = riders + ` WHERE g.status = 'Active' AND a.configid = 6 AND a.status = 'Active'
|
||
AND b.onduty = 1 AND e.logdate::date = CURRENT_DATE AND e.logstatus = 0 AND g.userid = ` + strconv.Itoa(uid)
|
||
}
|
||
|
||
err := r.db.Raw(q1).Find(&data).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error) {
|
||
var data []models.Partnerinfo
|
||
var q1 string
|
||
var args []interface{}
|
||
|
||
// Every variant joins partnerlocations, and that join is the whole point.
|
||
//
|
||
// ── It is what separates our partners from somebody else's ──────────────
|
||
//
|
||
// `partnerinfo` is shared. It has no column saying which product a row
|
||
// belongs to — no configid, no appid — so a partner created by another app
|
||
// on this database is indistinguishable from ours by its own fields, and
|
||
// this read used to return every Active row on the platform. The console
|
||
// made that worse rather than better: it asks `getapplocations` for EVERY
|
||
// region and then fetches partners region by region, so the applocationid
|
||
// filter below never narrowed anything.
|
||
//
|
||
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
|
||
// one row per region, in the same transaction as the partner — so a row in
|
||
// that table means "registered through this console". The partners that
|
||
// predate it were inserted by hand and have none, which is why two of them
|
||
// are called "Test".
|
||
//
|
||
// ── The region filter reads the link table, not the home region ─────────
|
||
//
|
||
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
|
||
// `regions[0]` there — while partnerlocations holds every region covered.
|
||
// Those are not the same thing, and not only in theory: partner 44,
|
||
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
|
||
// filtering on the partner row hid them from every Madurai query. That is
|
||
// the case the link table exists for.
|
||
//
|
||
// DISTINCT because such a partner has one row per region in the join and is
|
||
// still one partner. Only partnerinfo columns are selected, so there is
|
||
// nothing per-region for it to fail to collapse.
|
||
//
|
||
// A caller fanning out over regions and concatenating the answers still has
|
||
// to dedupe — the same partner is legitimately in two of them. The console's
|
||
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
|
||
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
|
||
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
|
||
from partnerinfo p
|
||
inner join partnerlocations l on l.partnerid = p.partnerid
|
||
where p.status='Active'`
|
||
|
||
if pid != 0 {
|
||
// Scoped the same way on purpose: asking for a partner by id must not
|
||
// be a way round the separation above.
|
||
q1 = columns + ` and p.partnerid=?`
|
||
args = append(args, pid)
|
||
|
||
} else if aid != 0 {
|
||
q1 = columns + ` and l.applocationid=?`
|
||
args = append(args, aid)
|
||
|
||
} else {
|
||
q1 = columns
|
||
}
|
||
|
||
q1 += ` order by p.partnername, p.partnerid`
|
||
|
||
err := r.db.Raw(q1, args...).Find(&data).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetRiderShifts(aid int) ([]models.Ridershifts, error) {
|
||
|
||
var data []models.Ridershifts
|
||
|
||
q1 := `Select a.*,concat(a.starttime,'-',a.endtime) as shiftname from ridershifts a where a.applocationid=` + strconv.Itoa(aid)
|
||
|
||
err := r.db.Raw(q1).Find(&data).Error
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
print(q1)
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error) {
|
||
|
||
var data []models.Locationconfigs
|
||
|
||
q1 := fmt.Sprintf(`SELECT a.applocationid,a.locationname FROM app_location a
|
||
inner join app_locationconfig b ON a.applocationid=b.applocationid
|
||
WHERE b.status='Active' and b.userid=%d`, uid)
|
||
|
||
err := r.db.Raw(q1).Find(&data).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
print(q1)
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) {
|
||
var data []models.RiderlogDetails
|
||
var args []interface{}
|
||
|
||
baseQuery := `
|
||
SELECT a.*, b.*, CONCAT(b.firstname,' ',b.lastname) AS username,
|
||
COALESCE(SUM(c.breakhours),0) AS breakhours
|
||
FROM riderlogs a
|
||
INNER JOIN app_users b ON a.userid = b.userid
|
||
LEFT JOIN riderbreaks c ON a.logid = c.logid AND a.userid = c.userid
|
||
WHERE 1=1
|
||
`
|
||
|
||
if pid != 0 {
|
||
baseQuery += " AND a.partnerid = ?"
|
||
args = append(args, pid)
|
||
}
|
||
|
||
if aid != 0 {
|
||
baseQuery += " AND b.applocationid = ?"
|
||
args = append(args, aid)
|
||
}
|
||
|
||
if fdate != "" && tdate != "" {
|
||
baseQuery += " AND logdate::date BETWEEN ? AND ?"
|
||
args = append(args, fdate, tdate)
|
||
} else {
|
||
baseQuery += " AND a.logdate::date = CURRENT_DATE"
|
||
}
|
||
|
||
// GROUP BY the two PRIMARY KEYS, not by a.userid.
|
||
//
|
||
// The select is "a.*, b.*" — every column of riderlogs and app_users — and
|
||
// grouping by a.userid leaves all of them unaggregated: userid is not
|
||
// riderlogs' key (logid is), so it does not functionally determine a.logid
|
||
// or anything else. Postgres refuses the whole statement, so this endpoint
|
||
// answered 500 "column a.logid must appear in the GROUP BY clause" on every
|
||
// call it has ever received. Verified on production 2026-09-04.
|
||
//
|
||
// Grouping by both primary keys is what the query means: one row per LOG
|
||
// (the model is RiderlogDetails and the sort is by logid), carrying that
|
||
// log's break hours summed. Postgres treats a PK in GROUP BY as determining
|
||
// the rest of its table's columns, so "a.*, b.*" is then legal.
|
||
baseQuery += " GROUP BY a.logid, b.userid ORDER BY a.logid ASC"
|
||
|
||
if err := r.db.Raw(baseQuery, args...).Find(&data).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetRiderInfo(userid int) (models.RiderInfo, error) {
|
||
var data models.RiderInfo
|
||
|
||
q1 := `SELECT a.userid,a.firstname,a.lastname, CONCAT(a.firstname,' ',a.lastname) as fullname,
|
||
a.partnerid,a.configid,a.contactno,
|
||
a.address,a.suburb,a.city,a.state,a.postcode,a.latitude,a.longitude,
|
||
b.identificationno,b.vehicleno,b.vehiclename,b.licenseno,b.insuranceno,b.insurancedate,
|
||
c.shiftid,c.starttime,c.endtime,c.shifthours,c.basefare,c.additionalcharges,c.orders,c.fuelcharge,a.status,
|
||
a.applocationid,d.locationname as applocation,d.logseconds
|
||
FROM app_users a
|
||
INNER JOIN ridersettings b ON a.userid=b.userid
|
||
INNER JOIN ridershifts c ON b.shiftid=c.shiftid
|
||
INNER JOIN app_location d ON a.applocationid=d.applocationid
|
||
WHERE a.userid = ?`
|
||
|
||
if err := r.db.Raw(q1, userid).Scan(&data).Error; err != nil {
|
||
return models.RiderInfo{}, err
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (r *partnerRepository) GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error) {
|
||
var summary models.FleetSummary
|
||
|
||
// --- Fleet membership filter (app_users aliased as a) ---
|
||
memberWhere := "a.configid = 6"
|
||
var memberArgs []interface{}
|
||
if aid != 0 {
|
||
memberWhere += " AND a.applocationid = ?"
|
||
memberArgs = append(memberArgs, aid)
|
||
}
|
||
if tid != 0 {
|
||
memberWhere += " AND a.tenantid = ?"
|
||
memberArgs = append(memberArgs, tid)
|
||
}
|
||
|
||
countsQuery := `
|
||
SELECT
|
||
COUNT(*) AS totalriders,
|
||
COUNT(*) FILTER (WHERE a.status = 'Active') AS activeriders
|
||
FROM app_users a
|
||
WHERE ` + memberWhere
|
||
|
||
if err := r.db.Raw(countsQuery, memberArgs...).Scan(&summary.Counts).Error; err != nil {
|
||
return models.FleetSummary{}, err
|
||
}
|
||
|
||
// --- Log-range filter (app_users aliased as b, riderlogs as a) ---
|
||
logWhere := "b.configid = 6"
|
||
var logArgs []interface{}
|
||
if aid != 0 {
|
||
logWhere += " AND b.applocationid = ?"
|
||
logArgs = append(logArgs, aid)
|
||
}
|
||
if tid != 0 {
|
||
logWhere += " AND b.tenantid = ?"
|
||
logArgs = append(logArgs, tid)
|
||
}
|
||
if fdate != "" && tdate != "" {
|
||
logWhere += " AND a.logdate::date BETWEEN ? AND ?"
|
||
logArgs = append(logArgs, fdate, tdate)
|
||
} else {
|
||
logWhere += " AND a.logdate::date = CURRENT_DATE"
|
||
}
|
||
|
||
var worked struct {
|
||
Ridersworked int `json:"ridersworked"`
|
||
Totallogins int `json:"totallogins"`
|
||
}
|
||
|
||
workedQuery := `
|
||
SELECT
|
||
COUNT(DISTINCT a.userid) AS ridersworked,
|
||
COUNT(*) AS totallogins
|
||
FROM riderlogs a
|
||
INNER JOIN app_users b ON a.userid = b.userid
|
||
WHERE ` + logWhere
|
||
|
||
if err := r.db.Raw(workedQuery, logArgs...).Scan(&worked).Error; err != nil {
|
||
return models.FleetSummary{}, err
|
||
}
|
||
summary.Counts.Ridersworked = worked.Ridersworked
|
||
summary.Counts.Totallogins = worked.Totallogins
|
||
|
||
// --- Per-rider breakdown over the range ---
|
||
// Breaks are pre-aggregated per log so the join cannot inflate the work/short-hour sums.
|
||
ridersQuery := `
|
||
SELECT
|
||
b.userid,
|
||
CONCAT(b.firstname, ' ', b.lastname) AS fullname,
|
||
b.contactno,
|
||
b.partnerid,
|
||
b.status,
|
||
rs.vehicleno,
|
||
rs.vehiclename,
|
||
COUNT(DISTINCT a.logdate::date) AS dayslogged,
|
||
COALESCE(SUM(a.workhours), 0) AS totalworkhours,
|
||
COALESCE(SUM(a.shorthours), 0) AS totalshorthours,
|
||
COALESCE(SUM(bk.breakhours), 0) AS totalbreakhours
|
||
FROM riderlogs a
|
||
INNER JOIN app_users b ON a.userid = b.userid
|
||
LEFT JOIN ridersettings rs ON b.userid = rs.userid
|
||
LEFT JOIN (
|
||
SELECT logid, userid, SUM(breakhours) AS breakhours
|
||
FROM riderbreaks
|
||
GROUP BY logid, userid
|
||
) bk ON a.logid = bk.logid AND a.userid = bk.userid
|
||
WHERE ` + logWhere + `
|
||
GROUP BY b.userid, b.firstname, b.lastname, b.contactno, b.partnerid, b.status, rs.vehicleno, rs.vehiclename
|
||
ORDER BY fullname ASC`
|
||
|
||
if err := r.db.Raw(ridersQuery, logArgs...).Find(&summary.Riders).Error; err != nil {
|
||
return models.FleetSummary{}, err
|
||
}
|
||
|
||
return summary, nil
|
||
}
|
||
|
||
// CreateRider hires one rider, in one transaction.
|
||
//
|
||
// Three tables, all or nothing. Before this existed the only way to make a
|
||
// rider was POST /users/create, which writes `app_users` and stops — so it
|
||
// answered 201 Created and produced somebody every rider query ignored forever,
|
||
// because `getriders` INNER JOINs `ridersettings` and `app_userpools` as well.
|
||
// The old console papered over that by generating an SQL script for an operator
|
||
// to run by hand; the script named three columns that do not exist, so it never
|
||
// worked either.
|
||
//
|
||
// The guards below all catch the same class of fault: a write that succeeds and
|
||
// then cannot be seen. Postgres will not complain about any of them — a
|
||
// `shiftid` pointing nowhere is a perfectly good integer — but each one produces
|
||
// a rider who is invisible the moment the transaction commits.
|
||
func (r *partnerRepository) CreateRider(rider models.NewRider) (int, error) {
|
||
if strings.TrimSpace(rider.Firstname) == "" {
|
||
return 0, errors.New("the rider needs a name")
|
||
}
|
||
if strings.TrimSpace(rider.Contactno) == "" {
|
||
return 0, errors.New("the rider needs a contact number")
|
||
}
|
||
if rider.Applocationid == 0 {
|
||
return 0, errors.New("the rider needs a delivery region")
|
||
}
|
||
if rider.Shiftid == 0 {
|
||
return 0, errors.New("the rider needs a shift")
|
||
}
|
||
|
||
// A shift that does not exist takes the rider out of every listing:
|
||
// `getriders` joins ridershifts through ridersettings.shiftid.
|
||
var shifts int64
|
||
if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
if shifts == 0 {
|
||
return 0, fmt.Errorf("shift %d does not exist", rider.Shiftid)
|
||
}
|
||
|
||
// Same again for the region, which is joined twice — app_location AND
|
||
// app_locationconfig. A region with no config row hides every rider in it.
|
||
var configs int64
|
||
if err := r.db.Table("app_locationconfig").Where("applocationid = ?", rider.Applocationid).Count(&configs).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
if configs == 0 {
|
||
return 0, fmt.Errorf("delivery region %d is not configured, so a rider added to it would not appear anywhere", rider.Applocationid)
|
||
}
|
||
|
||
// Riders are looked up by phone more than by anything else, and two accounts
|
||
// on one number is how the wrong person gets the job.
|
||
var clash int64
|
||
if err := r.db.Table("app_users").
|
||
Where("contactno = ? AND configid = 6", strings.TrimSpace(rider.Contactno)).
|
||
Count(&clash).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
if clash > 0 {
|
||
return 0, fmt.Errorf("a rider already uses %s", strings.TrimSpace(rider.Contactno))
|
||
}
|
||
|
||
status := strings.TrimSpace(rider.Status)
|
||
if status == "" {
|
||
status = "Active"
|
||
}
|
||
|
||
tx := r.db.Begin()
|
||
if tx.Error != nil {
|
||
return 0, tx.Error
|
||
}
|
||
|
||
// configid 6 is what identifies a rider — there is no Rider row in
|
||
// app_roles, and `getriders` keys on the configid rather than on a role.
|
||
user := models.User{
|
||
Firstname: strings.TrimSpace(rider.Firstname),
|
||
Lastname: strings.TrimSpace(rider.Lastname),
|
||
Contactno: strings.TrimSpace(rider.Contactno),
|
||
Email: strings.TrimSpace(rider.Email),
|
||
Password: rider.Password,
|
||
Address: rider.Address,
|
||
Suburb: rider.Suburb,
|
||
City: rider.City,
|
||
State: rider.State,
|
||
Postcode: rider.Postcode,
|
||
Configid: 6,
|
||
Tenantid: rider.Tenantid,
|
||
Locationid: rider.Locationid,
|
||
Applocationid: rider.Applocationid,
|
||
Partnerid: rider.Partnerid,
|
||
Shiftid: rider.Shiftid,
|
||
Status: status,
|
||
}
|
||
|
||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
if user.Userid == 0 {
|
||
tx.Rollback()
|
||
return 0, errors.New("the rider account was written without an id")
|
||
}
|
||
|
||
settings := models.Ridersettings{
|
||
Userid: user.Userid,
|
||
Partnerid: rider.Partnerid,
|
||
Shiftid: rider.Shiftid,
|
||
Identificationno: rider.Identificationno,
|
||
Vehiclename: rider.Vehiclename,
|
||
Vehicleno: rider.Vehicleno,
|
||
Licenseno: rider.Licenseno,
|
||
Registrationno: rider.Registrationno,
|
||
}
|
||
if err := tx.Table("ridersettings").Create(&settings).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
// onduty 1 means "available for work", not "on shift now" — that second
|
||
// question is answered by riderlogs, which the rider's own app writes when
|
||
// they clock on. A rider created here is therefore correctly absent from
|
||
// getriders until they start a shift.
|
||
pool := models.Appuserpools{
|
||
Userid: user.Userid,
|
||
Partnerid: rider.Partnerid,
|
||
Onduty: 1,
|
||
Status: status,
|
||
}
|
||
if err := tx.Table("app_userpools").Create(&pool).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
if err := tx.Commit().Error; err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
return user.Userid, nil
|
||
}
|
||
|
||
// GetRiderRoster lists every rider, on duty or not.
|
||
//
|
||
// Distinct from GetActiveRiders, and the difference is the whole point. That
|
||
// one INNER JOINs a riderlog dated today with logstatus 0 — it answers "who can
|
||
// I give this delivery to right now", which is correct for an assignment picker
|
||
// and useless for a staff directory: somebody hired this morning, or simply not
|
||
// working today, is absent from it. A directory that hides the person you just
|
||
// created reads as a failed save.
|
||
//
|
||
// So every join here is LEFT except the rider's own settings, and the duty
|
||
// state is returned as facts — `onduty`, the last log date, and whether that
|
||
// log is today — rather than used as a filter.
|
||
func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) {
|
||
var data []models.RiderRosterRow
|
||
|
||
q1 := `SELECT a.userid, a.firstname, a.lastname,
|
||
CONCAT(a.firstname, ' ', a.lastname) AS fullname,
|
||
a.contactno, a.email, a.tenantid, a.locationid, a.applocationid, a.partnerid, a.status,
|
||
f.locationname AS applocation,
|
||
COALESCE(g.locationname, '') AS locationname,
|
||
p.partnername,
|
||
c.identificationno, c.vehiclename, c.vehicleno, c.licenseno, c.registrationno,
|
||
c.shiftid, CONCAT(d.starttime, ' - ', d.endtime) AS shiftname,
|
||
COALESCE(b.onduty, 0) AS onduty,
|
||
e.logdate AS lastlogdate,
|
||
COALESCE(e.logdate::date = CURRENT_DATE AND e.logstatus = 0, false) AS isonduty
|
||
FROM app_users a
|
||
INNER JOIN ridersettings c ON a.userid = c.userid
|
||
LEFT JOIN app_userpools b ON a.userid = b.userid
|
||
LEFT JOIN ridershifts d ON c.shiftid = d.shiftid
|
||
LEFT JOIN app_location f ON a.applocationid = f.applocationid
|
||
LEFT JOIN partnerinfo p ON a.partnerid = p.partnerid
|
||
LEFT JOIN tenantlocations g ON a.locationid = g.locationid
|
||
LEFT JOIN (
|
||
SELECT r1.userid, r1.logdate, r1.logstatus
|
||
FROM riderlogs r1
|
||
INNER JOIN (
|
||
SELECT userid, MAX(logdate) AS max_logdate FROM riderlogs GROUP BY userid
|
||
) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate
|
||
) e ON a.userid = e.userid
|
||
WHERE a.configid = 6`
|
||
|
||
var args []interface{}
|
||
// Scoped by whichever id the caller has. Tenant first: it is the narrowest,
|
||
// and it is the scope a merchant's own directory wants.
|
||
if tid != 0 {
|
||
q1 += ` AND a.tenantid = ?`
|
||
args = append(args, tid)
|
||
} else if aid != 0 {
|
||
q1 += ` AND a.applocationid = ?`
|
||
args = append(args, aid)
|
||
} else if pid != 0 {
|
||
q1 += ` AND a.partnerid = ?`
|
||
args = append(args, pid)
|
||
}
|
||
|
||
q1 += ` ORDER BY a.firstname, a.lastname`
|
||
|
||
if err := r.db.Raw(q1, args...).Scan(&data).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
// UpdateRider edits a rider across both of their tables.
|
||
//
|
||
// Only the fields a person can change from the console: their contact details,
|
||
// their vehicle and licence, their shift, and their status. The identity
|
||
// columns and the tenant are not editable — moving a rider between merchants is
|
||
// not an edit, and doing it silently through a profile form is how a rider ends
|
||
// up on somebody else's books.
|
||
func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
|
||
if rider.Userid == 0 {
|
||
return errors.New("userid is required")
|
||
}
|
||
|
||
if rider.Shiftid != 0 {
|
||
var shifts int64
|
||
if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil {
|
||
return err
|
||
}
|
||
if shifts == 0 {
|
||
return fmt.Errorf("shift %d does not exist", rider.Shiftid)
|
||
}
|
||
}
|
||
|
||
tx := r.db.Begin()
|
||
if tx.Error != nil {
|
||
return tx.Error
|
||
}
|
||
|
||
// Built as a map rather than a struct: Updates() with a struct skips every
|
||
// zero value, so clearing a licence number or blanking an email would
|
||
// silently do nothing. A map says exactly what to write.
|
||
user := map[string]interface{}{
|
||
"firstname": strings.TrimSpace(rider.Firstname),
|
||
"lastname": strings.TrimSpace(rider.Lastname),
|
||
"contactno": strings.TrimSpace(rider.Contactno),
|
||
"email": strings.TrimSpace(rider.Email),
|
||
"address": rider.Address,
|
||
"suburb": rider.Suburb,
|
||
"city": rider.City,
|
||
"state": rider.State,
|
||
"postcode": rider.Postcode,
|
||
}
|
||
if rider.Shiftid != 0 {
|
||
user["shiftid"] = rider.Shiftid
|
||
}
|
||
// The branch an own rider works out of. Moving them between a merchant's
|
||
// outlets is an ordinary edit; moving them between MERCHANTS is not, which
|
||
// is why tenantid and partnerid stay out of this map.
|
||
if rider.Locationid != 0 {
|
||
user["locationid"] = rider.Locationid
|
||
}
|
||
if strings.TrimSpace(rider.Status) != "" {
|
||
user["status"] = strings.TrimSpace(rider.Status)
|
||
}
|
||
|
||
res := tx.Table("app_users").Where("userid = ? AND configid = 6", rider.Userid).Updates(user)
|
||
if res.Error != nil {
|
||
tx.Rollback()
|
||
return res.Error
|
||
}
|
||
// An UPDATE matching no rows is not an SQL error, so editing a userid that
|
||
// is not a rider would report success and change nothing.
|
||
if res.RowsAffected == 0 {
|
||
tx.Rollback()
|
||
return fmt.Errorf("rider %d not found", rider.Userid)
|
||
}
|
||
|
||
settings := map[string]interface{}{
|
||
"identificationno": rider.Identificationno,
|
||
"vehiclename": rider.Vehiclename,
|
||
"vehicleno": rider.Vehicleno,
|
||
"licenseno": rider.Licenseno,
|
||
"registrationno": rider.Registrationno,
|
||
}
|
||
if rider.Shiftid != 0 {
|
||
settings["shiftid"] = rider.Shiftid
|
||
}
|
||
if err := tx.Table("ridersettings").Where("userid = ?", rider.Userid).Updates(settings).Error; err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
|
||
return tx.Commit().Error
|
||
}
|
||
|
||
/* ── Onboarding a delivery partner ────────────────────────────────────────────
|
||
|
||
A partner is a company that supplies riders, and until now the platform could
|
||
only READ them: `getpartners` has always existed and nothing could create one.
|
||
The five partners live today were inserted by hand, which is also why two of
|
||
them are named "Test".
|
||
|
||
Where a partner works is recorded twice, on purpose and not by accident:
|
||
|
||
partnerinfo.applocationid their home region — the rider app reads it
|
||
partnerlocations every region they cover
|
||
|
||
Both are kept in step here. Writing only the first would confine a partner to
|
||
one city, and writing only the second would hide them from the rider app.
|
||
|
||
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
|
||
region query to every city a partner actually covers and — because only this
|
||
function writes that table — separates partners registered here from the ones
|
||
another product put in the shared `partnerinfo`. So the link rows are not
|
||
bookkeeping; they are what makes a partner ours. */
|
||
|
||
// CreatePartner onboards a delivery partner and records the regions they cover.
|
||
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
||
if strings.TrimSpace(input.Partnername) == "" {
|
||
return 0, errors.New("the partner needs a name")
|
||
}
|
||
if strings.TrimSpace(input.Primarycontact) == "" {
|
||
return 0, errors.New("the partner needs a contact number")
|
||
}
|
||
// A district that is not open yet is opened here rather than refused — the
|
||
// form offers all 38 and this is what makes that true. Resolved before the
|
||
// checks below, so everything after works on a region that exists.
|
||
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
|
||
opened, err := r.EnsureRegion(input.District)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
input.Applocationid = opened
|
||
}
|
||
if input.Applocationid == 0 {
|
||
return 0, errors.New("the partner needs a district to work in")
|
||
}
|
||
|
||
// A region with no config row hides every rider placed in it — the same
|
||
// guard `CreateRider` applies, for the same reason.
|
||
regions := regionsOf(input)
|
||
for _, aid := range regions {
|
||
var configs int64
|
||
if err := r.db.Table("app_locationconfig").Where("applocationid = ?", aid).Count(&configs).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
if configs == 0 {
|
||
return 0, fmt.Errorf("region %d is not configured, so riders placed in it would not appear anywhere", aid)
|
||
}
|
||
}
|
||
|
||
// Two partners on one number is how the wrong company gets the work.
|
||
var clash int64
|
||
if err := r.db.Table("partnerinfo").
|
||
Where("primarycontact = ?", strings.TrimSpace(input.Primarycontact)).
|
||
Count(&clash).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
if clash > 0 {
|
||
return 0, fmt.Errorf("a partner already uses %s", strings.TrimSpace(input.Primarycontact))
|
||
}
|
||
|
||
status := strings.TrimSpace(input.Status)
|
||
if status == "" {
|
||
status = "Active"
|
||
}
|
||
|
||
tx := r.db.Begin()
|
||
if tx.Error != nil {
|
||
return 0, tx.Error
|
||
}
|
||
|
||
row := map[string]any{
|
||
"partnername": strings.TrimSpace(input.Partnername),
|
||
"companyname": strings.TrimSpace(input.Companyname),
|
||
"registrationno": strings.TrimSpace(input.Registrationno),
|
||
"primarycontact": strings.TrimSpace(input.Primarycontact),
|
||
"primaryemail": strings.TrimSpace(input.Primaryemail),
|
||
"contactno": strings.TrimSpace(input.Contactno),
|
||
"address": input.Address,
|
||
"suburb": input.Suburb,
|
||
"city": input.City,
|
||
"state": input.State,
|
||
"postcode": input.Postcode,
|
||
"partnerinfo": input.Partnerinfo,
|
||
"partnerimage": input.Partnerimage,
|
||
"applocationid": regions[0],
|
||
"status": status,
|
||
}
|
||
|
||
var partnerid int
|
||
if err := tx.Table("partnerinfo").
|
||
Clauses(clause.Returning{Columns: []clause.Column{{Name: "partnerid"}}}).
|
||
Create(&row).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
if id, ok := row["partnerid"]; ok {
|
||
partnerid = toInt(id)
|
||
}
|
||
if partnerid == 0 {
|
||
tx.Rollback()
|
||
return 0, errors.New("the partner was written without an id")
|
||
}
|
||
|
||
if err := replaceLocations(tx, partnerid, regions, input); err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
if err := tx.Commit().Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return partnerid, nil
|
||
}
|
||
|
||
// UpdatePartner edits a partner and, when regions are supplied, re-states them.
|
||
//
|
||
// Regions are replaced rather than merged: the console sends the whole set it
|
||
// is showing, and a merge would make removing a region impossible. Sending none
|
||
// leaves them alone, so an edit that only changes a phone number cannot empty
|
||
// the list by omission.
|
||
func (r *partnerRepository) UpdatePartner(input models.NewPartner) error {
|
||
if input.Partnerid == 0 {
|
||
return errors.New("partnerid is required")
|
||
}
|
||
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
|
||
opened, err := r.EnsureRegion(input.District)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
input.Applocationid = opened
|
||
}
|
||
|
||
fields := map[string]any{}
|
||
set := func(key, value string) {
|
||
if strings.TrimSpace(value) != "" {
|
||
fields[key] = strings.TrimSpace(value)
|
||
}
|
||
}
|
||
set("partnername", input.Partnername)
|
||
set("companyname", input.Companyname)
|
||
set("registrationno", input.Registrationno)
|
||
set("primarycontact", input.Primarycontact)
|
||
set("primaryemail", input.Primaryemail)
|
||
set("contactno", input.Contactno)
|
||
set("address", input.Address)
|
||
set("suburb", input.Suburb)
|
||
set("city", input.City)
|
||
set("state", input.State)
|
||
set("partnerinfo", input.Partnerinfo)
|
||
set("partnerimage", input.Partnerimage)
|
||
set("status", input.Status)
|
||
if input.Postcode > 0 {
|
||
fields["postcode"] = input.Postcode
|
||
}
|
||
if input.Applocationid > 0 {
|
||
fields["applocationid"] = input.Applocationid
|
||
}
|
||
|
||
regions := regionsOf(input)
|
||
if len(fields) == 0 && len(regions) == 0 {
|
||
return errors.New("nothing to update")
|
||
}
|
||
|
||
tx := r.db.Begin()
|
||
if tx.Error != nil {
|
||
return tx.Error
|
||
}
|
||
|
||
if len(fields) > 0 {
|
||
fields["updated"] = gorm.Expr("NOW()")
|
||
res := tx.Table("partnerinfo").Where("partnerid = ?", input.Partnerid).Updates(fields)
|
||
if res.Error != nil {
|
||
tx.Rollback()
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
tx.Rollback()
|
||
return fmt.Errorf("no partner with partnerid %d", input.Partnerid)
|
||
}
|
||
}
|
||
|
||
if len(regions) > 0 {
|
||
if err := replaceLocations(tx, input.Partnerid, regions, input); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
}
|
||
|
||
return tx.Commit().Error
|
||
}
|
||
|
||
// GetPartnerLocations lists the regions a partner covers, named.
|
||
func (r *partnerRepository) GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error) {
|
||
var data []models.PartnerLocation
|
||
err := r.db.Raw(`
|
||
SELECT a.partnerlocationid, a.partnerid, a.applocationid,
|
||
COALESCE(b.locationname, '') AS applocation
|
||
FROM partnerlocations a
|
||
LEFT JOIN app_location b ON a.applocationid = b.applocationid
|
||
WHERE a.partnerid = ?
|
||
ORDER BY b.locationname`, partnerid).Scan(&data).Error
|
||
return data, err
|
||
}
|
||
|
||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||
|
||
// regionsOf is every region the partner covers, home region first and no
|
||
// duplicates. One list, so the row and the link table cannot disagree.
|
||
func regionsOf(input models.NewPartner) []int {
|
||
if input.Applocationid > 0 {
|
||
return []int{input.Applocationid}
|
||
}
|
||
return []int{}
|
||
}
|
||
|
||
// replaceLocations re-states a partner's regions inside the caller's tx.
|
||
func replaceLocations(tx *gorm.DB, partnerid int, regions []int, input models.NewPartner) error {
|
||
if len(regions) == 0 {
|
||
return nil
|
||
}
|
||
if err := tx.Table("partnerlocations").Where("partnerid = ?", partnerid).Delete(nil).Error; err != nil {
|
||
return err
|
||
}
|
||
rows := make([]map[string]any, 0, len(regions))
|
||
for _, aid := range regions {
|
||
rows = append(rows, map[string]any{
|
||
"partnerid": partnerid,
|
||
"applocationid": aid,
|
||
"address": input.Address,
|
||
"suburb": input.Suburb,
|
||
"city": input.City,
|
||
"state": input.State,
|
||
"postcode": strconv.Itoa(input.Postcode),
|
||
})
|
||
}
|
||
return tx.Table("partnerlocations").Create(&rows).Error
|
||
}
|
||
|
||
// toInt reads the id a RETURNING clause handed back, whatever numeric type the
|
||
// driver chose for it.
|
||
func toInt(value any) int {
|
||
switch n := value.(type) {
|
||
case int:
|
||
return n
|
||
case int32:
|
||
return int(n)
|
||
case int64:
|
||
return int(n)
|
||
case float64:
|
||
return int(n)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
/*
|
||
Opening a district.
|
||
|
||
`app_location` is not a geography table — it is the list of places Nearle
|
||
actually runs, each with a radius, opening hours and an image. Three rows exist.
|
||
That is why a partner could only be placed in three of Tamil Nadu's thirty-eight
|
||
districts: `partnerinfo.applocationid` has to point at one of these rows, every
|
||
rider query joins through it, and `CreateRider` refuses a region with no
|
||
`app_locationconfig`.
|
||
|
||
So onboarding a partner in a new district OPENS the district: it writes both
|
||
rows, copying the operating defaults from a region already running rather than
|
||
inventing them. The alternative was a form that lists 38 districts and accepts
|
||
3, which is a form that lies.
|
||
|
||
── Why the id is computed rather than defaulted ────────────────────────────
|
||
|
||
`app_location.applocationid` is a plain bigint: no identity, no default, no
|
||
sequence — the same shape as `productcategories.categoryid`. Every insert has to
|
||
supply one, so two people onboarding partners at the same moment would both read
|
||
the same MAX and write the same id. The advisory lock serialises that, and the
|
||
name lookup inside it makes a repeat call return the existing row instead of a
|
||
second Erode.
|
||
*/
|
||
|
||
// EnsureRegion returns the applocationid for a district, opening it if needed.
|
||
func (r *partnerRepository) EnsureRegion(district string) (int, error) {
|
||
name := strings.TrimSpace(district)
|
||
if name == "" {
|
||
return 0, errors.New("a district name is required")
|
||
}
|
||
|
||
if id := r.regionByName(r.db, name); id > 0 {
|
||
return id, nil
|
||
}
|
||
|
||
tx := r.db.Begin()
|
||
if tx.Error != nil {
|
||
return 0, tx.Error
|
||
}
|
||
|
||
// One writer at a time. The key is arbitrary and constant — it names this
|
||
// operation, not a row.
|
||
if err := tx.Exec(`SELECT pg_advisory_xact_lock(?)`, 8412771).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
// Checked again INSIDE the lock: the request that was waiting for it may
|
||
// have been opening the same district.
|
||
if id := r.regionByName(tx, name); id > 0 {
|
||
tx.Rollback()
|
||
return id, nil
|
||
}
|
||
|
||
var nextID int
|
||
if err := tx.Raw(`SELECT COALESCE(MAX(applocationid), 0) + 1 FROM app_location`).
|
||
Scan(&nextID).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
// Operating defaults copied from a region already running, so a new
|
||
// district behaves like the ones that work rather than like a blank row.
|
||
var template struct {
|
||
Countryid int
|
||
Radius int
|
||
Opentime string
|
||
Closetime string
|
||
}
|
||
if err := tx.Raw(`
|
||
SELECT COALESCE(countryid, 0) AS countryid, COALESCE(radius, 18) AS radius,
|
||
COALESCE(opentime, '08:00:00') AS opentime,
|
||
COALESCE(closetime, '23:59:00') AS closetime
|
||
FROM app_location WHERE status = 'Active' ORDER BY applocationid LIMIT 1`).
|
||
Scan(&template).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
if template.Radius == 0 {
|
||
template.Radius = 18
|
||
}
|
||
|
||
if err := tx.Exec(`
|
||
INSERT INTO app_location
|
||
(applocationid, countryid, locationname, city, state, radius,
|
||
deliveryradius, opentime, closetime, status)
|
||
VALUES (?, ?, ?, ?, 'Tamil Nadu', ?, ?, ?, ?, 'Active')`,
|
||
nextID, template.Countryid, name, name, template.Radius, template.Radius,
|
||
template.Opentime, template.Closetime).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
// Without this row every rider in the district is invisible — `getriders`
|
||
// joins it, and `CreateRider` refuses a region that lacks it. Opening a
|
||
// district means both rows or neither.
|
||
var nextConfig int
|
||
if err := tx.Raw(`SELECT COALESCE(MAX(applocationconfigid), 0) + 1 FROM app_locationconfig`).
|
||
Scan(&nextConfig).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
if err := tx.Exec(`
|
||
INSERT INTO app_locationconfig (applocationconfigid, applocationid, configid, status)
|
||
VALUES (?, ?, 1, 'Active')`, nextConfig, nextID).Error; err != nil {
|
||
tx.Rollback()
|
||
return 0, err
|
||
}
|
||
|
||
if err := tx.Commit().Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return nextID, nil
|
||
}
|
||
|
||
// regionByName finds a district by name, case-insensitively and space-tolerant.
|
||
func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
|
||
var id int
|
||
db.Raw(`SELECT applocationid FROM app_location
|
||
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
|
||
return id
|
||
}
|
||
|
||
// ── Rider shifts ────────────────────────────────────────────────────────────
|
||
//
|
||
// A shift is the window a rider works, and `CreateRider` refuses a rider
|
||
// without one — `getriders` joins `ridershifts` through `ridersettings.shiftid`,
|
||
// so a rider on a shift that does not exist is a rider nobody can see.
|
||
//
|
||
// Until now the table could only be READ. There was no endpoint, no service
|
||
// method and not even a field for `applocationid` on the model, so a region
|
||
// that shipped without shift rows could never have a rider added to it at all:
|
||
// the console showed "No shifts set up for this region" and there was nothing
|
||
// anybody could do from the product to change that. Till staff had
|
||
// `createstaffshift` from the beginning; riders were simply missed.
|
||
|
||
// riderShiftClock is a start or end time as the column stores it.
|
||
//
|
||
// Accepts `9:00`, `09:00` and `09:00:00` and normalises to `HH:MM`. The rows
|
||
// inserted by hand over the years use all three spellings, and `GetRiderShifts`
|
||
// builds its label by concatenating the two columns raw — so `9:00-17:00` and
|
||
// `09:00-17:00` are two different labels for one window in the same dropdown.
|
||
func riderShiftClock(raw string) (string, error) {
|
||
text := strings.TrimSpace(raw)
|
||
if text == "" {
|
||
return "", errors.New("a shift needs a start and an end time")
|
||
}
|
||
|
||
parts := strings.Split(text, ":")
|
||
if len(parts) < 2 || len(parts) > 3 {
|
||
return "", fmt.Errorf("%q is not a time — write it as HH:MM", raw)
|
||
}
|
||
|
||
hour, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
if err != nil || hour < 0 || hour > 23 {
|
||
return "", fmt.Errorf("%q is not a time — the hour must be 0 to 23", raw)
|
||
}
|
||
minute, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||
if err != nil || minute < 0 || minute > 59 {
|
||
return "", fmt.Errorf("%q is not a time — the minutes must be 0 to 59", raw)
|
||
}
|
||
return fmt.Sprintf("%02d:%02d", hour, minute), nil
|
||
}
|
||
|
||
// riderShiftHours is how long the window runs, in hours.
|
||
//
|
||
// Computed rather than asked for, because it is the one field a person gets
|
||
// wrong and nothing downstream checks: `shifthours` feeds rider pay, and a
|
||
// window of 09:00–17:00 recorded as 4 hours underpays every rider on it.
|
||
//
|
||
// A window that ends before it starts crosses midnight and is measured that
|
||
// way — a 22:00–06:00 night shift is eight hours, not minus sixteen.
|
||
func riderShiftHours(start, end string) float32 {
|
||
toMinutes := func(clock string) int {
|
||
parts := strings.Split(clock, ":")
|
||
hour, _ := strconv.Atoi(parts[0])
|
||
minute, _ := strconv.Atoi(parts[1])
|
||
return hour*60 + minute
|
||
}
|
||
|
||
span := toMinutes(end) - toMinutes(start)
|
||
if span <= 0 {
|
||
span += 24 * 60
|
||
}
|
||
return float32(span) / 60
|
||
}
|
||
|
||
// validateRiderShift checks everything that does not need the database.
|
||
//
|
||
// Split out so the rules are testable without one, and returns the shift with
|
||
// its times normalised and its hours worked out rather than reporting on a copy
|
||
// the caller then has to rebuild.
|
||
func validateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||
if shift.Applocationid == 0 {
|
||
return shift, errors.New("a shift needs a delivery region")
|
||
}
|
||
|
||
start, err := riderShiftClock(shift.Starttime)
|
||
if err != nil {
|
||
return shift, err
|
||
}
|
||
end, err := riderShiftClock(shift.Endtime)
|
||
if err != nil {
|
||
return shift, err
|
||
}
|
||
if start == end {
|
||
return shift, errors.New("a shift cannot start and end at the same time")
|
||
}
|
||
|
||
shift.Starttime = start
|
||
shift.Endtime = end
|
||
// Always recomputed, never taken from the request. A caller that sends its
|
||
// own number is a caller that can disagree with the window it just sent.
|
||
shift.Shifthours = riderShiftHours(start, end)
|
||
|
||
if shift.Basefare < 0 || shift.Additionalcharges < 0 || shift.Fuelcharge < 0 {
|
||
return shift, errors.New("pay cannot be negative")
|
||
}
|
||
return shift, nil
|
||
}
|
||
|
||
// CreateRiderShift opens a shift window in one region.
|
||
func (r *partnerRepository) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||
shift, err := validateRiderShift(shift)
|
||
if err != nil {
|
||
return models.Ridershifts{}, err
|
||
}
|
||
|
||
// Same guard `CreateRider` applies to a rider's region, for the same reason:
|
||
// `getriders` joins app_locationconfig, so a shift in a region with no
|
||
// config row would be offered in the dropdown and then hide every rider put
|
||
// on it.
|
||
var configs int64
|
||
if err := r.db.Table("app_locationconfig").
|
||
Where("applocationid = ?", shift.Applocationid).Count(&configs).Error; err != nil {
|
||
return models.Ridershifts{}, err
|
||
}
|
||
if configs == 0 {
|
||
return models.Ridershifts{}, fmt.Errorf(
|
||
"delivery region %d is not configured, so a shift there would hide every rider on it", shift.Applocationid)
|
||
}
|
||
|
||
// The dropdown labels a shift by its times alone, so a duplicate window is
|
||
// two identical-looking choices and no way to tell which one a rider is on.
|
||
var clash int64
|
||
if err := r.db.Table("ridershifts").
|
||
Where("applocationid = ? AND starttime = ? AND endtime = ?",
|
||
shift.Applocationid, shift.Starttime, shift.Endtime).
|
||
Count(&clash).Error; err != nil {
|
||
return models.Ridershifts{}, err
|
||
}
|
||
if clash > 0 {
|
||
return models.Ridershifts{}, fmt.Errorf(
|
||
"a %s-%s shift already exists in this region", shift.Starttime, shift.Endtime)
|
||
}
|
||
|
||
// A column map with RETURNING, the same way CreatePartner writes its row,
|
||
// rather than inserting the struct.
|
||
//
|
||
// Inserting the struct would carry `shiftid` at zero into the statement and
|
||
// leave whether the sequence is used to how GORM reads a `Primary_Key` tag
|
||
// written in the v1 spelling — which is exactly the kind of thing that works
|
||
// on one driver and writes a row with id 0 on another. Naming the columns
|
||
// removes the question: the id is the database's to assign.
|
||
row := map[string]any{
|
||
"applocationid": shift.Applocationid,
|
||
"shiftdate": shift.Shiftdate,
|
||
"starttime": shift.Starttime,
|
||
"endtime": shift.Endtime,
|
||
"shifthours": shift.Shifthours,
|
||
"basefare": shift.Basefare,
|
||
"additionalkm": shift.Additionalkm,
|
||
"additionalcharges": shift.Additionalcharges,
|
||
"orders": shift.Orders,
|
||
"fuelcharge": shift.Fuelcharge,
|
||
}
|
||
|
||
if err := r.db.Table("ridershifts").
|
||
Clauses(clause.Returning{Columns: []clause.Column{{Name: "shiftid"}}}).
|
||
Create(&row).Error; err != nil {
|
||
return models.Ridershifts{}, err
|
||
}
|
||
|
||
id, ok := row["shiftid"]
|
||
if !ok || toInt(id) == 0 {
|
||
// The rider form selects the new shift by id the moment this returns. A
|
||
// shift written without one would leave the drawer selecting nothing and
|
||
// reading as a failed save.
|
||
return models.Ridershifts{}, errors.New("the shift was written without an id")
|
||
}
|
||
shift.Shiftid = toInt(id)
|
||
|
||
// The label the dropdown shows, built the same way GetRiderShifts builds it
|
||
// so a shift reads identically the moment it is created and after a reload.
|
||
shift.Shiftname = shift.Starttime + "-" + shift.Endtime
|
||
return shift, nil
|
||
}
|