This commit is contained in:
2026-09-24 15:51:47 +05:30
parent cf3e4ea159
commit 299871b820
6 changed files with 356 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ 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)
@@ -1027,3 +1028,178 @@ func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
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
}