From 8d71a1fc0866c2fedbe487ac05f30762b800f476 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 28 Jul 2026 08:44:54 +0800 Subject: [PATCH] refactor product domain models --- .../api/internal/logic/platform/product.go | 361 ++++++++++++++++++ .../internal/logic/platform/product_test.go | 60 +++ .../api/internal/logic/platform/resource.go | 8 +- .../internal/logic/platform/resource_test.go | 7 + .../logic/platform/task4_resources.go | 41 +- .../api/internal/models/dev_device_binding.go | 18 - .../models/dev_smart_cylinder_valve.go | 15 - backend/api/internal/models/dev_telemetry.go | 18 - backend/api/internal/models/product_info.go | 26 ++ backend/api/internal/models/product_owner.go | 26 ++ backend/api/internal/models/product_repair.go | 25 ++ backend/api/internal/models/product_type.go | 13 + .../api/internal/models/product_warehouse.go | 17 + backend/api/internal/routers/platform.go | 58 ++- backend/api/internal/routers/platform_test.go | 26 +- 15 files changed, 634 insertions(+), 85 deletions(-) create mode 100644 backend/api/internal/logic/platform/product.go create mode 100644 backend/api/internal/logic/platform/product_test.go delete mode 100644 backend/api/internal/models/dev_device_binding.go delete mode 100644 backend/api/internal/models/dev_smart_cylinder_valve.go delete mode 100644 backend/api/internal/models/dev_telemetry.go create mode 100644 backend/api/internal/models/product_info.go create mode 100644 backend/api/internal/models/product_owner.go create mode 100644 backend/api/internal/models/product_repair.go create mode 100644 backend/api/internal/models/product_type.go create mode 100644 backend/api/internal/models/product_warehouse.go diff --git a/backend/api/internal/logic/platform/product.go b/backend/api/internal/logic/platform/product.go new file mode 100644 index 0000000..65c76ff --- /dev/null +++ b/backend/api/internal/logic/platform/product.go @@ -0,0 +1,361 @@ +package platform + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "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/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var productOwnerActions = map[string]bool{ + "created": true, "warehouse": true, "assigned": true, "returned": true, "manual": true, +} + +var productLifecycleStatuses = map[string]bool{ + "pending": true, "in_stock": true, "in_transit": true, "in_use": true, "repairing": true, "scrapped": true, +} + +func ProductInfoHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + fields := []string{"code", "name", "params", "produced_at", "is_enabled", "status", "action", "reason", "remark"} + return func(ctx *gin.Context) { listResource(ctx, &models.ProductInfo{}) }, + func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) }, + func(ctx *gin.Context) { getResource(ctx, &models.ProductInfo{}) }, + func(ctx *gin.Context) { updateProductInfo(ctx, fields, relations) } +} + +func createProductInfo(ctx *gin.Context, fields []string, relations []ResourceRelation) { + values, err := prepareResourceValues(ctx, &models.ProductInfo{}, fields, relations) + if err != nil || !validParamsText(values["params"]) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + delete(values, "action") + delete(values, "reason") + delete(values, "remark") + data := models.ProductInfo{Entity: newEntity("pending"), Params: "{}", IsEnabled: false} + if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data.Status = "pending" + if data.IsEnabled { + now := time.Now() + data.EnabledAt = &now + data.Status = initialProductStatus(data) + } + operatorIdentity, operatorName := productOperator(ctx) + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&data).Error; err != nil { + return err + } + return tx.Create(newProductOwner(data, "created", "", "", operatorIdentity, operatorName, time.Now())).Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + respondCreatedResource(ctx, &data) +} + +func updateProductInfo(ctx *gin.Context, fields []string, relations []ResourceRelation) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values, err := resolveResourceRelations(input, fields, relations, false) + if err != nil || len(values) == 0 || !validParamsText(values["params"]) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + action, _ := input["action"].(string) + reason, _ := input["reason"].(string) + remark, _ := input["remark"].(string) + delete(values, "action") + delete(values, "reason") + delete(values, "remark") + operatorIdentity, operatorName := productOperator(ctx) + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var current models.ProductInfo + if err := tx.Where("identity = ?", ctx.Param("identity")).First(¤t).Error; err != nil { + return err + } + if _, changingCode := values["code"]; changingCode { + delete(values, "code") + } + if status, ok := values["status"].(string); ok && !productLifecycleStatuses[status] { + return errors.New("invalid product status") + } + if enabled, ok := values["is_enabled"].(bool); ok && enabled && !current.IsEnabled { + if current.Status == "scrapped" { + return errors.New("scrapped product cannot be enabled") + } + if current.EnabledAt == nil { + now := time.Now() + values["enabled_at"] = &now + if _, supplied := values["status"]; !supplied { + preview := current + _ = decodeValues(values, &preview) + values["status"] = initialProductStatus(preview) + } + } + } + ownershipChanged := ownershipValuesChanged(current, values) + if ownershipChanged && !productOwnerActions[action] { + return errors.New("invalid ownership action") + } + if err := tx.Model(¤t).Updates(values).Error; err != nil { + return err + } + if !ownershipChanged { + return nil + } + if err := tx.Where("id = ?", current.ID).First(¤t).Error; err != nil { + return err + } + return tx.Create(newProductOwner(current, action, reason, remark, operatorIdentity, operatorName, time.Now())).Error + }) + if err != nil { + respondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func ProductOwnerHandlers(relations ...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) { getResource(ctx, &models.ProductOwner{}) } +} + +func listProductOwners(ctx *gin.Context) { + page, size := pageSize(ctx) + var list []models.ProductOwner + var total int64 + query := applyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{}) + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("occurred_at desc, id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := publicResourceResponse(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": response}) +} + +func UpdateProductInfoStatus(ctx *gin.Context) { + var request struct { + Status string `json:"status" binding:"required,max=32"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || !productLifecycleStatuses[request.Status] { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"}) +} + +func ProductRepairHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + fields := []string{"repair_no", "repair_type", "started_at", "completed_at", "result", "target_status", "content", "operator", "remark"} + return func(ctx *gin.Context) { listResource(ctx, &models.ProductRepair{}) }, + func(ctx *gin.Context) { createProductRepair(ctx, fields, relations) }, + func(ctx *gin.Context) { getResource(ctx, &models.ProductRepair{}) }, + func(ctx *gin.Context) { updateProductRepair(ctx, fields, relations) } +} + +func createProductRepair(ctx *gin.Context, fields []string, relations []ResourceRelation) { + values, err := prepareResourceValues(ctx, &models.ProductRepair{}, fields, relations) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data := models.ProductRepair{Entity: newEntity("active"), Result: "pending"} + if err := decodeValues(values, &data); err != nil || data.Result != "pending" || !validRepair(data) || data.ProductInfoID == 0 || data.RepairNo == "" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&data).Error; err != nil { + return err + } + return tx.Model(&models.ProductInfo{}).Where("id = ?", data.ProductInfoID).Update("status", "repairing").Error + }) + if err != nil { + infra.Response.Error(ctx, err) + return + } + respondCreatedResource(ctx, &data) +} + +func updateProductRepair(ctx *gin.Context, fields []string, relations []ResourceRelation) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values, err := resolveResourceRelations(input, fields, relations, false) + if err != nil || len(values) == 0 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var current models.ProductRepair + if err := tx.Where("identity = ?", ctx.Param("identity")).First(¤t).Error; err != nil { + return err + } + if current.Result != "pending" { + if len(values) != 1 { + return errors.New("completed repair is locked") + } + if _, ok := values["remark"]; !ok { + return errors.New("only remark can be updated") + } + } + preview := current + if err := decodeValues(values, &preview); err != nil || !validRepair(preview) { + return errors.New("invalid repair") + } + if err := tx.Model(¤t).Updates(values).Error; err != nil { + return err + } + if current.Result == "pending" && preview.Result != "pending" { + return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error + } + return nil + }) + if err != nil { + respondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +func validRepair(repair models.ProductRepair) bool { + if repair.StartedAt.IsZero() { + return false + } + switch repair.Result { + case "pending": + return repair.CompletedAt == nil + case "passed", "failed": + return repair.CompletedAt != nil && !repair.CompletedAt.Before(repair.StartedAt) && + productLifecycleStatuses[repair.TargetStatus] && repair.TargetStatus != "repairing" + default: + return false + } +} + +func createProductOwner(ctx *gin.Context, fields []string, relations []ResourceRelation) { + values, err := 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: newEntity("recorded")} + 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 + } + respondCreatedResource(ctx, &data) +} + +func validParamsText(value any) bool { + if value == nil { + return true + } + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return false + } + var object map[string]any + return json.Unmarshal([]byte(text), &object) == nil && object != nil +} + +func decodeValues(values map[string]any, target any) error { + encoded, err := json.Marshal(values) + if err != nil { + return err + } + return json.Unmarshal(encoded, target) +} + +func initialProductStatus(product models.ProductInfo) string { + if product.WarehouseID != 0 { + return "in_stock" + } + if product.UserAccountID != 0 { + return "in_use" + } + return "pending" +} + +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, + "delivery_basic_id": current.DeliveryBasicID, "user_account_id": current.UserAccountID, + } { + if raw, ok := values[key]; ok { + if next, ok := numericID(raw); ok && next != old { + return true + } + } + } + return false +} + +func numericID(value any) (uint64, bool) { + switch raw := value.(type) { + case uint64: + return raw, true + case float64: + return uint64(raw), true + default: + return 0, false + } +} + +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: "recorded", Version: 1}, + ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID, + DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID, + Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark, + OperatorIdentity: operatorIdentity, OperatorName: operatorName, + } +} + +func productOperator(ctx *gin.Context) (string, string) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + return "", "" + } + var account models.PlatfromAccount + if err := impl.DBService.Select("display_name").Where("identity = ?", claims.Identity).First(&account).Error; err != nil { + return claims.Identity, "" + } + return claims.Identity, account.DisplayName +} diff --git a/backend/api/internal/logic/platform/product_test.go b/backend/api/internal/logic/platform/product_test.go new file mode 100644 index 0000000..37d9b04 --- /dev/null +++ b/backend/api/internal/logic/platform/product_test.go @@ -0,0 +1,60 @@ +package platform + +import ( + "testing" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +func TestValidParamsTextRequiresJSONObjectText(t *testing.T) { + for _, value := range []any{`{"capacity":15}`, `{}`} { + if !validParamsText(value) { + t.Fatalf("valid JSON object text was rejected: %v", value) + } + } + for _, value := range []any{"", "[]", `"text"`, "{", 1} { + if validParamsText(value) { + t.Fatalf("invalid params text was accepted: %v", value) + } + } +} + +func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) { + if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != "in_stock" { + t.Fatalf("warehouse product status = %s", got) + } + if got := initialProductStatus(models.ProductInfo{UserAccountID: 2}); got != "in_use" { + t.Fatalf("user product status = %s", got) + } + if got := initialProductStatus(models.ProductInfo{}); got != "pending" { + t.Fatalf("unlocated product status = %s", got) + } +} + +func TestValidRepairEnforcesCompletionRules(t *testing.T) { + started := time.Now() + completed := started.Add(time.Hour) + if !validRepair(models.ProductRepair{StartedAt: started, Result: "pending"}) { + t.Fatal("pending repair was rejected") + } + if !validRepair(models.ProductRepair{StartedAt: started, CompletedAt: &completed, Result: "passed", TargetStatus: "in_stock"}) { + t.Fatal("completed repair was rejected") + } + if validRepair(models.ProductRepair{StartedAt: started, Result: "passed", TargetStatus: "in_stock"}) { + t.Fatal("completed result without completion time was accepted") + } + if validRepair(models.ProductRepair{StartedAt: completed, CompletedAt: &started, Result: "failed", TargetStatus: "in_stock"}) { + t.Fatal("completion before start was accepted") + } +} + +func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) { + current := models.ProductInfo{WarehouseID: 1, GasBasicID: 2} + if ownershipValuesChanged(current, map[string]any{"warehouse_id": uint64(1)}) { + t.Fatal("unchanged warehouse produced a history record") + } + if !ownershipValuesChanged(current, map[string]any{"gas_basic_id": uint64(3)}) { + t.Fatal("changed ownership was not detected") + } +} diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index a014440..f8106f3 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -13,6 +13,7 @@ const ( Writable ResourceMode = "writable" ReadOnly ResourceMode = "readonly" AppendOnly ResourceMode = "append_only" + Editable ResourceMode = "editable" ) // ResourceContract is the expected cross-layer representation of one resource. @@ -41,6 +42,8 @@ func (d ResourceDefinition) Allows(method string) bool { return method == http.MethodGet case AppendOnly: return method == http.MethodGet || method == http.MethodPost + case Editable: + return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch case Writable: return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete default: @@ -74,7 +77,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("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "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("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("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", ReadOnly, "list"), resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), @@ -89,6 +92,9 @@ func resourceContract(domain, name string, mode ResourceMode, pageKind string) R } func resourcePath(domain, name string) string { + if domain == "product" { + return "/" + name + } switch name { case "staff_account": return "/staff/account" diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 75ea221..039adc5 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -28,6 +28,8 @@ func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list") + assertContract(t, ExpectedResources(), "product", "product_info", Editable, "list") + assertContract(t, ExpectedResources(), "product", "product_owner", AppendOnly, "list") } func TestContentUsesCMSResourceAndRemovedDomainsAreAbsent(t *testing.T) { @@ -91,6 +93,11 @@ func TestResourceDefinitionAllowsOnlyMethodsForEachMode(t *testing.T) { {"append-only GET", AppendOnly, http.MethodGet, true}, {"append-only POST", AppendOnly, http.MethodPost, true}, {"append-only PUT", AppendOnly, http.MethodPut, false}, + {"editable GET", Editable, http.MethodGet, true}, + {"editable POST", Editable, http.MethodPost, true}, + {"editable PUT", Editable, http.MethodPut, true}, + {"editable PATCH", Editable, http.MethodPatch, true}, + {"editable DELETE", Editable, http.MethodDelete, false}, {"writable GET", Writable, http.MethodGet, true}, {"writable POST", Writable, http.MethodPost, true}, {"writable PUT", Writable, http.MethodPut, true}, diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index 29c30c5..99cd41f 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -299,7 +299,14 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela continue } identity, ok := raw.(string) - if !ok || strings.TrimSpace(identity) == "" { + if !ok { + return nil, errors.New("invalid relation identity") + } + if strings.TrimSpace(identity) == "" && !relation.Required { + values[relation.Column] = uint64(0) + continue + } + if strings.TrimSpace(identity) == "" { return nil, errors.New("invalid relation identity") } id, err := resolveIdentityID(relation.Model, identity, true) @@ -366,21 +373,23 @@ func publicResourceResponse(value any) (any, error) { } var relationIdentityModels = map[string]any{ - "gas_basic_id": &models.GasBasic{}, - "gas_station_id": &models.GasBasic{}, - "delivery_basic_id": &models.DeliveryBasic{}, - "delivery_point_id": &models.DeliveryBasic{}, - "user_account_id": &models.UserAccount{}, - "staff_account_id": &models.StaffAccount{}, - "smart_cylinder_valve_id": &models.DevSmartCylinderValve{}, - "ec_category_id": &models.EcCategory{}, - "ec_product_id": &models.EcProduct{}, - "ec_order_id": &models.EcOrder{}, - "delivery_task_id": &models.DeliveryTask{}, - "delivery_track_id": &models.DeliveryTrack{}, - "platform_role_id": &models.PlatformRole{}, - "platform_menu_id": &models.PlatformMenu{}, - "wallet_id": &models.Wallet{}, + "gas_basic_id": &models.GasBasic{}, + "gas_station_id": &models.GasBasic{}, + "delivery_basic_id": &models.DeliveryBasic{}, + "delivery_point_id": &models.DeliveryBasic{}, + "user_account_id": &models.UserAccount{}, + "staff_account_id": &models.StaffAccount{}, + "product_type_id": &models.ProductType{}, + "product_info_id": &models.ProductInfo{}, + "warehouse_id": &models.ProductWarehouse{}, + "ec_category_id": &models.EcCategory{}, + "ec_product_id": &models.EcProduct{}, + "ec_order_id": &models.EcOrder{}, + "delivery_task_id": &models.DeliveryTask{}, + "delivery_track_id": &models.DeliveryTrack{}, + "platform_role_id": &models.PlatformRole{}, + "platform_menu_id": &models.PlatformMenu{}, + "wallet_id": &models.Wallet{}, } var relationIdentityKeys = map[string]string{ diff --git a/backend/api/internal/models/dev_device_binding.go b/backend/api/internal/models/dev_device_binding.go deleted file mode 100644 index cbffcb6..0000000 --- a/backend/api/internal/models/dev_device_binding.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "git.apinb.com/bsm-sdk/core/database" - "time" -) - -// DevDeviceBinding 对应 dev_device_binding,保存设备授权绑定。 -type DevDeviceBinding struct { - Entity // 公共实体字段 - SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"` // smart_cylinder_valve_id 业务字段 - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 - EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // effective_at 业务字段 - ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段 -} - -func init() { database.AppendMigrate(&DevDeviceBinding{}) } -func (table *DevDeviceBinding) TableName() string { return "dev_device_binding" } diff --git a/backend/api/internal/models/dev_smart_cylinder_valve.go b/backend/api/internal/models/dev_smart_cylinder_valve.go deleted file mode 100644 index a824531..0000000 --- a/backend/api/internal/models/dev_smart_cylinder_valve.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// DevSmartCylinderValve 对应 dev_smart_cylinder_valve,保存智能瓶阀档案。 -type DevSmartCylinderValve struct { - Entity // 公共实体字段 - DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"` // device_no 业务字段 - Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"` // model 业务字段 - OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"` // online_status 业务字段 - OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"` // owner_identity 业务字段 -} - -func init() { database.AppendMigrate(&DevSmartCylinderValve{}) } -func (table *DevSmartCylinderValve) TableName() string { return "dev_smart_cylinder_valve" } diff --git a/backend/api/internal/models/dev_telemetry.go b/backend/api/internal/models/dev_telemetry.go deleted file mode 100644 index 73a92de..0000000 --- a/backend/api/internal/models/dev_telemetry.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "git.apinb.com/bsm-sdk/core/database" - "time" -) - -// DevTelemetry 对应 dev_telemetry,保存设备遥测摘要。 -type DevTelemetry struct { - Entity // 公共实体字段 - SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段 - ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"` // reported_at 业务字段 - Payload string `gorm:"column:payload;type:text;not null;default:''" json:"payload"` // payload 业务字段 - QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` // quality_flag 业务字段 -} - -func init() { database.AppendMigrate(&DevTelemetry{}) } -func (table *DevTelemetry) TableName() string { return "dev_telemetry" } diff --git a/backend/api/internal/models/product_info.go b/backend/api/internal/models/product_info.go new file mode 100644 index 0000000..fd7289b --- /dev/null +++ b/backend/api/internal/models/product_info.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// ProductInfo 对应 product_info,保存一物一码的实体产品档案。 +type ProductInfo struct { + Entity // 公共实体字段 + Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品唯一标识 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品名称 + ProductTypeID uint64 `gorm:"column:product_type_id;not null;index" json:"product_type_id"` // 产品类型自增主键 + Params string `gorm:"column:params;type:text;not null;default:'{}'" json:"params"` // 产品参数 JSON 对象文本 + WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键 + UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键 + ProducedAt time.Time `gorm:"column:produced_at;type:timestamptz;not null" json:"produced_at"` // 生产时间 + IsEnabled bool `gorm:"column:is_enabled;not null;default:false" json:"is_enabled"` // 是否启用 + EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间 +} + +func init() { database.AppendMigrate(&ProductInfo{}) } +func (table *ProductInfo) TableName() string { return "product_info" } diff --git a/backend/api/internal/models/product_owner.go b/backend/api/internal/models/product_owner.go new file mode 100644 index 0000000..2e881b7 --- /dev/null +++ b/backend/api/internal/models/product_owner.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// ProductOwner 对应 product_owner,保存产品库房与责任归属的不可变快照。 +type ProductOwner struct { + Entity // 公共实体字段 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 产品档案自增主键 + WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键快照 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键快照 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键快照 + UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键快照 + Action string `gorm:"column:action;type:varchar(32);not null" json:"action"` // 归属变更动作 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 实际发生时间 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 变更原因 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 操作者业务标识 + OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 操作者姓名快照 +} + +func init() { database.AppendMigrate(&ProductOwner{}) } +func (table *ProductOwner) TableName() string { return "product_owner" } diff --git a/backend/api/internal/models/product_repair.go b/backend/api/internal/models/product_repair.go new file mode 100644 index 0000000..39db17f --- /dev/null +++ b/backend/api/internal/models/product_repair.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// 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 string `gorm:"column:target_status;type:varchar(32);not null;default:''" 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{}) } +func (table *ProductRepair) TableName() string { return "product_repair" } diff --git a/backend/api/internal/models/product_type.go b/backend/api/internal/models/product_type.go new file mode 100644 index 0000000..fcfa22e --- /dev/null +++ b/backend/api/internal/models/product_type.go @@ -0,0 +1,13 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// ProductType 对应 product_type,保存实体产品的类型与规格名称。 +type ProductType struct { + Entity // 公共实体字段 + Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品类型编码 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品类型名称 +} + +func init() { database.AppendMigrate(&ProductType{}) } +func (table *ProductType) TableName() string { return "product_type" } diff --git a/backend/api/internal/models/product_warehouse.go b/backend/api/internal/models/product_warehouse.go new file mode 100644 index 0000000..85e6cdd --- /dev/null +++ b/backend/api/internal/models/product_warehouse.go @@ -0,0 +1,17 @@ +package models + +import "git.apinb.com/bsm-sdk/core/database" + +// ProductWarehouse 对应 product_warehouse,保存独立库房档案。 +type ProductWarehouse struct { + Entity // 公共实体字段 + Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 库房编码 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 库房名称 + Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 库房地址 + Manager string `gorm:"column:manager;type:varchar(64);not null;default:''" json:"manager"` // 库房负责人 + Phone string `gorm:"column:phone;type:varchar(32);not null;default:''" json:"phone"` // 联系电话 + IsEnabled bool `gorm:"column:is_enabled;not null;default:false" json:"is_enabled"` // 是否启用 +} + +func init() { database.AppendMigrate(&ProductWarehouse{}) } +func (table *ProductWarehouse) TableName() string { return "product_warehouse" } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 29b96c0..18608a5 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -27,7 +27,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeliveryRoute(protected) registerStaffRoute(protected) registerUserRoute(protected) - registerDeviceRoute(protected) + registerProductRoute(protected) registerCommerceRoute(protected) registerFinanceRoute(protected) registerContentRoute(protected) @@ -50,13 +50,41 @@ func registerDeliveryRoute(group *gin.RouterGroup) { registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}) } -func registerDeviceRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/device/dev_smart_cylinder_valve", &models.DevSmartCylinderValve{}, []string{"device_no", "model", "online_status", "owner_identity"}) - registerRestrictedWritableResource(group, "/device/dev_device_binding", &models.DevDeviceBinding{}, []string{"effective_at", "expired_at"}, requiredRelation("smart_cylinder_valve_identity", "smart_cylinder_valve_id", &models.DevSmartCylinderValve{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) - list, _, get, _ := platform.ResourceHandlers(&models.DevTelemetry{}, nil, nil) - telemetry := group.Group("/device/dev_telemetry") - telemetry.GET("", list) - telemetry.GET("/:identity", get) +func registerProductRoute(group *gin.RouterGroup) { + registerRestrictedNoDeleteResource(group, "/product_type", &models.ProductType{}, []string{"code", "name"}) + registerRestrictedNoDeleteResource(group, "/product_warehouse", &models.ProductWarehouse{}, []string{"code", "name", "address", "manager", "phone", "is_enabled"}) + + infoRelations := []platform.ResourceRelation{ + requiredRelation("product_type_identity", "product_type_id", &models.ProductType{}), + 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{}), + } + infoList, infoCreate, infoGet, infoUpdate := platform.ProductInfoHandlers(infoRelations...) + info := group.Group("/product_info") + info.GET("", infoList) + info.POST("", infoCreate) + info.GET("/:identity", infoGet) + info.PUT("/:identity", infoUpdate) + info.PATCH("/:identity/status", platform.UpdateProductInfoStatus) + + repairList, repairCreate, repairGet, repairUpdate := platform.ProductRepairHandlers( + requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}), + ) + registerNoDeleteResource(group, "/product_repair", repairList, repairCreate, repairGet, repairUpdate, &models.ProductRepair{}) + + ownerList, ownerCreate, ownerGet := platform.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{}), + ) + owner := group.Group("/product_owner") + owner.GET("", ownerList) + owner.POST("", ownerCreate) + owner.GET("/:identity", ownerGet) } func registerCommerceRoute(group *gin.RouterGroup) { @@ -138,6 +166,20 @@ func registerRestrictedWritableResource(group *gin.RouterGroup, path string, mod registerWritableResource(group, path, list, create, get, update, model) } +func registerNoDeleteResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) { + resource := group.Group(path) + resource.GET("", list) + resource.POST("", create) + resource.GET("/:identity", get) + resource.PUT("/:identity", update) + resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) }) +} + +func registerRestrictedNoDeleteResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...platform.ResourceRelation) { + list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...) + registerNoDeleteResource(group, path, list, create, get, update, model) +} + func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) { list, _, get, _ := platform.ResourceHandlers(model, nil, nil) resource := group.Group(path) diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index b1e8f7c..f5c63d5 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -29,6 +29,12 @@ func TestEveryContractHasRegisteredRoute(t *testing.T) { case platform.AppendOnly: assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertNoRouteMethods(t, routes, path, http.MethodPut, http.MethodPatch, http.MethodDelete) + case platform.Editable: + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut) + assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) + assertNoRouteMethods(t, routes, path, http.MethodDelete) + assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete) default: assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) @@ -87,7 +93,7 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) } -func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { +func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { engine := gin.New() RegisterPlatform("heqi", engine) @@ -100,7 +106,6 @@ func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing. } for _, resource := range []string{ - "/device/dev_smart_cylinder_valve", "/device/dev_device_binding", "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review", "/delivery/delivery_task", "/delivery/delivery_track", } { @@ -116,14 +121,17 @@ func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing. assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete) assertNoRouteMethods(t, routes, trackPoint+"/:identity/status", http.MethodPatch) - telemetry := "/heqi/platform/v1/device/dev_telemetry" - assertRouteMethods(t, routes, telemetry, http.MethodGet) - assertRouteMethods(t, routes, telemetry+"/:identity", http.MethodGet) - for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { - if routes[telemetry][method] || routes[telemetry+"/:identity"][method] { - t.Errorf("telemetry unexpectedly permits %s", method) - } + for _, resource := range []string{"/product_type", "/product_warehouse", "/product_info", "/product_repair"} { + path := "/heqi/platform/v1" + resource + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut) + assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) + 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+"/:identity", http.MethodGet) + assertNoRouteMethods(t, routes, owner+"/:identity", http.MethodPut, http.MethodPatch, http.MethodDelete) for _, resource := range []string{ "/safety/safe_rule", "/safety/safe_event", "/safety/safe_inspection",