无需发货 手动发货 仓库导入导出
This commit is contained in:
parent
5e638b826d
commit
aa9b357f16
10
.idea/go.imports.xml
generated
Normal file
10
.idea/go.imports.xml
generated
Normal 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>
|
||||||
@ -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"`
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"` // 物流单号
|
||||||
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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) // 获取波次任务详情
|
||||||
|
|||||||
@ -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(不绘制文字)
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user