feat: 商品详情页、XLS导入修复、分页选择器、导出功能
后端: - 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%) - products 表新增 public_id(UUID)、description 字段 - 新增商品详情接口、二维码接口、公开商品接口(无鉴权) - 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls - 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug) - 所有导入接口返回 total/imported/skipped 统计 - config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖 - 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正 前端: - 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制 - 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA - 商品详情列表(批次追踪)商品名超链接跳转详情页 - 导航「商品管理」改名「商品详情」 - 所有列表表格新增每页条数选择(10/20/50/100) - 表格列头内嵌筛选(FilterableColumnHeader) - 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位) - 网络恢复自动刷新 + 离线缓存展示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -11,11 +12,13 @@ type Config struct {
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
License LicenseConfig
|
||||
Storage StorageConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug | release
|
||||
Port string `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug | release
|
||||
CORSOrigin string `mapstructure:"cors_origin"` // 允许的 CORS 来源,生产设为具体域名
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -32,6 +35,11 @@ type LicenseConfig struct {
|
||||
HMACSecret string `mapstructure:"hmac_secret"` // 许可证签名密钥
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
UploadDir string `mapstructure:"upload_dir"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
}
|
||||
|
||||
var C Config
|
||||
|
||||
func Load() {
|
||||
@@ -41,13 +49,24 @@ func Load() {
|
||||
viper.AddConfigPath("./config")
|
||||
|
||||
// 环境变量覆盖(生产部署时使用)
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// 显式绑定没有默认值的 key,确保 AutomaticEnv 能找到对应 env var
|
||||
_ = viper.BindEnv("database.dsn", "DATABASE_DSN")
|
||||
_ = viper.BindEnv("jwt.secret", "JWT_SECRET")
|
||||
_ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET")
|
||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||
|
||||
// 默认值
|
||||
viper.SetDefault("server.port", "8080")
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.cors_origin", "*")
|
||||
viper.SetDefault("jwt.access_expire_min", 60)
|
||||
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Println("[config] no config file found, using defaults and env vars")
|
||||
|
||||
@@ -13,3 +13,7 @@ jwt:
|
||||
|
||||
license:
|
||||
hmac_secret: "change-this-license-secret-in-production"
|
||||
|
||||
storage:
|
||||
upload_dir: "./uploads/images"
|
||||
base_url: "http://localhost:8080/images"
|
||||
|
||||
@@ -21,6 +21,9 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/extrame/xls v0.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
@@ -31,6 +34,7 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -47,6 +51,7 @@ require (
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -60,6 +65,7 @@ require (
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
@@ -11,6 +11,12 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
|
||||
github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k=
|
||||
github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -42,6 +48,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -81,6 +89,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -126,6 +136,7 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
@@ -133,6 +144,7 @@ golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/extrame/xls"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gorm.io/gorm"
|
||||
@@ -92,60 +98,513 @@ func (h *ImportHandler) ImportProducts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ImportPartners POST /api/v1/import/partners
|
||||
// 列顺序:名称,类型(supplier/customer),联系人,电话,地址,备注
|
||||
// 列顺序(来往单位.xls):编号,类型,状态,名称,电话,卡号,初始金额,单位,地址,...,备注
|
||||
func (h *ImportHandler) ImportPartners(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xl, err := excelize.OpenReader(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := xl.GetRows(xl.GetSheetName(0))
|
||||
if err != nil || len(rows) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "empty sheet"})
|
||||
return
|
||||
}
|
||||
|
||||
var partners []model.Partner
|
||||
total, imported, skipped := 0, 0, 0
|
||||
for _, row := range rows[1:] {
|
||||
if len(row) < 1 || strings.TrimSpace(row[0]) == "" {
|
||||
name := cell(row, 3)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
t := cell(row, 1)
|
||||
if t == "" {
|
||||
t = "supplier"
|
||||
total++
|
||||
var existing model.Partner
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
partners = append(partners, model.Partner{
|
||||
balance, _ := strconv.ParseFloat(cell(row, 6), 64)
|
||||
status := "enabled"
|
||||
if cell(row, 2) == "禁用" {
|
||||
status = "disabled"
|
||||
}
|
||||
p := model.Partner{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: cell(row, 0),
|
||||
Type: parsePartnerType(cell(row, 1)),
|
||||
Status: status,
|
||||
Name: name,
|
||||
Phone: cell(row, 4),
|
||||
BankAccount: cell(row, 5),
|
||||
Balance: balance,
|
||||
Address: cell(row, 8),
|
||||
Remark: cell(row, 11),
|
||||
}
|
||||
if h.db.Create(&p).Error == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// ImportProductNames POST /api/v1/import/product-names
|
||||
// 列顺序(商品名称.xls):选项编号,选项名称,备注
|
||||
func (h *ImportHandler) ImportProductNames(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, imported, skipped := 0, 0, 0
|
||||
for _, row := range rows[1:] {
|
||||
name := cell(row, 1)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
var existing model.ProductNameOption
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
opt := model.ProductNameOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Name: cell(row, 0),
|
||||
Type: t,
|
||||
Contact: cell(row, 2),
|
||||
Phone: cell(row, 3),
|
||||
Address: cell(row, 4),
|
||||
Remark: cell(row, 5),
|
||||
Code: cell(row, 0),
|
||||
Name: name,
|
||||
Remark: cell(row, 2),
|
||||
}
|
||||
if h.db.Create(&opt).Error == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// ImportProductSeries POST /api/v1/import/product-series
|
||||
// 列顺序(商品系列.xls):选项编号,选项名称,备注
|
||||
func (h *ImportHandler) ImportProductSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, imported, skipped := 0, 0, 0
|
||||
for _, row := range rows[1:] {
|
||||
name := cell(row, 1)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
var existing model.ProductSeriesOption
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
opt := model.ProductSeriesOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: cell(row, 0),
|
||||
Name: name,
|
||||
Remark: cell(row, 2),
|
||||
}
|
||||
if h.db.Create(&opt).Error == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// ImportProductSpecs POST /api/v1/import/product-specs
|
||||
// 列顺序(商品规格.xls):选项编号,选项名称,单品数量,备注
|
||||
func (h *ImportHandler) ImportProductSpecs(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, imported, skipped := 0, 0, 0
|
||||
for _, row := range rows[1:] {
|
||||
name := cell(row, 1)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
var existing model.ProductSpecOption
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&existing).Error == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
qty, _ := strconv.Atoi(strings.TrimSuffix(cell(row, 2), ".0")) // "12" 或 "12.0"
|
||||
if qty == 0 {
|
||||
qtyF, _ := strconv.ParseFloat(cell(row, 2), 64)
|
||||
qty = int(qtyF)
|
||||
}
|
||||
opt := model.ProductSpecOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: cell(row, 0),
|
||||
Name: name,
|
||||
Quantity: qty,
|
||||
Remark: cell(row, 3),
|
||||
}
|
||||
if h.db.Create(&opt).Error == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// ImportStockIn POST /api/v1/import/stock-in
|
||||
// 支持老系统打印格式(每文件一张入库单)
|
||||
func (h *ImportHandler) ImportStockIn(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(rows) < 7 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析单据头
|
||||
partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:")
|
||||
dateStr := strings.TrimPrefix(cell(rows[3], 7), "单据日期:")
|
||||
orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.")
|
||||
|
||||
if orderNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查重复
|
||||
var existing model.StockInOrder
|
||||
if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析日期
|
||||
orderDate := parseDate(dateStr)
|
||||
|
||||
// 往来单位
|
||||
partnerID := findOrCreatePartner(h.db, shopID, partnerName, "supplier")
|
||||
|
||||
// 默认仓库
|
||||
var wh model.Warehouse
|
||||
if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析明细
|
||||
var items []model.StockInItem
|
||||
var totalAmount float64
|
||||
for _, row := range rows[6:] {
|
||||
if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") {
|
||||
break
|
||||
}
|
||||
productName := cell(row, 1)
|
||||
if productName == "" {
|
||||
continue
|
||||
}
|
||||
series := cell(row, 5)
|
||||
spec := cell(row, 6)
|
||||
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
|
||||
price, _ := strconv.ParseFloat(cell(row, 11), 64)
|
||||
batchNo := cell(row, 16)
|
||||
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total := qty * price
|
||||
totalAmount += total
|
||||
items = append(items, model.StockInItem{
|
||||
ShopID: shopID,
|
||||
ProductID: prod.ID,
|
||||
Quantity: qty,
|
||||
UnitPrice: price,
|
||||
TotalPrice: total,
|
||||
BatchNo: batchNo,
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.db.CreateInBatches(&partners, 100).Error; err != nil {
|
||||
if len(items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"})
|
||||
return
|
||||
}
|
||||
|
||||
order := model.StockInOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: "purchase",
|
||||
WarehouseID: wh.ID,
|
||||
PartnerID: partnerID,
|
||||
OperatorID: userID,
|
||||
Status: "draft",
|
||||
OrderDate: orderDate,
|
||||
TotalAmount: totalAmount,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"imported": len(partners)})
|
||||
for i := range items {
|
||||
items[i].OrderID = order.ID
|
||||
}
|
||||
if err := h.db.CreateInBatches(&items, 50).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)})
|
||||
}
|
||||
|
||||
// ImportStockOut POST /api/v1/import/stock-out
|
||||
// 支持老系统打印格式(每文件一张出库单)
|
||||
func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(rows) < 7 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件行数不足,请检查格式"})
|
||||
return
|
||||
}
|
||||
|
||||
// 出库单日期在 col8(比入库单多一个空列)
|
||||
partnerName := strings.TrimPrefix(cell(rows[3], 0), "来往单位名称:")
|
||||
dateStr := strings.TrimPrefix(cell(rows[3], 8), "单据日期:")
|
||||
orderNo := strings.TrimPrefix(cell(rows[3], 18), "NO.")
|
||||
|
||||
if orderNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到单据号,请检查文件格式"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing model.StockOutOrder
|
||||
if h.db.Where("shop_id = ? AND order_no = ?", shopID, orderNo).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "skipped": true, "message": "单据已存在,已跳过"})
|
||||
return
|
||||
}
|
||||
|
||||
orderDate := parseDate(dateStr)
|
||||
partnerID := findOrCreatePartner(h.db, shopID, partnerName, "customer")
|
||||
|
||||
var wh model.Warehouse
|
||||
if h.db.Where("shop_id = ? AND is_default = 1", shopID).First(&wh).Error != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统设置中设置默认仓库"})
|
||||
return
|
||||
}
|
||||
|
||||
var items []model.StockOutItem
|
||||
var totalAmount float64
|
||||
for _, row := range rows[6:] {
|
||||
if cell(row, 0) == "" || strings.HasPrefix(cell(row, 0), "单据总计") {
|
||||
break
|
||||
}
|
||||
productName := cell(row, 1)
|
||||
if productName == "" {
|
||||
continue
|
||||
}
|
||||
series := cell(row, 5)
|
||||
spec := cell(row, 6)
|
||||
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
|
||||
price, _ := strconv.ParseFloat(cell(row, 11), 64)
|
||||
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total := qty * price
|
||||
totalAmount += total
|
||||
items = append(items, model.StockOutItem{
|
||||
ShopID: shopID,
|
||||
ProductID: prod.ID,
|
||||
Quantity: qty,
|
||||
UnitPrice: price,
|
||||
TotalPrice: total,
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未解析到有效明细行"})
|
||||
return
|
||||
}
|
||||
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
OrderNo: orderNo,
|
||||
Type: "sale",
|
||||
WarehouseID: wh.ID,
|
||||
PartnerID: partnerID,
|
||||
OperatorID: userID,
|
||||
Status: "draft",
|
||||
OrderDate: orderDate,
|
||||
TotalAmount: totalAmount,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
items[i].OrderID = order.ID
|
||||
}
|
||||
if err := h.db.CreateInBatches(&items, 50).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"order_no": orderNo, "items": len(items)})
|
||||
}
|
||||
|
||||
// ── 内部辅助函数 ─────────────────────────────────────────────
|
||||
|
||||
func parseUploadedExcel(c *gin.Context) ([][]string, error) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file required")
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 读取前 4 字节检测 OLE2 格式(D0 CF 11 E0 = 老式 .xls BIFF)
|
||||
var magic [4]byte
|
||||
f.Read(magic[:])
|
||||
f.Seek(0, 0)
|
||||
isOLE := magic[0] == 0xD0 && magic[1] == 0xCF && magic[2] == 0x11 && magic[3] == 0xE0
|
||||
|
||||
var rows [][]string
|
||||
|
||||
if isOLE {
|
||||
// 老格式 BIFF — extrame/xls 需要文件路径,写入临时文件
|
||||
tmp, tmpErr := os.CreateTemp("", "import_*.xls")
|
||||
if tmpErr != nil {
|
||||
return nil, fmt.Errorf("cannot create temp file: %s", tmpErr.Error())
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, cpErr := io.Copy(tmp, f); cpErr != nil {
|
||||
tmp.Close()
|
||||
return nil, fmt.Errorf("cannot write temp file: %s", cpErr.Error())
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
wb, xlErr := xls.Open(tmp.Name(), "utf-8")
|
||||
if xlErr != nil {
|
||||
return nil, fmt.Errorf("invalid xls file: %s", xlErr.Error())
|
||||
}
|
||||
sheet := wb.GetSheet(0)
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("no sheet found")
|
||||
}
|
||||
// LastCol() returns 0 for many data rows in extrame/xls; derive column
|
||||
// count from the header row instead.
|
||||
numCols := 0
|
||||
headerRow := sheet.Row(0)
|
||||
for c := 0; c < headerRow.LastCol(); c++ {
|
||||
if strings.TrimSpace(headerRow.Col(c)) != "" {
|
||||
numCols = c + 1
|
||||
}
|
||||
}
|
||||
if numCols == 0 {
|
||||
numCols = 20
|
||||
}
|
||||
for r := 0; r <= int(sheet.MaxRow); r++ {
|
||||
row := sheet.Row(r)
|
||||
cells := make([]string, numCols)
|
||||
for c := 0; c < numCols; c++ {
|
||||
cells[c] = strings.TrimSpace(row.Col(c))
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
} else {
|
||||
// xlsx / xlsm(ZIP 格式)
|
||||
xl, xlErr := excelize.OpenReader(f)
|
||||
if xlErr != nil {
|
||||
return nil, fmt.Errorf("invalid xlsx file: %s", xlErr.Error())
|
||||
}
|
||||
rows, err = xl.GetRows(xl.GetSheetName(0))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read sheet: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) < 2 {
|
||||
return nil, fmt.Errorf("empty or invalid sheet")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func findOrCreateProductFn(db *gorm.DB, shopID uint64, name, series, spec string) (model.Product, error) {
|
||||
var p model.Product
|
||||
if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
|
||||
shopID, name, series, spec).First(&p).Error == nil {
|
||||
return p, nil
|
||||
}
|
||||
p = model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Name: name,
|
||||
Series: series,
|
||||
Spec: spec,
|
||||
Unit: "瓶",
|
||||
}
|
||||
return p, db.Create(&p).Error
|
||||
}
|
||||
|
||||
func findOrCreatePartner(db *gorm.DB, shopID uint64, name, ptype string) *uint64 {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
var p model.Partner
|
||||
if db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&p).Error == nil {
|
||||
id := p.ID
|
||||
return &id
|
||||
}
|
||||
p = model.Partner{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Name: name,
|
||||
Type: ptype,
|
||||
Status: "enabled",
|
||||
}
|
||||
if db.Create(&p).Error != nil {
|
||||
return nil
|
||||
}
|
||||
id := p.ID
|
||||
return &id
|
||||
}
|
||||
|
||||
func parseDate(s string) model.Date {
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
if err != nil {
|
||||
return model.Date{Time: time.Now()}
|
||||
}
|
||||
return model.Date{Time: t}
|
||||
}
|
||||
|
||||
func parsePartnerType(raw string) string {
|
||||
hasCust := strings.Contains(raw, "客户")
|
||||
hasSupp := strings.Contains(raw, "供应商")
|
||||
switch {
|
||||
case hasCust && hasSupp:
|
||||
return "supplier,customer"
|
||||
case hasCust:
|
||||
return "customer"
|
||||
default:
|
||||
return "supplier"
|
||||
}
|
||||
}
|
||||
|
||||
func cell(row []string, idx int) string {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
@@ -65,6 +68,7 @@ func (h *ProductHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
product.ShopID = shopID
|
||||
product.PublicID = uuid.New().String()
|
||||
|
||||
// Auto-generate product code if not provided (e.g. P001, P002)
|
||||
// Retry up to 5 times on duplicate key to handle concurrent creates
|
||||
@@ -130,6 +134,7 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
"purchase_price": req.PurchasePrice,
|
||||
"sale_price": req.SalePrice,
|
||||
"min_stock": req.MinStock,
|
||||
"description": req.Description,
|
||||
"remark": req.Remark,
|
||||
"custom_fields": req.CustomFields,
|
||||
}).Error; err != nil {
|
||||
@@ -142,6 +147,97 @@ func (h *ProductHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
}
|
||||
|
||||
// Detail GET /api/v1/products/:id/detail
|
||||
func (h *ProductHandler) Detail(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Preload("Category").Preload("Images").
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 老数据可能没有 public_id,按需补生成
|
||||
if product.PublicID == "" {
|
||||
product.PublicID = uuid.New().String()
|
||||
h.db.Model(&product).Update("public_id", product.PublicID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
}
|
||||
|
||||
// QRCode GET /api/v1/products/:id/qrcode
|
||||
func (h *ProductHandler) QRCode(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Select("id, public_id").First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
url := "https://jiu.51yanmei.com/product/" + product.PublicID
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.DataFromReader(http.StatusOK, int64(len(png)), "image/png", bytes.NewReader(png), nil)
|
||||
}
|
||||
|
||||
// FindOrCreate POST /api/v1/products/find-or-create
|
||||
func (h *ProductHandler) FindOrCreate(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Series string `json:"series"`
|
||||
Spec string `json:"spec"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var product model.Product
|
||||
err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
|
||||
shopID, req.Name, req.Series, req.Spec).First(&product).Error
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
|
||||
product = model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Series: req.Series,
|
||||
Spec: req.Spec,
|
||||
Code: fmt.Sprintf("P%03d", count+1),
|
||||
}
|
||||
if createErr := h.db.Create(&product).Error; createErr != nil {
|
||||
// Race condition: try to find the record created by another request
|
||||
if h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
|
||||
shopID, req.Name, req.Series, req.Spec).First(&product).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": product})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": createErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": product})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/v1/products/:id (软删除)
|
||||
func (h *ProductHandler) Delete(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ProductImageHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProductImageHandler(db *gorm.DB) *ProductImageHandler {
|
||||
return &ProductImageHandler{db: db}
|
||||
}
|
||||
|
||||
// Upload POST /api/v1/products/:id/images
|
||||
func (h *ProductImageHandler) Upload(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
productID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify product belongs to shop
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", productID, shopID).
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check image count limit
|
||||
var count int64
|
||||
h.db.Model(&model.ProductImage{}).Where("product_id = ? AND shop_id = ?", productID, shopID).Count(&count)
|
||||
if count >= 5 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "最多上传 5 张图片"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart (1MB limit)
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 1MB 限制"})
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate image format via decoding
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
|
||||
return
|
||||
}
|
||||
|
||||
// Resize if needed (max 1200px on either dimension, preserve aspect ratio)
|
||||
resized := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
||||
|
||||
// Prepare output path
|
||||
filename := uuid.New().String() + ".jpg"
|
||||
subdir := fmt.Sprintf("%s/products/%d", config.C.Storage.UploadDir, productID)
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
|
||||
return
|
||||
}
|
||||
fullPath := filepath.Join(subdir, filename)
|
||||
|
||||
// Save as JPEG with quality 85
|
||||
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(85)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Relative URL served by Nginx / dev static handler
|
||||
relURL := fmt.Sprintf("/images/products/%d/%s", productID, filename)
|
||||
|
||||
pi := model.ProductImage{
|
||||
ProductID: productID,
|
||||
ShopID: shopID,
|
||||
URL: relURL,
|
||||
}
|
||||
if err := h.db.Create(&pi).Error; err != nil {
|
||||
os.Remove(fullPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": pi})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/v1/products/:id/images/:image_id
|
||||
func (h *ProductImageHandler) Delete(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
productID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
|
||||
return
|
||||
}
|
||||
imageID, err := strconv.ParseUint(c.Param("image_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image id"})
|
||||
return
|
||||
}
|
||||
|
||||
var pi model.ProductImage
|
||||
if err := h.db.Where("id = ? AND product_id = ? AND shop_id = ?", imageID, productID, shopID).
|
||||
First(&pi).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
filename := filepath.Base(pi.URL)
|
||||
fullPath := filepath.Join(config.C.Storage.UploadDir, fmt.Sprintf("products/%d/%s", productID, filename))
|
||||
os.Remove(fullPath)
|
||||
|
||||
if err := h.db.Delete(&pi).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ProductOptionHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
|
||||
return &ProductOptionHandler{db: db}
|
||||
}
|
||||
|
||||
// ── 商品名称 ──────────────────────────────────────────────────
|
||||
|
||||
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductNameOption
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) CreateName(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductNameOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductNameOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── 商品系列 ──────────────────────────────────────────────────
|
||||
|
||||
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSeriesOption
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductSeriesOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSeriesOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── 商品规格 ──────────────────────────────────────────────────
|
||||
|
||||
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSpecOption
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Quantity int `json:"quantity"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := model.ProductSpecOption{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Code: req.Code,
|
||||
Name: req.Name,
|
||||
Quantity: req.Quantity,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSpecOption{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type PublicHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPublicHandler(db *gorm.DB) *PublicHandler {
|
||||
return &PublicHandler{db: db}
|
||||
}
|
||||
|
||||
// GetProduct GET /api/v1/public/products/:public_id (no auth)
|
||||
func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
publicID := c.Param("public_id")
|
||||
|
||||
var product model.Product
|
||||
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
|
||||
Preload("Images").
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return only public-safe fields (no price/stock info)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"series": product.Series,
|
||||
"spec": product.Spec,
|
||||
"brand": product.Brand,
|
||||
"unit": product.Unit,
|
||||
"description": product.Description,
|
||||
"images": product.Images,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockInOrder
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
func (h *StockInHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var order model.StockInOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -45,7 +45,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockOutOrder
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -56,7 +56,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var order model.StockOutOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -11,6 +11,7 @@ type Partner struct {
|
||||
BankAccount string `gorm:"size:100" json:"bank_account"`
|
||||
CreditLimit float64 `gorm:"type:decimal(12,2)" json:"credit_limit"`
|
||||
Balance float64 `gorm:"type:decimal(12,2);default:0" json:"balance"`
|
||||
Status string `gorm:"type:enum('enabled','disabled');not null;default:'enabled'" json:"status"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type ProductCategory struct {
|
||||
|
||||
type Product struct {
|
||||
TenantBase
|
||||
PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"`
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Barcode string `gorm:"size:100" json:"barcode"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
@@ -20,8 +21,10 @@ type Product struct {
|
||||
PurchasePrice float64 `gorm:"type:decimal(12,2)" json:"purchase_price"`
|
||||
SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"`
|
||||
MinStock int `gorm:"default:0" json:"min_stock"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
|
||||
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ProductImage struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ProductID uint64 `gorm:"not null;index" json:"product_id"`
|
||||
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||
URL string `gorm:"size:500;not null" json:"url"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
type ProductNameOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
type ProductSeriesOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
|
||||
type ProductSpecOption struct {
|
||||
TenantBase
|
||||
Code string `gorm:"size:50" json:"code"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Quantity int `gorm:"default:0" json:"quantity"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type StockInOrder struct {
|
||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||
Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"`
|
||||
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
|
||||
Reviewer *User `gorm:"foreignKey:ReviewerID" json:"reviewer,omitempty"`
|
||||
}
|
||||
|
||||
type StockInItem struct {
|
||||
@@ -62,6 +63,7 @@ type StockOutOrder struct {
|
||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||
Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"`
|
||||
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
|
||||
Reviewer *User `gorm:"foreignKey:ReviewerID" json:"reviewer,omitempty"`
|
||||
}
|
||||
|
||||
type StockOutItem struct {
|
||||
|
||||
@@ -26,8 +26,11 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
inventoryH := handler.NewInventoryHandler(db)
|
||||
importH := handler.NewImportHandler(db)
|
||||
userH := handler.NewUserHandler(db)
|
||||
productOptH := handler.NewProductOptionHandler(db)
|
||||
productImageH := handler.NewProductImageHandler(db)
|
||||
financeH := handler.NewFinanceHandler(db)
|
||||
numberRuleH := handler.NewNumberRuleHandler(db)
|
||||
publicH := handler.NewPublicHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -46,6 +49,12 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
auth.POST("/refresh", authH.Refresh)
|
||||
}
|
||||
|
||||
// 商品公开详情(无需登录)
|
||||
public := v1.Group("/public")
|
||||
{
|
||||
public.GET("/products/:public_id", publicH.GetProduct)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
api := v1.Group("")
|
||||
api.Use(middleware.JWT(), middleware.ReadOnly())
|
||||
@@ -64,8 +73,13 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
products.GET("", productH.List)
|
||||
products.POST("", productH.Create)
|
||||
products.POST("/find-or-create", productH.FindOrCreate)
|
||||
products.GET("/:id/detail", productH.Detail)
|
||||
products.GET("/:id/qrcode", productH.QRCode)
|
||||
products.PUT("/:id", productH.Update)
|
||||
products.DELETE("/:id", productH.Delete)
|
||||
products.POST("/:id/images", productImageH.Upload)
|
||||
products.DELETE("/:id/images/:image_id", productImageH.Delete)
|
||||
}
|
||||
|
||||
// 仓库
|
||||
@@ -148,8 +162,29 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 导入
|
||||
imp := api.Group("/import")
|
||||
{
|
||||
imp.POST("/products", importH.ImportProducts)
|
||||
imp.POST("/partners", importH.ImportPartners)
|
||||
imp.POST("/products", importH.ImportProducts)
|
||||
imp.POST("/partners", importH.ImportPartners)
|
||||
imp.POST("/product-names", importH.ImportProductNames)
|
||||
imp.POST("/product-series", importH.ImportProductSeries)
|
||||
imp.POST("/product-specs", importH.ImportProductSpecs)
|
||||
imp.POST("/stock-in", importH.ImportStockIn)
|
||||
imp.POST("/stock-out", importH.ImportStockOut)
|
||||
}
|
||||
|
||||
// 基础数据选项(名称/系列/规格)
|
||||
opts := api.Group("/product-options")
|
||||
{
|
||||
opts.GET("/names", productOptH.ListNames)
|
||||
opts.POST("/names", productOptH.CreateName)
|
||||
opts.DELETE("/names/:id", productOptH.DeleteName)
|
||||
|
||||
opts.GET("/series", productOptH.ListSeries)
|
||||
opts.POST("/series", productOptH.CreateSeries)
|
||||
opts.DELETE("/series/:id", productOptH.DeleteSeries)
|
||||
|
||||
opts.GET("/specs", productOptH.ListSpecs)
|
||||
opts.POST("/specs", productOptH.CreateSpec)
|
||||
opts.DELETE("/specs/:id", productOptH.DeleteSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -21,19 +21,18 @@ func main() {
|
||||
// 初始化数据库
|
||||
db := initDB()
|
||||
|
||||
// 自动迁移(开发环境用,生产使用 golang-migrate)
|
||||
if config.C.Server.Mode == "debug" {
|
||||
autoMigrate(db)
|
||||
}
|
||||
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
||||
autoMigrate(db)
|
||||
|
||||
// 启动 Gin
|
||||
gin.SetMode(config.C.Server.Mode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
// CORS(开发期间允许所有来源)
|
||||
// CORS
|
||||
corsOrigin := config.C.Server.CORSOrigin
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Origin", corsOrigin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
@@ -43,6 +42,9 @@ func main() {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Serve uploaded images (in production, Nginx handles /images/)
|
||||
r.Static("/images", config.C.Storage.UploadDir)
|
||||
|
||||
router.Setup(r, db)
|
||||
|
||||
addr := fmt.Sprintf(":%s", config.C.Server.Port)
|
||||
@@ -95,6 +97,10 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.InventoryCheckItem{},
|
||||
&model.FinanceRecord{},
|
||||
&model.NumberRule{},
|
||||
&model.ProductNameOption{},
|
||||
&model.ProductSeriesOption{},
|
||||
&model.ProductSpecOption{},
|
||||
&model.ProductImage{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
|
||||
@@ -102,7 +102,9 @@ CREATE TABLE IF NOT EXISTS `products` (
|
||||
`brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌',
|
||||
`purchase_price` DECIMAL(16,2) DEFAULT NULL COMMENT '参考进价',
|
||||
`sale_price` DECIMAL(16,2) DEFAULT NULL COMMENT '参考售价',
|
||||
`public_id` CHAR(36) NOT NULL DEFAULT '' COMMENT 'UUID,公开URL使用',
|
||||
`min_stock` INT DEFAULT 0 COMMENT '库存预警值',
|
||||
`description` TEXT DEFAULT NULL COMMENT '商品描述',
|
||||
`custom_fields` JSON DEFAULT NULL COMMENT '动态扩展字段',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -113,9 +115,25 @@ CREATE TABLE IF NOT EXISTS `products` (
|
||||
KEY `idx_category` (`category_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`),
|
||||
UNIQUE KEY `uk_product_code` (`shop_id`, `code`),
|
||||
UNIQUE KEY `uk_public_id` (`public_id`),
|
||||
FULLTEXT KEY `ft_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 商品图片
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `product_images` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`product_id` BIGINT UNSIGNED NOT NULL,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`url` VARCHAR(500) NOT NULL COMMENT '相对路径,如 /images/products/1/xxx.jpg',
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_product_id` (`product_id`),
|
||||
KEY `idx_shop_id` (`shop_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品图片';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 仓库
|
||||
-- ------------------------------------------------------------
|
||||
@@ -146,6 +164,7 @@ CREATE TABLE IF NOT EXISTS `partners` (
|
||||
`phone` VARCHAR(30) DEFAULT NULL,
|
||||
`address` VARCHAR(255) DEFAULT NULL,
|
||||
`bank_account` VARCHAR(100) DEFAULT NULL COMMENT '银行账号',
|
||||
`status` ENUM('enabled','disabled') NOT NULL DEFAULT 'enabled' COMMENT '正常/禁用',
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -255,7 +274,7 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
||||
-- ------------------------------------------------------------
|
||||
-- 库存(实时)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `inventory` (
|
||||
CREATE TABLE IF NOT EXISTS `inventories` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`warehouse_id` BIGINT UNSIGNED NOT NULL,
|
||||
@@ -358,4 +377,50 @@ CREATE TABLE IF NOT EXISTS `number_rules` (
|
||||
UNIQUE KEY `uk_shop_type` (`shop_id`, `type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单号规则';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 基础数据选项
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `product_name_options` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '名称',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品名称选项';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `product_series_options` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '系列名称',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品系列选项';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `product_spec_options` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`code` VARCHAR(50) DEFAULT NULL COMMENT '选项编号',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '规格名称',
|
||||
`quantity` INT DEFAULT 0 COMMENT '单品数量',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格选项';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
+41
-9
@@ -19,9 +19,13 @@ TRUNCATE TABLE stock_in_items;
|
||||
TRUNCATE TABLE stock_in_orders;
|
||||
TRUNCATE TABLE finance_records;
|
||||
TRUNCATE TABLE number_rules;
|
||||
TRUNCATE TABLE product_images;
|
||||
TRUNCATE TABLE partners;
|
||||
TRUNCATE TABLE warehouses;
|
||||
TRUNCATE TABLE products;
|
||||
TRUNCATE TABLE product_spec_options;
|
||||
TRUNCATE TABLE product_series_options;
|
||||
TRUNCATE TABLE product_name_options;
|
||||
TRUNCATE TABLE product_categories;
|
||||
TRUNCATE TABLE users;
|
||||
TRUNCATE TABLE shops;
|
||||
@@ -57,16 +61,44 @@ INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updat
|
||||
|
||||
-- ── 商品 ────────────────────────────────────────────────────
|
||||
-- id=1~6 白酒, id=7~8 进口烈酒
|
||||
INSERT INTO products (id, shop_id, code, barcode, name, series, spec, unit, category_id, brand,
|
||||
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
|
||||
purchase_price, sale_price, min_stock, remark, created_at, updated_at) VALUES
|
||||
(1, 1, 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
|
||||
(2, 1, 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
|
||||
(3, 1, 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
|
||||
(4, 1, 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
||||
(5, 1, 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
||||
(6, 1, 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
|
||||
(7, 1, 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
|
||||
(8, 1, 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
|
||||
(1, 1, 'a1b2c3d4-0001-0001-0001-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
|
||||
(2, 1, 'a1b2c3d4-0001-0001-0001-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
|
||||
(3, 1, 'a1b2c3d4-0001-0001-0001-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
|
||||
(4, 1, 'a1b2c3d4-0001-0001-0001-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
||||
(5, 1, 'a1b2c3d4-0001-0001-0001-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
||||
(6, 1, 'a1b2c3d4-0001-0001-0001-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
|
||||
(7, 1, 'a1b2c3d4-0001-0001-0001-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
|
||||
(8, 1, 'a1b2c3d4-0001-0001-0001-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
|
||||
|
||||
-- ── 商品名称选项 ────────────────────────────────────────────
|
||||
INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
|
||||
(1, 'NA001', '飞天茅台 53度', NOW(), NOW()),
|
||||
(1, 'NA002', '五粮液 52度', NOW(), NOW()),
|
||||
(1, 'NA003', '洋河梦之蓝 M6', NOW(), NOW()),
|
||||
(1, 'NA004', '泸州老窖 特曲', NOW(), NOW()),
|
||||
(1, 'NA005', '剑南春 水晶剑', NOW(), NOW()),
|
||||
(1, 'NA006', '郎酒 红花郎10', NOW(), NOW()),
|
||||
(1, 'NA007', '拉菲古堡 2018', NOW(), NOW()),
|
||||
(1, 'NA008', '人头马 VSOP', NOW(), NOW());
|
||||
|
||||
-- ── 商品系列选项 ────────────────────────────────────────────
|
||||
INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES
|
||||
(1, 'SE001', '茅台', NOW(), NOW()),
|
||||
(1, 'SE002', '五粮液', NOW(), NOW()),
|
||||
(1, 'SE003', '洋河', NOW(), NOW()),
|
||||
(1, 'SE004', '泸州老窖', NOW(), NOW()),
|
||||
(1, 'SE005', '剑南春', NOW(), NOW()),
|
||||
(1, 'SE006', '郎酒', NOW(), NOW()),
|
||||
(1, 'SE007', '波尔多', NOW(), NOW()),
|
||||
(1, 'SE008', '人头马', NOW(), NOW());
|
||||
|
||||
-- ── 商品规格选项 ────────────────────────────────────────────
|
||||
INSERT INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES
|
||||
(1, 'GG001', '500ml/瓶', 1, NOW(), NOW()),
|
||||
(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()),
|
||||
(1, 'GG003', '700ml/瓶', 1, NOW(), NOW());
|
||||
|
||||
-- ── 往来单位 ────────────────────────────────────────────────
|
||||
-- id=1~4 供应商, id=5~7 客户
|
||||
|
||||
+10
-9
@@ -18,6 +18,7 @@ TRUNCATE TABLE stock_in_items;
|
||||
TRUNCATE TABLE stock_in_orders;
|
||||
TRUNCATE TABLE finance_records;
|
||||
TRUNCATE TABLE number_rules;
|
||||
TRUNCATE TABLE product_images;
|
||||
TRUNCATE TABLE partners;
|
||||
TRUNCATE TABLE warehouses;
|
||||
TRUNCATE TABLE products;
|
||||
@@ -50,16 +51,16 @@ INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updat
|
||||
(2, 1, '进口烈酒', 2, NOW(), NOW());
|
||||
|
||||
-- ── 商品 ────────────────────────────────────────────────────
|
||||
INSERT INTO products (id, shop_id, code, barcode, name, series, spec, unit, category_id, brand,
|
||||
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
|
||||
purchase_price, sale_price, min_stock, remark, created_at, updated_at) VALUES
|
||||
(1, 1, 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
|
||||
(2, 1, 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
|
||||
(3, 1, 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
|
||||
(4, 1, 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
||||
(5, 1, 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
||||
(6, 1, 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
|
||||
(7, 1, 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
|
||||
(8, 1, 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
|
||||
(1, 1, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '瓶', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
|
||||
(2, 1, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '瓶', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
|
||||
(3, 1, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '瓶', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
|
||||
(4, 1, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
||||
(5, 1, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
||||
(6, 1, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
|
||||
(7, 1, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '瓶', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
|
||||
(8, 1, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '瓶', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
|
||||
|
||||
-- ── 往来单位 ────────────────────────────────────────────────
|
||||
INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
|
||||
|
||||
Reference in New Issue
Block a user