对接旺店通采购单接口

This commit is contained in:
97694732@qq.com 2026-06-23 14:26:46 +08:00
parent 4253c6ade0
commit aa22953f2f
7 changed files with 330 additions and 16 deletions

View File

@ -56,6 +56,15 @@ external_api:
es_update_book_url: "https://book.center.yushutx.com/api/es/updateBookFieldsByISBN" es_update_book_url: "https://book.center.yushutx.com/api/es/updateBookFieldsByISBN"
# sync_task_url: "http://192.168.101.156:8080/task/create" # sync_task_url: "http://192.168.101.156:8080/task/create"
sync_task_url: "http://36.212.7.246:8283/task/create" sync_task_url: "http://36.212.7.246:8283/task/create"
# sync_task_body_url: "http://192.168.101.156:8080./task/setTaskBody" # sync_task_body_url: "http://192.168.101.156:8080/task/setTaskBody"
sync_task_body_url: "http://36.212.7.246:8283/task/setTaskBody" sync_task_body_url: "http://36.212.7.246:8283/task/setTaskBody"
timeout: 30 timeout: 30
wangdian:
url: "https://api.wangdian.cn/openapi2/purchase_order_push.php"
sandbox_url: "https://sandbox.wangdian.cn/openapi2/purchase_order_push.php"
sandbox: true
sid: "apidevnew2"
appkey: "skxz2-test"
appsecret: "85bf423bb"
timeout: 30

View File

