无需发货 手动发货 仓库导入导出

This commit is contained in:
97694731 2026-07-02 16:57:43 +08:00
parent 5e638b826d
commit aa9b357f16
16 changed files with 760 additions and 137 deletions

10
.idea/go.imports.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@ -17,6 +17,7 @@ type Config struct {
Log LogConfig `yaml:"log"` Log LogConfig `yaml:"log"`
ES ESConfig `yaml:"es"` ES ESConfig `yaml:"es"`
OCR OCRConfig `yaml:"ocr"` OCR OCRConfig `yaml:"ocr"`
Font FontConfig `yaml:"font"`
ExternalAPI ExternalAPIConfig `yaml:"external_api"` ExternalAPI ExternalAPIConfig `yaml:"external_api"`
Wangdian WangdianConfig `yaml:"wangdian"` Wangdian WangdianConfig `yaml:"wangdian"`
} }
@ -67,6 +68,10 @@ type OCRConfig struct {
ExeUrl string `yaml:"exe_url"` ExeUrl string `yaml:"exe_url"`
} }
type FontConfig struct {
Path string `yaml:"path"`
}
type ExternalAPIConfig struct { type ExternalAPIConfig struct {
SyncProductURL string `yaml:"sync_product_url"` SyncProductURL string `yaml:"sync_product_url"`
SyncTaskURL string `yaml:"sync_task_url"` SyncTaskURL string `yaml:"sync_task_url"`

View File

@ -2,11 +2,15 @@ package controllers
import ( import (
"fmt" "fmt"
"io"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http"
"psi/constant" "psi/constant"
"psi/database" "psi/database"
"psi/models"
systemReq "psi/models/request" systemReq "psi/models/request"
systemRes "psi/models/response" systemRes "psi/models/response"
"psi/service" "psi/service"
@ -17,6 +21,79 @@ type LocationApi struct{}
var locationService = service.LocationService{} 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) { func (r *LocationApi) GetLocationList(c *gin.Context) {
var req systemReq.QueryLocationRequest var req systemReq.QueryLocationRequest
if err := c.ShouldBindQuery(&req); err != nil { if err := c.ShouldBindQuery(&req); err != nil {

View File

@ -864,6 +864,42 @@ func getPostFormInt64(c *gin.Context, key string) (int64, bool, error) {
return val, true, nil 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 业务统一的错误日志和响应函数 // logAndFail 业务统一的错误日志和响应函数
func logAndFail(channel string, source, errMsg string, c *gin.Context) { func logAndFail(channel string, source, errMsg string, c *gin.Context) {
utils.ErrorLog(channel, logrus.Fields{ utils.ErrorLog(channel, logrus.Fields{

View File

@ -77,3 +77,25 @@ func (r *ShippingApi) GetShippingOrderDetailList(c *gin.Context) {
"data": result, "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,
})
}

View File

@ -5,15 +5,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"net/http" "net/http"
"os"
"psi/constant" "psi/constant"
"psi/database" "psi/database"
systemReq "psi/models/request" systemReq "psi/models/request"
systemRes "psi/models/response" systemRes "psi/models/response"
"psi/service" "psi/service"
"psi/utils" "psi/utils"
"strings"
"time"
) )
type WarehouseApi struct{} type WarehouseApi struct{}
@ -137,66 +134,6 @@ func (r *WarehouseApi) DeleteWarehouse(c *gin.Context) {
systemRes.OkWithMessage("删除成功", c) 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 获取用户的仓库映射列表 // GetUserWarehouseMappings 获取用户的仓库映射列表
func (r *WarehouseApi) GetUserWarehouseMappings(c *gin.Context) { func (r *WarehouseApi) GetUserWarehouseMappings(c *gin.Context) {
data, err := warehouseService.GetUserWarehouseMappings() data, err := warehouseService.GetUserWarehouseMappings()

View File

@ -119,7 +119,7 @@ func createTenantDB(dbName string) (*gorm.DB, error) {
sqlDB.SetConnMaxLifetime(10 * time.Minute) // 单个连接最大存活10分钟 sqlDB.SetConnMaxLifetime(10 * time.Minute) // 单个连接最大存活10分钟
sqlDB.SetConnMaxIdleTime(2 * time.Minute) // 空闲连接2分钟后关闭 sqlDB.SetConnMaxIdleTime(2 * time.Minute) // 空闲连接2分钟后关闭
//migrateTenantTables(tenantDB) migrateTenantTables(tenantDB)
return tenantDB, nil return tenantDB, nil
} }

View File

@ -161,3 +161,15 @@ type StockCheckReturnRequest struct {
SalesOrderItemID int64 `form:"sales_order_item_id" binding:"required"` // 销售订单明细ID SalesOrderItemID int64 `form:"sales_order_item_id" binding:"required"` // 销售订单明细ID
Remark string `form:"remark"` // 备注 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"` // 物流单号
}

View File

@ -36,3 +36,16 @@ type GetShippingOrderDetailListRequest struct {
WarehouseID int64 `form:"warehouse_id" json:"warehouse_id"` WarehouseID int64 `form:"warehouse_id" json:"warehouse_id"`
LocationID int64 `form:"location_id" json:"location_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"`
}

View File

@ -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 获取发货单状态文本 // GetShippingOrderStatusText 获取发货单状态文本
func GetShippingOrderStatusText(status int8) string { func GetShippingOrderStatusText(status int8) string {
statusMap := map[int8]string{ statusMap := map[int8]string{

View File

@ -219,6 +219,8 @@ func initRouter() (r *gin.Engine) {
auth.GET("/outbound/detail/:id", processApi.GetOutboundDetail) // 获取出库单详情 auth.GET("/outbound/detail/:id", processApi.GetOutboundDetail) // 获取出库单详情
auth.POST("/shipping-order/create", processApi.CreateShippingOrder) // 创建发货单 auth.POST("/shipping-order/create", processApi.CreateShippingOrder) // 创建发货单
auth.POST("/shipping-order/update", processApi.UpdateShippingLogistics) // 更新发货单物流信息 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/cancel", processApi.CancelSalesOrder) // 取消销售订
//auth.POST("/sales-order/unlock-inventory", processApi.UnlockSalesOrderInventory) // 解锁销售订单库存 //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/list", shippingApi.GetShippingOrderList) // 获取发货单列表
auth.GET("/shipping-order/detail", shippingApi.GetShippingOrderDetail) // 获取发货单详情 auth.GET("/shipping-order/detail", shippingApi.GetShippingOrderDetail) // 获取发货单详情
auth.GET("/shipping-order/detaillist", shippingApi.GetShippingOrderDetailList) // 获取发货单详情列表(按状态) 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/list", waveApi.GetWaveTaskList) // 获取波次任务列表
auth.GET("/wave/task/detail", waveApi.GetWaveTaskDetail) // 获取波次任务详情 auth.GET("/wave/task/detail", waveApi.GetWaveTaskDetail) // 获取波次任务详情

View File

@ -9,6 +9,7 @@ import (
"image/draw" "image/draw"
"image/png" "image/png"
"os" "os"
"path/filepath"
systemRes "psi/models/response" systemRes "psi/models/response"
"strings" "strings"
@ -35,11 +36,10 @@ type Config struct {
// GenerateBarcode 根据货号生成条形码图片(Base64) // GenerateBarcode 根据货号生成条形码图片(Base64)
func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeResponse, error) { func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeResponse, error) {
// 配置参数 // 配置参数
config := &Config{ cfg := &Config{
BarcodeHeight: 120, BarcodeHeight: 120,
ModuleWidth: 2, ModuleWidth: 2,
Padding: 20, Padding: 20,
FontPath: "fonts/youaimoshouheiti-regular.ttf",
FontSize: 50, FontSize: 50,
BgColor: color.White, BgColor: color.White,
BarColor: color.Black, BarColor: color.Black,
@ -47,11 +47,11 @@ func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeRespo
// 若内容包含isbn则使用较小字号且不加粗 // 若内容包含isbn则使用较小字号且不加粗
if strings.Contains(strings.ToLower(content), "9787") { if strings.Contains(strings.ToLower(content), "9787") {
config.FontSize = 35 cfg.FontSize = 35
} }
// 生成条形码图片的Base64 // 生成条形码图片的Base64
base64Image, err := generateBase64Barcode(content, config) base64Image, err := generateBase64Barcode(content, cfg)
if err != nil { if err != nil {
return systemRes.BarcodeResponse{}, fmt.Errorf("生成条形码失败: %w", err) return systemRes.BarcodeResponse{}, fmt.Errorf("生成条形码失败: %w", err)
} }
@ -62,9 +62,9 @@ func (s *BarcodeService) GenerateBarcode(content string) (systemRes.BarcodeRespo
}, nil }, nil
} }
func generateBase64Barcode(content string, config *Config) (string, error) { func generateBase64Barcode(content string, cfg *Config) (string, error) {
// 1. 生成条形码图像 // 1. 生成条形码图像
barcodeImg, err := generateBarcodeImage(content, config) barcodeImg, err := generateBarcodeImage(content, cfg)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -73,42 +73,46 @@ func generateBase64Barcode(content string, config *Config) (string, error) {
barcodeWidth := barcodeBounds.Dx() barcodeWidth := barcodeBounds.Dx()
barcodeHeight := barcodeBounds.Dy() barcodeHeight := barcodeBounds.Dy()
// 2. 加载字体 // 2. 尝试加载字体(自定义字体 -> 系统字体 -> 跳过文字)
face, err := loadFont(config) face, fontErr := loadFont(cfg.FontSize)
if err != nil { var textHeight, ascent, descent int
return "", err var textWidth int
} if fontErr == nil && face != nil {
defer face.Close() defer face.Close()
// 3. 获取文字度量信息 // 3. 获取文字度量信息
metrics := face.Metrics() metrics := face.Metrics()
ascent := metrics.Ascent.Ceil() ascent = metrics.Ascent.Ceil()
descent := metrics.Descent.Ceil() descent = metrics.Descent.Ceil()
textHeight := ascent + descent textHeight = ascent + descent
// 计算文字宽度 // 计算文字宽度
drawer := &font.Drawer{Face: face} drawer := &font.Drawer{Face: face}
advance := drawer.MeasureString(content) advance := drawer.MeasureString(content)
textWidth := advance.Ceil() textWidth = advance.Ceil()
}
// 4. 计算最终图片尺寸 // 4. 计算最终图片尺寸
gapBetween := 12 gapBetween := 12
textBottomPadding := 12 textBottomPadding := 12
finalWidth := barcodeWidth + cfg.Padding*2
finalHeight := barcodeHeight + cfg.Padding*2
if face != nil {
maxContentWidth := barcodeWidth maxContentWidth := barcodeWidth
if textWidth > maxContentWidth { if textWidth > maxContentWidth {
maxContentWidth = textWidth maxContentWidth = textWidth
} }
finalWidth := maxContentWidth + config.Padding*2 finalWidth = maxContentWidth + cfg.Padding*2
finalHeight := barcodeHeight + gapBetween + textHeight + config.Padding*2 + textBottomPadding finalHeight = barcodeHeight + gapBetween + textHeight + cfg.Padding*2 + textBottomPadding
}
// 5. 创建最终图片 // 5. 创建最终图片
finalImg := image.NewRGBA(image.Rect(0, 0, finalWidth, finalHeight)) 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. 绘制条形码(居中) // 6. 绘制条形码(居中)
barcodeStartX := (finalWidth - barcodeWidth) / 2 barcodeStartX := (finalWidth - barcodeWidth) / 2
barcodeStartY := config.Padding barcodeStartY := cfg.Padding
draw.Draw(finalImg, draw.Draw(finalImg,
image.Rect(barcodeStartX, barcodeStartY, barcodeStartX+barcodeWidth, barcodeStartY+barcodeHeight), image.Rect(barcodeStartX, barcodeStartY, barcodeStartX+barcodeWidth, barcodeStartY+barcodeHeight),
barcodeImg, barcodeImg,
@ -116,18 +120,19 @@ func generateBase64Barcode(content string, config *Config) (string, error) {
draw.Over) draw.Over)
// 7. 绘制货号文字(居中,加粗字体) // 7. 绘制货号文字(居中,加粗字体)
if face != nil {
textAreaTop := barcodeStartY + barcodeHeight + gapBetween textAreaTop := barcodeStartY + barcodeHeight + gapBetween
textAreaHeight := textHeight + textBottomPadding textAreaHeight := textHeight + textBottomPadding
baselineY := textAreaTop + (textAreaHeight / 2) + (ascent / 2) - 2 baselineY := textAreaTop + (textAreaHeight / 2) + (ascent / 2) - 2
textX := (finalWidth - textWidth) / 2 textX := (finalWidth - textWidth) / 2
if textX < 0 { if textX < 0 {
textX = config.Padding textX = cfg.Padding
} }
textDrawer := &font.Drawer{ textDrawer := &font.Drawer{
Dst: finalImg, Dst: finalImg,
Src: image.NewUniform(config.BarColor), Src: image.NewUniform(cfg.BarColor),
Face: face, Face: face,
Dot: fixed.Point26_6{ Dot: fixed.Point26_6{
X: fixed.I(textX), X: fixed.I(textX),
@ -135,6 +140,7 @@ func generateBase64Barcode(content string, config *Config) (string, error) {
}, },
} }
textDrawer.DrawString(content) textDrawer.DrawString(content)
}
// 8. 转换为Base64 // 8. 转换为Base64
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
@ -161,25 +167,45 @@ func generateBarcodeImage(content string, config *Config) (image.Image, error) {
return barcode.Scale(code128Barcode, targetWidth, targetHeight) return barcode.Scale(code128Barcode, targetWidth, targetHeight)
} }
// loadFont 加载加粗TrueType字体 // loadFont 尝试加载字体,按优先级:项目字体目录 -> Windows系统字体 -> Linux系统字体
func loadFont(config *Config) (font.Face, error) { // 如果都失败,返回 nil调用方会跳过文字绘制
fontBytes, err := os.ReadFile(config.FontPath) func loadFont(fontSize float64) (font.Face, error) {
if err != nil { // 按优先级尝试的字体路径列表
return nil, fmt.Errorf("读取字体文件失败: %w", err) 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
} }
// 如果是相对路径,尝试基于可执行文件目录解析
exePath, exeErr := os.Executable()
for i, p := range fontPaths {
if !filepath.IsAbs(p) && exeErr == nil {
fontPaths[i] = filepath.Join(filepath.Dir(exePath), p)
}
}
for _, fontPath := range fontPaths {
fontBytes, err := os.ReadFile(fontPath)
if err != nil {
continue
}
parsedFont, err := opentype.Parse(fontBytes) parsedFont, err := opentype.Parse(fontBytes)
if err != nil { if err != nil {
return nil, fmt.Errorf("解析字体失败: %w", err) continue
} }
face, err := opentype.NewFace(parsedFont, &opentype.FaceOptions{ face, err := opentype.NewFace(parsedFont, &opentype.FaceOptions{
Size: config.FontSize, Size: fontSize,
DPI: 96, DPI: 96,
Hinting: font.HintingFull, Hinting: font.HintingFull,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("创建字体失败: %w", err) continue
} }
return face, nil return face, nil
}
return nil, nil // 所有字体都加载失败,返回 nil不绘制文字
} }

View File

@ -3,17 +3,22 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gorm.io/datatypes"
"gorm.io/gorm"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/xuri/excelize/v2"
"gorm.io/datatypes"
"gorm.io/gorm"
"psi/constant"
"psi/database" "psi/database"
"psi/models" "psi/models"
systemReq "psi/models/request" systemReq "psi/models/request"
systemRes "psi/models/response" systemRes "psi/models/response"
"psi/utils"
) )
type LocationService struct{} type LocationService struct{}
@ -1125,3 +1130,143 @@ func numToChar(n int) byte {
} }
return 0 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)
}

View File

@ -77,7 +77,7 @@ func getCell(row []string, idx int) string {
return "" return ""
} }
// ImportLocationsFromExcel 从Excel导入库位事务内批量创建 // ImportLocationsFromExcel 从Excel导入库位覆盖模式:事务内先删后插,保证原子性
func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int64, rows []request.LocationImportRow) (*LocationImportResult, error) { func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int64, rows []request.LocationImportRow) (*LocationImportResult, error) {
databaseConn, err := database.GetTenantDB(userID) databaseConn, err := database.GetTenantDB(userID)
if err != nil { if err != nil {
@ -89,6 +89,13 @@ func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int
err = databaseConn.Transaction(func(tx *gorm.DB) error { err = databaseConn.Transaction(func(tx *gorm.DB) error {
now := time.Now().Unix() 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 { for _, row := range rows {
code := strings.TrimSpace(row.Code) code := strings.TrimSpace(row.Code)
if code == "" { if code == "" {
@ -96,19 +103,13 @@ func (s *LocationImportService) ImportLocationsFromExcel(userID, warehouseID int
continue continue
} }
// 检查同仓库下是否已存在 rowIndex++
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
}
loc := models.Location{ loc := models.Location{
WarehouseID: warehouseID, WarehouseID: warehouseID,
Code: code, Code: code,
Type: 1, // 存储库位 Type: 1, // 存储库位
Capacity: 255, Capacity: 255,
Sort: 0, Sort: rowIndex,
Status: 1, // 启用 Status: 1, // 启用
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,

View File

@ -4708,3 +4708,164 @@ func parseImageList(liveImage datatypes.JSON) []string {
// 都不成功,返回空数组 // 都不成功,返回空数组
return []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
})
}

View File

@ -538,3 +538,146 @@ func (s *ShippingService) GetShippingOrderDetailList(req systemReq.GetShippingOr
PageSize: req.PageSize, PageSize: req.PageSize,
}, nil }, 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
}