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:
文睿 张 2026-10-10 15:46:57 +08:00
parent f34803e1a2
commit 7b926ff60a
5 changed files with 146 additions and 22 deletions

View File

@ -27,6 +27,7 @@ type Handler struct {
masterDataSvc *service.MasterDataService
partnerSvc *service.PartnerService
warehouseSvc *service.WarehouseService
materialImportSvc *service.MaterialImportService
}
func userFacingServiceErrorMessage(err error) string {
@ -49,6 +50,7 @@ func NewHandler() *Handler {
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())
{

View 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)
}

View 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)
}
}

View File

@ -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] {

View File

@ -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)
}