rider partner
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"net/http"
|
||||
@@ -218,11 +219,35 @@ func (ctl *PartnerController) CreateRider(c *fiber.Ctx) error {
|
||||
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
|
||||
rider.Tenantid = tid
|
||||
}
|
||||
if rider.Tenantid == 0 {
|
||||
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
|
||||
rider.Partnerid = pid
|
||||
}
|
||||
|
||||
// A rider belongs to a merchant OR to a delivery partner — never to
|
||||
// neither, and never to both.
|
||||
//
|
||||
// This used to demand a tenantid outright, which made a PARTNER's rider
|
||||
// impossible to create: a partner supplies riders to many merchants and
|
||||
// their riders sit under no single one. It is the same endpoint because it
|
||||
// is the same rider — the difference is only who they ride for, and that is
|
||||
// what the assign screen later branches on.
|
||||
//
|
||||
// Both at once is refused rather than silently preferred. A rider carrying
|
||||
// a tenantid AND a partnerid appears in two directories and two assign
|
||||
// pickers, and nothing downstream says which one owns them.
|
||||
switch {
|
||||
case rider.Tenantid == 0 && rider.Partnerid == 0:
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"status": false,
|
||||
"message": "a rider needs either a tenantid (the shop's own rider) " +
|
||||
"or a partnerid (a delivery partner's rider)",
|
||||
})
|
||||
case rider.Tenantid != 0 && rider.Partnerid != 0:
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"status": false,
|
||||
"message": "tenantid is required",
|
||||
"message": "a rider belongs to a shop or to a partner, not to both",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,3 +335,97 @@ func (ctl *PartnerController) GetRiderRoster(c *fiber.Ctx) error {
|
||||
"details": result,
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Onboarding a delivery partner ────────────────────────────────────────────
|
||||
|
||||
Platform-side only. A merchant does not create the company that supplies their
|
||||
riders — they are assigned one, which is `AssignPartner` on the tenant. Both of
|
||||
these live in the web group for the same reason `createrider` does: a partner is
|
||||
onboarded from a console, not from a phone. */
|
||||
|
||||
// CreatePartner onboards a delivery partner and the regions they cover.
|
||||
func (ctl *PartnerController) CreatePartner(c *fiber.Ctx) error {
|
||||
var input models.NewPartner
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
partnerid, err := ctl.partnerService.CreatePartner(input)
|
||||
if err != nil {
|
||||
// 400, not 500: every failure this produces is something the caller
|
||||
// sent — a missing name, an unconfigured region, a number already in
|
||||
// use. A 500 sends them to look at the server for their own form.
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||
"code": http.StatusCreated, "status": true, "message": "Successfully Created",
|
||||
"details": fiber.Map{"partnerid": partnerid},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePartner edits a partner and, when regions are sent, re-states them.
|
||||
func (ctl *PartnerController) UpdatePartner(c *fiber.Ctx) error {
|
||||
var input models.NewPartner
|
||||
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
|
||||
input.Partnerid = pid
|
||||
}
|
||||
|
||||
if err := ctl.partnerService.UpdatePartner(input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPartnerLocations lists the regions one partner covers.
|
||||
func (ctl *PartnerController) GetPartnerLocations(c *fiber.Ctx) error {
|
||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||
if pid == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "partnerid is required",
|
||||
})
|
||||
}
|
||||
|
||||
rows, err := ctl.partnerService.GetPartnerLocations(pid)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success", "details": rows,
|
||||
})
|
||||
}
|
||||
|
||||
// riderOwner enforces that a rider belongs to a shop or to a partner.
|
||||
//
|
||||
// Its own function so the rule can be tested without a request, and so both
|
||||
// call sites — create today, anything that moves a rider tomorrow — cannot
|
||||
// drift apart on it.
|
||||
func riderOwner(tenantid, partnerid int) error {
|
||||
switch {
|
||||
case tenantid == 0 && partnerid == 0:
|
||||
return errors.New("a rider needs either a tenantid (the shop's own rider) " +
|
||||
"or a partnerid (a delivery partner's rider)")
|
||||
case tenantid != 0 && partnerid != 0:
|
||||
return errors.New("a rider belongs to a shop or to a partner, not to both")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
41
controllers/riderOwnership_test.go
Normal file
41
controllers/riderOwnership_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package controllers
|
||||
|
||||
import "testing"
|
||||
|
||||
/*
|
||||
A rider belongs to a shop or to a delivery partner — never to neither, never to
|
||||
both.
|
||||
|
||||
`CreateRider` used to demand a tenantid outright, which made a partner's rider
|
||||
impossible to create: a partner supplies riders to many merchants and their
|
||||
riders sit under no single one. The two ids are now exclusive, and both mistakes
|
||||
are refused rather than one being silently preferred — a rider carrying both
|
||||
appears in two directories and two assign pickers, with nothing downstream
|
||||
saying which owns them.
|
||||
|
||||
`riderOwner` is the rule on its own so it can be checked without a request.
|
||||
*/
|
||||
|
||||
func TestRiderOwnerAcceptsAShopsOwnRider(t *testing.T) {
|
||||
if err := riderOwner(1147, 0); err != nil {
|
||||
t.Fatalf("a tenant's own rider is valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiderOwnerAcceptsAPartnersRider(t *testing.T) {
|
||||
if err := riderOwner(0, 44); err != nil {
|
||||
t.Fatalf("a partner's rider is valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiderOwnerRefusesNeither(t *testing.T) {
|
||||
if err := riderOwner(0, 0); err == nil {
|
||||
t.Fatal("a rider owned by nobody appears in no directory and must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiderOwnerRefusesBoth(t *testing.T) {
|
||||
if err := riderOwner(1147, 44); err == nil {
|
||||
t.Fatal("a rider owned by both appears in two pickers and must be refused")
|
||||
}
|
||||
}
|
||||
@@ -710,3 +710,50 @@ func (ctl *TenantController) UpdateOwnProfile(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
|
||||
}
|
||||
|
||||
// AssignPartner puts a merchant under a delivery partner, or takes them out of
|
||||
// one.
|
||||
//
|
||||
// `partnerid` 0 is a real instruction here — it means "this merchant uses their
|
||||
// own riders" — so it is read as sent rather than treated as absent. Everywhere
|
||||
// else in the tenant API a zero means "not supplied"; this is the exception and
|
||||
// it is the reason the route exists separately.
|
||||
func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Partnerid *int `json:"partnerid"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
|
||||
body.Tenantid = tid
|
||||
}
|
||||
if body.Tenantid <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "tenantid is required",
|
||||
})
|
||||
}
|
||||
// A pointer, so "no partner" and "field omitted" are different requests.
|
||||
// Sent as a plain int, an omitted field would read as 0 and quietly unassign
|
||||
// a merchant's partner.
|
||||
if body.Partnerid == nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "partnerid is required — send 0 to take the partner away",
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.tenantService.AssignPartner(body.Tenantid, *body.Partnerid); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,6 +61,15 @@ type Partnerinfo struct {
|
||||
Postcode string `json:"postcode"`
|
||||
Partnerinfo string `json:"partnerinfo"`
|
||||
Partnerimage string `json:"partnerimage"`
|
||||
// Read back so a directory can show who the partner is and whether they
|
||||
// are trading. Both columns have always been on the table; the struct had
|
||||
// no field for them, so `getpartners` could not report either.
|
||||
Companyname string `json:"companyname"`
|
||||
Status string `json:"status"`
|
||||
// Every region this partner covers, from `partnerlocations`. A partner is
|
||||
// not confined to the one city on their own row — that column records where
|
||||
// they were set up, and the link table records where they actually work.
|
||||
Locations []PartnerLocation `json:"locations" gorm:"-"`
|
||||
}
|
||||
|
||||
type Ridershifts struct {
|
||||
@@ -156,7 +165,16 @@ type NewRider struct {
|
||||
Postcode string `json:"postcode"`
|
||||
|
||||
// Whose rider they are, where they ride, and who they ride for.
|
||||
//
|
||||
// Tenantid and Partnerid are exclusive: a rider belongs to a shop or to a
|
||||
// delivery partner, never to both and never to neither. The controller
|
||||
// refuses either mistake.
|
||||
//
|
||||
// Locationid is the branch an OWN rider works out of. It is meaningless for
|
||||
// a partner's rider — a partner supplies several merchants and is not tied
|
||||
// to any one shop's outlet — so it is only ever set alongside a tenantid.
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Partnerid int `json:"partnerid"`
|
||||
Shiftid int `json:"shiftid"`
|
||||
@@ -185,6 +203,8 @@ type RiderRosterRow struct {
|
||||
Contactno string `json:"contactno"`
|
||||
Email string `json:"email"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Locationname string `json:"locationname"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Applocation string `json:"applocation"`
|
||||
Partnerid int `json:"partnerid"`
|
||||
@@ -236,3 +256,61 @@ type Appuserpools struct {
|
||||
Onduty int `json:"onduty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PartnerLocation is one region a partner covers — a row of `partnerlocations`.
|
||||
type PartnerLocation struct {
|
||||
Partnerlocationid int `json:"partnerlocationid" gorm:"column:partnerlocationid"`
|
||||
Partnerid int `json:"partnerid" gorm:"column:partnerid"`
|
||||
Applocationid int `json:"applocationid" gorm:"column:applocationid"`
|
||||
Applocation string `json:"applocation" gorm:"column:applocation"`
|
||||
}
|
||||
|
||||
// NewPartner is everything the console collects to onboard a delivery partner.
|
||||
//
|
||||
// Separate from `Partnerinfo`, which is the READ shape. Binding a create to the
|
||||
// read model is how a caller ends up able to set columns nobody meant to expose
|
||||
// — `allocationid`, `partnertypeid`, the billing links — because a form happened
|
||||
// to send them.
|
||||
//
|
||||
// A partner is a company that supplies riders. It is onboarded by the platform,
|
||||
// never by a merchant: the merchant's side of this is being ASSIGNED one, which
|
||||
// is `AssignPartner` on the tenant.
|
||||
type NewPartner struct {
|
||||
Partnerid int `json:"partnerid"`
|
||||
Partnername string `json:"partnername"`
|
||||
Companyname string `json:"companyname"`
|
||||
Registrationno string `json:"registrationno"`
|
||||
Primarycontact string `json:"primarycontact"`
|
||||
Primaryemail string `json:"primaryemail"`
|
||||
Contactno string `json:"contactno"`
|
||||
Address string `json:"address"`
|
||||
Suburb string `json:"suburb"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
Postcode int `json:"postcode"`
|
||||
Partnerinfo string `json:"partnerinfo"`
|
||||
Partnerimage string `json:"partnerimage"`
|
||||
Status string `json:"status"`
|
||||
|
||||
/*
|
||||
Where they work — ONE district, not a set.
|
||||
|
||||
`Applocationid` is the home region and goes on the partner row itself,
|
||||
because `GetPartners` filters on it and the rider app reads it.
|
||||
`Applocationids` is every region they cover and goes to
|
||||
`partnerlocations` — one partner routinely serves several cities, and
|
||||
that is the whole reason the link table exists.
|
||||
*/
|
||||
Applocationid int `json:"applocationid"`
|
||||
/*
|
||||
The district by NAME, for one that is not open yet.
|
||||
|
||||
Nearle runs three of Tamil Nadu's thirty-eight districts, and a partner
|
||||
could only be placed in those three because `partnerinfo.applocationid`
|
||||
has to point at an `app_location` row. Sending the name instead opens the
|
||||
district — see `EnsureRegion` — so the form can offer all 38 and mean it.
|
||||
|
||||
Ignored when `Applocationid` is set, which is the ordinary case.
|
||||
*/
|
||||
District string `json:"district"`
|
||||
}
|
||||
|
||||
41
repositories/partnerOnboard_test.go
Normal file
41
repositories/partnerOnboard_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
/*
|
||||
One partner, one district.
|
||||
|
||||
The form used to ask for a home region AND a coverage set, which put the same
|
||||
partner in `partnerinfo.applocationid` and several `partnerlocations` rows. A
|
||||
partner works one district — that is the operating rule — so the two writes
|
||||
carry the same single id and `regionsOf` is what guarantees they cannot
|
||||
disagree.
|
||||
*/
|
||||
|
||||
func TestRegionsOfIsTheOneDistrict(t *testing.T) {
|
||||
got := regionsOf(models.NewPartner{Applocationid: 2})
|
||||
if len(got) != 1 || got[0] != 2 {
|
||||
t.Fatalf("got %v, want [2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Zero is not a district. It arrives from a form field nobody filled in, and a
|
||||
// partnerlocations row pointing at region 0 would join to nothing for ever.
|
||||
func TestRegionsOfIgnoresZero(t *testing.T) {
|
||||
if got := regionsOf(models.NewPartner{Applocationid: 0}); len(got) != 0 {
|
||||
t.Fatalf("got %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A district sent only by NAME has not been resolved yet — CreatePartner opens
|
||||
// it and sets the id before this is reached. Until then there is no region, and
|
||||
// saying so beats guessing one.
|
||||
func TestRegionsOfDoesNotInventOneFromAName(t *testing.T) {
|
||||
if got := regionsOf(models.NewPartner{District: "Erode"}); len(got) != 0 {
|
||||
t.Fatalf("got %v, want none until the district is opened", got)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PartnerRepository interface {
|
||||
@@ -21,6 +22,10 @@ type PartnerRepository interface {
|
||||
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 {
|
||||
@@ -399,6 +404,7 @@ func (r *partnerRepository) CreateRider(rider models.NewRider) (int, error) {
|
||||
Postcode: rider.Postcode,
|
||||
Configid: 6,
|
||||
Tenantid: rider.Tenantid,
|
||||
Locationid: rider.Locationid,
|
||||
Applocationid: rider.Applocationid,
|
||||
Partnerid: rider.Partnerid,
|
||||
Shiftid: rider.Shiftid,
|
||||
@@ -468,8 +474,9 @@ func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRos
|
||||
|
||||
q1 := `SELECT a.userid, a.firstname, a.lastname,
|
||||
CONCAT(a.firstname, ' ', a.lastname) AS fullname,
|
||||
a.contactno, a.email, a.tenantid, a.applocationid, a.partnerid, a.status,
|
||||
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,
|
||||
@@ -482,6 +489,7 @@ func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRos
|
||||
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
|
||||
@@ -558,6 +566,12 @@ func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
|
||||
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)
|
||||
}
|
||||
@@ -591,3 +605,384 @@ func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
|
||||
|
||||
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 — `GetPartners` filters on it
|
||||
and 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 every existing
|
||||
query. */
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type TenantRepository interface {
|
||||
CreateTenantUser(data models.Tenants) (bool, error)
|
||||
GetUserByNo(cno string) models.UserInfo
|
||||
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
||||
AssignPartner(tenantID, partnerID int) error
|
||||
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
||||
}
|
||||
|
||||
@@ -956,3 +957,43 @@ func (r *tenantRepository) UpdateOwnProfile(userID, tenantID int, fields map[str
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignPartner sets which delivery partner supplies a merchant's riders.
|
||||
//
|
||||
// A route of its own rather than a field on `updatetenant`, and that is the
|
||||
// whole point. `partnerid` is deliberately absent from `editableTenantFields`:
|
||||
// a merchant who could set it would move themselves under another partner's
|
||||
// billing and pick up their riders. This is the platform's decision, so it gets
|
||||
// the platform's endpoint.
|
||||
//
|
||||
// `partnerid` 0 is meaningful and allowed — it takes the partner away, which a
|
||||
// merchant switching to their own riders needs. So the value is written rather
|
||||
// than skipped when zero, unlike everywhere else in this file.
|
||||
func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
|
||||
if tenantID <= 0 {
|
||||
return errors.New("tenantid is required")
|
||||
}
|
||||
|
||||
// A partner that does not exist would silently orphan every rider lookup
|
||||
// the merchant makes afterwards — `getriders?partnerid=` would answer 200
|
||||
// with nothing, which reads as "no riders on duty".
|
||||
if partnerID > 0 {
|
||||
var found int64
|
||||
if err := r.db.Table("partnerinfo").Where("partnerid = ?", partnerID).Count(&found).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if found == 0 {
|
||||
return fmt.Errorf("no delivery partner with partnerid %d", partnerID)
|
||||
}
|
||||
}
|
||||
|
||||
res := r.db.Table("tenants").Where("tenantid = ?", tenantID).
|
||||
Updates(map[string]any{"partnerid": partnerID, "updated": gorm.Expr("NOW()")})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return fmt.Errorf("no business with tenantid %d", tenantID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
|
||||
//
|
||||
// Web group only. A rider is onboarded from the merchant's console, not from
|
||||
// a phone, so the mobile group below stays read-only as it was.
|
||||
// Onboarding the COMPANY that supplies riders, as opposed to the riders
|
||||
// themselves. `getpartners` has always existed and nothing could create
|
||||
// one, which is why the five partners live today were inserted by hand —
|
||||
// two of them are still named "Test".
|
||||
//
|
||||
// Web group only, and deliberately not in the mob group below: a partner is
|
||||
// onboarded by the platform from a console, never from a phone and never by
|
||||
// a merchant. A merchant's side of this is being assigned one.
|
||||
partner.Post("/createpartner", f.PartnerController.CreatePartner)
|
||||
partner.Put("/updatepartner", f.PartnerController.UpdatePartner)
|
||||
partner.Get("/getpartnerlocations", f.PartnerController.GetPartnerLocations)
|
||||
|
||||
partner.Post("/createrider", f.PartnerController.CreateRider)
|
||||
partner.Put("/updaterider", f.PartnerController.UpdateRider)
|
||||
partner.Get("/getriderroster", f.PartnerController.GetRiderRoster)
|
||||
|
||||
@@ -29,6 +29,13 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
||||
// two was simply not found. A profile screen cannot be built on that.
|
||||
tenant.Get("/gettenantinfo", f.TenantController.GetTenantInfo)
|
||||
|
||||
// Which delivery partner supplies this merchant's riders.
|
||||
//
|
||||
// Its own route rather than a field on updatetenant: `partnerid` is kept
|
||||
// out of the merchant-editable allowlist on purpose, because a merchant
|
||||
// who could set it would move themselves under another partner.
|
||||
tenant.Put("/assignpartner", f.TenantController.AssignPartner)
|
||||
|
||||
// A merchant editing their own business record. The first write path the
|
||||
// `tenants` table has ever had — see UpdateTenantProfile for what may be
|
||||
// set, and what deliberately may not.
|
||||
|
||||
@@ -16,6 +16,9 @@ type PartnerService interface {
|
||||
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)
|
||||
}
|
||||
|
||||
type partnerService struct {
|
||||
@@ -73,3 +76,19 @@ func (s *partnerService) UpdateRider(rider models.NewRider) error {
|
||||
func (s *partnerService) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) {
|
||||
return s.repo.GetRiderRoster(tid, aid, pid)
|
||||
}
|
||||
|
||||
/* ── Partners ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// CreatePartner onboards a delivery partner. Platform-side only — a merchant is
|
||||
// ASSIGNED a partner, never allowed to invent one.
|
||||
func (s *partnerService) CreatePartner(input models.NewPartner) (int, error) {
|
||||
return s.repo.CreatePartner(input)
|
||||
}
|
||||
|
||||
func (s *partnerService) UpdatePartner(input models.NewPartner) error {
|
||||
return s.repo.UpdatePartner(input)
|
||||
}
|
||||
|
||||
func (s *partnerService) GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error) {
|
||||
return s.repo.GetPartnerLocations(partnerid)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type TenantService interface {
|
||||
GetTenantLocations(tid int) ([]models.Tenantlocations, error)
|
||||
GetTenantSlot() (models.Tenantslot, error)
|
||||
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
|
||||
AssignPartner(tenantID, partnerID int) error
|
||||
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
|
||||
GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
|
||||
UpdateLocation(input models.Tenantlocations) error
|
||||
@@ -259,3 +260,11 @@ func (s *tenantService) UpdateOwnProfile(userID, tenantID int, fields map[string
|
||||
}
|
||||
return s.repo.UpdateOwnProfile(userID, tenantID, clean)
|
||||
}
|
||||
|
||||
// AssignPartner records which delivery partner supplies a merchant's riders.
|
||||
//
|
||||
// Not part of UpdateTenantProfile: that endpoint is the merchant's own, and
|
||||
// which partner they sit under is the platform's call. See the repository.
|
||||
func (s *tenantService) AssignPartner(tenantID, partnerID int) error {
|
||||
return s.repo.AssignPartner(tenantID, partnerID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user