@ -18,6 +18,7 @@ type Config struct {
ES ESConfig `yaml:"es"` ES ESConfig `yaml:"es"`
OCR OCRConfig `yaml:"ocr"` OCR OCRConfig `yaml:"ocr"`
ExternalAPI ExternalAPIConfig `yaml:"external_api"` ExternalAPI ExternalAPIConfig `yaml:"external_api"`
Wangdian WangdianConfig `yaml:"wangdian"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -74,6 +75,16 @@ type ExternalAPIConfig struct {
Timeout int `yaml:"timeout"` Timeout int `yaml:"timeout"`
} }
type WangdianConfig struct {
URL string `yaml:"url"`
SandboxURL string `yaml:"sandbox_url"`
Sandbox bool `yaml:"sandbox"`
Sid string `yaml:"sid"`
AppKey string `yaml:"appkey"`
AppSecret string `yaml:"appsecret"`
Timeout int `yaml:"timeout"`
}
var AppConfig *Config var AppConfig *Config
func Init() { func Init() {

37
controllers/wangdian.go Normal file
View File

@ -0,0 +1,37 @@
package controllers
import (
"net/http"
"psi/constant"
"psi/database"
"psi/service"
"psi/utils"
"strconv"
"github.com/gin-gonic/gin"
)
type WangdianApi struct{}
func (i *WangdianApi) CreatePurchaseOrder(c *gin.Context) {
raw := c.PostForm("purchase_order_id")
if raw == "" {
raw = c.Query("purchase_order_id")
}
purchaseOrderID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || purchaseOrderID <= 0 {
utils.FailWithRequestLog(constant.LoggerChannelRequest, "参数错误: purchase_order_id 必须为正整数", nil, c, nil)
return
}
resp, err := service.PushPurchaseOrder(purchaseOrderID, database.GetDB(c))
if err != nil {
utils.FailWithRequestLog(constant.LoggerChannelWork, "推送采购单到旺店通失败: "+err.Error(), err, c, nil)
return
}
c.JSON(http.StatusOK, gin.H{
"code": resp.Code,
"message": resp.Message,
})
}

View File

@ -1,15 +1,16 @@
package database package database
import ( import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log" "log"
"os" "os"
"psi/config" "psi/config"
"psi/models" "psi/models"
"psi/utils" "psi/utils"
"time" "time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var DB *gorm.DB var DB *gorm.DB

View File

@ -2,11 +2,12 @@ package router
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"psi/config" "psi/config"
"psi/controllers" "psi/controllers"
"psi/middleware" "psi/middleware"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
) )
var employeeApi = &controllers.EmployeeApi{} var employeeApi = &controllers.EmployeeApi{}
@ -39,6 +40,7 @@ var splitAccountConfigApi = &controllers.SplitAccountConfigApi{}
var splitAccountDeductionLogApi = &controllers.SplitAccountDeductionLogApi{} var splitAccountDeductionLogApi = &controllers.SplitAccountDeductionLogApi{}
var configApi = &controllers.ConfigApi{} var configApi = &controllers.ConfigApi{}
var cancelLogisticsApi = &controllers.CancelLogisticsApi{} var cancelLogisticsApi = &controllers.CancelLogisticsApi{}
var wangdianApi = &controllers.WangdianApi{}
func Run() { func Run() {
defer func() { defer func() {
@ -105,6 +107,13 @@ func initRouter() (r *gin.Engine) {
sign.POST("/logistics/cancel", cancelLogisticsApi.CancelLogistics) // 取消物流单号 sign.POST("/logistics/cancel", cancelLogisticsApi.CancelLogistics) // 取消物流单号
} }
// 需要认证(JWT)但不签名验证的接口
authOnly := api.Group("")
authOnly.Use(middleware.JWTAuth())
{
authOnly.POST("/wangdian/purchase-order-push", wangdianApi.CreatePurchaseOrder) // 推送采购单到旺店通
}
// 需要认证的接口 // 需要认证的接口
auth := api.Group("") auth := api.Group("")
auth.Use(middleware.APISign()) auth.Use(middleware.APISign())

187
service/wangdian.go Normal file
View File

@ -0,0 +1,187 @@
package service
import (
"encoding/json"
"fmt"
"log"
"psi/config"
"psi/database"
"psi/models"
"psi/utils"
"strconv"
"time"
"gorm.io/gorm"
)
type WangdianPurchaseInfo struct {
ProviderNo string `json:"provider_no"`
WarehouseNo string `json:"warehouse_no"`
OuterNo string `json:"outer_no"`
IsUseOuterNo int8 `json:"is_use_outer_no,omitempty"`
IsCheck int8 `json:"is_check,omitempty"`
Contact string `json:"contact,omitempty"`
PurchaseName string `json:"purchase_name,omitempty"`
Telno string `json:"telno,omitempty"`
ReceiveAddress string `json:"receive_address,omitempty"`
LogisticsType int8 `json:"logistics_type,omitempty"` // 暂未使用 运货方式(物流公司)
ExpectArriveTime string `json:"expect_arrive_time,omitempty"`
Remark string `json:"remark,omitempty"`
OtherFee float64 `json:"other_fee,omitempty"` // 暂未使用 其他费用
PostFee float64 `json:"post_fee,omitempty"` // 暂未使用 邮资
Prop1 string `json:"prop1,omitempty"` // 暂未使用 自定义属性1
Prop2 string `json:"prop2,omitempty"` // 暂未使用 自定义属性2
DetailsList []WangdianPurchaseDetailItem `json:"details_list"`
}
type WangdianPurchaseDetailItem struct {
SpecNo string `json:"spec_no"`
Num float64 `json:"num"`
Price float64 `json:"price"`
Discount float64 `json:"discount,omitempty"`
Tax float64 `json:"tax,omitempty"` //暂未使用 税率
TaxPrice float64 `json:"tax_price,omitempty"` //暂未使用 税后单价
TaxAmount float64 `json:"tax_amount,omitempty"` //暂未使用 税后金额
Remark string `json:"remark,omitempty"`
Prop1 string `json:"prop1,omitempty"` // 暂未使用 自定义属性1
Prop2 string `json:"prop2,omitempty"` // 暂未使用 自定义属性2
}
type WangdianPurchasePushResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
func PushPurchaseOrder(purchaseOrderID int64, db ...*gorm.DB) (*WangdianPurchasePushResponse, error) {
databaseConn := database.OptionalDB(db...)
dbName := databaseConn.Migrator().CurrentDatabase()
log.Printf("[旺店通推送] 使用数据库: %s", dbName)
// 1. 查询采购单
var po models.PurchaseOrder
if err := databaseConn.Where("id = ? AND is_del = 0", purchaseOrderID).First(&po).Error; err != nil {
return nil, fmt.Errorf("采购单不存在: %v", err)
}
// 2. 查询供应商编号(必须)
var supplier models.Supplier
if err := databaseConn.Select("code, contact_person, contact_phone, address").
Where("id = ? AND is_del = 0", po.SupplierID).First(&supplier).Error; err != nil {
return nil, fmt.Errorf("获取供应商信息失败: %v", err)
}
if supplier.Code == "" {
return nil, fmt.Errorf("供应商编码为空,请先维护供应商档案")
}
// 3. 查询仓库编号(必须)
var warehouse models.Warehouse
if err := databaseConn.Select("code, contact_person, contact_phone, address").
Where("id = ? AND is_del = 0", po.WarehouseID).First(&warehouse).Error; err != nil {
return nil, fmt.Errorf("获取仓库信息失败: %v", err)
}
if warehouse.Code == "" {
return nil, fmt.Errorf("仓库编码为空,请先维护仓库档案")
}
// 4. 查询采购明细,关联商品条码
type itemRow struct {
models.PurchaseOrderItem
Barcode string `gorm:"column:barcode"`
}
var items []itemRow
if err := databaseConn.Table("purchase_order_item").
Select("purchase_order_item.*, product.barcode").
Joins("LEFT JOIN product ON purchase_order_item.product_id = product.id AND product.is_del = 0").
Where("purchase_order_item.purchase_order_id = ? AND purchase_order_item.is_del = 0", purchaseOrderID).
Debug().Scan(&items).Error; err != nil {
return nil, fmt.Errorf("获取采购明细失败: %v", err)
}
if len(items) == 0 {
return nil, fmt.Errorf("采购明细为空,无法推送")
}
// 5. 组装 details_list
detailList := make([]WangdianPurchaseDetailItem, 0, len(items))
for _, it := range items {
specNo := it.Barcode
if specNo == "" {
specNo = strconv.FormatInt(it.ProductID, 10)
}
detailList = append(detailList, WangdianPurchaseDetailItem{
SpecNo: specNo,
Num: float64(it.Quantity),
Price: float64(it.UnitPrice) / 100.0,
Discount: 1.0,
})
}
// 6. 组装 purchase_info
info := WangdianPurchaseInfo{
ProviderNo: supplier.Code,
WarehouseNo: warehouse.Code,
OuterNo: po.PoNo,
IsUseOuterNo: 0,
IsCheck: 0,
Contact: warehouse.ContactPerson,
PurchaseName: warehouse.ContactPerson,
Telno: warehouse.ContactPhone,
ReceiveAddress: warehouse.Address,
Remark: po.Remark,
DetailsList: detailList,
}
if po.ExpectedArrivalDate > 0 {
info.ExpectArriveTime = time.Unix(po.ExpectedArrivalDate, 0).Format("2006-01-02 15:04:05")
}
infoBytes, err := json.Marshal(info)
if err != nil {
return nil, fmt.Errorf("序列化采购信息失败: %v", err)
}
// 7. 校验配置
cfg := config.AppConfig.Wangdian
if cfg.Sid == "" || cfg.AppKey == "" || cfg.AppSecret == "" {
return nil, fmt.Errorf("旺店通接口配置不完整(sid/appkey/appsecret)")
}
// 8. 组装公共请求参数 + 计算签名
timestamp := time.Now().Unix()
params := map[string]string{
"sid": cfg.Sid,
"appkey": cfg.AppKey,
"timestamp": strconv.FormatInt(timestamp, 10),
"purchase_info": string(infoBytes),
}
params["sign"] = utils.WangdianSign(params, cfg.AppSecret)
// 9. 提交请求
url := cfg.URL
if cfg.Sandbox && cfg.SandboxURL != "" {
url = cfg.SandboxURL
}
if url == "" {
url = "https://api.wangdian.cn/openapi2/purchase_order_push.php"
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30
}
log.Printf("[旺店通推送] 请求URL: %s (sandbox=%v, form-urlencoded), 参数: sid=%s appkey=%s timestamp=%s sign=%s", url, cfg.Sandbox, cfg.Sid, cfg.AppKey, params["timestamp"], params["sign"])
log.Printf("[旺店通推送] purchase_info: %s", string(infoBytes))
respBody, err := utils.SubmitFormDataWithTimeout(url, params, timeout)
if err != nil {
return nil, fmt.Errorf("推送请求失败: %v", err)
}
log.Printf("[旺店通推送] 返回信息: %s", string(respBody))
var resp WangdianPurchasePushResponse
if err := json.Unmarshal([]byte(respBody), &resp); err != nil {
return nil, fmt.Errorf("解析响应失败(raw=%s): %v", respBody, err)
}
return &resp, nil
}

View File

@ -7,12 +7,15 @@ import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"io" "io"
"log"
"math/rand" "math/rand"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strings" "strings"
"time" "time"
"unicode/utf8"
) )
// GenerateEmployeeID 生成工号 (5位数字不足补零) // GenerateEmployeeID 生成工号 (5位数字不足补零)
@ -242,31 +245,23 @@ func SubmitMultiBody(url string, taskID string, bodyList []string, sign string)
// @param params 表单数据 // @param params 表单数据
// @return error 错误信息 // @return error 错误信息
func SubmitFormData(url string, params map[string]string) (string, error) { func SubmitFormData(url string, params map[string]string) (string, error) {
// 创建multipart writer
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
// 添加文本字段
for key, value := range params { for key, value := range params {
err := writer.WriteField(key, value) err := writer.WriteField(key, value)
if err != nil { if err != nil {
return "", fmt.Errorf("write field error: %v", err) return "", fmt.Errorf("write field error: %v", err)
} }
} }
// 关闭writer
writer.Close() writer.Close()
// 创建请求
req, err := http.NewRequest("POST", url, body) req, err := http.NewRequest("POST", url, body)
if err != nil { if err != nil {
return "", fmt.Errorf("create request error: %v", err) return "", fmt.Errorf("create request error: %v", err)
} }
// 设置Content-Type
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
// 发送请求
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
@ -274,11 +269,76 @@ func SubmitFormData(url string, params map[string]string) (string, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("read response error: %v", err) return "", fmt.Errorf("read response error: %v", err)
} }
return string(respBody), nil return string(respBody), nil
} }
// SubmitFormDataWithTimeout 提交表单数据(application/x-www-form-urlencoded)
func SubmitFormDataWithTimeout(requestURL string, params map[string]string, timeoutSeconds int) (string, error) {
form := url.Values{}
for key, value := range params {
form.Set(key, value)
}
log.Printf("[旺店通推送] 发送form-urlencoded:\n%s", form.Encode())
req, err := http.NewRequest("POST", requestURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("create request error: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
timeout := time.Duration(timeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("send request error: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response error: %v", err)
}
return string(respBody), nil
}
// WangdianSign 旺店通接口签名算法
// 与PHP SDK保持一致使用UTF-8字符长度(iconv_strlen)而非字节长度
func WangdianSign(params map[string]string, appSecret string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var builder strings.Builder
for idx, key := range keys {
value := params[key]
keyLen := utf8.RuneCountInString(key)
valLen := utf8.RuneCountInString(value)
builder.WriteString(fmt.Sprintf("%02d", keyLen))
builder.WriteString("-")
builder.WriteString(key)
builder.WriteString(":")
builder.WriteString(fmt.Sprintf("%04d", valLen))
builder.WriteString("-")
builder.WriteString(value)
if idx < len(keys)-1 {
builder.WriteString(";")
}
}
builder.WriteString(appSecret)
hash := md5.Sum([]byte(builder.String()))
return hex.EncodeToString(hash[:])
}