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

@@ -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"))

View File

@@ -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"`

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
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)