From aa9b357f1689926cfa8949493206a6a8f96ed8df Mon Sep 17 00:00:00 2001 From: 97694731 <97694731@qq.com> Date: Thu, 2 Jul 2026 16:57:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A0=E9=9C=80=E5=8F=91=E8=B4=A7=20?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E5=8F=91=E8=B4=A7=20=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/go.imports.xml | 10 +++ config/config.go | 5 ++ controllers/location.go | 79 +++++++++++++++++- controllers/process.go | 36 ++++++++ controllers/shipping.go | 22 +++++ controllers/warehouse.go | 63 -------------- database/tenant.go | 2 +- models/request/process.go | 12 +++ models/request/shipping.go | 13 +++ models/response/shipping.go | 32 +++++++ routes/routes.go | 3 + service/barcode.go | 148 +++++++++++++++++++-------------- service/location.go | 149 ++++++++++++++++++++++++++++++++- service/location_import.go | 19 +++-- service/process.go | 161 ++++++++++++++++++++++++++++++++++++ service/shipping.go | 143 ++++++++++++++++++++++++++++++++ 16 files changed, 760 insertions(+), 137 deletions(-) create mode 100644 .idea/go.imports.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/config/config.go b/config/config.go index 63702f5..18b3446 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,7 @@ type Config struct { Log LogConfig `yaml:"log"` ES ESConfig `yaml:"es"` OCR OCRConfig `yaml:"ocr"` + Font FontConfig `yaml:"font"` ExternalAPI ExternalAPIConfig `yaml:"external_api"` Wangdian WangdianConfig `yaml:"wangdian"` } @@ -67,6 +68,10 @@ type OCRConfig struct { ExeUrl string `yaml:"exe_url"` } +type FontConfig struct { + Path string `yaml:"path"` +} + type ExternalAPIConfig struct { SyncProductURL string `yaml:"sync_product_url"` SyncTaskURL string `yaml:"sync_task_url"` diff --git a/controllers/location.go b/controllers/location.go index e0f8863..1cb12d2 100644 --- a/controllers/location.go +++ b/controllers/location.go @@ -2,11 +2,15 @@ package controllers import ( "fmt" + "io" + "net/http" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "net/http" + "psi/constant" "psi/database" + "psi/models" systemReq "psi/models/request" systemRes "psi/models/response" "psi/service" @@ -17,6 +21,79 @@ type LocationApi struct{} var locationService = service.LocationService{} +// LocationToCsv 导出库位到Excel +// POST /api/location/to-csv +func (r *LocationApi) LocationToCsv(c *gin.Context) { + var req systemReq.ExportLocationRequest + + if err := c.ShouldBind(&req); err != nil { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "导出库位请求参数异常", fmt.Errorf("参数错误: %v", err), c, nil) + return + } + + fileBytes, fileName, total, err := locationService.ExportLocationToCSV(req, database.GetDB(c)) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "导出库位异常", err, c, req) + return + } + + if req.Type == 0 { + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "data": gin.H{"total": total}, + }) + return + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) + c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileBytes) +} + +// CsvToLocation 从CSV/Excel导入库位 +// POST /api/location/csv-to +func (r *LocationApi) CsvToLocation(c *gin.Context) { + var req systemReq.ImportLocationRequest + + if err := c.ShouldBind(&req); err != nil { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "导入库位请求参数异常", fmt.Errorf("参数错误: %v", err), c, nil) + return + } + + file, _, err := c.Request.FormFile("file") + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "导入库位未上传文件", fmt.Errorf("未上传文件: %v", err), c, nil) + return + } + defer file.Close() + + fileBytes, err := io.ReadAll(file) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "导入库位读取文件失败", fmt.Errorf("读取文件失败: %v", err), c, nil) + return + } + + // 从JWT中获取当前用户信息 + employeeObj, exists := c.Get("employee") + if !exists { + utils.FailWithRequestLog(constant.LoggerChannelRequest, "导入库位未登录", fmt.Errorf("未获取到用户信息"), c, nil) + return + } + currentEmployee := employeeObj.(models.Employee) + + result, err := locationService.ImportLocationFromCSV(currentEmployee.ID, req.WarehouseID, fileBytes) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "导入库位失败", err, c, req) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "msg": "导入完成", + "data": result, + }) +} + func (r *LocationApi) GetLocationList(c *gin.Context) { var req systemReq.QueryLocationRequest if err := c.ShouldBindQuery(&req); err != nil { diff --git a/controllers/process.go b/controllers/process.go index 3e08f0a..dd0fff0 100644 --- a/controllers/process.go +++ b/controllers/process.go @@ -864,6 +864,42 @@ func getPostFormInt64(c *gin.Context, key string) (int64, bool, error) { return val, true, nil } +// DirectShip 无需发货:直接将订单状态改为已发货 +func (r *ProcessApi) DirectShip(c *gin.Context) { + var req systemReq.DirectShipRequest + + if err := c.ShouldBind(&req); err != nil { + ValidAndFail(constant.LoggerChannelRequest, "无需发货请求参数异常", "参数错误: "+err.Error(), c, err) + return + } + + err := processService.DirectShip(req, utils.GetUserInfo(c).ID, database.GetDB(c)) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "无需发货异常", err, c, req) + return + } + + systemRes.OkWithMessage("已标记为已发货", c) +} + +// ManualShip 手动填写发货:填写快递单号后直接将订单状态改为已发货 +func (r *ProcessApi) ManualShip(c *gin.Context) { + var req systemReq.ManualShipRequest + + if err := c.ShouldBind(&req); err != nil { + ValidAndFail(constant.LoggerChannelRequest, "手动发货请求参数异常", "参数错误: "+err.Error(), c, err) + return + } + + err := processService.ManualShip(req, utils.GetUserInfo(c).ID, database.GetDB(c)) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "手动发货异常", err, c, req) + return + } + + systemRes.OkWithMessage("手动发货成功", c) +} + // logAndFail 业务统一的错误日志和响应函数 func logAndFail(channel string, source, errMsg string, c *gin.Context) { utils.ErrorLog(channel, logrus.Fields{ diff --git a/controllers/shipping.go b/controllers/shipping.go index 4b3ba19..b7d6fde 100644 --- a/controllers/shipping.go +++ b/controllers/shipping.go @@ -77,3 +77,25 @@ func (r *ShippingApi) GetShippingOrderDetailList(c *gin.Context) { "data": result, }) } + +// GetShippingOrderFlatList 获取发货单平铺列表(每条记录对应一个商品明细) +func (r *ShippingApi) GetShippingOrderFlatList(c *gin.Context) { + var req systemReq.GetShippingOrderFlatListRequest + + if err := c.ShouldBindQuery(&req); err != nil { + ValidAndFail(constant.LoggerChannelRequest, "发货单平铺列表请求参数异常", "参数错误: "+err.Error(), c, err) + return + } + + userInfo := utils.GetUserInfo(c) + result, err := shippingService.GetShippingOrderFlatList(req, userInfo.ID, userInfo.Role, database.GetDB(c)) + if err != nil { + utils.FailWithRequestLog(constant.LoggerChannelWork, "发货单平铺列表异常", err, c, req) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 200, + "data": result, + }) +} diff --git a/controllers/warehouse.go b/controllers/warehouse.go index 8563784..59f0067 100644 --- a/controllers/warehouse.go +++ b/controllers/warehouse.go @@ -5,15 +5,12 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "net/http" - "os" "psi/constant" "psi/database" systemReq "psi/models/request" systemRes "psi/models/response" "psi/service" "psi/utils" - "strings" - "time" ) type WarehouseApi struct{} @@ -137,66 +134,6 @@ func (r *WarehouseApi) DeleteWarehouse(c *gin.Context) { systemRes.OkWithMessage("删除成功", c) } -// LocationToCsv 导出库位 -func (r *LocationApi) LocationToCsv(c *gin.Context) { - var req systemReq.ExportLocationRequest - if err := c.ShouldBindQuery(&req); err != nil { - ValidAndFail(constant.LoggerChannelRequest, "导出库位请求参数异常", "参数错误: "+err.Error(), c, err) - return - } - - result, err := locationService.ExportLocations(req, database.GetDB(c)) - if err != nil { - utils.FailWithRequestLog(constant.LoggerChannelWork, "导出库位异常", err, c, req) - return - } - - systemRes.OkWithDetailed(result, "导出成功", c) -} - -// CsvToLocation 导入库位 -func (r *LocationApi) CsvToLocation(c *gin.Context) { - var req systemReq.ImportLocationRequest - if err := c.ShouldBind(&req); err != nil { - ValidAndFail(constant.LoggerChannelRequest, "导入库位请求参数异常", "参数错误: "+err.Error(), c, err) - return - } - - file, err := c.FormFile("file") - if err != nil { - systemRes.FailWithValidateMessage("请上传文件", c) - return - } - - if !strings.HasSuffix(strings.ToLower(file.Filename), ".xlsx") && !strings.HasSuffix(strings.ToLower(file.Filename), ".xls") { - systemRes.FailWithValidateMessage("只支持Excel文件格式(.xlsx, .xls)", c) - return - } - - filePath := fmt.Sprintf("excel/import_%s_%d.xlsx", time.Now().Format("20060102150405"), time.Now().UnixNano()) - if err := c.SaveUploadedFile(file, filePath); err != nil { - utils.FailWithRequestLog(constant.LoggerChannelWork, "保存上传文件异常", err, c, gin.H{"filename": file.Filename}) - return - } - - defer func() { - if err := os.Remove(filePath); err != nil { - utils.ErrorLog(constant.LoggerChannelWork, logrus.Fields{ - "source": "删除临时文件失败", - "err_msg": err.Error(), - }) - } - }() - - result, err := locationService.ImportLocationsFromCSV(req, filePath, database.GetDB(c)) - if err != nil { - utils.FailWithRequestLog(constant.LoggerChannelWork, "导入库位异常", err, c, gin.H{"filename": file.Filename}) - return - } - - systemRes.OkWithDetailed(result, result.Message, c) -} - // GetUserWarehouseMappings 获取用户的仓库映射列表 func (r *WarehouseApi) GetUserWarehouseMappings(c *gin.Context) { data, err := warehouseService.GetUserWarehouseMappings() diff --git a/database/tenant.go b/database/tenant.go index 1bacb58..218211a 100644 --- a/database/tenant.go +++ b/database/tenant.go @@ -119,7 +119,7 @@ func createTenantDB(dbName string) (*gorm.DB, error) { sqlDB.SetConnMaxLifetime(10 * time.Minute) // 单个连接最大存活10分钟 sqlDB.SetConnMaxIdleTime(2 * time.Minute) // 空闲连接2分钟后关闭 - //migrateTenantTables(tenantDB) + migrateTenantTables(tenantDB) return tenantDB, nil } diff --git a/models/request/process.go b/models/request/process.go index 2801b65..451b4d5 100644 --- a/models/request/process.go +++ b/models/request/process.go @@ -161,3 +161,15 @@ type StockCheckReturnRequest struct { SalesOrderItemID int64 `form:"sales_order_item_id" binding:"required"` // 销售订单明细ID Remark string `form:"remark"` // 备注 } + +// DirectShipRequest 无需发货请求(按明细行标记) +type DirectShipRequest struct { + ShippingOrderItemID int64 `form:"shipping_order_item_id" json:"shipping_order_item_id" binding:"required"` // 发货单明细ID +} + +// ManualShipRequest 手动填写发货请求(按明细行标记) +type ManualShipRequest struct { + ShippingOrderItemID int64 `form:"shipping_order_item_id" json:"shipping_order_item_id" binding:"required"` // 发货单明细ID + LogisticsCompany string `form:"logistics_company" json:"logistics_company" binding:"required"` // 物流公司编码 + LogisticsNo string `form:"logistics_no" json:"logistics_no" binding:"required"` // 物流单号 +} diff --git a/models/request/shipping.go b/models/request/shipping.go index 35ff292..0df180d 100644 --- a/models/request/shipping.go +++ b/models/request/shipping.go @@ -36,3 +36,16 @@ type GetShippingOrderDetailListRequest struct { WarehouseID int64 `form:"warehouse_id" json:"warehouse_id"` LocationID int64 `form:"location_id" json:"location_id"` } + +// GetShippingOrderFlatListRequest 获取发货单平铺列表请求 +type GetShippingOrderFlatListRequest struct { + Page int `form:"page" json:"page" binding:"omitempty,min=1"` + PageSize int `form:"page_size" json:"page_size" binding:"omitempty,min=1,max=100"` + Status int8 `form:"status" json:"status"` + ShippingNo string `form:"shipping_no" json:"shipping_no"` + ShopType int `form:"shop_type" json:"shop_type"` + AssociationOrderNo string `form:"association_order_no" json:"association_order_no"` + LogisticsNo string `form:"logistics_no" json:"logistics_no"` + WarehouseID int64 `form:"warehouse_id" json:"warehouse_id"` + LocationID int64 `form:"location_id" json:"location_id"` +} diff --git a/models/response/shipping.go b/models/response/shipping.go index df82d93..7041071 100644 --- a/models/response/shipping.go +++ b/models/response/shipping.go @@ -215,6 +215,38 @@ func ConvertShippingOrderToDetailListItem(order models.ShippingOrder, customerNa } } +// ShippingOrderFlatItem 发货单平铺记录(每条记录对应一个商品明细) +type ShippingOrderFlatItem struct { + ItemID int64 `json:"item_id"` // shipping_order_item.id,用于操作按钮 + ShippingOrderID int64 `json:"shipping_order_id"` // 发货单ID + ShippingNo string `json:"shipping_no"` // 发货单号 + ShopType int8 `json:"shop_type"` // 平台类型 + ShopTypeText string `json:"shop_type_text"` // 平台类型文本 + LogisticsCompany string `json:"logistics_company"` // 快递公司(来自 sales_order_item) + Status int8 `json:"status"` // 发货单状态 + StatusText string `json:"status_text"` // 发货单状态文本 + ProductName string `json:"product_name"` // 书名 + ProductCode string `json:"product_code"` // ISBN + ReceiverAddress string `json:"receiver_address"` // 收件人地址 + SalesOrderNo string `json:"sales_order_no"` // 销售单号 + OutboundOrderNo string `json:"outbound_order_no"` // 出库单号 + AssociationOrderNo string `json:"association_order_no"` // 第三方订单编号 + LogisticsNo string `json:"logistics_no"` // 快递单号 + SalesOrderCreatedAt int64 `json:"sales_order_created_at"` // 销售时间 + UnitPrice int64 `json:"unit_price"` // 售价(分) + Quantity int64 `json:"quantity"` // 出库数量 + SalesOrderItemID int64 `json:"sales_order_item_id"` // sales_order_item.id,打印流程需要 + ProductID int64 `json:"product_id"` // 商品ID,左侧详情展示需要 +} + +// ShippingOrderFlatListResponse 发货单平铺列表响应 +type ShippingOrderFlatListResponse struct { + List []ShippingOrderFlatItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + // GetShippingOrderStatusText 获取发货单状态文本 func GetShippingOrderStatusText(status int8) string { statusMap := map[int8]string{ diff --git a/routes/routes.go b/routes/routes.go index 105e4a8..78270a5 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -219,6 +219,8 @@ func initRouter() (r *gin.Engine) { auth.GET("/outbound/detail/:id", processApi.GetOutboundDetail) // 获取出库单详情 auth.POST("/shipping-order/create", processApi.CreateShippingOrder) // 创建发货单 auth.POST("/shipping-order/update", processApi.UpdateShippingLogistics) // 更新发货单物流信息 + auth.POST("/shipping-order/direct-ship", processApi.DirectShip) // 无需发货 + auth.POST("/shipping-order/manual-ship", processApi.ManualShip) // 手动填写发货 auth.POST("/sales-order/cancel", processApi.CancelSalesOrder) // 取消销售订 //auth.POST("/sales-order/unlock-inventory", processApi.UnlockSalesOrderInventory) // 解锁销售订单库存 @@ -245,6 +247,7 @@ func initRouter() (r *gin.Engine) { auth.GET("/shipping-order/list", shippingApi.GetShippingOrderList) // 获取发货单列表 auth.GET("/shipping-order/detail", shippingApi.GetShippingOrderDetail) // 获取发货单详情 auth.GET("/shipping-order/detaillist", shippingApi.GetShippingOrderDetailList) // 获取发货单详情列表(按状态) + auth.GET("/shipping-order/flat-list", shippingApi.GetShippingOrderFlatList) // 获取发货单平铺列表 // 波次任务管理 auth.GET("/wave/task/list", waveApi.GetWaveTaskList) // 获取波次任务列表 auth.GET("/wave/task/detail", waveApi.GetWaveTaskDetail) // 获取波次任务详情 diff --git a/service/barcode.go b/service/barcode.go index 3768682..d7b3ada 100644 --- a/service/barcode.go +++ b/service/barcode.go @@ -9,6 +9,7 @@ import ( "image/draw" "image/png" "os" + "path/filepath" systemRes "psi/models/response" "strings" @@ -35,11 +36,10 @@ type Config struct { // GenerateBarcode 根据货号生成条形码图片(Base64) func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeResponse, error) { // 配置参数 - config := &Config{ + cfg := &Config{ BarcodeHeight: 120, ModuleWidth: 2, Padding: 20, - FontPath: "fonts/youaimoshouheiti-regular.ttf", FontSize: 50, BgColor: color.White, BarColor: color.Black, @@ -47,11 +47,11 @@ func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeRespo // 若内容包含isbn,则使用较小字号且不加粗 if strings.Contains(strings.ToLower(content), "9787") { - config.FontSize = 35 + cfg.FontSize = 35 } // 生成条形码图片的Base64 - base64Image, err := generateBase64Barcode(content, config) + base64Image, err := generateBase64Barcode(content, cfg) if err != nil { return systemRes.BarcodeResponse{}, fmt.Errorf("生成条形码失败: %w", err) } @@ -62,9 +62,9 @@ func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeRespo }, nil } -func generateBase64Barcode(content string, config *Config) (string, error) { +func generateBase64Barcode(content string, cfg *Config) (string, error) { // 1. 生成条形码图像 - barcodeImg, err := generateBarcodeImage(content, config) + barcodeImg, err := generateBarcodeImage(content, cfg) if err != nil { return "", err } @@ -73,42 +73,46 @@ func generateBase64Barcode(content string, config *Config) (string, error) { barcodeWidth := barcodeBounds.Dx() barcodeHeight := barcodeBounds.Dy() - // 2. 加载字体 - face, err := loadFont(config) - if err != nil { - return "", err + // 2. 尝试加载字体(自定义字体 -> 系统字体 -> 跳过文字) + face, fontErr := loadFont(cfg.FontSize) + var textHeight, ascent, descent int + var textWidth int + if fontErr == nil && face != nil { + defer face.Close() + // 3. 获取文字度量信息 + metrics := face.Metrics() + ascent = metrics.Ascent.Ceil() + descent = metrics.Descent.Ceil() + textHeight = ascent + descent + + // 计算文字宽度 + drawer := &font.Drawer{Face: face} + advance := drawer.MeasureString(content) + textWidth = advance.Ceil() } - defer face.Close() - - // 3. 获取文字度量信息 - metrics := face.Metrics() - ascent := metrics.Ascent.Ceil() - descent := metrics.Descent.Ceil() - textHeight := ascent + descent - - // 计算文字宽度 - drawer := &font.Drawer{Face: face} - advance := drawer.MeasureString(content) - textWidth := advance.Ceil() // 4. 计算最终图片尺寸 gapBetween := 12 textBottomPadding := 12 - maxContentWidth := barcodeWidth - if textWidth > maxContentWidth { - maxContentWidth = textWidth + finalWidth := barcodeWidth + cfg.Padding*2 + finalHeight := barcodeHeight + cfg.Padding*2 + if face != nil { + maxContentWidth := barcodeWidth + if textWidth > maxContentWidth { + maxContentWidth = textWidth + } + finalWidth = maxContentWidth + cfg.Padding*2 + finalHeight = barcodeHeight + gapBetween + textHeight + cfg.Padding*2 + textBottomPadding } - finalWidth := maxContentWidth + config.Padding*2 - finalHeight := barcodeHeight + gapBetween + textHeight + config.Padding*2 + textBottomPadding // 5. 创建最终图片 finalImg := image.NewRGBA(image.Rect(0, 0, finalWidth, finalHeight)) - draw.Draw(finalImg, finalImg.Bounds(), &image.Uniform{config.BgColor}, image.Point{}, draw.Src) + draw.Draw(finalImg, finalImg.Bounds(), &image.Uniform{cfg.BgColor}, image.Point{}, draw.Src) // 6. 绘制条形码(居中) barcodeStartX := (finalWidth - barcodeWidth) / 2 - barcodeStartY := config.Padding + barcodeStartY := cfg.Padding draw.Draw(finalImg, image.Rect(barcodeStartX, barcodeStartY, barcodeStartX+barcodeWidth, barcodeStartY+barcodeHeight), barcodeImg, @@ -116,25 +120,27 @@ func generateBase64Barcode(content string, config *Config) (string, error) { draw.Over) // 7. 绘制货号文字(居中,加粗字体) - textAreaTop := barcodeStartY + barcodeHeight + gapBetween - textAreaHeight := textHeight + textBottomPadding - baselineY := textAreaTop + (textAreaHeight / 2) + (ascent / 2) - 2 + if face != nil { + textAreaTop := barcodeStartY + barcodeHeight + gapBetween + textAreaHeight := textHeight + textBottomPadding + baselineY := textAreaTop + (textAreaHeight / 2) + (ascent / 2) - 2 - textX := (finalWidth - textWidth) / 2 - if textX < 0 { - textX = config.Padding - } + textX := (finalWidth - textWidth) / 2 + if textX < 0 { + textX = cfg.Padding + } - textDrawer := &font.Drawer{ - Dst: finalImg, - Src: image.NewUniform(config.BarColor), - Face: face, - Dot: fixed.Point26_6{ - X: fixed.I(textX), - Y: fixed.I(baselineY), - }, + textDrawer := &font.Drawer{ + Dst: finalImg, + Src: image.NewUniform(cfg.BarColor), + Face: face, + Dot: fixed.Point26_6{ + X: fixed.I(textX), + Y: fixed.I(baselineY), + }, + } + textDrawer.DrawString(content) } - textDrawer.DrawString(content) // 8. 转换为Base64 buf := new(bytes.Buffer) @@ -161,25 +167,45 @@ func generateBarcodeImage(content string, config *Config) (image.Image, error) { return barcode.Scale(code128Barcode, targetWidth, targetHeight) } -// loadFont 加载加粗TrueType字体 -func loadFont(config *Config) (font.Face, error) { - fontBytes, err := os.ReadFile(config.FontPath) - if err != nil { - return nil, fmt.Errorf("读取字体文件失败: %w", err) +// loadFont 尝试加载字体,按优先级:项目字体目录 -> Windows系统字体 -> Linux系统字体 +// 如果都失败,返回 nil(调用方会跳过文字绘制) +func loadFont(fontSize float64) (font.Face, error) { + // 按优先级尝试的字体路径列表 + fontPaths := []string{ + "fonts/youaimoshouheiti-regular.ttf", // 项目自定义字体 + "C:\\Windows\\Fonts\\arial.ttf", // Windows Arial + "C:\\Windows\\Fonts\\simhei.ttf", // Windows 黑体(支持中文) + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", // Linux DejaVu + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", // Linux Liberation } - parsedFont, err := opentype.Parse(fontBytes) - if err != nil { - return nil, fmt.Errorf("解析字体失败: %w", err) + // 如果是相对路径,尝试基于可执行文件目录解析 + exePath, exeErr := os.Executable() + for i, p := range fontPaths { + if !filepath.IsAbs(p) && exeErr == nil { + fontPaths[i] = filepath.Join(filepath.Dir(exePath), p) + } } - face, err := opentype.NewFace(parsedFont, &opentype.FaceOptions{ - Size: config.FontSize, - DPI: 96, - Hinting: font.HintingFull, - }) - if err != nil { - return nil, fmt.Errorf("创建字体失败: %w", err) + for _, fontPath := range fontPaths { + fontBytes, err := os.ReadFile(fontPath) + if err != nil { + continue + } + parsedFont, err := opentype.Parse(fontBytes) + if err != nil { + continue + } + face, err := opentype.NewFace(parsedFont, &opentype.FaceOptions{ + Size: fontSize, + DPI: 96, + Hinting: font.HintingFull, + }) + if err != nil { + continue + } + return face, nil } - return face, nil + + return nil, nil // 所有字体都加载失败,返回 nil(不绘制文字) } diff --git a/service/location.go b/service/location.go index 341c0b9..f59bdcd 100644 --- a/service/location.go +++ b/service/location.go @@ -3,17 +3,22 @@ package service import ( "encoding/json" "fmt" - "gorm.io/datatypes" - "gorm.io/gorm" "strconv" "strings" "time" "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/xuri/excelize/v2" + "gorm.io/datatypes" + "gorm.io/gorm" + + "psi/constant" "psi/database" "psi/models" systemReq "psi/models/request" systemRes "psi/models/response" + "psi/utils" ) type LocationService struct{} @@ -1125,3 +1130,143 @@ func numToChar(n int) byte { } return 0 } + +// ExportLocationToCSV 导出库位到Excel文件 +func (s *LocationService) ExportLocationToCSV(req systemReq.ExportLocationRequest, db ...*gorm.DB) ([]byte, string, int64, error) { + databaseConn := database.OptionalDB(db...) + + var total int64 + query := databaseConn.Model(&models.Location{}).Where("is_del = ?", 0).Where("warehouse_id = ?", req.WarehouseID) + if err := query.Count(&total).Error; err != nil { + return nil, "", 0, fmt.Errorf("查询库位总数失败: %v", err) + } + + if req.Type == 0 { + return nil, "", total, nil + } + + if total == 0 { + return nil, "", 0, fmt.Errorf("没有符合条件的库位数据") + } + + var locations []models.Location + if err := query.Order("sort ASC, code ASC").Find(&locations).Error; err != nil { + return nil, "", 0, fmt.Errorf("查询库位数据失败: %v", err) + } + + f := excelize.NewFile() + defer func() { + if err := f.Close(); err != nil { + utils.ErrorLog(constant.LoggerChannelWork, logrus.Fields{ + "source": "关闭Excel文件", + "error": fmt.Sprintf("关闭失败: %v", err), + }) + } + }() + + sheetName := "Sheet1" + f.SetSheetName("Sheet1", sheetName) + + headers := []string{"库位编码", "库位类型", "容量", "排序值", "状态", "创建时间", "更新时间"} + for i, header := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + f.SetCellValue(sheetName, cell, header) + } + + headerStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Size: 12, + }, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + }, + Fill: excelize.Fill{ + Type: "pattern", + Color: []string{"#E0E0E0"}, + Pattern: 1, + }, + }) + f.SetCellStyle(sheetName, "A1", "G1", headerStyle) + + typeMap := map[int8]string{ + 1: "存储库位", + 2: "拣货库位", + 3: "收货库位", + 4: "发货库位", + 5: "退货库位", + } + statusMap := map[int8]string{ + 0: "禁用", + 1: "启用", + } + + for idx, loc := range locations { + row := idx + 2 + + f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), loc.Code) + + typeName := typeMap[loc.Type] + if typeName == "" { + typeName = fmt.Sprintf("类型%d", loc.Type) + } + f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), typeName) + + f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), loc.Capacity) + + f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), loc.Sort) + + statusName := statusMap[loc.Status] + if statusName == "" { + statusName = fmt.Sprintf("未知(%d)", loc.Status) + } + f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), statusName) + + createdAtStr := time.Unix(loc.CreatedAt, 0).Format("2006-01-02 15:04:05") + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), createdAtStr) + + updatedAtStr := time.Unix(loc.UpdatedAt, 0).Format("2006-01-02 15:04:05") + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), updatedAtStr) + } + + colWidths := map[string]float64{ + "A": 20, + "B": 14, + "C": 10, + "D": 10, + "E": 10, + "F": 20, + "G": 20, + } + for col, width := range colWidths { + f.SetColWidth(sheetName, col, col, width) + } + + now := time.Now() + fileName := fmt.Sprintf("location_export_%s.xlsx", now.Format("20060102150405")) + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, "", 0, fmt.Errorf("生成Excel文件失败: %v", err) + } + + return buf.Bytes(), fileName, total, nil +} + +// ImportLocationFromCSV 从CSV/Excel文件导入库位 +func (s *LocationService) ImportLocationFromCSV(userID, warehouseID int64, fileBytes []byte) (*LocationImportResult, error) { + importSvc := &LocationImportService{} + rows, err := importSvc.ParseLocationImportExcel(fileBytes) + if err != nil { + return nil, fmt.Errorf("解析文件失败: %v", err) + } + + if len(rows) == 0 { + return &LocationImportResult{ + Message: "文件中没有有效的库位编码", + }, nil + } + + return importSvc.ImportLocationsFromExcel(userID, warehouseID, rows) +} diff --git a/service/location_import.go b/service/location_import.go index 5f13242..d21fd98 100644 --- a/service/location_import.go +++ b/service/location_import.go @@ -77,7 +77,7 @@ func getCell(row []string, idx int) string { return "" } -// ImportLocationsFromExcel 从Excel导入库位(事务内批量创建) +// ImportLocationsFromExcel 从Excel导入库位(覆盖模式:事务内先删后插,保证原子性) func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int64, rows []request.LocationImportRow) (*LocationImportResult, error) { databaseConn, err := database.GetTenantDB(userID) if err != nil { @@ -89,6 +89,13 @@ func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int err = databaseConn.Transaction(func(tx *gorm.DB) error { now := time.Now().Unix() + // 覆盖模式:物理删除当前仓库的所有库位,释放唯一索引 uk_warehouse_code + if err := tx.Where("warehouse_id = ?", warehouseID).Delete(&models.Location{}).Error; err != nil { + return fmt.Errorf("清空旧库位失败: %v", err) + } + + rowIndex := 0 + for _, row := range rows { code := strings.TrimSpace(row.Code) if code == "" { @@ -96,19 +103,13 @@ func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int continue } - // 检查同仓库下是否已存在 - var existing models.Location - if err := tx.Where("warehouse_id = ? AND code = ? AND is_del = ?", warehouseID, code, 0).First(&existing).Error; err == nil { - result.AddFail(fmt.Sprintf("%s: 库位已存在", code)) - continue - } - + rowIndex++ loc := models.Location{ WarehouseID: warehouseID, Code: code, Type: 1, // 存储库位 Capacity: 255, - Sort: 0, + Sort: rowIndex, Status: 1, // 启用 CreatedAt: now, UpdatedAt: now, diff --git a/service/process.go b/service/process.go index bd3de0e..3d903aa 100644 --- a/service/process.go +++ b/service/process.go @@ -4708,3 +4708,164 @@ func parseImageList(liveImage datatypes.JSON) []string { // 都不成功,返回空数组 return []string{} } + +// DirectShip 无需发货:更新指定明细行的物流信息,并同步更新订单状态 +func (s *ProcessService) DirectShip(req systemReq.DirectShipRequest, operatorID int64, db ...*gorm.DB) error { + databaseConn := database.OptionalDB(db...) + now := time.Now().Unix() + + return executeInTransactionWithDB(databaseConn, func(tx *gorm.DB) error { + // 查找发货单明细 + var shippingItem models.ShippingOrderItem + if err := tx.Where("id = ? AND is_del = 0", req.ShippingOrderItemID).First(&shippingItem).Error; err != nil { + return utils.NewError("发货单明细不存在") + } + + // 校验发货单存在 + var shippingOrder models.ShippingOrder + if err := tx.Where("id = ? AND is_del = 0", shippingItem.ShippingOrderID).First(&shippingOrder).Error; err != nil { + return utils.NewError("发货单不存在") + } + + if shippingItem.OutboundOrderItemID == nil { + return utils.NewError("发货单明细无关联出库单") + } + + var outboundItem models.OutboundOrderItem + if err := tx.Where("id = ? AND is_del = 0", *shippingItem.OutboundOrderItemID).First(&outboundItem).Error; err != nil { + return utils.NewError("出库单明细不存在") + } + + // 更新 sales_order_item:物流公司=无需发货,已出库 + if outboundItem.SalesOrderID > 0 { + if err := tx.Model(&models.SalesOrderItem{}). + Where("sales_order_id = ? AND product_id = ? AND is_del = 0", outboundItem.SalesOrderID, outboundItem.ProductID). + Updates(map[string]interface{}{ + "shipped_quantity": 1, + "logistics_company": "无需发货", + "logistics_no": "", + "updated_at": now, + }).Error; err != nil { + return utils.NewError("更新销售订单明细失败") + } + + // 更新销售订单状态为已发货 + if err := tx.Model(&models.SalesOrder{}). + Where("id = ? AND is_del = ?", outboundItem.SalesOrderID, 0). + Update("status", constant.SalesStatusShipped).Error; err != nil { + return utils.NewError("更新销售订单状态失败") + } + } + + // 检查该发货单所有明细是否全部发货,若是则更新出库单和发货单状态 + var allShippingItems []models.ShippingOrderItem + if err := tx.Where("shipping_order_id = ? AND is_del = 0", shippingItem.ShippingOrderID).Find(&allShippingItems).Error; err != nil { + return utils.NewError("查询发货单明细失败") + } + + // 收集关联的出库单明细ID + outboundOrderItemIDs := make([]int64, 0, len(allShippingItems)) + for _, item := range allShippingItems { + if item.OutboundOrderItemID != nil && *item.OutboundOrderItemID > 0 { + outboundOrderItemIDs = append(outboundOrderItemIDs, *item.OutboundOrderItemID) + } + } + + if len(outboundOrderItemIDs) > 0 { + // 查询所有关联的销售订单明细,判断是否全部已发货 + var relatedSalesOrderItems []models.SalesOrderItem + if err := tx.Where("sales_order_id IN (?) AND is_del = 0", + tx.Table("outbound_order_item").Select("sales_order_id").Where("id IN ? AND is_del = 0", outboundOrderItemIDs).QueryExpr()). + Find(&relatedSalesOrderItems).Error; err != nil { + return utils.NewError("查询销售订单明细失败") + } + + allShipped := len(relatedSalesOrderItems) > 0 + for _, item := range relatedSalesOrderItems { + if item.ShippedQuantity == 0 { + allShipped = false + break + } + } + + if allShipped { + // 更新出库单状态为已发货 + var outboundOrderIDs []int64 + if err := tx.Model(&models.OutboundOrderItem{}). + Where("id IN ? AND is_del = 0", outboundOrderItemIDs). + Pluck("DISTINCT out_order_id", &outboundOrderIDs).Error; err != nil { + return utils.NewError("查询出库单ID失败") + } + + if len(outboundOrderIDs) > 0 { + if err := tx.Model(&models.OutboundOrder{}). + Where("id IN ? AND is_del = 0", outboundOrderIDs). + Updates(map[string]interface{}{ + "status": constant.OutboundStatusShipped, + "updated_at": now, + }).Error; err != nil { + return utils.NewError("更新出库单状态失败") + } + } + + // 更新发货单状态为已发货 + if err := tx.Model(&models.ShippingOrder{}). + Where("id = ? AND is_del = ?", shippingItem.ShippingOrderID, 0). + Updates(map[string]interface{}{ + "status": constant.ShippingStatusShipped, + "operator": operatorID, + "updated_at": now, + }).Error; err != nil { + return utils.NewError("更新发货单状态失败") + } + } + } + + return nil + }) +} + +// ManualShip 手动填写发货:仅更新指定明细行的物流信息 +func (s *ProcessService) ManualShip(req systemReq.ManualShipRequest, operatorID int64, db ...*gorm.DB) error { + databaseConn := database.OptionalDB(db...) + now := time.Now().Unix() + + return executeInTransactionWithDB(databaseConn, func(tx *gorm.DB) error { + // 查找发货单明细 + var shippingItem models.ShippingOrderItem + if err := tx.Where("id = ? AND is_del = 0", req.ShippingOrderItemID).First(&shippingItem).Error; err != nil { + return utils.NewError("发货单明细不存在") + } + + // 校验发货单存在 + var shippingOrder models.ShippingOrder + if err := tx.Where("id = ? AND is_del = 0", shippingItem.ShippingOrderID).First(&shippingOrder).Error; err != nil { + return utils.NewError("发货单不存在") + } + + if shippingItem.OutboundOrderItemID == nil { + return utils.NewError("发货单明细无关联出库单") + } + + var outboundItem models.OutboundOrderItem + if err := tx.Where("id = ? AND is_del = 0", *shippingItem.OutboundOrderItemID).First(&outboundItem).Error; err != nil { + return utils.NewError("出库单明细不存在") + } + + // 更新 sales_order_item:物流公司、物流单号,已出库 + if outboundItem.SalesOrderID > 0 { + if err := tx.Model(&models.SalesOrderItem{}). + Where("sales_order_id = ? AND product_id = ? AND is_del = 0", outboundItem.SalesOrderID, outboundItem.ProductID). + Updates(map[string]interface{}{ + "shipped_quantity": 1, + "logistics_company": req.LogisticsCompany, + "logistics_no": req.LogisticsNo, + "updated_at": now, + }).Error; err != nil { + return utils.NewError("更新销售订单明细失败") + } + } + + return nil + }) +} diff --git a/service/shipping.go b/service/shipping.go index 36433fc..db0521c 100644 --- a/service/shipping.go +++ b/service/shipping.go @@ -538,3 +538,146 @@ func (s *ShippingService) GetShippingOrderDetailList(req systemReq.GetShippingOr PageSize: req.PageSize, }, nil } + +// GetShippingOrderFlatList 获取发货单平铺列表(每条记录对应一个商品明细) +func (s *ShippingService) GetShippingOrderFlatList(req systemReq.GetShippingOrderFlatListRequest, creatorID int64, role int64, db ...*gorm.DB) (*systemRes.ShippingOrderFlatListResponse, error) { + databaseConn := database.OptionalDB(db...) + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + + // 基础查询:以 shipping_order_item 为主表 + query := databaseConn.Table("shipping_order_item soi"). + Select(`soi.id as item_id, + so.id as shipping_order_id, + so.shipping_no, + so.status, + p.name as product_name, + p.barcode as product_code, + soi2.receiver_address, + so2.so_no as sales_order_no, + oo.out_no as outbound_order_no, + so2.association_order_no, + soi2.logistics_no, + soi2.logistics_company, + so2.created_at as sales_order_created_at, + ooi.unit_price, + ooi.quantity, + so2.shop_type, + soi2.id as sales_order_item_id, + p.id as product_id`). + Joins("INNER JOIN shipping_order so ON soi.shipping_order_id = so.id AND so.is_del = 0"). + Joins("INNER JOIN outbound_order_item ooi ON soi.outbound_order_item_id = ooi.id AND ooi.is_del = 0"). + Joins("INNER JOIN outbound_order oo ON ooi.out_order_id = oo.id AND oo.is_del = 0"). + Joins("INNER JOIN product p ON ooi.product_id = p.id AND p.is_del = 0"). + Joins("LEFT JOIN sales_order so2 ON ooi.sales_order_id = so2.id AND so2.is_del = 0"). + Joins("LEFT JOIN sales_order_item soi2 ON ooi.sales_order_id = soi2.sales_order_id AND ooi.product_id = soi2.product_id AND soi2.is_del = 0"). + Where("soi.is_del = 0") + + // 筛选条件 + if req.Status > 0 { + query = query.Where("so.status = ?", req.Status) + } + if req.ShippingNo != "" { + query = query.Where("so.shipping_no LIKE ?", "%"+req.ShippingNo+"%") + } + if req.ShopType > 0 { + query = query.Where("so2.shop_type = ?", req.ShopType) + } + if req.AssociationOrderNo != "" { + query = query.Where("so2.association_order_no LIKE ?", "%"+req.AssociationOrderNo+"%") + } + if req.LogisticsNo != "" { + query = query.Where("soi2.logistics_no LIKE ?", "%"+req.LogisticsNo+"%") + } + if req.WarehouseID > 0 { + query = query.Joins("INNER JOIN location l ON ooi.location_id = l.id AND l.is_del = 0"). + Where("l.warehouse_id = ?", req.WarehouseID) + } + if req.LocationID > 0 { + query = query.Where("ooi.location_id = ?", req.LocationID) + } + + // 统计总数 + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, utils.NewError("查询平铺列表总数失败") + } + + if total == 0 { + return &systemRes.ShippingOrderFlatListResponse{ + List: []systemRes.ShippingOrderFlatItem{}, + Total: 0, + Page: req.Page, + PageSize: req.PageSize, + }, nil + } + + // 查询分页数据 + type flatRow struct { + ItemID int64 `gorm:"column:item_id"` + ShippingOrderID int64 `gorm:"column:shipping_order_id"` + ShippingNo string `gorm:"column:shipping_no"` + Status int8 `gorm:"column:status"` + ProductName string `gorm:"column:product_name"` + ProductCode string `gorm:"column:product_code"` + ReceiverAddress string `gorm:"column:receiver_address"` + SalesOrderNo string `gorm:"column:sales_order_no"` + OutboundOrderNo string `gorm:"column:outbound_order_no"` + AssociationOrderNo string `gorm:"column:association_order_no"` + LogisticsNo string `gorm:"column:logistics_no"` + LogisticsCompany string `gorm:"column:logistics_company"` + SalesOrderCreatedAt int64 `gorm:"column:sales_order_created_at"` + UnitPrice int64 `gorm:"column:unit_price"` + Quantity int64 `gorm:"column:quantity"` + ShopType int8 `gorm:"column:shop_type"` + SalesOrderItemID int64 `gorm:"column:sales_order_item_id"` + ProductID int64 `gorm:"column:product_id"` + } + + var rows []flatRow + offset := (req.Page - 1) * req.PageSize + if err := query.Order("so.created_at DESC, soi.id ASC"). + Offset(offset). + Limit(req.PageSize). + Scan(&rows).Error; err != nil { + return nil, utils.NewError("查询平铺列表失败") + } + + items := make([]systemRes.ShippingOrderFlatItem, 0, len(rows)) + for _, row := range rows { + items = append(items, systemRes.ShippingOrderFlatItem{ + ItemID: row.ItemID, + ShippingOrderID: row.ShippingOrderID, + ShippingNo: row.ShippingNo, + ShopType: row.ShopType, + ShopTypeText: systemRes.GetShopTypeText(row.ShopType), + LogisticsCompany: row.LogisticsCompany, + Status: row.Status, + StatusText: systemRes.GetShippingOrderStatusText(row.Status), + ProductName: row.ProductName, + ProductCode: row.ProductCode, + ReceiverAddress: row.ReceiverAddress, + SalesOrderNo: row.SalesOrderNo, + OutboundOrderNo: row.OutboundOrderNo, + AssociationOrderNo: row.AssociationOrderNo, + LogisticsNo: row.LogisticsNo, + SalesOrderCreatedAt: row.SalesOrderCreatedAt, + UnitPrice: row.UnitPrice, + Quantity: row.Quantity, + SalesOrderItemID: row.SalesOrderItemID, + ProductID: row.ProductID, + }) + } + + return &systemRes.ShippingOrderFlatListResponse{ + List: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +}