Files

439 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package staff
import (
"math"
"strconv"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListDeliveryOrders 返回仅分派给当前配送人员的订单。
func ListDeliveryOrders(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var orders []models.GasorderBasic
if err := impl.DBService.Where("staff_account_id = ? AND status <> ?", account.ID, common.StatusArchived).
Order("created_at desc").Find(&orders).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, deliveryOrderResponses(orders))
}
// GetDeliveryOrder 返回当前配送人员订单详情及气瓶项目。
func GetDeliveryOrder(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
order, ok := requireDeliveryOrder(ctx, account, false)
if !ok {
return
}
var items []models.GasorderItem
if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Order("created_at asc").Find(&items).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"order": deliveryOrderResponse(order), "items": common.ResourceResponse(items)})
}
// StartDeliveryOrder 将已就绪订单置为配送中并创建本次轨迹。
func StartDeliveryOrder(ctx *gin.Context) {
transitionDeliveryOrder(ctx, common.StatusReady, common.StatusDelivering, true)
}
// AppendDeliveryTracks 批量补传配送中轨迹点request_no 保证重复补传不重复落库。
func AppendDeliveryTracks(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
Points []deliveryTrackPointRequest `json:"points" binding:"required,min=1,max=100"`
}
if ctx.ShouldBindJSON(&request) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != common.StatusDelivering {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
var track models.GasorderTrack
if impl.DBService.Where("gasorder_basic_id = ? AND staff_account_id = ? AND completed_at IS NULL", order.ID, account.ID).
First(&track).Error != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
receivedAt := time.Now()
points := make([]models.GasorderTrackPoint, 0, len(request.Points))
for _, item := range request.Points {
if !validTrackPoint(item) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
points = append(points, models.GasorderTrackPoint{
Entity: common.NewEntity(common.StatusEnable), GasorderTrackID: track.ID, RequestNo: item.RequestNo,
Longitude: item.Longitude, Latitude: item.Latitude, OccurredAt: item.OccurredAt,
ReceivedAt: receivedAt, Source: item.Source, Accuracy: item.Accuracy,
Speed: item.Speed, Direction: item.Direction,
})
}
if err := impl.DBService.Clauses(clause.OnConflict{DoNothing: true}).Create(&points).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"accepted": len(points)})
}
// ArriveDeliveryOrder 校验地理围栏并把配送单推进到待签收。
func ArriveDeliveryOrder(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request deliveryTrackPointRequest
if ctx.ShouldBindJSON(&request) != nil || !validTrackPoint(request) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok {
return
}
distance, valid := coordinateDistanceMeters(order.Longitude, order.Latitude, request.Longitude, request.Latitude)
if order.OrderStatus != common.StatusDelivering || !valid || distance > config.Spec.Global.DeliveryArrivalRadiusMeters {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
var track models.GasorderTrack
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("gasorder_basic_id = ? AND staff_account_id = ? AND completed_at IS NULL", order.ID, account.ID).
First(&track).Error; err != nil {
return err
}
now := time.Now()
point := models.GasorderTrackPoint{
Entity: common.NewEntity(common.StatusEnable), GasorderTrackID: track.ID, RequestNo: request.RequestNo,
Longitude: request.Longitude, Latitude: request.Latitude, OccurredAt: request.OccurredAt,
ReceivedAt: now, Source: request.Source, Accuracy: request.Accuracy, Speed: request.Speed, Direction: request.Direction,
}
if err := tx.Create(&point).Error; err != nil {
return err
}
if err := tx.Model(&track).Update("completed_at", &now).Error; err != nil {
return err
}
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, common.StatusDelivering).
Update("order_status", common.StatusAwaitingConfirmation)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Create(deliveryStatusRecord(order, account, common.StatusDelivering, common.StatusAwaitingConfirmation, "配送到达")).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"order_status": common.StatusAwaitingConfirmation, "distance_meters": math.Round(distance)})
}
// ExceptionDeliveryOrder 将配送中或待签收订单置为异常。
func ExceptionDeliveryOrder(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if ctx.ShouldBindJSON(&request) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || (order.OrderStatus != common.StatusDelivering && order.OrderStatus != common.StatusAwaitingConfirmation) {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
updateDeliveryStatus(ctx, order, account, common.StatusException, request.Reason, gin.H{"previous_order_status": order.OrderStatus})
}
// RecoverDeliveryOrder 将本人异常订单恢复到异常前状态。
func RecoverDeliveryOrder(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if ctx.ShouldBindJSON(&request) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != common.StatusException ||
(order.PreviousOrderStatus != common.StatusDelivering && order.PreviousOrderStatus != common.StatusAwaitingConfirmation) {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
target := order.PreviousOrderStatus
updateDeliveryStatus(ctx, order, account, target, request.Reason, gin.H{"previous_order_status": common.StatusDraft})
}
// SubmitDeliveryReceipt 保存签收凭证并完成订单,重复 request_no 返回既有结果。
func SubmitDeliveryReceipt(ctx *gin.Context) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
RequestNo string `json:"request_no" binding:"required"`
ConfirmType string `json:"confirm_type" binding:"required,oneof=signature receipt_code"`
RecipientName string `json:"recipient_name" binding:"required,max=64"`
RecipientPhone string `json:"recipient_phone" binding:"max=32"`
ProofURI string `json:"proof_uri" binding:"required,max=512"`
Remark string `json:"remark" binding:"max=1000"`
}
if ctx.ShouldBindJSON(&request) != nil || !strings.HasPrefix(request.ProofURI, "/uploads/") {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var existing models.GasorderConfirm
if impl.DBService.Where("request_no = ?", request.RequestNo).First(&existing).Error == nil {
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": existing.Identity, "order_status": common.StatusCompleted})
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != common.StatusAwaitingConfirmation {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
confirm := models.GasorderConfirm{
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID, RequestNo: request.RequestNo,
ConfirmType: request.ConfirmType, RecipientName: request.RecipientName, RecipientPhone: request.RecipientPhone,
ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark,
}
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&confirm).Error; err != nil {
return err
}
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, common.StatusAwaitingConfirmation).
Update("order_status", common.StatusCompleted)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
if err := tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", order.ID).Update("active", false).Error; err != nil {
return err
}
return tx.Create(deliveryStatusRecord(order, account, common.StatusAwaitingConfirmation, common.StatusCompleted, "用户签收")).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": confirm.Identity, "order_status": common.StatusCompleted})
}
type deliveryTrackPointRequest struct {
RequestNo string `json:"request_no" binding:"required"`
Longitude string `json:"longitude" binding:"required"`
Latitude string `json:"latitude" binding:"required"`
OccurredAt time.Time `json:"occurred_at" binding:"required"`
Source string `json:"source" binding:"required,oneof=gps network manual"`
Accuracy string `json:"accuracy"`
Speed string `json:"speed"`
Direction string `json:"direction"`
}
func requireDeliveryAccount(ctx *gin.Context) (models.StaffAccount, bool) {
account, ok := common.StaffAccount(ctx)
if !ok {
return account, false
}
if account.RoleCode != "delivery" {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return account, false
}
return account, true
}
func requireDeliveryOrder(ctx *gin.Context, account models.StaffAccount, lock bool) (models.GasorderBasic, bool) {
var order models.GasorderBasic
query := impl.DBService
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if query.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).
First(&order).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return order, false
}
return order, true
}
func transitionDeliveryOrder(ctx *gin.Context, from, to int, createTrack bool) {
account, ok := requireDeliveryAccount(ctx)
if !ok {
return
}
var request struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if ctx.ShouldBindJSON(&request) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != from {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, from).
Update("order_status", to)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
if createTrack {
var attempt int
if err := tx.Model(&models.GasorderTrack{}).Where("gasorder_basic_id = ?", order.ID).
Select("COALESCE(MAX(attempt_no), 0)").Scan(&attempt).Error; err != nil {
return err
}
if err := tx.Create(&models.GasorderTrack{
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
StaffAccountID: account.ID, AttemptNo: attempt + 1, StartedAt: time.Now(),
}).Error; err != nil {
return err
}
}
return tx.Create(deliveryStatusRecord(order, account, from, to, request.Reason)).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"order_status": to})
}
func updateDeliveryStatus(ctx *gin.Context, order models.GasorderBasic, account models.StaffAccount, target int, reason string, extra gin.H) {
updates := gin.H{"order_status": target}
for key, value := range extra {
updates[key] = value
}
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, order.OrderStatus).
Updates(updates)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Create(deliveryStatusRecord(order, account, order.OrderStatus, target, reason)).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"order_status": target})
}
func deliveryStatusRecord(order models.GasorderBasic, account models.StaffAccount, from, to int, reason string) models.GasorderStatus {
return models.GasorderStatus{
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
FromStatus: from, ToStatus: to, OperatorIdentity: account.Identity,
OperatorName: account.Name, OccurredAt: time.Now(), Reason: strings.TrimSpace(reason),
}
}
func deliveryOrderResponses(orders []models.GasorderBasic) []gin.H {
responses := make([]gin.H, 0, len(orders))
for _, order := range orders {
responses = append(responses, deliveryOrderResponse(order))
}
return responses
}
func deliveryOrderResponse(order models.GasorderBasic) gin.H {
return gin.H{
"identity": order.Identity, "order_no": order.OrderNo, "order_status": order.OrderStatus,
"address": order.Address, "longitude": order.Longitude, "latitude": order.Latitude,
"contact_name": order.ContactName, "contact_phone": order.ContactPhone,
"payable_amount": order.PayableAmount, "remark": order.Remark,
"created_at": order.CreatedAt, "updated_at": order.UpdatedAt,
"allowed_actions": deliveryAllowedActions(order.OrderStatus),
}
}
func deliveryAllowedActions(status int) []string {
switch status {
case common.StatusReady:
return []string{"start"}
case common.StatusDelivering:
return []string{"append_tracks", "arrive", "exception"}
case common.StatusAwaitingConfirmation:
return []string{"submit_receipt", "exception"}
case common.StatusException:
return []string{"recover"}
default:
return []string{}
}
}
func validTrackPoint(point deliveryTrackPointRequest) bool {
_, longitudeOK := parseCoordinate(point.Longitude, -180, 180)
_, latitudeOK := parseCoordinate(point.Latitude, -90, 90)
return point.RequestNo != "" && longitudeOK && latitudeOK && !point.OccurredAt.IsZero()
}
func coordinateDistanceMeters(longitudeA, latitudeA, longitudeB, latitudeB string) (float64, bool) {
lonA, okA := parseCoordinate(longitudeA, -180, 180)
latA, okB := parseCoordinate(latitudeA, -90, 90)
lonB, okC := parseCoordinate(longitudeB, -180, 180)
latB, okD := parseCoordinate(latitudeB, -90, 90)
if !(okA && okB && okC && okD) {
return 0, false
}
const earthRadiusMeters = 6371000
latitudeDelta := (latB - latA) * math.Pi / 180
longitudeDelta := (lonB - lonA) * math.Pi / 180
a := math.Sin(latitudeDelta/2)*math.Sin(latitudeDelta/2) +
math.Cos(latA*math.Pi/180)*math.Cos(latB*math.Pi/180)*
math.Sin(longitudeDelta/2)*math.Sin(longitudeDelta/2)
return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)), true
}
func parseCoordinate(value string, minimum, maximum float64) (float64, bool) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return parsed, err == nil && parsed >= minimum && parsed <= maximum && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)
}