rider partner
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user