From 299871b820d92a4440cd7a68b567eb313f7b4ace Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 24 Sep 2026 15:51:47 +0530 Subject: [PATCH] shifts --- controllers/partnerController.go | 34 ++++++ models/partner.go | 6 +- repositories/partnerRepository.go | 176 ++++++++++++++++++++++++++++++ repositories/riderShift_test.go | 126 +++++++++++++++++++++ routes/partnerroutes.go | 3 + services/partnerService.go | 12 ++ 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 repositories/riderShift_test.go diff --git a/controllers/partnerController.go b/controllers/partnerController.go index f016e3f..a83b807 100644 --- a/controllers/partnerController.go +++ b/controllers/partnerController.go @@ -73,6 +73,40 @@ func (ctl *PartnerController) GetPartners(c *fiber.Ctx) error { }) } +// CreateRiderShift opens a working window in a delivery region. +// +// Riders cannot be hired without one, and until this existed the table could +// only be read — a region that shipped with no shift rows was a region no rider +// could ever be added to, with nothing in the product able to change that. +// +// The region comes from the body rather than the query because this is a write +// and the whole shift is one object; `getridershifts` beside it reads the same +// id from a param, which is the existing convention for reads here. +func (ctl *PartnerController) CreateRiderShift(c *fiber.Ctx) error { + + var shift models.Ridershifts + if err := c.BodyParser(&shift); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", + }) + } + + result, err := ctl.partnerService.CreateRiderShift(shift) + if err != nil { + // 400, not 500. Every failure here is something the person typed — a + // region that is not configured, a window that already exists, a time + // that is not a time — and each message says which. + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "status": false, "code": http.StatusBadRequest, "message": err.Error(), + }) + } + + return c.Status(http.StatusCreated).JSON(fiber.Map{ + "status": true, "code": http.StatusCreated, + "message": "Shift created", "details": result, + }) +} + func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error { aid, _ := strconv.Atoi(c.Query("applocationid")) diff --git a/models/partner.go b/models/partner.go index 129fc7f..297c08e 100644 --- a/models/partner.go +++ b/models/partner.go @@ -73,7 +73,11 @@ type Partnerinfo struct { } type Ridershifts struct { - Shiftid int `json:"shiftid" gorm:"Primary_Key"` + Shiftid int `json:"shiftid" gorm:"Primary_Key"` + // The region this shift belongs to. The column has always been on the table + // — `GetRiderShifts` filters on it — but there was no field for it here, so + // nothing could write one. That is why shifts could only ever be read. + Applocationid int `json:"applocationid"` Shiftdate string `json:"shiftdate"` Starttime string `json:"starttime"` Endtime string `json:"endtime"` diff --git a/repositories/partnerRepository.go b/repositories/partnerRepository.go index 963df1f..5265837 100644 --- a/repositories/partnerRepository.go +++ b/repositories/partnerRepository.go @@ -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 +} diff --git a/repositories/riderShift_test.go b/repositories/riderShift_test.go new file mode 100644 index 0000000..67f924d --- /dev/null +++ b/repositories/riderShift_test.go @@ -0,0 +1,126 @@ +package repositories + +import ( + "strings" + "testing" + + "nearle/models" +) + +/* +Shift windows, which riders cannot be hired without. + +`CreateRider` refuses a rider with no shift — correctly, because `getriders` +joins `ridershifts` and a rider on a shift that does not exist is a rider nobody +can see. But the table could only be READ: no endpoint, no service method, and +no `applocationid` field on the model to write one with. A region that shipped +without shift rows was a region no rider could ever be added to, and the console +said "No shifts set up for this region" with nothing anybody could do about it. + +These cover the rules that do not need a database. The two that do — the region +must be configured, and the window must not already exist — are enforced in +CreateRiderShift against real tables. +*/ + +func TestATimeIsNormalisedSoOneWindowHasOneLabel(t *testing.T) { + // The dropdown labels a shift by concatenating its two columns raw, so + // `9:00-17:00` and `09:00-17:00` are two different labels for one window. + // Rows inserted by hand over the years use every spelling. + for _, tc := range []struct{ in, want string }{ + {"9:00", "09:00"}, + {"09:00", "09:00"}, + {"09:00:00", "09:00"}, + {" 9:5 ", "09:05"}, + {"23:59", "23:59"}, + {"0:00", "00:00"}, + } { + got, err := riderShiftClock(tc.in) + if err != nil { + t.Fatalf("%q: %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("%q became %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSomethingThatIsNotATimeIsRefused(t *testing.T) { + for _, bad := range []string{"", " ", "morning", "25:00", "09:60", "9", "9:00:00:00", "-1:00"} { + if _, err := riderShiftClock(bad); err == nil { + t.Fatalf("%q was accepted as a time", bad) + } + } +} + +func TestHoursAreWorkedOutRatherThanAskedFor(t *testing.T) { + // `shifthours` feeds rider pay. It is the one field a person gets wrong and + // nothing downstream checks, so it is computed and the request's own number + // is discarded. + shift, err := validateRiderShift(models.Ridershifts{ + Applocationid: 2, Starttime: "09:00", Endtime: "17:00", + Shifthours: 4, // wrong, and sent anyway + }) + if err != nil { + t.Fatalf("a good shift was refused: %v", err) + } + if shift.Shifthours != 8 { + t.Fatalf("09:00-17:00 came out as %v hours, want 8", shift.Shifthours) + } +} + +func TestANightShiftCrossesMidnightRatherThanGoingNegative(t *testing.T) { + // 22:00-06:00 is eight hours. Subtracting the clocks gives minus sixteen, + // which would pay a night rider for a negative shift. + if got := riderShiftHours("22:00", "06:00"); got != 8 { + t.Fatalf("22:00-06:00 came out as %v hours, want 8", got) + } + if got := riderShiftHours("09:30", "17:00"); got != 7.5 { + t.Fatalf("09:30-17:00 came out as %v hours, want 7.5", got) + } +} + +func TestAShiftNeedsARegion(t *testing.T) { + // Region 0 arrives from a form field nobody filled in. A shift there would + // be offered to nobody and joined to nothing. + _, err := validateRiderShift(models.Ridershifts{Starttime: "09:00", Endtime: "17:00"}) + if err == nil || !strings.Contains(err.Error(), "region") { + t.Fatalf("a shift with no region was accepted: %v", err) + } +} + +func TestAShiftCannotStartAndEndAtTheSameTime(t *testing.T) { + // Would compute as a 24-hour window under the midnight rule, which is not + // what anybody who typed the same time twice meant. + _, err := validateRiderShift(models.Ridershifts{ + Applocationid: 2, Starttime: "09:00", Endtime: "9:00", + }) + if err == nil { + t.Fatal("a zero-length window was accepted") + } +} + +func TestPayCannotBeNegative(t *testing.T) { + for _, shift := range []models.Ridershifts{ + {Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Basefare: -1}, + {Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Additionalcharges: -5}, + {Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Fuelcharge: -0.5}, + } { + if _, err := validateRiderShift(shift); err == nil { + t.Fatalf("negative pay was accepted: %+v", shift) + } + } +} + +func TestTheNormalisedTimesComeBackOnTheShift(t *testing.T) { + // The caller inserts what validation returned, not what it was handed — + // otherwise the normalising is computed and then thrown away. + shift, err := validateRiderShift(models.Ridershifts{ + Applocationid: 2, Starttime: "9:0", Endtime: "17:00:00", + }) + if err != nil { + t.Fatalf("refused: %v", err) + } + if shift.Starttime != "09:00" || shift.Endtime != "17:00" { + t.Fatalf("times were not normalised on the way out: %q-%q", shift.Starttime, shift.Endtime) + } +} diff --git a/routes/partnerroutes.go b/routes/partnerroutes.go index 704e17a..685353a 100644 --- a/routes/partnerroutes.go +++ b/routes/partnerroutes.go @@ -13,6 +13,9 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) { partner.Get("/getriders", f.PartnerController.GetActiveRiders) partner.Get("/getpartners", f.PartnerController.GetPartners) partner.Get("/getridershifts", f.PartnerController.GetRiderShifts) + // Opening a shift window. Riders cannot be hired without one, and this table + // was read-only until now — see partnerController.CreateRiderShift. + partner.Post("/createridershift", f.PartnerController.CreateRiderShift) partner.Get("/getlocations", f.PartnerController.GetLocationConfig) partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs) partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary) diff --git a/services/partnerService.go b/services/partnerService.go index f60e1e2..4829923 100644 --- a/services/partnerService.go +++ b/services/partnerService.go @@ -9,6 +9,7 @@ type PartnerService 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) @@ -48,6 +49,17 @@ func (s *partnerService) GetRiderShifts(aid int) ([]models.Ridershifts, error) { return s.repo.GetRiderShifts(aid) } +// CreateRiderShift opens a working window in a region. +// +// The counterpart the table never had. `CreateRider` refuses a rider with no +// shift, so a region shipped without shift rows could not be given a rider from +// the product at all — the dropdown said "No shifts set up for this region" and +// that was the end of it. +func (s *partnerService) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) { + + return s.repo.CreateRiderShift(shift) +} + func (s *partnerService) GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error) { return s.repo.GetLocationConfig(uid, cid)