shifts
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
126
repositories/riderShift_test.go
Normal file
126
repositories/riderShift_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user