feat: 物料导入与模板下载接口
- 新增 response.ErrorWithData:校验失败要把逐行明细一起带回,不能只给一句话 - 新增 GET /api/material-import/template 与 POST /api/material-import - 路由挂在独立前缀 /material-import:materials 组已有 GET /:id,gin 的路由树 不允许同一段既有静态前缀又有通配参数 - 权限沿用物料新增的口径:登录 + 公司头校验,不额外要求 super_admin - 模板随代码生成不落盘,下载到的永远与当前校验规则一致
This commit is contained in:
parent
f34803e1a2
commit
7b926ff60a
|
|
@ -16,17 +16,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
materialSvc *service.MaterialService
|
materialSvc *service.MaterialService
|
||||||
inboundSvc *service.InboundService
|
inboundSvc *service.InboundService
|
||||||
outboundSvc *service.OutboundService
|
outboundSvc *service.OutboundService
|
||||||
stockSvc *service.StockService
|
stockSvc *service.StockService
|
||||||
logSvc *service.MaterialLogService
|
logSvc *service.MaterialLogService
|
||||||
authSvc *service.AuthService
|
authSvc *service.AuthService
|
||||||
appSvc *service.ApplicationService
|
appSvc *service.ApplicationService
|
||||||
organizationSvc *service.OrganizationService
|
organizationSvc *service.OrganizationService
|
||||||
masterDataSvc *service.MasterDataService
|
masterDataSvc *service.MasterDataService
|
||||||
partnerSvc *service.PartnerService
|
partnerSvc *service.PartnerService
|
||||||
warehouseSvc *service.WarehouseService
|
warehouseSvc *service.WarehouseService
|
||||||
|
materialImportSvc *service.MaterialImportService
|
||||||
}
|
}
|
||||||
|
|
||||||
func userFacingServiceErrorMessage(err error) string {
|
func userFacingServiceErrorMessage(err error) string {
|
||||||
|
|
@ -38,17 +39,18 @@ func userFacingServiceErrorMessage(err error) string {
|
||||||
|
|
||||||
func NewHandler() *Handler {
|
func NewHandler() *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
materialSvc: service.NewMaterialService(),
|
materialSvc: service.NewMaterialService(),
|
||||||
inboundSvc: service.NewInboundService(),
|
inboundSvc: service.NewInboundService(),
|
||||||
outboundSvc: service.NewOutboundService(),
|
outboundSvc: service.NewOutboundService(),
|
||||||
stockSvc: service.NewStockService(),
|
stockSvc: service.NewStockService(),
|
||||||
logSvc: service.NewMaterialLogService(),
|
logSvc: service.NewMaterialLogService(),
|
||||||
authSvc: service.NewAuthService(),
|
authSvc: service.NewAuthService(),
|
||||||
appSvc: service.NewApplicationService(),
|
appSvc: service.NewApplicationService(),
|
||||||
organizationSvc: service.NewOrganizationService(),
|
organizationSvc: service.NewOrganizationService(),
|
||||||
masterDataSvc: service.NewMasterDataService(),
|
masterDataSvc: service.NewMasterDataService(),
|
||||||
partnerSvc: service.NewPartnerService(),
|
partnerSvc: service.NewPartnerService(),
|
||||||
warehouseSvc: service.NewWarehouseService(),
|
warehouseSvc: service.NewWarehouseService(),
|
||||||
|
materialImportSvc: service.NewMaterialImportService(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +95,15 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
||||||
materials.POST("/:id/restore", h.RestoreMaterial)
|
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 := api.Group("/inbound")
|
||||||
inbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
inbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
73
internal/handler/material_import.go
Normal file
73
internal/handler/material_import.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
29
internal/handler/material_import_test.go
Normal file
29
internal/handler/material_import_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) {
|
||||||
"POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore",
|
"POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore",
|
||||||
"PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details",
|
"PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details",
|
||||||
"POST /api/applications", "GET /api/logs",
|
"POST /api/applications", "GET /api/logs",
|
||||||
|
"GET /api/material-import/template", "POST /api/material-import",
|
||||||
}
|
}
|
||||||
for _, route := range want {
|
for _, route := range want {
|
||||||
if !registered[route] {
|
if !registered[route] {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func BadRequest(c *gin.Context, message string) {
|
||||||
Error(c, 400, message)
|
Error(c, 400, message)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user