fix platform workflow integrity and permissions
This commit is contained in:
@@ -147,8 +147,12 @@ func CreateGasorderContract(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if !deliveryBelongsToGas(deliveryID, gasID) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
contract := models.GasorderContract{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft},
|
||||
ContractNo: request.ContractNo, UserAccountID: userID, GasBasicID: gasID, DeliveryBasicID: deliveryID,
|
||||
Title: request.Title, Terms: request.Terms, FileURI: request.FileURI, DefaultDeliveryFee: request.DefaultDeliveryFee,
|
||||
SignedAt: request.SignedAt, EffectiveAt: request.EffectiveAt, ExpiredAt: request.ExpiredAt,
|
||||
@@ -181,6 +185,12 @@ func UpdateGasorderContract(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var contract models.GasorderContract
|
||||
if err := impl.DBService.Select("gas_basic_id").Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil ||
|
||||
!deliveryBelongsToGas(deliveryID, contract.GasBasicID) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
result := impl.DBService.Model(&models.GasorderContract{}).
|
||||
Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusDraft).
|
||||
Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms,
|
||||
@@ -280,7 +290,7 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
|
||||
|
||||
func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision {
|
||||
return &models.GasorderContractRevision{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderContractID: contract.ID, Action: action, ContractStatus: contract.Status,
|
||||
EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt,
|
||||
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
|
||||
@@ -318,7 +328,7 @@ func BindGasorderContractProduct(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
binding := models.GasorderContractProduct{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound},
|
||||
GasorderContractID: contract.ID, ProductInfoID: product.ID, ProductCode: product.Code,
|
||||
ProductTypeName: productType.Name, ProductParams: product.Params, UnitPrice: request.UnitPrice, BoundAt: time.Now(),
|
||||
}
|
||||
@@ -330,10 +340,17 @@ func BindGasorderContractProduct(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func UnbindGasorderContractProduct(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
result := impl.DBService.Model(&models.GasorderContractProduct{}).
|
||||
Where("identity = ? AND unbound_at IS NULL", ctx.Param("identity")).
|
||||
Updates(map[string]any{"status": "unbound", "unbound_at": &now})
|
||||
Updates(gasorderUnbindUpdates(request.Reason, now))
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
@@ -404,13 +421,6 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
!product.IsEnabled || product.Status == common.StatusScrapped || product.UserAccountID != contract.UserAccountID {
|
||||
return errors.New("contract product is no longer eligible")
|
||||
}
|
||||
var activeOrderCount int64
|
||||
if err := tx.Table("gasorder_item").
|
||||
Joins("JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id").
|
||||
Where("gasorder_item.product_info_id = ? AND gasorder_basic.status NOT IN ?", binding.ProductInfoID, []int{common.StatusCompleted, common.StatusCancelled}).
|
||||
Count(&activeOrderCount).Error; err != nil || activeOrderCount != 0 {
|
||||
return errors.New("contract product already has an active order")
|
||||
}
|
||||
productAmount += binding.UnitPrice
|
||||
}
|
||||
payable := productAmount + contract.DefaultDeliveryFee - request.DiscountAmount
|
||||
@@ -418,7 +428,7 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
return errors.New("invalid payable amount")
|
||||
}
|
||||
order = models.GasorderBasic{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated},
|
||||
OrderNo: models.NewIdentity(), RequestNo: request.RequestNo, GasorderContractID: contract.ID,
|
||||
UserAccountID: contract.UserAccountID, CreatorType: request.CreatorType, CreatorID: creatorID,
|
||||
CreatorIdentity: request.CreatorIdentity, GasBasicID: contract.GasBasicID, DeliveryBasicID: contract.DeliveryBasicID,
|
||||
@@ -433,8 +443,9 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
item := models.GasorderItem{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered},
|
||||
GasorderBasicID: order.ID, GasorderContractProductID: binding.ID, ProductInfoID: binding.ProductInfoID,
|
||||
Active: true,
|
||||
ProductCode: binding.ProductCode, ProductTypeName: binding.ProductTypeName,
|
||||
ProductParams: binding.ProductParams, UnitPrice: binding.UnitPrice,
|
||||
}
|
||||
@@ -445,6 +456,10 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
||||
return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "order created", operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&order).Error; lookupErr == nil {
|
||||
common.RespondCreatedResource(ctx, order)
|
||||
return
|
||||
}
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -481,6 +496,9 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
if order.Status != common.StatusCreated && order.Status != common.StatusAssigned {
|
||||
return errors.New("order cannot be assigned")
|
||||
}
|
||||
if !validGasorderAssignment(order, delivery, staff) {
|
||||
return errors.New("delivery or staff does not belong to the order organization")
|
||||
}
|
||||
previous := order.Status
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"delivery_basic_id": delivery.ID, "staff_account_id": staff.ID, "status": common.StatusAssigned,
|
||||
@@ -488,7 +506,7 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
return err
|
||||
}
|
||||
assignment := models.GasorderAssign{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: order.ID, GasBasicID: order.GasBasicID, DeliveryBasicID: delivery.ID,
|
||||
StaffAccountID: staff.ID, AssignerIdentity: operatorIdentity, AssignerName: operatorName,
|
||||
AssignedAt: time.Now(), Reason: request.Reason,
|
||||
@@ -509,20 +527,68 @@ func AssignGasorderBasic(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func GasorderStartFilling(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusFilling, map[int]bool{common.StatusAssigned: true})
|
||||
transitionGasorder(ctx, common.StatusFilling)
|
||||
}
|
||||
func GasorderReady(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusReady, map[int]bool{common.StatusFilling: true})
|
||||
transitionGasorder(ctx, common.StatusReady)
|
||||
}
|
||||
func GasorderCancel(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusCancelled, map[int]bool{common.StatusCreated: true, common.StatusAssigned: true})
|
||||
transitionGasorder(ctx, common.StatusCancelled)
|
||||
}
|
||||
func GasorderStartDelivering(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusDelivering)
|
||||
}
|
||||
func GasorderAwaitingConfirmation(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusAwaitingConfirmation)
|
||||
}
|
||||
|
||||
func GasorderComplete(ctx *gin.Context) {
|
||||
var request struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
ConfirmType string `json:"confirm_type" binding:"required,max=32"`
|
||||
RecipientName string `json:"recipient_name" binding:"required,max=64"`
|
||||
RecipientPhone string `json:"recipient_phone" binding:"max=32"`
|
||||
ProofURI string `json:"proof_uri" binding:"max=512"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
operatorIdentity, operatorName := common.PlatformOperator(ctx)
|
||||
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var order models.GasorderBasic
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status != common.StatusAwaitingConfirmation {
|
||||
return errors.New("order cannot be completed")
|
||||
}
|
||||
if err := tx.Model(&order).Update("status", common.StatusCompleted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
confirm := models.GasorderConfirm{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: order.ID, ConfirmType: request.ConfirmType, RecipientName: request.RecipientName,
|
||||
RecipientPhone: request.RecipientPhone, ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark,
|
||||
}
|
||||
if err := tx.Create(&confirm).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseGasorderProducts(tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(gasorderStatusRecord(order.ID, order.Status, common.StatusCompleted, request.Reason, operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true, "status": common.StatusCompleted})
|
||||
}
|
||||
|
||||
func GasorderException(ctx *gin.Context) {
|
||||
transitionGasorder(ctx, common.StatusException, map[int]bool{
|
||||
common.StatusFilling: true, common.StatusReady: true,
|
||||
common.StatusDelivering: true, common.StatusAwaitingConfirmation: true,
|
||||
})
|
||||
transitionGasorder(ctx, common.StatusException)
|
||||
}
|
||||
|
||||
func GasorderRecover(ctx *gin.Context) {
|
||||
@@ -555,7 +621,7 @@ func GasorderRecover(ctx *gin.Context) {
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
func transitionGasorder(ctx *gin.Context, target int) {
|
||||
var request struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
@@ -569,7 +635,7 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed[order.Status] || (target == common.StatusFilling && order.DeliveryBasicID == 0) {
|
||||
if !gasorderTransitionAllowed(order.Status, target) || (target == common.StatusFilling && order.DeliveryBasicID == 0) {
|
||||
return errors.New("invalid order transition")
|
||||
}
|
||||
updates := map[string]any{"status": target}
|
||||
@@ -579,6 +645,35 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if target == common.StatusDelivering {
|
||||
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
|
||||
}
|
||||
track := models.GasorderTrack{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusActive},
|
||||
GasorderBasicID: order.ID, StaffAccountID: order.StaffAccountID,
|
||||
AttemptNo: attempt + 1, StartedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&track).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if target == common.StatusAwaitingConfirmation {
|
||||
now := time.Now()
|
||||
result := tx.Model(&models.GasorderTrack{}).
|
||||
Where("gasorder_basic_id = ? AND completed_at IS NULL", order.ID).
|
||||
Update("completed_at", &now)
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
return errors.New("active delivery track not found")
|
||||
}
|
||||
}
|
||||
if target == common.StatusCancelled {
|
||||
if err := releaseGasorderProducts(tx, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(gasorderStatusRecord(order.ID, order.Status, target, request.Reason, operatorIdentity, operatorName)).Error
|
||||
})
|
||||
if err != nil {
|
||||
@@ -588,9 +683,58 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
|
||||
infra.Response.Success(ctx, gin.H{"updated": true, "status": target})
|
||||
}
|
||||
|
||||
func gasorderTransitionAllowed(from, target int) bool {
|
||||
switch target {
|
||||
case common.StatusFilling:
|
||||
return from == common.StatusAssigned
|
||||
case common.StatusReady:
|
||||
return from == common.StatusFilling
|
||||
case common.StatusDelivering:
|
||||
return from == common.StatusReady
|
||||
case common.StatusAwaitingConfirmation:
|
||||
return from == common.StatusDelivering
|
||||
case common.StatusCompleted:
|
||||
return from == common.StatusAwaitingConfirmation
|
||||
case common.StatusCancelled:
|
||||
return from == common.StatusCreated || from == common.StatusAssigned
|
||||
case common.StatusException:
|
||||
return from == common.StatusFilling || from == common.StatusReady ||
|
||||
from == common.StatusDelivering || from == common.StatusAwaitingConfirmation
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gasorderUnbindUpdates(reason string, now time.Time) map[string]any {
|
||||
return map[string]any{"status": common.StatusDisable, "unbound_at": &now, "unbind_reason": strings.TrimSpace(reason)}
|
||||
}
|
||||
|
||||
func releaseGasorderProducts(tx *gorm.DB, orderID uint64) error {
|
||||
return tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", orderID).Update("active", false).Error
|
||||
}
|
||||
|
||||
func validGasorderAssignment(order models.GasorderBasic, delivery models.DeliveryBasic, staff models.StaffAccount) bool {
|
||||
if delivery.GasBasicID != 0 && delivery.GasBasicID != order.GasBasicID {
|
||||
return false
|
||||
}
|
||||
if staff.DeliveryBasicID != delivery.ID {
|
||||
return false
|
||||
}
|
||||
return staff.GasBasicID == 0 || staff.GasBasicID == order.GasBasicID
|
||||
}
|
||||
|
||||
func deliveryBelongsToGas(deliveryID, gasID uint64) bool {
|
||||
if deliveryID == 0 {
|
||||
return true
|
||||
}
|
||||
var delivery models.DeliveryBasic
|
||||
return impl.DBService.Select("gas_basic_id").First(&delivery, deliveryID).Error == nil &&
|
||||
(delivery.GasBasicID == 0 || delivery.GasBasicID == gasID)
|
||||
}
|
||||
|
||||
func gasorderStatusRecord(orderID uint64, from, to int, reason, operatorIdentity, operatorName string) *models.GasorderStatus {
|
||||
return &models.GasorderStatus{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
GasorderBasicID: orderID, FromStatus: from, ToStatus: to,
|
||||
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package gasorder
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
@@ -18,6 +21,71 @@ func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) {
|
||||
updates := gasorderUnbindUpdates(" contract ended ", time.Now())
|
||||
status, ok := updates["status"].(int)
|
||||
if !ok || status != common.StatusDisable {
|
||||
t.Fatalf("unbind status = %#v, want integer disabled status", updates["status"])
|
||||
}
|
||||
if updates["unbind_reason"] != "contract ended" {
|
||||
t.Fatalf("unbind reason was not retained: %#v", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderCompleteStateMachine(t *testing.T) {
|
||||
path := []int{
|
||||
common.StatusCreated,
|
||||
common.StatusAssigned,
|
||||
common.StatusFilling,
|
||||
common.StatusReady,
|
||||
common.StatusDelivering,
|
||||
common.StatusAwaitingConfirmation,
|
||||
common.StatusCompleted,
|
||||
}
|
||||
for index := 1; index < len(path); index++ {
|
||||
if index == 1 {
|
||||
continue // assignment has organization validation and its own handler
|
||||
}
|
||||
if !gasorderTransitionAllowed(path[index-1], path[index]) {
|
||||
t.Fatalf("transition %d -> %d is not reachable", path[index-1], path[index])
|
||||
}
|
||||
}
|
||||
if gasorderTransitionAllowed(common.StatusReady, common.StatusCompleted) {
|
||||
t.Fatal("state machine allows skipping delivery and confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderAssignmentRequiresSameOrganization(t *testing.T) {
|
||||
order := models.GasorderBasic{Entity: models.Entity{ID: 1}, GasBasicID: 10}
|
||||
delivery := models.DeliveryBasic{Entity: models.Entity{ID: 20}, GasBasicID: 10}
|
||||
staff := models.StaffAccount{GasBasicID: 10, DeliveryBasicID: 20}
|
||||
if !validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("valid organization assignment was rejected")
|
||||
}
|
||||
staff.DeliveryBasicID = 21
|
||||
if validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("cross-delivery staff assignment was accepted")
|
||||
}
|
||||
staff.DeliveryBasicID = 20
|
||||
delivery.GasBasicID = 11
|
||||
if validGasorderAssignment(order, delivery, staff) {
|
||||
t.Fatal("cross-gas delivery assignment was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderProductHasConcurrentActiveReservationConstraint(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(models.GasorderItem{}).FieldByName("ProductInfoID")
|
||||
if !ok {
|
||||
t.Fatal("ProductInfoID field missing")
|
||||
}
|
||||
tag := field.Tag.Get("gorm")
|
||||
for _, required := range []string{"uniqueIndex:idx_active_gasorder_product", "where:active = true"} {
|
||||
if !strings.Contains(tag, required) {
|
||||
t.Fatalf("active reservation constraint missing %q from %q", required, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasorderStatusRecordIsImmutableSnapshot(t *testing.T) {
|
||||
record := gasorderStatusRecord(7, common.StatusAssigned, common.StatusFilling, "start filling", "operator-a", "Operator")
|
||||
if record.GasorderBasicID != 7 || record.FromStatus != common.StatusAssigned || record.ToStatus != common.StatusFilling {
|
||||
|
||||
Reference in New Issue
Block a user