fix platform workflow integrity and permissions
This commit is contained in:
@@ -22,7 +22,7 @@ const (
|
||||
// InitPlatformAccess 幂等初始化 root 角色;菜单定义位于逻辑层静态数据中。
|
||||
func InitPlatformAccess(database *gorm.DB) error {
|
||||
rootRole := models.PlatformRole{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
||||
RoleCode: PlatformRootRoleCode,
|
||||
Name: "系统管理员",
|
||||
DataScope: "global",
|
||||
|
||||
@@ -22,8 +22,8 @@ func TestInitPlatformAccessSeedsRootRole(t *testing.T) {
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 ORDER BY "platform_role"."id" LIMIT $2`)).
|
||||
WithArgs("root", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "version", "role_code", "name", "data_scope", "is_system"}).
|
||||
AddRow(uint64(1), "root-role", 1, 1, "root", "Root", "global", true))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "role_code", "name", "data_scope", "is_system"}).
|
||||
AddRow(uint64(1), "root-role", 1, "root", "Root", "global", true))
|
||||
|
||||
if err := InitPlatformAccess(database); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -42,7 +42,7 @@ func UpdateRecordStatus(ctx *gin.Context, model any) {
|
||||
var request struct {
|
||||
Status int `json:"status" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,22 @@ func ArchiveRecord(ctx *gin.Context, model any) {
|
||||
}
|
||||
|
||||
func NewEntity(status int) models.Entity {
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status}
|
||||
}
|
||||
|
||||
// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle.
|
||||
func IsGenericRecordStatus(status int) bool {
|
||||
switch status {
|
||||
case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveRecords excludes logically archived records from operational queries.
|
||||
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
||||
return query.Where("status <> ?", StatusArchived)
|
||||
}
|
||||
|
||||
func ListPage[T any](ctx *gin.Context) {
|
||||
@@ -63,7 +78,7 @@ func ListPage[T any](ctx *gin.Context) {
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := databaseQuery.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ func ListResource(ctx *gin.Context, model any) {
|
||||
page, size := PageSize(ctx)
|
||||
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
|
||||
var total int64
|
||||
query := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
query := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -74,6 +74,10 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := ValidateResourceValues(model, values, true); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
@@ -123,7 +127,7 @@ func maskCreatedSensitiveFields(value any) any {
|
||||
|
||||
func isCreatedResponseField(key string) bool {
|
||||
switch key {
|
||||
case "identity", "status", "version", "created_at", "updated_at":
|
||||
case "identity", "status", "created_at", "updated_at":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(key, "_identity")
|
||||
@@ -226,7 +230,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
if len(values) == 0 || ValidateResourceValues(model, values, false) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -245,6 +249,74 @@ func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string,
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ValidateResourceValues enforces invariants that database nullability and
|
||||
// frontend form metadata cannot express.
|
||||
func ValidateResourceValues(model any, values map[string]any, creating bool) error {
|
||||
positive := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value > 0
|
||||
}
|
||||
nonNegative := func(key string) bool {
|
||||
value, exists := numericValue(values[key])
|
||||
return !exists || value >= 0
|
||||
}
|
||||
nonEmpty := func(key string) bool {
|
||||
value, exists := values[key]
|
||||
if !exists {
|
||||
return !creating
|
||||
}
|
||||
text, ok := value.(string)
|
||||
return ok && strings.TrimSpace(text) != ""
|
||||
}
|
||||
switch model.(type) {
|
||||
case *models.EcProduct:
|
||||
if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") {
|
||||
return errors.New("invalid commerce product")
|
||||
}
|
||||
case *models.EcCart:
|
||||
if !positive("quantity") {
|
||||
return errors.New("invalid cart quantity")
|
||||
}
|
||||
case *models.EcOrder:
|
||||
if !nonEmpty("order_no") || !nonNegative("total_amount") {
|
||||
return errors.New("invalid order")
|
||||
}
|
||||
case *models.EcOrderItem:
|
||||
if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") {
|
||||
return errors.New("invalid order item")
|
||||
}
|
||||
case *models.EcReview:
|
||||
score, exists := numericValue(values["score"])
|
||||
if (exists && (score < 1 || score > 5)) || !nonEmpty("content") {
|
||||
return errors.New("invalid review")
|
||||
}
|
||||
case *models.FinPayment:
|
||||
if !positive("amount") || !nonEmpty("channel") {
|
||||
return errors.New("invalid payment")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return number, true
|
||||
case float32:
|
||||
return float64(number), true
|
||||
case int:
|
||||
return float64(number), true
|
||||
case int64:
|
||||
return float64(number), true
|
||||
case uint:
|
||||
return float64(number), true
|
||||
case uint64:
|
||||
return float64(number), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := FilterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
@@ -286,7 +358,7 @@ func ResolveIdentityID(model any, identity string, required bool) (uint64, error
|
||||
return 0, nil
|
||||
}
|
||||
var related struct{ ID uint64 }
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ? AND status <> ?", identity, StatusArchived).First(&related).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return related.ID, nil
|
||||
|
||||
@@ -2,9 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
@@ -14,6 +18,24 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) {
|
||||
sqlDatabase, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlDatabase.Close()
|
||||
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
|
||||
})
|
||||
if !strings.Contains(statement, `status <> 3`) {
|
||||
t.Fatalf("archive filter missing from operational query: %s", statement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
||||
got := ResourceResponse(map[string]any{
|
||||
"id": uint64(1), "identity": "root",
|
||||
@@ -56,3 +78,33 @@ func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
|
||||
t.Fatal("standard HTTP methods unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericStatusRejectsDomainLifecycleValues(t *testing.T) {
|
||||
for _, status := range []int{StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen} {
|
||||
if !IsGenericRecordStatus(status) {
|
||||
t.Fatalf("generic status %d was rejected", status)
|
||||
}
|
||||
}
|
||||
if IsGenericRecordStatus(StatusCompleted) {
|
||||
t.Fatal("business lifecycle status was accepted as generic entity status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommerceAndFinanceRejectInvalidAmounts(t *testing.T) {
|
||||
tests := []struct {
|
||||
model any
|
||||
values map[string]any
|
||||
}{
|
||||
{&models.EcProduct{}, map[string]any{"product_code": "p", "name": "P", "price_amount": -1.0}},
|
||||
{&models.EcCart{}, map[string]any{"quantity": 0.0}},
|
||||
{&models.EcOrder{}, map[string]any{"order_no": "o", "total_amount": -1.0}},
|
||||
{&models.EcOrderItem{}, map[string]any{"product_snapshot": "{}", "quantity": -1.0, "sale_amount": 1.0}},
|
||||
{&models.EcReview{}, map[string]any{"score": 6.0, "content": "bad"}},
|
||||
{&models.FinPayment{}, map[string]any{"channel": "wallet", "amount": -1.0}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if ValidateResourceValues(test.model, test.values, true) == nil {
|
||||
t.Fatalf("%T accepted invalid values %#v", test.model, test.values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,18 +38,3 @@ const (
|
||||
StatusSuccess = 37 // 成功
|
||||
StatusMatched = 38 // 已匹配
|
||||
)
|
||||
|
||||
var statusNames = map[int]string{
|
||||
StatusDraft: "draft", StatusEnable: "enabled", StatusDisable: "disabled", StatusArchived: "archived", StatusFrozen: "frozen",
|
||||
StatusPending: "pending", StatusActive: "active", StatusExpired: "expired", StatusTerminated: "terminated",
|
||||
StatusRecorded: "recorded", StatusBound: "bound", StatusCreated: "created", StatusOrdered: "ordered",
|
||||
StatusAssigned: "assigned", StatusFilling: "filling", StatusReady: "ready", StatusException: "exception",
|
||||
StatusCancelled: "cancelled", StatusCompleted: "completed", StatusPosted: "posted", StatusApproved: "approved",
|
||||
StatusRejected: "rejected", StatusScrapped: "scrapped", StatusInStock: "in_stock", StatusInTransit: "in_transit",
|
||||
StatusInUse: "in_use", StatusRepairing: "repairing", StatusOpen: "open", StatusDelivering: "delivering",
|
||||
StatusAwaitingConfirmation: "awaiting_confirmation",
|
||||
StatusPaid: "paid", StatusPublished: "published", StatusSuccess: "success", StatusMatched: "matched",
|
||||
}
|
||||
|
||||
// StatusName 返回状态整数对应的稳定英文名称,供审计快照字段使用。
|
||||
func StatusName(status int) string { return statusNames[status] }
|
||||
|
||||
@@ -58,12 +58,22 @@ func Login(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
extend := map[string]string{"username": account.Username, "display_name": account.DisplayName}
|
||||
if account.PlatformRoleCode == "root" {
|
||||
extend["location_scope"] = "precise"
|
||||
} else {
|
||||
var role models.PlatformRole
|
||||
if impl.DBService.Select("data_scope").Where("role_code = ? AND status = ?", account.PlatformRoleCode, common.StatusEnable).First(&role).Error == nil &&
|
||||
role.DataScope == "precise" {
|
||||
extend["location_scope"] = "precise"
|
||||
}
|
||||
}
|
||||
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
|
||||
0,
|
||||
account.Identity,
|
||||
"platform_admin",
|
||||
account.PlatformRoleCode,
|
||||
map[string]string{"username": account.Username, "display_name": account.DisplayName},
|
||||
extend,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -99,7 +109,7 @@ func CurrentProfile(ctx *gin.Context) {
|
||||
menuCodes := make([]string, 0, len(menus))
|
||||
seenMenuCodes := make(map[string]bool, len(menus))
|
||||
for _, menu := range menus {
|
||||
codes := []string{menu.MenuCode}
|
||||
codes := []string{menu.Identity}
|
||||
if path := strings.Trim(menu.Path, "/"); path != "" {
|
||||
codes = append(codes, strings.Split(path, "/")[0])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
@@ -8,6 +9,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type categoryRequest struct {
|
||||
ParentIdentity string `json:"parent_identity"`
|
||||
Name string `json:"name" binding:"required,max=128"`
|
||||
SortNo int `json:"sort_no"`
|
||||
}
|
||||
|
||||
type ecCategoryView struct {
|
||||
Identity string `json:"identity"`
|
||||
ParentIdentity string `json:"parent_identity,omitempty"`
|
||||
@@ -50,11 +57,12 @@ func ListEcCategory(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var list []models.EcCategory
|
||||
var total int64
|
||||
if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil {
|
||||
query := common.ActiveRecords(impl.DBService.Model(&models.EcCategory{}))
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
if err := query.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -79,3 +87,66 @@ func GetEcCategory(ctx *gin.Context) {
|
||||
}
|
||||
infra.Response.Success(ctx, views[0])
|
||||
}
|
||||
|
||||
func CreateEcCategory(ctx *gin.Context) {
|
||||
var request categoryRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
category := models.EcCategory{Entity: common.NewEntity(common.StatusDraft), ParentID: parentID, Name: request.Name, SortNo: request.SortNo}
|
||||
if err := impl.DBService.Create(&category).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
common.RespondCreatedResource(ctx, category)
|
||||
}
|
||||
|
||||
func UpdateEcCategory(ctx *gin.Context) {
|
||||
var request categoryRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var category models.EcCategory
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var rows []models.EcCategory
|
||||
if err := impl.DBService.Select("id", "parent_id").Find(&rows).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
parents := make(map[uint64]uint64, len(rows))
|
||||
for _, row := range rows {
|
||||
parents[row.ID] = row.ParentID
|
||||
}
|
||||
if wouldCreateCategoryCycle(category.ID, parentID, parents) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.EcCategory{}, gin.H{"parent_id": parentID, "name": request.Name, "sort_no": request.SortNo}, []string{"parent_id", "name", "sort_no"})
|
||||
}
|
||||
|
||||
func wouldCreateCategoryCycle(categoryID, parentID uint64, parents map[uint64]uint64) bool {
|
||||
seen := map[uint64]bool{}
|
||||
for parentID != 0 {
|
||||
if parentID == categoryID || seen[parentID] {
|
||||
return true
|
||||
}
|
||||
seen[parentID] = true
|
||||
parentID = parents[parentID]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
16
backend/api/internal/logic/platform/ec/ec_test.go
Normal file
16
backend/api/internal/logic/platform/ec/ec_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCategoryHierarchyRejectsCycles(t *testing.T) {
|
||||
parents := map[uint64]uint64{2: 1, 3: 2}
|
||||
if !wouldCreateCategoryCycle(1, 3, parents) {
|
||||
t.Fatal("moving a category under its descendant did not form a detected cycle")
|
||||
}
|
||||
if !wouldCreateCategoryCycle(2, 2, parents) {
|
||||
t.Fatal("self-parent was accepted")
|
||||
}
|
||||
if wouldCreateCategoryCycle(3, 1, parents) {
|
||||
t.Fatal("valid move to an ancestor was rejected")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
@@ -40,6 +41,9 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validSettlementPeriod(input["period_start"], input["period_end"]) {
|
||||
return errors.New("invalid settlement period")
|
||||
}
|
||||
subjectType, _ := input["subject_type"].(string)
|
||||
identity, _ := input["subject_identity"].(string)
|
||||
var model any
|
||||
@@ -66,3 +70,14 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validSettlementPeriod(startValue, endValue any) bool {
|
||||
startText, startOK := startValue.(string)
|
||||
endText, endOK := endValue.(string)
|
||||
if !startOK || !endOK {
|
||||
return false
|
||||
}
|
||||
start, startErr := time.Parse(time.RFC3339, startText)
|
||||
end, endErr := time.Parse(time.RFC3339, endText)
|
||||
return startErr == nil && endErr == nil && end.After(start)
|
||||
}
|
||||
|
||||
12
backend/api/internal/logic/platform/fin/fin_test.go
Normal file
12
backend/api/internal/logic/platform/fin/fin_test.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package fin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSettlementPeriodMustMoveForward(t *testing.T) {
|
||||
if !validSettlementPeriod("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") {
|
||||
t.Fatal("valid settlement period was rejected")
|
||||
}
|
||||
if validSettlementPeriod("2026-02-01T00:00:00Z", "2026-01-01T00:00:00Z") {
|
||||
t.Fatal("reversed settlement period was accepted")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,10 +53,10 @@ var PlatformMenus = [][]Menu{
|
||||
{Identity: "product_info", ParentIdentity: "device", MenuCode: "device", Name: "产品信息", Path: "/product/product-info", SortNo: 3, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "gasorder", MenuCode: "delivery", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
|
||||
{Identity: "gasorder", MenuCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "gasorder_contract", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "gasorder_basic", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
|
||||
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "gasorder_track", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
|
||||
},
|
||||
{
|
||||
{Identity: "ec", MenuCode: "ec", Name: "商城管理", Icon: "icon-gift", Path: "/ec", SortNo: 80, Status: common.StatusEnable},
|
||||
@@ -127,19 +127,24 @@ func LoadPlatformMenus(roleCode string) ([]Menu, error) {
|
||||
if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, common.StatusEnable).First(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var codes []string
|
||||
var identities []string
|
||||
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
|
||||
Where("platform_role_id = ?", role.ID).
|
||||
Pluck("menu_code", &codes).Error; err != nil {
|
||||
Pluck("menu_identity", &identities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(codes))
|
||||
for _, code := range codes {
|
||||
allowed[code] = struct{}{}
|
||||
allowed := make(map[string]struct{}, len(identities))
|
||||
for _, identity := range identities {
|
||||
allowed[identity] = struct{}{}
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, ok := allowed[menu.Identity]; ok && menu.ParentIdentity != "" {
|
||||
allowed[menu.ParentIdentity] = struct{}{}
|
||||
}
|
||||
}
|
||||
filtered := make([]Menu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if _, ok := allowed[menu.MenuCode]; ok {
|
||||
if _, ok := allowed[menu.Identity]; ok {
|
||||
filtered = append(filtered, menu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,36 +19,35 @@ func platformMenuAllowsPath(menus []platformbase.Menu, requestPath string) bool
|
||||
}
|
||||
relative := strings.Trim(requestPath[index+len(marker):], "/")
|
||||
resource := strings.Split(relative, "/")[0]
|
||||
domain := platformRouteDomain(resource)
|
||||
menuIdentity := platformRouteMenuIdentity(resource)
|
||||
for _, menu := range menus {
|
||||
if menu.MenuCode == domain {
|
||||
return true
|
||||
}
|
||||
menuPath := strings.Trim(menu.Path, "/")
|
||||
if menuPath != "" && strings.Split(menuPath, "/")[0] == domain {
|
||||
if menu.Identity == menuIdentity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func platformRouteDomain(resource string) string {
|
||||
prefix := strings.Split(resource, "_")[0]
|
||||
switch prefix {
|
||||
case "product":
|
||||
return "device"
|
||||
case "gasorder":
|
||||
return "delivery"
|
||||
case "fin":
|
||||
return "finance"
|
||||
case "cms":
|
||||
return "content"
|
||||
case "cs":
|
||||
return "customer_service"
|
||||
case "platform":
|
||||
return "platform"
|
||||
func platformRouteMenuIdentity(resource string) string {
|
||||
switch {
|
||||
case resource == "dashboard":
|
||||
return "dashboard_overview"
|
||||
case strings.HasPrefix(resource, "gasorder_contract"):
|
||||
return "gasorder_contract"
|
||||
case resource == "gasorder_track" || resource == "gasorder_track_point":
|
||||
return "gasorder_track"
|
||||
case strings.HasPrefix(resource, "gasorder_"):
|
||||
return "gasorder_basic"
|
||||
case resource == "product_type" || resource == "product_warehouse":
|
||||
return resource
|
||||
case strings.HasPrefix(resource, "product_"):
|
||||
return "product_info"
|
||||
case strings.HasPrefix(resource, "cms_"):
|
||||
return "cms_content"
|
||||
case strings.HasPrefix(resource, "cs_"):
|
||||
return "cs_ticket"
|
||||
default:
|
||||
return prefix
|
||||
return resource
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
backend/api/internal/logic/platform/platform/access_test.go
Normal file
30
backend/api/internal/logic/platform/platform/access_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
|
||||
)
|
||||
|
||||
func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) {
|
||||
menus := []platformbase.Menu{{Identity: "delivery_basic"}}
|
||||
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") {
|
||||
t.Fatal("selected second-level menu did not grant its resource")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") {
|
||||
t.Fatal("selected second-level menu granted a sibling resource")
|
||||
}
|
||||
if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") {
|
||||
t.Fatal("delivery permission leaked into gasorder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenGasorderResourcesFollowOwningSecondLevelMenu(t *testing.T) {
|
||||
orderMenus := []platformbase.Menu{{Identity: "gasorder_basic"}}
|
||||
if !platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_confirm") {
|
||||
t.Fatal("order confirmation was not covered by order menu")
|
||||
}
|
||||
if platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_contract") {
|
||||
t.Fatal("order menu granted contract management")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package platform
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/bsm-sdk/core/middleware"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
@@ -15,7 +16,7 @@ func ListPlatformAccount(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var list []models.PlatformAccount
|
||||
var total int64
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.PlatformAccount{}), &models.PlatformAccount{})
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.PlatformAccount{})), &models.PlatformAccount{})
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -98,6 +99,15 @@ func UpdatePlatformAccount(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
claims, err := middleware.ParseAuth(ctx)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if claims.Role != "root" && claims.Identity != ctx.Param("identity") {
|
||||
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone}
|
||||
if request.PlatformRoleCode != nil {
|
||||
if !common.RequirePlatformRoot(ctx) {
|
||||
|
||||
@@ -36,19 +36,21 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) {
|
||||
if role.IsSystem {
|
||||
return errSystemPlatformRole
|
||||
}
|
||||
menuCodes := make(map[string]struct{}, len(request.MenuIdentities))
|
||||
menuIdentities := make(map[string]struct{}, len(request.MenuIdentities))
|
||||
for _, identity := range request.MenuIdentities {
|
||||
menu, ok := platformbase.FindPlatformMenu(identity)
|
||||
if !ok {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
menuCodes[menu.MenuCode] = struct{}{}
|
||||
if menu.ParentIdentity != "" {
|
||||
menuIdentities[menu.Identity] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := transaction.Where("platform_role_id = ?", role.ID).Delete(&models.PlatformRoleMenu{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for menuCode := range menuCodes {
|
||||
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuCode: menuCode}
|
||||
for menuIdentity := range menuIdentities {
|
||||
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuIdentity: menuIdentity}
|
||||
if err := transaction.Create(&relation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,22 +81,12 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
var codes []string
|
||||
var identities []string
|
||||
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
|
||||
Where("platform_role_id = ?", role.ID).
|
||||
Pluck("menu_code", &codes).Error; err != nil {
|
||||
Pluck("menu_identity", &identities).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
assigned := make(map[string]struct{}, len(codes))
|
||||
for _, code := range codes {
|
||||
assigned[code] = struct{}{}
|
||||
}
|
||||
identities := make([]string, 0, len(codes))
|
||||
for _, menu := range platformbase.AllPlatformMenus() {
|
||||
if _, ok := assigned[menu.MenuCode]; ok {
|
||||
identities = append(identities, menu.Identity)
|
||||
}
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"menu_identities": identities})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var productOwnerActions = map[string]bool{
|
||||
@@ -43,7 +44,7 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
delete(values, "reason")
|
||||
delete(values, "remark")
|
||||
data := models.ProductInfo{Entity: common.NewEntity(common.StatusPending), Params: "{}", IsEnabled: false}
|
||||
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() {
|
||||
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() || !validProductOwnership(data) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
@@ -67,6 +68,8 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
common.RespondCreatedResource(ctx, &data)
|
||||
}
|
||||
|
||||
func canStartProductRepair(pendingCount int64) bool { return pendingCount == 0 }
|
||||
|
||||
func updateProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
@@ -114,6 +117,13 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
}
|
||||
}
|
||||
}
|
||||
if enabled, ok := values["is_enabled"].(bool); ok && !enabled {
|
||||
values["status"] = common.StatusPending
|
||||
}
|
||||
preview := current
|
||||
if err := decodeValues(values, &preview); err != nil || !validProductOwnership(preview) {
|
||||
return errors.New("product can have at most one current owner")
|
||||
}
|
||||
ownershipChanged := ownershipValuesChanged(current, values)
|
||||
if ownershipChanged && !productOwnerActions[action] {
|
||||
return errors.New("invalid ownership action")
|
||||
@@ -136,18 +146,15 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func ProductOwnerHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
fields := []string{"action", "occurred_at", "reason", "remark"}
|
||||
return listProductOwners,
|
||||
func(ctx *gin.Context) { createProductOwner(ctx, fields, relations) },
|
||||
func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) }
|
||||
func ProductOwnerHandlers() (gin.HandlerFunc, gin.HandlerFunc) {
|
||||
return listProductOwners, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) }
|
||||
}
|
||||
|
||||
func listProductOwners(ctx *gin.Context) {
|
||||
page, size := common.PageSize(ctx)
|
||||
var list []models.ProductOwner
|
||||
var total int64
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{})
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.ProductOwner{})), &models.ProductOwner{})
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -172,7 +179,11 @@ func UpdateProductInfoStatus(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"})
|
||||
values := gin.H{"status": request.Status}
|
||||
if request.Status == common.StatusScrapped {
|
||||
values["is_enabled"] = false
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, values, []string{"status", "is_enabled"})
|
||||
}
|
||||
|
||||
func ProductRepairHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
@@ -195,6 +206,14 @@ func createProductRepair(ctx *gin.Context, fields []string, relations []common.R
|
||||
return
|
||||
}
|
||||
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var product models.ProductInfo
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, data.ProductInfoID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pending int64
|
||||
if err := tx.Model(&models.ProductRepair{}).Where("product_info_id = ? AND result = ?", data.ProductInfoID, "pending").Count(&pending).Error; err != nil || !canStartProductRepair(pending) {
|
||||
return errors.New("product already has a pending repair")
|
||||
}
|
||||
if err := tx.Create(&data).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,6 +258,19 @@ func updateProductRepair(ctx *gin.Context, fields []string, relations []common.R
|
||||
return err
|
||||
}
|
||||
if current.Result == "pending" && preview.Result != "pending" {
|
||||
var product models.ProductInfo
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, preview.ProductInfoID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var remaining int64
|
||||
if err := tx.Model(&models.ProductRepair{}).
|
||||
Where("product_info_id = ? AND result = ? AND id <> ?", preview.ProductInfoID, "pending", current.ID).
|
||||
Count(&remaining).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining != 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error
|
||||
}
|
||||
return nil
|
||||
@@ -265,30 +297,6 @@ func validRepair(repair models.ProductRepair) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func createProductOwner(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
|
||||
values, err := common.PrepareResourceValues(ctx, &models.ProductOwner{}, fields, relations)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
action, _ := values["action"].(string)
|
||||
if action != "manual" {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data := models.ProductOwner{Entity: common.NewEntity(common.StatusRecorded)}
|
||||
if err := decodeValues(values, &data); err != nil || data.ProductInfoID == 0 || data.OccurredAt.IsZero() {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data.OperatorIdentity, data.OperatorName = productOperator(ctx)
|
||||
if err := impl.DBService.Create(&data).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
common.RespondCreatedResource(ctx, &data)
|
||||
}
|
||||
|
||||
func validParamsText(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
@@ -319,6 +327,16 @@ func initialProductStatus(product models.ProductInfo) int {
|
||||
return common.StatusPending
|
||||
}
|
||||
|
||||
func validProductOwnership(product models.ProductInfo) bool {
|
||||
count := 0
|
||||
for _, id := range []uint64{product.WarehouseID, product.GasBasicID, product.DeliveryBasicID, product.UserAccountID} {
|
||||
if id != 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count <= 1
|
||||
}
|
||||
|
||||
func ownershipValuesChanged(current models.ProductInfo, values map[string]any) bool {
|
||||
for key, old := range map[string]uint64{
|
||||
"warehouse_id": current.WarehouseID, "gas_basic_id": current.GasBasicID,
|
||||
@@ -357,7 +375,7 @@ func intValue(value any) (int, bool) {
|
||||
|
||||
func newProductOwner(product models.ProductInfo, action, reason, remark, operatorIdentity, operatorName string, occurredAt time.Time) *models.ProductOwner {
|
||||
return &models.ProductOwner{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
|
||||
ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID,
|
||||
DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID,
|
||||
Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package product
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +23,21 @@ func TestValidParamsTextRequiresJSONObjectText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOnePendingRepairCanStart(t *testing.T) {
|
||||
if !canStartProductRepair(0) {
|
||||
t.Fatal("first pending repair was rejected")
|
||||
}
|
||||
if canStartProductRepair(1) {
|
||||
t.Fatal("second concurrent pending repair was accepted")
|
||||
}
|
||||
field, _ := reflect.TypeOf(models.ProductRepair{}).FieldByName("ProductInfoID")
|
||||
tag := field.Tag.Get("gorm")
|
||||
if !strings.Contains(tag, "uniqueIndex:idx_pending_product_repair") ||
|
||||
!strings.Contains(tag, "where:result = 'pending'") {
|
||||
t.Fatalf("database pending-repair constraint missing: %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) {
|
||||
if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != common.StatusInStock {
|
||||
t.Fatalf("warehouse product status = %d", got)
|
||||
@@ -59,3 +76,12 @@ func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) {
|
||||
t.Fatal("changed ownership was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductHasAtMostOneCurrentOwner(t *testing.T) {
|
||||
if !validProductOwnership(models.ProductInfo{WarehouseID: 1}) {
|
||||
t.Fatal("single warehouse owner was rejected")
|
||||
}
|
||||
if validProductOwnership(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}) {
|
||||
t.Fatal("conflicting warehouse and user owners were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func ExpectedResources() []ResourceContract {
|
||||
resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"),
|
||||
resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"),
|
||||
resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"),
|
||||
resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", AppendOnly, "list"),
|
||||
resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", ReadOnly, "list"),
|
||||
resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"),
|
||||
resourceContract("gasorder", "gasorder_contract", Managed, "list"), resourceContract("gasorder", "gasorder_contract_product", AppendOnly, "list"), resourceContract("gasorder", "gasorder_contract_revision", ReadOnly, "list"),
|
||||
resourceContract("gasorder", "gasorder_basic", AppendOnly, "list"), resourceContract("gasorder", "gasorder_item", ReadOnly, "list"), resourceContract("gasorder", "gasorder_assign", ReadOnly, "list"), resourceContract("gasorder", "gasorder_status", ReadOnly, "list"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
type addressRequest struct {
|
||||
@@ -31,7 +32,14 @@ func CreateUserAddress(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
address := models.UserAddress{Entity: common.NewEntity(common.StatusEnable), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault}
|
||||
if err := impl.DBService.Create(&address).Error; err != nil {
|
||||
if err := impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if request.IsDefault {
|
||||
if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", userAccountID).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&address).Error
|
||||
}); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
@@ -48,7 +56,25 @@ func UpdateUserAddress(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
common.UpdateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"})
|
||||
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.UserAddress
|
||||
if err := tx.Where("identity = ?", ctx.Param("identity")).First(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if request.IsDefault {
|
||||
if err := tx.Model(&models.UserAddress{}).
|
||||
Where("user_account_id = ? AND id <> ?", userAccountID, current.ID).
|
||||
Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(¤t).Updates(gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}).Error
|
||||
})
|
||||
if err != nil {
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
type serviceRelationRequest struct {
|
||||
|
||||
@@ -43,7 +43,7 @@ func listWalletPage[T any](ctx *gin.Context) {
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(model)), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
@@ -133,7 +133,7 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) {
|
||||
return err
|
||||
}
|
||||
candidate := models.WalletBasic{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
||||
OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil {
|
||||
@@ -227,7 +227,7 @@ func RechargeWalletBasic(ctx *gin.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
record = models.WalletRecord{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted, Version: 1},
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted},
|
||||
WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo,
|
||||
Direction: "income", TradeType: "recharge", Amount: request.Amount,
|
||||
BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
|
||||
@@ -239,6 +239,15 @@ func RechargeWalletBasic(ctx *gin.Context) {
|
||||
return tx.Create(&record).Error
|
||||
})
|
||||
if err != nil {
|
||||
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&record).Error; lookupErr == nil {
|
||||
response, responseErr := common.PublicResourceResponse(record)
|
||||
if responseErr != nil {
|
||||
infra.Response.Error(ctx, responseErr)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, response)
|
||||
return
|
||||
}
|
||||
common.RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// EcCart 对应 ec_cart,保存用户购物车明细。
|
||||
type EcCart struct {
|
||||
Entity // 公共实体字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段
|
||||
Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` // selected 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
Quantity int `gorm:"column:quantity;not null;default:1;check:quantity > 0" json:"quantity"` // quantity 业务字段
|
||||
Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` // selected 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&EcCart{}) }
|
||||
|
||||
@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// EcOrder 对应 ec_order,保存电商订单与组织快照。
|
||||
type EcOrder struct {
|
||||
Entity // 公共实体字段
|
||||
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段
|
||||
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段
|
||||
TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"` // total_amount 业务字段
|
||||
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段
|
||||
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段
|
||||
TotalAmount int64 `gorm:"column:total_amount;not null;default:0;check:total_amount >= 0" json:"total_amount"` // total_amount 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&EcOrder{}) }
|
||||
|
||||
@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// EcOrderItem 对应 ec_order_item,保存订单商品快照。
|
||||
type EcOrderItem struct {
|
||||
Entity // 公共实体字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
ProductSnapshot string `gorm:"column:product_snapshot;type:text;not null;default:''" json:"product_snapshot"` // product_snapshot 业务字段
|
||||
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段
|
||||
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"` // sale_amount 业务字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
ProductSnapshot string `gorm:"column:product_snapshot;type:text;not null;default:''" json:"product_snapshot"` // product_snapshot 业务字段
|
||||
Quantity int `gorm:"column:quantity;not null;default:1;check:quantity > 0" json:"quantity"` // quantity 业务字段
|
||||
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0;check:sale_amount >= 0" json:"sale_amount"` // sale_amount 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&EcOrderItem{}) }
|
||||
|
||||
@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// EcProduct 对应 ec_product,保存可燃气体商品与服务。
|
||||
type EcProduct struct {
|
||||
Entity // 公共实体字段
|
||||
EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"` // ec_category_id 业务字段
|
||||
ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"` // product_code 业务字段
|
||||
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段
|
||||
PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"` // price_amount 业务字段
|
||||
StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"` // stock_quantity 业务字段
|
||||
EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"` // ec_category_id 业务字段
|
||||
ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"` // product_code 业务字段
|
||||
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段
|
||||
PriceAmount int64 `gorm:"column:price_amount;not null;default:0;check:price_amount >= 0" json:"price_amount"` // price_amount 业务字段
|
||||
StockQuantity int `gorm:"column:stock_quantity;not null;default:0;check:stock_quantity >= 0" json:"stock_quantity"` // stock_quantity 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&EcProduct{}) }
|
||||
|
||||
@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// EcReview 对应 ec_review,保存商品评论与审核状态。
|
||||
type EcReview struct {
|
||||
Entity // 公共实体字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
Score int `gorm:"column:score;not null;default:5" json:"score"` // score 业务字段
|
||||
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // content 业务字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
Score int `gorm:"column:score;not null;default:5;check:score >= 1 AND score <= 5" json:"score"` // score 业务字段
|
||||
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // content 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&EcReview{}) }
|
||||
|
||||
@@ -14,7 +14,6 @@ type Entity struct {
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间
|
||||
Status int `gorm:"column:status;not null;default:0" json:"status"` // 业务状态
|
||||
Version int `gorm:"column:version;not null;default:1" json:"version"` // 乐观锁版本
|
||||
}
|
||||
|
||||
// NewIdentity 生成时间有序的 UUID V7 字符串,生成失败属于不可恢复的运行时错误。
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
// FinPayment 对应 fin_payment,保存支付与退款记录。
|
||||
type FinPayment struct {
|
||||
Entity // 公共实体字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段
|
||||
Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` // amount 业务字段
|
||||
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // paid_at 业务字段
|
||||
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
|
||||
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段
|
||||
Amount int64 `gorm:"column:amount;not null;default:0;check:amount > 0" json:"amount"` // amount 业务字段
|
||||
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // paid_at 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&FinPayment{}) }
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
// FinSettlement 对应 fin_settlement,保存结算单。
|
||||
type FinSettlement struct {
|
||||
Entity // 公共实体字段
|
||||
SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"` // settlement_no 业务字段
|
||||
SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"` // subject_type 业务字段
|
||||
SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"` // subject_id 业务字段
|
||||
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"` // period_start 业务字段
|
||||
PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"` // period_end 业务字段
|
||||
SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"` // settlement_no 业务字段
|
||||
SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"` // subject_type 业务字段
|
||||
SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"` // subject_id 业务字段
|
||||
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"` // period_start 业务字段
|
||||
PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null;check:period_end > period_start" json:"period_end"` // period_end 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&FinSettlement{}) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
|
||||
// GasorderBasic 对应 gasorder_basic,保存气体配送订单当前快照。
|
||||
type GasorderBasic struct {
|
||||
Entity // 公共实体字段,Status 保存订单当前状态
|
||||
Entity // 公共实体字段
|
||||
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // 订单编号
|
||||
RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号
|
||||
GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键
|
||||
|
||||
@@ -17,6 +17,7 @@ type GasorderContractProduct struct {
|
||||
UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 约定充装单价,单位分
|
||||
BoundAt time.Time `gorm:"column:bound_at;type:timestamptz;not null" json:"bound_at"` // 绑定时间
|
||||
UnboundAt *time.Time `gorm:"column:unbound_at;type:timestamptz;index" json:"unbound_at"` // 解绑时间
|
||||
UnbindReason string `gorm:"column:unbind_reason;type:text;not null;default:''" json:"unbind_reason"` // 解绑原因
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&GasorderContractProduct{}) }
|
||||
|
||||
@@ -5,13 +5,14 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// GasorderItem 对应 gasorder_item,保存订单气瓶与规格价格快照。
|
||||
type GasorderItem struct {
|
||||
Entity // 公共实体字段
|
||||
GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键
|
||||
GasorderContractProductID uint64 `gorm:"column:gasorder_contract_product_id;not null;index" json:"gasorder_contract_product_id"` // 合同气瓶绑定自增主键
|
||||
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 实体气瓶自增主键
|
||||
ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照
|
||||
ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照
|
||||
ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照
|
||||
UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 成交单价,单位分
|
||||
GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键
|
||||
GasorderContractProductID uint64 `gorm:"column:gasorder_contract_product_id;not null;index" json:"gasorder_contract_product_id"` // 合同气瓶绑定自增主键
|
||||
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_active_gasorder_product,where:active = true" json:"product_info_id"` // 实体气瓶自增主键
|
||||
Active bool `gorm:"column:active;not null;default:true;index" json:"active"` // 是否仍占用该气瓶
|
||||
ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照
|
||||
ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照
|
||||
ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照
|
||||
UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 成交单价,单位分
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&GasorderItem{}) }
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
// PlatformRoleMenu 对应 platform_role_menu,记录角色拥有的静态菜单权限。
|
||||
type PlatformRoleMenu struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
|
||||
PlatformRoleID uint64 `gorm:"column:platform_role_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_role_id"` // 角色自增主键
|
||||
MenuCode string `gorm:"column:menu_code;type:varchar(64);not null;uniqueIndex:uk_platform_role_menu" json:"menu_code"` // 静态菜单编码
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
|
||||
PlatformRoleID uint64 `gorm:"column:platform_role_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_role_id"` // 角色自增主键
|
||||
MenuIdentity string `gorm:"column:menu_identity;type:varchar(64);not null;uniqueIndex:uk_platform_role_menu" json:"menu_identity"` // 静态菜单唯一标识
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&PlatformRoleMenu{}) }
|
||||
|
||||
@@ -9,16 +9,16 @@ import (
|
||||
// ProductRepair 对应 product_repair,保存产品检修过程与结果。
|
||||
type ProductRepair struct {
|
||||
Entity // 公共实体字段
|
||||
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 产品档案自增主键
|
||||
RepairNo string `gorm:"column:repair_no;type:varchar(64);not null;uniqueIndex" json:"repair_no"` // 检修单号
|
||||
RepairType string `gorm:"column:repair_type;type:varchar(32);not null" json:"repair_type"` // 检修类型
|
||||
StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null" json:"started_at"` // 开始时间
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间
|
||||
Result string `gorm:"column:result;type:varchar(32);not null;default:'pending'" json:"result"` // 检修结果
|
||||
TargetStatus int `gorm:"column:target_status;not null;default:0" json:"target_status"` // 完成后的产品状态
|
||||
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // 检修内容
|
||||
Operator string `gorm:"column:operator;type:varchar(64);not null;default:''" json:"operator"` // 检修人员
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注
|
||||
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_pending_product_repair,where:result = 'pending'" json:"product_info_id"` // 产品档案自增主键
|
||||
RepairNo string `gorm:"column:repair_no;type:varchar(64);not null;uniqueIndex" json:"repair_no"` // 检修单号
|
||||
RepairType string `gorm:"column:repair_type;type:varchar(32);not null" json:"repair_type"` // 检修类型
|
||||
StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null" json:"started_at"` // 开始时间
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间
|
||||
Result string `gorm:"column:result;type:varchar(32);not null;default:'pending'" json:"result"` // 检修结果
|
||||
TargetStatus int `gorm:"column:target_status;not null;default:0" json:"target_status"` // 完成后的产品状态
|
||||
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // 检修内容
|
||||
Operator string `gorm:"column:operator;type:varchar(64);not null;default:''" json:"operator"` // 检修人员
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&ProductRepair{}) }
|
||||
|
||||
@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// UserAddress 对应 user_address,保存用户地址。
|
||||
type UserAddress struct {
|
||||
Entity // 公共实体字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
|
||||
Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段
|
||||
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段
|
||||
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段
|
||||
IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` // is_default 业务字段
|
||||
UserAccountID uint64 `gorm:"column:user_account_id;not null;index;uniqueIndex:idx_default_user_address,where:is_default = true" json:"user_account_id"` // user_account_id 业务字段
|
||||
Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段
|
||||
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段
|
||||
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段
|
||||
IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` // is_default 业务字段
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&UserAddress{}) }
|
||||
|
||||
@@ -82,6 +82,9 @@ func registerGasorderRoute(group *gin.RouterGroup) {
|
||||
order.POST("/:identity/assign", gasorder.AssignGasorderBasic)
|
||||
order.POST("/:identity/filling", gasorder.GasorderStartFilling)
|
||||
order.POST("/:identity/ready", gasorder.GasorderReady)
|
||||
order.POST("/:identity/delivering", gasorder.GasorderStartDelivering)
|
||||
order.POST("/:identity/awaiting-confirmation", gasorder.GasorderAwaitingConfirmation)
|
||||
order.POST("/:identity/complete", gasorder.GasorderComplete)
|
||||
order.POST("/:identity/exception", gasorder.GasorderException)
|
||||
order.POST("/:identity/recover", gasorder.GasorderRecover)
|
||||
order.POST("/:identity/cancel", gasorder.GasorderCancel)
|
||||
@@ -119,16 +122,9 @@ func registerProductRoute(group *gin.RouterGroup) {
|
||||
)
|
||||
registerNoDeleteResource(group, "/product_repair", repairList, repairCreate, repairGet, repairUpdate, &models.ProductRepair{})
|
||||
|
||||
ownerList, ownerCreate, ownerGet := product.ProductOwnerHandlers(
|
||||
requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}),
|
||||
optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}),
|
||||
optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}),
|
||||
optionalRelation("delivery_basic_identity", "delivery_basic_id", &models.DeliveryBasic{}),
|
||||
optionalRelation("user_account_identity", "user_account_id", &models.UserAccount{}),
|
||||
)
|
||||
ownerList, ownerGet := product.ProductOwnerHandlers()
|
||||
owner := group.Group("/product_owner")
|
||||
owner.GET("", ownerList)
|
||||
owner.POST("", ownerCreate)
|
||||
owner.GET("/:identity", ownerGet)
|
||||
}
|
||||
|
||||
@@ -164,9 +160,7 @@ func registerWalletRoute(group *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func registerCommerceRoute(group *gin.RouterGroup) {
|
||||
categoryRelations := []common.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})}
|
||||
_, categoryCreate, _, categoryUpdate := common.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...)
|
||||
registerWritableResource(group, "/ec_category", ec.ListEcCategory, categoryCreate, ec.GetEcCategory, categoryUpdate, &models.EcCategory{})
|
||||
registerWritableResource(group, "/ec_category", ec.ListEcCategory, ec.CreateEcCategory, ec.GetEcCategory, ec.UpdateEcCategory, &models.EcCategory{})
|
||||
registerRestrictedWritableResource(group, "/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{}))
|
||||
registerRestrictedWritableResource(group, "/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{}))
|
||||
registerRestrictedWritableResource(group, "/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{}))
|
||||
|
||||
@@ -148,7 +148,8 @@ func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing
|
||||
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete)
|
||||
}
|
||||
owner := "/heqi/platform/v1/product_owner"
|
||||
assertRouteMethods(t, routes, owner, http.MethodGet, http.MethodPost)
|
||||
assertRouteMethods(t, routes, owner, http.MethodGet)
|
||||
assertNoRouteMethods(t, routes, owner, http.MethodPost)
|
||||
assertRouteMethods(t, routes, owner+"/:identity", http.MethodGet)
|
||||
assertNoRouteMethods(t, routes, owner+"/:identity", http.MethodPut, http.MethodPatch, http.MethodDelete)
|
||||
|
||||
|
||||
@@ -455,11 +455,11 @@ func MockData(database *gorm.DB) error {
|
||||
}
|
||||
|
||||
roleMenu := models.PlatformRoleMenu{
|
||||
PlatformRoleID: role.ID, MenuCode: "dashboard",
|
||||
PlatformRoleID: role.ID, MenuIdentity: "dashboard_overview",
|
||||
}
|
||||
if err := tx.Where(
|
||||
"platform_role_id = ? AND menu_code = ?",
|
||||
role.ID, roleMenu.MenuCode,
|
||||
"platform_role_id = ? AND menu_identity = ?",
|
||||
role.ID, roleMenu.MenuIdentity,
|
||||
).FirstOrCreate(&roleMenu).Error; err != nil {
|
||||
return fmt.Errorf("seed platform_role_menu: %w", err)
|
||||
}
|
||||
@@ -471,7 +471,6 @@ func entity(sequence int, status int) models.Entity {
|
||||
return models.Entity{
|
||||
Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence),
|
||||
Status: status,
|
||||
Version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestMockEntityIdentitiesAreStableAndUnique(t *testing.T) {
|
||||
t.Fatalf("duplicate identity %q", record.Identity)
|
||||
}
|
||||
seen[record.Identity] = struct{}{}
|
||||
if record.Status != common.StatusEnable || record.Version != 1 {
|
||||
if record.Status != common.StatusEnable {
|
||||
t.Fatalf("entity(%d) has unexpected defaults: %#v", sequence, record)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user