diff --git a/internal/handler/api.go b/internal/handler/api.go index bab4b9ca..502ec1aa 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -16,17 +16,18 @@ import ( ) type Handler struct { - materialSvc *service.MaterialService - inboundSvc *service.InboundService - outboundSvc *service.OutboundService - stockSvc *service.StockService - logSvc *service.MaterialLogService - authSvc *service.AuthService - appSvc *service.ApplicationService - organizationSvc *service.OrganizationService - masterDataSvc *service.MasterDataService - partnerSvc *service.PartnerService - warehouseSvc *service.WarehouseService + materialSvc *service.MaterialService + inboundSvc *service.InboundService + outboundSvc *service.OutboundService + stockSvc *service.StockService + logSvc *service.MaterialLogService + authSvc *service.AuthService + appSvc *service.ApplicationService + organizationSvc *service.OrganizationService + masterDataSvc *service.MasterDataService + partnerSvc *service.PartnerService + warehouseSvc *service.WarehouseService + materialImportSvc *service.MaterialImportService } func userFacingServiceErrorMessage(err error) string { @@ -38,17 +39,18 @@ func userFacingServiceErrorMessage(err error) string { func NewHandler() *Handler { return &Handler{ - materialSvc: service.NewMaterialService(), - inboundSvc: service.NewInboundService(), - outboundSvc: service.NewOutboundService(), - stockSvc: service.NewStockService(), - logSvc: service.NewMaterialLogService(), - authSvc: service.NewAuthService(), - appSvc: service.NewApplicationService(), - organizationSvc: service.NewOrganizationService(), - masterDataSvc: service.NewMasterDataService(), - partnerSvc: service.NewPartnerService(), - warehouseSvc: service.NewWarehouseService(), + materialSvc: service.NewMaterialService(), + inboundSvc: service.NewInboundService(), + outboundSvc: service.NewOutboundService(), + stockSvc: service.NewStockService(), + logSvc: service.NewMaterialLogService(), + authSvc: service.NewAuthService(), + appSvc: service.NewApplicationService(), + organizationSvc: service.NewOrganizationService(), + masterDataSvc: service.NewMasterDataService(), + partnerSvc: service.NewPartnerService(), + warehouseSvc: service.NewWarehouseService(), + materialImportSvc: service.NewMaterialImportService(), } } @@ -93,6 +95,15 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { materials.POST("/:id/restore", h.RestoreMaterial) } + // 物料批量导入单独挂前缀:materials 组已有 GET /:id, + // gin 的路由树不允许同段既有静态前缀又有通配参数。 + materialImport := api.Group("/material-import") + materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) + { + materialImport.GET("/template", h.DownloadMaterialImportTemplate) + materialImport.POST("", h.ImportMaterials) + } + inbound := api.Group("/inbound") inbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) { diff --git a/internal/handler/material_import.go b/internal/handler/material_import.go new file mode 100644 index 00000000..5f234c18 --- /dev/null +++ b/internal/handler/material_import.go @@ -0,0 +1,73 @@ +package handler + +import ( + "errors" + "net/http" + "net/url" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/wmms/wmms/internal/middleware" + "github.com/wmms/wmms/internal/service" + "github.com/wmms/wmms/pkg/response" +) + +// DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成, +// 不落盘,保证下载到的永远和当前校验规则一致。 +func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) { + data, err := service.BuildMaterialImportTemplate() + if err != nil { + response.InternalError(c, "模板生成失败") + return + } + fileName := url.PathEscape("物料导入模板.xlsx") + c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName) + c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) +} + +// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。 +// 校验失败返回 400 且 data 里带逐行错误明细。 +func (h *Handler) ImportMaterials(c *gin.Context) { + orgID, ok := organizationID(c) + if !ok { + return + } + + file, err := c.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要导入的 xlsx 文件") + return + } + if file.Size > service.MaterialImportMaxBytes { + response.BadRequest(c, "文件超过 5MB 上限") + return + } + + source, err := file.Open() + if err != nil { + response.BadRequest(c, "文件读取失败") + return + } + defer source.Close() + + operator := middleware.GetCurrentUsername(c) + if operator == "" { + operator = "系统" + } + + result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source) + if err != nil { + if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) { + response.BadRequest(c, err.Error()) + return + } + response.InternalError(c, err.Error()) + return + } + if result.FailedRows > 0 { + response.ErrorWithData(c, http.StatusBadRequest, + "有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result) + return + } + response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result) +} diff --git a/internal/handler/material_import_test.go b/internal/handler/material_import_test.go new file mode 100644 index 00000000..f832322a --- /dev/null +++ b/internal/handler/material_import_test.go @@ -0,0 +1,29 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/wmms/wmms/pkg/response" +) + +func TestErrorWithDataKeepsPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{ + "total_rows": 1, "failed_rows": 1, + }) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } + body := recorder.Body.String() + if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) { + t.Fatalf("body = %s", body) + } +} diff --git a/internal/handler/routes_test.go b/internal/handler/routes_test.go index c6e69b9d..6d92fb7e 100644 --- a/internal/handler/routes_test.go +++ b/internal/handler/routes_test.go @@ -31,6 +31,7 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) { "POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore", "PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details", "POST /api/applications", "GET /api/logs", + "GET /api/material-import/template", "POST /api/material-import", } for _, route := range want { if !registered[route] { diff --git a/pkg/response/response.go b/pkg/response/response.go index 5a8b5ae9..67b57720 100644 --- a/pkg/response/response.go +++ b/pkg/response/response.go @@ -65,6 +65,16 @@ func Error(c *gin.Context, httpStatus int, message string) { }) } +// ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误 +// 一起带回去,不能只给一句话。 +func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) { + c.JSON(httpStatus, Response{ + Code: httpStatus, + Message: message, + Data: data, + }) +} + func BadRequest(c *gin.Context, message string) { Error(c, 400, message) }