rider partner

This commit is contained in:
2026-09-09 15:44:27 +05:30
parent 62a441d798
commit e7577fe0cf
11 changed files with 812 additions and 3 deletions

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

View File

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

View File

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