commit 0e42f0e417a5942272e1186410f756d6252e38c6 Author: wangjia <809946525@qq.com> Date: Sat Apr 4 01:24:53 2026 +0800 init: 后端框架脚手架 (Go + Gin + GORM + MySQL) - 项目目录结构:backend/ deploy/ schema/ migrations/ - 数据库 Schema:所有建表 SQL,含 hotel_id 多租户隔离 - Go 后端:config、model、handler、service、middleware、router - 认证:账号密码登录 + JWT(Access + Refresh Token) - 许可证:HMAC-SHA256 激活码生成 + 设备绑定验证 - 业务模块:商品、仓库、往来单位、入库、出库、库存、盘点 - 库存事务:入库/出库审核时原子更新库存 + 流水记录 - 数据导入:Excel/CSV 批量导入商品、往来单位 - Docker Compose:本地 MySQL + Adminer Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..145d479 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# 环境变量 +.env +.env.local +.env.*.local + +# Go 构建产物 +backend/bin/ +backend/*.exe + +# Flutter 构建产物 +client/build/ +client/.dart_tool/ +client/.flutter-plugins +client/.flutter-plugins-dependencies + +# macOS +.DS_Store +**/.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 日志 +*.log + +# 测试覆盖率 +*.out +coverage/ diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..36d3e22 --- /dev/null +++ b/backend/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "log" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + License LicenseConfig +} + +type ServerConfig struct { + Port string + Mode string // debug | release +} + +type DatabaseConfig struct { + DSN string +} + +type JWTConfig struct { + Secret string + AccessExpireMin int // Access Token 有效分钟数 + RefreshExpireH int // Refresh Token 有效小时数 +} + +type LicenseConfig struct { + HMACSecret string // 许可证签名密钥 +} + +var C Config + +func Load() { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + // 环境变量覆盖(生产部署时使用) + viper.AutomaticEnv() + + // 默认值 + viper.SetDefault("server.port", "8080") + viper.SetDefault("server.mode", "debug") + viper.SetDefault("jwt.access_expire_min", 60) + viper.SetDefault("jwt.refresh_expire_h", 168) // 7天 + + if err := viper.ReadInConfig(); err != nil { + log.Println("[config] no config file found, using defaults and env vars") + } + + if err := viper.Unmarshal(&C); err != nil { + log.Fatalf("[config] failed to unmarshal config: %v", err) + } +} diff --git a/backend/config/config.yaml b/backend/config/config.yaml new file mode 100644 index 0000000..7900c55 --- /dev/null +++ b/backend/config/config.yaml @@ -0,0 +1,15 @@ +server: + port: "8080" + mode: "debug" # debug | release + +database: + # 格式: user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local + dsn: "root:password@tcp(127.0.0.1:3306)/jiu_db?charset=utf8mb4&parseTime=True&loc=Local" + +jwt: + secret: "change-this-to-a-random-secret-in-production" + access_expire_min: 60 + refresh_expire_h: 168 + +license: + hmac_secret: "change-this-license-secret-in-production" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4ed366d --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,62 @@ +module github.com/wangjia/jiu/backend + +go 1.26.1 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/viper v1.21.0 + github.com/xuri/excelize/v2 v2.10.1 + golang.org/x/crypto v0.49.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // 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 + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + 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/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + 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/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 + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + 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/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..d0d47cb --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,147 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/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= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/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= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +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/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= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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.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= +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.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= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go new file mode 100644 index 0000000..c53634d --- /dev/null +++ b/backend/internal/handler/auth.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/wangjia/jiu/backend/internal/service" +) + +type AuthHandler struct { + svc *service.AuthService +} + +func NewAuthHandler(svc *service.AuthService) *AuthHandler { + return &AuthHandler{svc: svc} +} + +// Login POST /api/v1/auth/login +func (h *AuthHandler) Login(c *gin.Context) { + var req struct { + HotelCode string `json:"hotel_code" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + pair, user, err := h.svc.Login(req.HotelCode, req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "access_token": pair.AccessToken, + "refresh_token": pair.RefreshToken, + "expires_in": pair.ExpiresIn, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "real_name": user.RealName, + "role": user.Role, + }, + }, + }) +} + +// Refresh POST /api/v1/auth/refresh +func (h *AuthHandler) Refresh(c *gin.Context) { + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + pair, err := h.svc.RefreshTokens(req.RefreshToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": pair}) +} diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go new file mode 100644 index 0000000..6e47de8 --- /dev/null +++ b/backend/internal/handler/import.go @@ -0,0 +1,157 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/xuri/excelize/v2" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type ImportHandler struct { + db *gorm.DB +} + +func NewImportHandler(db *gorm.DB) *ImportHandler { + return &ImportHandler{db: db} +} + +// ImportProducts POST /api/v1/import/products +// 支持 .xlsx / .csv,列顺序:名称,系列,规格,单位,品牌,进价,售价,最低库存,备注 +func (h *ImportHandler) ImportProducts(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + 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: " + err.Error()}) + return + } + + sheetName := xl.GetSheetName(0) + rows, err := xl.GetRows(sheetName) + if err != nil || len(rows) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "empty or invalid sheet"}) + return + } + + var products []model.Product + var errRows []map[string]interface{} + + for i, row := range rows[1:] { // 跳过表头 + if len(row) < 1 || strings.TrimSpace(row[0]) == "" { + continue + } + p := model.Product{ + TenantBase: model.TenantBase{HotelID: hotelID}, + } + p.Name = cell(row, 0) + p.Series = cell(row, 1) + p.Spec = cell(row, 2) + p.Unit = cell(row, 3) + p.Brand = cell(row, 4) + p.Remark = cell(row, 8) + + if p.Name == "" { + errRows = append(errRows, map[string]interface{}{"row": i + 2, "error": "name is empty"}) + continue + } + products = append(products, p) + } + + if len(products) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rows", "errors": errRows}) + return + } + + // 批量写入(upsert by hotel_id+name+spec) + if err := h.db.CreateInBatches(&products, 100).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "imported": len(products), + "errors": errRows, + }) +} + +// ImportPartners POST /api/v1/import/partners +// 列顺序:名称,类型(supplier/customer),联系人,电话,地址,备注 +func (h *ImportHandler) ImportPartners(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + 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 + for _, row := range rows[1:] { + if len(row) < 1 || strings.TrimSpace(row[0]) == "" { + continue + } + t := cell(row, 1) + if t == "" { + t = "supplier" + } + partners = append(partners, model.Partner{ + TenantBase: model.TenantBase{HotelID: hotelID}, + Name: cell(row, 0), + Type: t, + Contact: cell(row, 2), + Phone: cell(row, 3), + Address: cell(row, 4), + Remark: cell(row, 5), + }) + } + + if err := h.db.CreateInBatches(&partners, 100).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"imported": len(partners)}) +} + +func cell(row []string, idx int) string { + if idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go new file mode 100644 index 0000000..07554db --- /dev/null +++ b/backend/internal/handler/inventory.go @@ -0,0 +1,116 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type InventoryHandler struct { + db *gorm.DB +} + +func NewInventoryHandler(db *gorm.DB) *InventoryHandler { + return &InventoryHandler{db: db} +} + +// List GET /api/v1/inventory +func (h *InventoryHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := h.db.Model(&model.Inventory{}).Where("hotel_id = ?", hotelID) + + if warehouseID := c.Query("warehouse_id"); warehouseID != "" { + query = query.Where("warehouse_id = ?", warehouseID) + } + if productID := c.Query("product_id"); productID != "" { + query = query.Where("product_id = ?", productID) + } + // 仅显示有库存 + if c.Query("in_stock") == "1" { + query = query.Where("quantity > 0") + } + + var total int64 + query.Count(&total) + + var inventory []model.Inventory + query.Preload("Product").Preload("Warehouse"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&inventory) + + c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize}) +} + +// Logs GET /api/v1/inventory/logs +func (h *InventoryHandler) Logs(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := h.db.Model(&model.InventoryLog{}).Where("hotel_id = ?", hotelID) + + if productID := c.Query("product_id"); productID != "" { + query = query.Where("product_id = ?", productID) + } + + var total int64 + query.Count(&total) + + var logs []model.InventoryLog + query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs) + + c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize}) +} + +// CreateCheck POST /api/v1/inventory/checks +func (h *InventoryHandler) CreateCheck(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + operatorID := middleware.GetUserID(c) + + var req model.InventoryCheck + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.HotelID = hotelID + req.OperatorID = operatorID + req.Status = "draft" + + // 自动填入系统库存数量 + for i := range req.Items { + req.Items[i].HotelID = hotelID + var inv model.Inventory + if err := h.db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?", + hotelID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil { + req.Items[i].SystemQty = inv.Quantity + } + } + + if err := h.db.Create(&req).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": req}) +} + +// GetCheck GET /api/v1/inventory/checks/:id +func (h *InventoryHandler) GetCheck(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var check model.InventoryCheck + if err := h.db.Preload("Items.Product"). + Where("id = ? AND hotel_id = ?", c.Param("id"), hotelID). + First(&check).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": check}) +} diff --git a/backend/internal/handler/license.go b/backend/internal/handler/license.go new file mode 100644 index 0000000..e176c36 --- /dev/null +++ b/backend/internal/handler/license.go @@ -0,0 +1,71 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/service" +) + +type LicenseHandler struct { + svc *service.LicenseService +} + +func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler { + return &LicenseHandler{svc: svc} +} + +// Activate POST /api/v1/license/activate +func (h *LicenseHandler) Activate(c *gin.Context) { + var req struct { + LicenseKey string `json:"license_key" binding:"required"` + DeviceID string `json:"device_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + lic, err := h.svc.Activate(req.LicenseKey, req.DeviceID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": lic}) +} + +// Verify GET /api/v1/license/verify +func (h *LicenseHandler) Verify(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + deviceID := c.Query("device_id") + if deviceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "device_id required"}) + return + } + + lic, err := h.svc.Verify(hotelID, deviceID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": lic}) +} + +// Deactivate POST /api/v1/license/deactivate +func (h *LicenseHandler) Deactivate(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var req struct { + DeviceID string `json:"device_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.svc.Deactivate(hotelID, req.DeviceID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deactivated"}) +} diff --git a/backend/internal/handler/partner.go b/backend/internal/handler/partner.go new file mode 100644 index 0000000..020c4ea --- /dev/null +++ b/backend/internal/handler/partner.go @@ -0,0 +1,89 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type PartnerHandler struct { + db *gorm.DB +} + +func NewPartnerHandler(db *gorm.DB) *PartnerHandler { + return &PartnerHandler{db: db} +} + +func (h *PartnerHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := h.db.Model(&model.Partner{}). + Where("hotel_id = ? AND deleted_at IS NULL", hotelID) + + if t := c.Query("type"); t != "" { + query = query.Where("FIND_IN_SET(?, type)", t) + } + if kw := c.Query("keyword"); kw != "" { + query = query.Where("name LIKE ? OR phone LIKE ?", "%"+kw+"%", "%"+kw+"%") + } + + var total int64 + query.Count(&total) + + var partners []model.Partner + query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners) + + c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize}) +} + +func (h *PartnerHandler) Create(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var p model.Partner + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + p.HotelID = hotelID + if err := h.db.Create(&p).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": p}) +} + +func (h *PartnerHandler) Update(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var p model.Partner + if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + First(&p).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + p.HotelID = hotelID + h.db.Save(&p) + c.JSON(http.StatusOK, gin.H{"data": p}) +} + +func (h *PartnerHandler) Delete(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + now := timeNow() + result := h.db.Model(&model.Partner{}). + Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + Update("deleted_at", now) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go new file mode 100644 index 0000000..81f9676 --- /dev/null +++ b/backend/internal/handler/product.go @@ -0,0 +1,114 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type ProductHandler struct { + db *gorm.DB +} + +func NewProductHandler(db *gorm.DB) *ProductHandler { + return &ProductHandler{db: db} +} + +// List GET /api/v1/products +func (h *ProductHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + categoryID := c.Query("category_id") + + query := h.db.Model(&model.Product{}). + Where("hotel_id = ? AND deleted_at IS NULL", hotelID) + + if keyword != "" { + query = query.Where("name LIKE ? OR code LIKE ? OR barcode LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + if categoryID != "" { + query = query.Where("category_id = ?", categoryID) + } + + var total int64 + query.Count(&total) + + var products []model.Product + offset := (page - 1) * pageSize + query.Preload("Category").Offset(offset).Limit(pageSize). + Order("id DESC").Find(&products) + + c.JSON(http.StatusOK, gin.H{ + "data": products, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// Create POST /api/v1/products +func (h *ProductHandler) Create(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var product model.Product + if err := c.ShouldBindJSON(&product); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + product.HotelID = hotelID + + if err := h.db.Create(&product).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": product}) +} + +// Update PUT /api/v1/products/:id +func (h *ProductHandler) Update(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + id := c.Param("id") + + var product model.Product + if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID). + First(&product).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + if err := c.ShouldBindJSON(&product); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + product.HotelID = hotelID // 防止篡改 + + if err := h.db.Save(&product).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": product}) +} + +// Delete DELETE /api/v1/products/:id (软删除) +func (h *ProductHandler) Delete(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + id := c.Param("id") + + now := timeNow() + result := h.db.Model(&model.Product{}). + Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", id, hotelID). + Update("deleted_at", now) + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go new file mode 100644 index 0000000..05632de --- /dev/null +++ b/backend/internal/handler/stock_in.go @@ -0,0 +1,156 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/service" +) + +func timeNow() *time.Time { + t := time.Now() + return &t +} + +type StockInHandler struct { + db *gorm.DB + stockSvc *service.StockService +} + +func NewStockInHandler(db *gorm.DB, svc *service.StockService) *StockInHandler { + return &StockInHandler{db: db, stockSvc: svc} +} + +// List GET /api/v1/stock-in/orders +func (h *StockInHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := h.db.Model(&model.StockInOrder{}). + Where("hotel_id = ? AND deleted_at IS NULL", hotelID) + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + if startDate := c.Query("start_date"); startDate != "" { + query = query.Where("order_date >= ?", startDate) + } + if endDate := c.Query("end_date"); endDate != "" { + query = query.Where("order_date <= ?", endDate) + } + + var total int64 + query.Count(&total) + + var orders []model.StockInOrder + query.Preload("Warehouse").Preload("Partner").Preload("Operator"). + Offset((page - 1) * pageSize).Limit(pageSize). + Order("id DESC").Find(&orders) + + c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) +} + +// Get GET /api/v1/stock-in/orders/:id +func (h *StockInHandler) Get(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var order model.StockInOrder + if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner"). + Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": order}) +} + +// Create POST /api/v1/stock-in/orders +func (h *StockInHandler) Create(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + operatorID := middleware.GetUserID(c) + + var req model.StockInOrder + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.HotelID = hotelID + req.OperatorID = operatorID + req.Status = "draft" + + // 生成单号 + orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "stock_in") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + req.OrderNo = orderNo + + // 计算总金额 + var total float64 + for i := range req.Items { + req.Items[i].HotelID = hotelID + req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice + total += req.Items[i].TotalPrice + } + req.TotalAmount = total + + if err := h.db.Create(&req).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": req}) +} + +// Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核) +func (h *StockInHandler) Submit(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + result := h.db.Model(&model.StockInOrder{}). + Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID). + Update("status", "pending") + if result.RowsAffected == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "submitted"}) +} + +// Approve PUT /api/v1/stock-in/orders/:id/approve +func (h *StockInHandler) Approve(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + reviewerID := middleware.GetUserID(c) + + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.stockSvc.ApproveStockIn(hotelID, id, reviewerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "approved"}) +} + +// Reject PUT /api/v1/stock-in/orders/:id/reject +func (h *StockInHandler) Reject(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + reviewerID := middleware.GetUserID(c) + now := timeNow() + + result := h.db.Model(&model.StockInOrder{}). + Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID). + Updates(map[string]interface{}{ + "status": "rejected", + "reviewer_id": reviewerID, + "reviewed_at": now, + }) + if result.RowsAffected == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "rejected"}) +} diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go new file mode 100644 index 0000000..cf73480 --- /dev/null +++ b/backend/internal/handler/stock_out.go @@ -0,0 +1,148 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/service" +) + +type StockOutHandler struct { + db *gorm.DB + stockSvc *service.StockService +} + +func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler { + return &StockOutHandler{db: db, stockSvc: svc} +} + +// List GET /api/v1/stock-out/orders +func (h *StockOutHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + query := h.db.Model(&model.StockOutOrder{}). + Where("hotel_id = ? AND deleted_at IS NULL", hotelID) + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + if startDate := c.Query("start_date"); startDate != "" { + query = query.Where("order_date >= ?", startDate) + } + if endDate := c.Query("end_date"); endDate != "" { + query = query.Where("order_date <= ?", endDate) + } + + var total int64 + query.Count(&total) + + var orders []model.StockOutOrder + query.Preload("Warehouse").Preload("Partner").Preload("Operator"). + Offset((page - 1) * pageSize).Limit(pageSize). + Order("id DESC").Find(&orders) + + c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) +} + +// Get GET /api/v1/stock-out/orders/:id +func (h *StockOutHandler) Get(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var order model.StockOutOrder + if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner"). + Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + First(&order).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": order}) +} + +// Create POST /api/v1/stock-out/orders +func (h *StockOutHandler) Create(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + operatorID := middleware.GetUserID(c) + + var req model.StockOutOrder + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.HotelID = hotelID + req.OperatorID = operatorID + req.Status = "draft" + + orderNo, err := h.stockSvc.GenerateOrderNo(hotelID, "stock_out") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + req.OrderNo = orderNo + + var total float64 + for i := range req.Items { + req.Items[i].HotelID = hotelID + req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice + total += req.Items[i].TotalPrice + } + req.TotalAmount = total + + if err := h.db.Create(&req).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": req}) +} + +// Submit PUT /api/v1/stock-out/orders/:id/submit +func (h *StockOutHandler) Submit(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + result := h.db.Model(&model.StockOutOrder{}). + Where("id = ? AND hotel_id = ? AND status = 'draft'", c.Param("id"), hotelID). + Update("status", "pending") + if result.RowsAffected == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "submitted"}) +} + +// Approve PUT /api/v1/stock-out/orders/:id/approve +func (h *StockOutHandler) Approve(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + reviewerID := middleware.GetUserID(c) + + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.stockSvc.ApproveStockOut(hotelID, id, reviewerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "approved"}) +} + +// Reject PUT /api/v1/stock-out/orders/:id/reject +func (h *StockOutHandler) Reject(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + reviewerID := middleware.GetUserID(c) + now := timeNow() + + result := h.db.Model(&model.StockOutOrder{}). + Where("id = ? AND hotel_id = ? AND status = 'pending'", c.Param("id"), hotelID). + Updates(map[string]interface{}{ + "status": "rejected", + "reviewer_id": reviewerID, + "reviewed_at": now, + }) + if result.RowsAffected == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "rejected"}) +} diff --git a/backend/internal/handler/warehouse.go b/backend/internal/handler/warehouse.go new file mode 100644 index 0000000..a2d7ee1 --- /dev/null +++ b/backend/internal/handler/warehouse.go @@ -0,0 +1,61 @@ +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 WarehouseHandler struct { + db *gorm.DB +} + +func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler { + return &WarehouseHandler{db: db} +} + +func (h *WarehouseHandler) List(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var warehouses []model.Warehouse + h.db.Where("hotel_id = ? AND deleted_at IS NULL", hotelID).Find(&warehouses) + c.JSON(http.StatusOK, gin.H{"data": warehouses}) +} + +func (h *WarehouseHandler) Create(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var w model.Warehouse + if err := c.ShouldBindJSON(&w); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + w.HotelID = hotelID + h.db.Create(&w) + c.JSON(http.StatusCreated, gin.H{"data": w}) +} + +func (h *WarehouseHandler) Update(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + var w model.Warehouse + if err := h.db.Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + First(&w).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.ShouldBindJSON(&w) + w.HotelID = hotelID + h.db.Save(&w) + c.JSON(http.StatusOK, gin.H{"data": w}) +} + +func (h *WarehouseHandler) Delete(c *gin.Context) { + hotelID := middleware.GetHotelID(c) + now := timeNow() + h.db.Model(&model.Warehouse{}). + Where("id = ? AND hotel_id = ? AND deleted_at IS NULL", c.Param("id"), hotelID). + Update("deleted_at", now) + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..4cfca1d --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/wangjia/jiu/backend/config" +) + +type Claims struct { + UserID uint64 `json:"user_id"` + HotelID uint64 `json:"hotel_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +const ( + CtxUserID = "user_id" + CtxHotelID = "hotel_id" + CtxRole = "role" +) + +func JWT() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + return []byte(config.C.JWT.Secret), nil + }) + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + c.Set(CtxUserID, claims.UserID) + c.Set(CtxHotelID, claims.HotelID) + c.Set(CtxRole, claims.Role) + c.Next() + } +} + +// AdminOnly 仅管理员可访问 +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get(CtxRole) + if role != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"}) + return + } + c.Next() + } +} + +// GetHotelID 从 context 中安全获取 hotel_id +func GetHotelID(c *gin.Context) uint64 { + v, _ := c.Get(CtxHotelID) + id, _ := v.(uint64) + return id +} + +// GetUserID 从 context 中安全获取 user_id +func GetUserID(c *gin.Context) uint64 { + v, _ := c.Get(CtxUserID) + id, _ := v.(uint64) + return id +} diff --git a/backend/internal/model/base.go b/backend/internal/model/base.go new file mode 100644 index 0000000..5cd44b7 --- /dev/null +++ b/backend/internal/model/base.go @@ -0,0 +1,50 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +// JSON 类型,用于 custom_fields +type JSON map[string]interface{} + +func (j JSON) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + b, err := json.Marshal(j) + return string(b), err +} + +func (j *JSON) Scan(value interface{}) error { + if value == nil { + *j = nil + return nil + } + var bytes []byte + switch v := value.(type) { + case string: + bytes = []byte(v) + case []byte: + bytes = v + default: + return fmt.Errorf("cannot scan type %T into JSON", value) + } + return json.Unmarshal(bytes, j) +} + +// Base 公共字段 +type Base struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"-"` +} + +// TenantBase 含租户隔离的公共字段 +type TenantBase struct { + Base + HotelID uint64 `gorm:"not null;index" json:"hotel_id"` +} diff --git a/backend/internal/model/finance.go b/backend/internal/model/finance.go new file mode 100644 index 0000000..aca2d27 --- /dev/null +++ b/backend/internal/model/finance.go @@ -0,0 +1,30 @@ +package model + +import "time" + +type FinanceRecord struct { + Base + HotelID uint64 `gorm:"not null;index" json:"hotel_id"` + PartnerID *uint64 `json:"partner_id"` + Type string `gorm:"type:enum('receivable','payable','receipt','payment')" json:"type"` + Amount float64 `gorm:"type:decimal(14,2)" json:"amount"` + Balance float64 `gorm:"type:decimal(14,2)" json:"balance"` + RefType string `gorm:"size:30" json:"ref_type"` + RefID *uint64 `json:"ref_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + RecordDate time.Time `gorm:"type:date" json:"record_date"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + + Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"` +} + +type NumberRule struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + HotelID uint64 `gorm:"not null;uniqueIndex:uk_hotel_type" json:"hotel_id"` + Type string `gorm:"size:30;uniqueIndex:uk_hotel_type" json:"type"` + Prefix string `gorm:"size:20;default:''" json:"prefix"` + CurrentNo int `gorm:"default:0" json:"current_no"` + DateFormat string `gorm:"size:20;default:'YYYYMMDD'" json:"date_format"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/model/hotel.go b/backend/internal/model/hotel.go new file mode 100644 index 0000000..2d17257 --- /dev/null +++ b/backend/internal/model/hotel.go @@ -0,0 +1,10 @@ +package model + +type Hotel struct { + Base + Name string `gorm:"size:100;not null" json:"name"` + Code string `gorm:"size:50;uniqueIndex" json:"code"` + Address string `gorm:"size:255" json:"address"` + Phone string `gorm:"size:30" json:"phone"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` +} diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go new file mode 100644 index 0000000..be0847a --- /dev/null +++ b/backend/internal/model/license.go @@ -0,0 +1,15 @@ +package model + +import "time" + +type License struct { + Base + HotelID uint64 `gorm:"not null;index" json:"hotel_id"` + LicenseKey string `gorm:"size:255;uniqueIndex" json:"license_key"` + DeviceID string `gorm:"size:255" json:"device_id"` + Type string `gorm:"type:enum('trial','annual','lifetime');default:'trial'" json:"type"` + ExpiresAt *time.Time `json:"expires_at"` + IsActive bool `gorm:"default:true" json:"is_active"` + Features JSON `gorm:"type:json" json:"features,omitempty"` + ActivatedAt *time.Time `json:"activated_at"` +} diff --git a/backend/internal/model/partner.go b/backend/internal/model/partner.go new file mode 100644 index 0000000..2ed520a --- /dev/null +++ b/backend/internal/model/partner.go @@ -0,0 +1,16 @@ +package model + +type Partner struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name"` + Type string `gorm:"type:set('supplier','customer');default:'supplier'" json:"type"` + Contact string `gorm:"size:50" json:"contact"` + Phone string `gorm:"size:30" json:"phone"` + Address string `gorm:"size:255" json:"address"` + 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"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` +} diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go new file mode 100644 index 0000000..8f9c9d3 --- /dev/null +++ b/backend/internal/model/product.go @@ -0,0 +1,27 @@ +package model + +type ProductCategory struct { + TenantBase + Name string `gorm:"size:100;not null" json:"name"` + ParentID *uint64 `json:"parent_id"` + SortOrder int `gorm:"default:0" json:"sort_order"` +} + +type Product struct { + TenantBase + Code string `gorm:"size:50" json:"code"` + Barcode string `gorm:"size:100" json:"barcode"` + Name string `gorm:"size:200;not null" json:"name"` + Series string `gorm:"size:100" json:"series"` + Spec string `gorm:"size:100" json:"spec"` + Unit string `gorm:"size:20" json:"unit"` + CategoryID *uint64 `json:"category_id"` + Brand string `gorm:"size:100" json:"brand"` + 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"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + + Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"` +} diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go new file mode 100644 index 0000000..929b543 --- /dev/null +++ b/backend/internal/model/stock.go @@ -0,0 +1,136 @@ +package model + +import "time" + +// -------- 入库单 -------- + +type StockInOrder struct { + TenantBase + OrderNo string `gorm:"size:50;uniqueIndex:uk_hotel_order_no" json:"order_no"` + Type string `gorm:"size:30;default:'purchase'" json:"type"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` + PartnerID *uint64 `json:"partner_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + ReviewerID *uint64 `json:"reviewer_id"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate time.Time `gorm:"type:date" json:"order_date"` + TotalAmount float64 `gorm:"type:decimal(14,2);default:0" json:"total_amount"` + ReviewedAt *time.Time `json:"reviewed_at"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + + Items []StockInItem `gorm:"foreignKey:OrderID" json:"items,omitempty"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` + Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"` + Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` +} + +type StockInItem struct { + Base + OrderID uint64 `gorm:"not null;index" json:"order_id"` + HotelID uint64 `gorm:"not null" json:"hotel_id"` + ProductID uint64 `gorm:"not null" json:"product_id"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + UnitPrice float64 `gorm:"type:decimal(12,2);default:0" json:"unit_price"` + TotalPrice float64 `gorm:"type:decimal(14,2);default:0" json:"total_price"` + BatchNo string `gorm:"size:50" json:"batch_no"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:255" json:"remark"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` +} + +// -------- 出库单 -------- + +type StockOutOrder struct { + TenantBase + OrderNo string `gorm:"size:50;uniqueIndex:uk_hotel_order_no" json:"order_no"` + Type string `gorm:"size:30;default:'sale'" json:"type"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` + PartnerID *uint64 `json:"partner_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + ReviewerID *uint64 `json:"reviewer_id"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate time.Time `gorm:"type:date" json:"order_date"` + TotalAmount float64 `gorm:"type:decimal(14,2);default:0" json:"total_amount"` + ReviewedAt *time.Time `json:"reviewed_at"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + + Items []StockOutItem `gorm:"foreignKey:OrderID" json:"items,omitempty"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` + Partner *Partner `gorm:"foreignKey:PartnerID" json:"partner,omitempty"` + Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` +} + +type StockOutItem struct { + Base + OrderID uint64 `gorm:"not null;index" json:"order_id"` + HotelID uint64 `gorm:"not null" json:"hotel_id"` + ProductID uint64 `gorm:"not null" json:"product_id"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + UnitPrice float64 `gorm:"type:decimal(12,2);default:0" json:"unit_price"` + TotalPrice float64 `gorm:"type:decimal(14,2);default:0" json:"total_price"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:255" json:"remark"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` +} + +// -------- 实时库存 -------- + +type Inventory struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + HotelID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"hotel_id"` + WarehouseID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"warehouse_id"` + ProductID uint64 `gorm:"not null;uniqueIndex:uk_hotel_wh_product" json:"product_id"` + Quantity float64 `gorm:"type:decimal(12,3);default:0" json:"quantity"` + UpdatedAt time.Time `json:"updated_at"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` +} + +// -------- 库存流水 -------- + +type InventoryLog struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + HotelID uint64 `gorm:"not null;index:idx_hotel_product" json:"hotel_id"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` + ProductID uint64 `gorm:"not null;index:idx_hotel_product" json:"product_id"` + Direction string `gorm:"type:enum('in','out')" json:"direction"` + Quantity float64 `gorm:"type:decimal(12,3)" json:"quantity"` + QtyBefore float64 `gorm:"type:decimal(12,3)" json:"qty_before"` + QtyAfter float64 `gorm:"type:decimal(12,3)" json:"qty_after"` + RefType string `gorm:"size:30" json:"ref_type"` + RefID uint64 `json:"ref_id"` + OperatorID *uint64 `json:"operator_id"` + CreatedAt time.Time `json:"created_at"` +} + +// -------- 库存盘点 -------- + +type InventoryCheck struct { + TenantBase + CheckNo string `gorm:"size:50;uniqueIndex:uk_hotel_check_no" json:"check_no"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"` + CheckDate time.Time `gorm:"type:date" json:"check_date"` + Remark string `gorm:"size:500" json:"remark"` + + Items []InventoryCheckItem `gorm:"foreignKey:CheckID" json:"items,omitempty"` +} + +type InventoryCheckItem struct { + Base + CheckID uint64 `gorm:"not null;index" json:"check_id"` + HotelID uint64 `gorm:"not null" json:"hotel_id"` + ProductID uint64 `gorm:"not null" json:"product_id"` + SystemQty float64 `gorm:"type:decimal(12,3)" json:"system_qty"` + ActualQty float64 `gorm:"type:decimal(12,3)" json:"actual_qty"` + DiffQty float64 `gorm:"->" json:"diff_qty"` // generated column,只读 + Remark string `gorm:"size:255" json:"remark"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go new file mode 100644 index 0000000..966f20e --- /dev/null +++ b/backend/internal/model/user.go @@ -0,0 +1,21 @@ +package model + +type User struct { + TenantBase + Username string `gorm:"size:50;uniqueIndex:uk_hotel_username" json:"username"` + PasswordHash string `gorm:"size:255" json:"-"` + RealName string `gorm:"size:50" json:"real_name"` + Phone string `gorm:"size:30" json:"phone"` + Role string `gorm:"type:enum('admin','operator');default:'operator'" json:"role"` + IsActive bool `gorm:"default:true" json:"is_active"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` +} + +type OAuthProvider struct { + Base + UserID uint64 `gorm:"not null;index" json:"user_id"` + Provider string `gorm:"type:enum('wechat','google','apple')" json:"provider"` + ProviderUserID string `gorm:"size:255" json:"provider_user_id"` + AccessToken string `gorm:"type:text" json:"-"` + RefreshToken string `gorm:"type:text" json:"-"` +} diff --git a/backend/internal/model/warehouse.go b/backend/internal/model/warehouse.go new file mode 100644 index 0000000..9f6b083 --- /dev/null +++ b/backend/internal/model/warehouse.go @@ -0,0 +1,9 @@ +package model + +type Warehouse struct { + TenantBase + Name string `gorm:"size:100;not null" json:"name"` + Location string `gorm:"size:200" json:"location"` + IsDefault bool `gorm:"default:false" json:"is_default"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..f3f8ae5 --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,115 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/handler" + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/service" +) + +func Setup(r *gin.Engine, db *gorm.DB) { + // 服务层 + authSvc := service.NewAuthService(db) + licenseSvc := service.NewLicenseService(db) + stockSvc := service.NewStockService(db) + + // 处理器 + authH := handler.NewAuthHandler(authSvc) + licenseH := handler.NewLicenseHandler(licenseSvc) + productH := handler.NewProductHandler(db) + warehouseH := handler.NewWarehouseHandler(db) + partnerH := handler.NewPartnerHandler(db) + stockInH := handler.NewStockInHandler(db, stockSvc) + stockOutH := handler.NewStockOutHandler(db, stockSvc) + inventoryH := handler.NewInventoryHandler(db) + importH := handler.NewImportHandler(db) + + v1 := r.Group("/api/v1") + + // 公开路由(无需登录) + auth := v1.Group("/auth") + { + auth.POST("/login", authH.Login) + auth.POST("/refresh", authH.Refresh) + } + + // 需要 JWT 的路由 + api := v1.Group("") + api.Use(middleware.JWT()) + { + // 许可证 + license := api.Group("/license") + { + license.POST("/activate", licenseH.Activate) + license.GET("/verify", licenseH.Verify) + license.POST("/deactivate", licenseH.Deactivate) + } + + // 商品 + products := api.Group("/products") + { + products.GET("", productH.List) + products.POST("", productH.Create) + products.PUT("/:id", productH.Update) + products.DELETE("/:id", productH.Delete) + } + + // 仓库 + warehouses := api.Group("/warehouses") + { + warehouses.GET("", warehouseH.List) + warehouses.POST("", warehouseH.Create) + warehouses.PUT("/:id", warehouseH.Update) + warehouses.DELETE("/:id", warehouseH.Delete) + } + + // 往来单位 + partners := api.Group("/partners") + { + partners.GET("", partnerH.List) + partners.POST("", partnerH.Create) + partners.PUT("/:id", partnerH.Update) + partners.DELETE("/:id", partnerH.Delete) + } + + // 入库 + stockIn := api.Group("/stock-in") + { + stockIn.GET("/orders", stockInH.List) + stockIn.GET("/orders/:id", stockInH.Get) + stockIn.POST("/orders", stockInH.Create) + stockIn.PUT("/orders/:id/submit", stockInH.Submit) + stockIn.PUT("/orders/:id/approve", stockInH.Approve) + stockIn.PUT("/orders/:id/reject", stockInH.Reject) + } + + // 出库 + stockOut := api.Group("/stock-out") + { + stockOut.GET("/orders", stockOutH.List) + stockOut.GET("/orders/:id", stockOutH.Get) + stockOut.POST("/orders", stockOutH.Create) + stockOut.PUT("/orders/:id/submit", stockOutH.Submit) + stockOut.PUT("/orders/:id/approve", stockOutH.Approve) + stockOut.PUT("/orders/:id/reject", stockOutH.Reject) + } + + // 库存 + inventory := api.Group("/inventory") + { + inventory.GET("", inventoryH.List) + inventory.GET("/logs", inventoryH.Logs) + inventory.POST("/checks", inventoryH.CreateCheck) + inventory.GET("/checks/:id", inventoryH.GetCheck) + } + + // 导入 + imp := api.Group("/import") + { + imp.POST("/products", importH.ImportProducts) + imp.POST("/partners", importH.ImportPartners) + } + } +} diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go new file mode 100644 index 0000000..e9ee021 --- /dev/null +++ b/backend/internal/service/auth.go @@ -0,0 +1,120 @@ +package service + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrUserInactive = errors.New("user is disabled") +) + +type AuthService struct { + db *gorm.DB +} + +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{db: db} +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` // 秒 +} + +// Login 账号密码登录 +func (s *AuthService) Login(hotelCode, username, password string) (*TokenPair, *model.User, error) { + var hotel model.Hotel + if err := s.db.Where("code = ?", hotelCode).First(&hotel).Error; err != nil { + return nil, nil, ErrInvalidCredentials + } + + var user model.User + if err := s.db.Where("hotel_id = ? AND username = ? AND deleted_at IS NULL", hotel.ID, username). + First(&user).Error; err != nil { + return nil, nil, ErrInvalidCredentials + } + + if !user.IsActive { + return nil, nil, ErrUserInactive + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, nil, ErrInvalidCredentials + } + + pair, err := s.issueTokens(user.ID, hotel.ID, user.Role) + if err != nil { + return nil, nil, err + } + return pair, &user, nil +} + +// HashPassword 生成 bcrypt 哈希 +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + return string(b), err +} + +// RefreshTokens 用 Refresh Token 换新 Token Pair +func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { + claims := &middleware.Claims{} + token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) { + return []byte(config.C.JWT.Secret), nil + }) + if err != nil || !token.Valid { + return nil, errors.New("invalid refresh token") + } + return s.issueTokens(claims.UserID, claims.HotelID, claims.Role) +} + +func (s *AuthService) issueTokens(userID, hotelID uint64, role string) (*TokenPair, error) { + cfg := config.C.JWT + now := time.Now() + + accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute) + accessClaims := middleware.Claims{ + UserID: userID, + HotelID: hotelID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExp), + IssuedAt: jwt.NewNumericDate(now), + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.Secret)) + if err != nil { + return nil, err + } + + refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour) + refreshClaims := middleware.Claims{ + UserID: userID, + HotelID: hotelID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExp), + IssuedAt: jwt.NewNumericDate(now), + }, + } + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.Secret)) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: cfg.AccessExpireMin * 60, + }, nil +} diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go new file mode 100644 index 0000000..057ff7b --- /dev/null +++ b/backend/internal/service/license.go @@ -0,0 +1,90 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base32" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" +) + +var ( + ErrLicenseNotFound = errors.New("license not found") + ErrLicenseInactive = errors.New("license is inactive") + ErrLicenseExpired = errors.New("license has expired") + ErrDeviceMismatch = errors.New("license is bound to another device") +) + +type LicenseService struct { + db *gorm.DB +} + +func NewLicenseService(db *gorm.DB) *LicenseService { + return &LicenseService{db: db} +} + +// GenerateKey 生成许可证激活码 +// 格式:HMAC-SHA256(hotelID+deviceID+expiry, secret) → base32, 每5字符加'-' +func GenerateKey(hotelID uint64, licenseType string, expiresAt *time.Time) string { + payload := fmt.Sprintf("%d:%s", hotelID, licenseType) + if expiresAt != nil { + payload += ":" + expiresAt.Format("20060102") + } + mac := hmac.New(sha256.New, []byte(config.C.License.HMACSecret)) + mac.Write([]byte(payload)) + raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil)) + // 截取前20字符,分4段,每段5字符 + raw = strings.ToUpper(raw)[:20] + return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20]) +} + +// Activate 激活许可证(绑定设备) +func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License, error) { + var lic model.License + if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil { + return nil, ErrLicenseNotFound + } + if !lic.IsActive { + return nil, ErrLicenseInactive + } + if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) { + return nil, ErrLicenseExpired + } + // 若已绑定设备,校验是否一致 + if lic.DeviceID != "" && lic.DeviceID != deviceID { + return nil, ErrDeviceMismatch + } + + now := time.Now() + lic.DeviceID = deviceID + lic.ActivatedAt = &now + s.db.Save(&lic) + return &lic, nil +} + +// Verify 验证(客户端启动时调用) +func (s *LicenseService) Verify(hotelID uint64, deviceID string) (*model.License, error) { + var lic model.License + if err := s.db.Where("hotel_id = ? AND device_id = ? AND is_active = 1", hotelID, deviceID). + First(&lic).Error; err != nil { + return nil, ErrLicenseNotFound + } + if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) { + return nil, ErrLicenseExpired + } + return &lic, nil +} + +// Deactivate 解绑设备(换机时使用) +func (s *LicenseService) Deactivate(hotelID uint64, deviceID string) error { + return s.db.Model(&model.License{}). + Where("hotel_id = ? AND device_id = ?", hotelID, deviceID). + Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error +} diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go new file mode 100644 index 0000000..fd5763a --- /dev/null +++ b/backend/internal/service/stock.go @@ -0,0 +1,160 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" +) + +var ErrInsufficientStock = errors.New("insufficient stock") + +type StockService struct { + db *gorm.DB +} + +func NewStockService(db *gorm.DB) *StockService { + return &StockService{db: db} +} + +// ApproveStockIn 审核入库单,审核通过后更新库存(事务) +func (s *StockService) ApproveStockIn(hotelID, orderID, reviewerID uint64) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var order model.StockInOrder + if err := tx.Preload("Items"). + Where("id = ? AND hotel_id = ?", orderID, hotelID). + First(&order).Error; err != nil { + return err + } + if order.Status != "pending" { + return errors.New("order is not in pending status") + } + + now := time.Now() + for _, item := range order.Items { + if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID, + "in", item.Quantity, orderID, "stock_in", reviewerID); err != nil { + return err + } + } + + return tx.Model(&order).Updates(map[string]interface{}{ + "status": "approved", + "reviewer_id": reviewerID, + "reviewed_at": now, + }).Error + }) +} + +// ApproveStockOut 审核出库单 +func (s *StockService) ApproveStockOut(hotelID, orderID, reviewerID uint64) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var order model.StockOutOrder + if err := tx.Preload("Items"). + Where("id = ? AND hotel_id = ?", orderID, hotelID). + First(&order).Error; err != nil { + return err + } + if order.Status != "pending" { + return errors.New("order is not in pending status") + } + + // 预检库存 + for _, item := range order.Items { + var inv model.Inventory + if err := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?", + hotelID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil { + return fmt.Errorf("product %d not in inventory", item.ProductID) + } + if inv.Quantity < item.Quantity { + return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f", + ErrInsufficientStock, item.ProductID, inv.Quantity, item.Quantity) + } + } + + now := time.Now() + for _, item := range order.Items { + if err := s.updateInventory(tx, hotelID, order.WarehouseID, item.ProductID, + "out", item.Quantity, orderID, "stock_out", reviewerID); err != nil { + return err + } + } + + return tx.Model(&order).Updates(map[string]interface{}{ + "status": "approved", + "reviewer_id": reviewerID, + "reviewed_at": now, + }).Error + }) +} + +// updateInventory 更新库存并写流水(在事务中调用) +func (s *StockService) updateInventory(tx *gorm.DB, hotelID, warehouseID, productID uint64, + direction string, qty float64, refID uint64, refType string, operatorID uint64) error { + + var inv model.Inventory + result := tx.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?", + hotelID, warehouseID, productID).First(&inv) + + qtyBefore := inv.Quantity + var qtyAfter float64 + + if direction == "in" { + qtyAfter = qtyBefore + qty + if result.Error != nil { + // 不存在则创建 + inv = model.Inventory{HotelID: hotelID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter} + if err := tx.Create(&inv).Error; err != nil { + return err + } + } else { + if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil { + return err + } + } + } else { + qtyAfter = qtyBefore - qty + if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil { + return err + } + } + + log := model.InventoryLog{ + HotelID: hotelID, + WarehouseID: warehouseID, + ProductID: productID, + Direction: direction, + Quantity: qty, + QtyBefore: qtyBefore, + QtyAfter: qtyAfter, + RefType: refType, + RefID: refID, + OperatorID: &operatorID, + } + return tx.Create(&log).Error +} + +// GenerateOrderNo 生成单号(事务安全) +func (s *StockService) GenerateOrderNo(hotelID uint64, orderType string) (string, error) { + var no string + err := s.db.Transaction(func(tx *gorm.DB) error { + var rule model.NumberRule + result := tx.Where("hotel_id = ? AND type = ?", hotelID, orderType).First(&rule) + if result.Error != nil { + // 初始化规则 + rule = model.NumberRule{HotelID: hotelID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0} + tx.Create(&rule) + } + + rule.CurrentNo++ + tx.Model(&rule).Update("current_no", rule.CurrentNo) + + dateStr := time.Now().Format("20060102") + no = fmt.Sprintf("%s%s%06d", rule.Prefix, dateStr, rule.CurrentNo) + return nil + }) + return no, err +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..c940f44 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "log" + + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/router" +) + +func main() { + // 加载配置 + config.Load() + + // 初始化数据库 + db := initDB() + + // 自动迁移(开发环境用,生产使用 golang-migrate) + if config.C.Server.Mode == "debug" { + autoMigrate(db) + } + + // 启动 Gin + gin.SetMode(config.C.Server.Mode) + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + + // CORS(开发期间允许所有来源) + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + 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" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + router.Setup(r, db) + + addr := fmt.Sprintf(":%s", config.C.Server.Port) + log.Printf("Server starting on %s (mode: %s)", addr, config.C.Server.Mode) + if err := r.Run(addr); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func initDB() *gorm.DB { + dsn := config.C.Database.DSN + if dsn == "" { + log.Fatal("database.dsn is required in config") + } + + logLevel := logger.Silent + if config.C.Server.Mode == "debug" { + logLevel = logger.Info + } + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logLevel), + }) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + sqlDB, _ := db.DB() + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + return db +} + +func autoMigrate(db *gorm.DB) { + err := db.AutoMigrate( + &model.Hotel{}, + &model.User{}, + &model.OAuthProvider{}, + &model.License{}, + &model.ProductCategory{}, + &model.Product{}, + &model.Warehouse{}, + &model.Partner{}, + &model.StockInOrder{}, + &model.StockInItem{}, + &model.StockOutOrder{}, + &model.StockOutItem{}, + &model.Inventory{}, + &model.InventoryLog{}, + &model.InventoryCheck{}, + &model.InventoryCheckItem{}, + &model.FinanceRecord{}, + &model.NumberRule{}, + ) + if err != nil { + log.Fatalf("auto migrate failed: %v", err) + } + log.Println("AutoMigrate completed") +} diff --git a/backend/migrations/001_create_hotels.down.sql b/backend/migrations/001_create_hotels.down.sql new file mode 100644 index 0000000..5612dd7 --- /dev/null +++ b/backend/migrations/001_create_hotels.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `hotels`; diff --git a/backend/migrations/001_create_hotels.up.sql b/backend/migrations/001_create_hotels.up.sql new file mode 100644 index 0000000..a50362b --- /dev/null +++ b/backend/migrations/001_create_hotels.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `hotels` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `code` VARCHAR(50) NOT NULL, + `address` VARCHAR(255) DEFAULT NULL, + `phone` VARCHAR(30) DEFAULT NULL, + `custom_fields` JSON 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`), + UNIQUE KEY `uk_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/002_create_users.down.sql b/backend/migrations/002_create_users.down.sql new file mode 100644 index 0000000..ecf33ed --- /dev/null +++ b/backend/migrations/002_create_users.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS `licenses`; +DROP TABLE IF EXISTS `oauth_providers`; +DROP TABLE IF EXISTS `users`; diff --git a/backend/migrations/002_create_users.up.sql b/backend/migrations/002_create_users.up.sql new file mode 100644 index 0000000..70af54d --- /dev/null +++ b/backend/migrations/002_create_users.up.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `username` VARCHAR(50) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `real_name` VARCHAR(50) DEFAULT NULL, + `phone` VARCHAR(30) DEFAULT NULL, + `role` ENUM('admin','operator') NOT NULL DEFAULT 'operator', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `custom_fields` JSON 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`), + UNIQUE KEY `uk_hotel_username` (`hotel_id`, `username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `oauth_providers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `provider` ENUM('wechat','google','apple') NOT NULL, + `provider_user_id` VARCHAR(255) NOT NULL, + `access_token` TEXT DEFAULT NULL, + `refresh_token` TEXT DEFAULT NULL, + `expires_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_provider_uid` (`provider`, `provider_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `licenses` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `license_key` VARCHAR(255) NOT NULL, + `device_id` VARCHAR(255) DEFAULT NULL, + `type` ENUM('trial','annual','lifetime') NOT NULL DEFAULT 'trial', + `expires_at` DATETIME DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `features` JSON DEFAULT NULL, + `activated_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_license_key` (`license_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/003_create_products.down.sql b/backend/migrations/003_create_products.down.sql new file mode 100644 index 0000000..9ebc76e --- /dev/null +++ b/backend/migrations/003_create_products.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS `partners`; +DROP TABLE IF EXISTS `warehouses`; +DROP TABLE IF EXISTS `products`; +DROP TABLE IF EXISTS `product_categories`; diff --git a/backend/migrations/003_create_products.up.sql b/backend/migrations/003_create_products.up.sql new file mode 100644 index 0000000..9693bb3 --- /dev/null +++ b/backend/migrations/003_create_products.up.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS `product_categories` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `parent_id` BIGINT UNSIGNED DEFAULT NULL, + `sort_order` INT NOT NULL DEFAULT 0, + `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_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `products` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL, + `barcode` VARCHAR(100) DEFAULT NULL, + `name` VARCHAR(200) NOT NULL, + `series` VARCHAR(100) DEFAULT NULL, + `spec` VARCHAR(100) DEFAULT NULL, + `unit` VARCHAR(20) DEFAULT NULL, + `category_id` BIGINT UNSIGNED DEFAULT NULL, + `brand` VARCHAR(100) DEFAULT NULL, + `purchase_price` DECIMAL(12,2) DEFAULT NULL, + `sale_price` DECIMAL(12,2) DEFAULT NULL, + `min_stock` INT DEFAULT 0, + `custom_fields` JSON DEFAULT NULL, + `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_hotel_id` (`hotel_id`), + FULLTEXT KEY `ft_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `warehouses` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `location` VARCHAR(200) DEFAULT NULL, + `is_default` TINYINT(1) NOT NULL DEFAULT 0, + `custom_fields` JSON 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_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `partners` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL, + `name` VARCHAR(200) NOT NULL, + `type` SET('supplier','customer') NOT NULL DEFAULT 'supplier', + `contact` VARCHAR(50) DEFAULT NULL, + `phone` VARCHAR(30) DEFAULT NULL, + `address` VARCHAR(255) DEFAULT NULL, + `bank_account` VARCHAR(100) DEFAULT NULL, + `credit_limit` DECIMAL(12,2) DEFAULT NULL, + `balance` DECIMAL(12,2) NOT NULL DEFAULT 0, + `custom_fields` JSON DEFAULT NULL, + `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_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/004_create_stock.down.sql b/backend/migrations/004_create_stock.down.sql new file mode 100644 index 0000000..b4d359e --- /dev/null +++ b/backend/migrations/004_create_stock.down.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS `number_rules`; +DROP TABLE IF EXISTS `finance_records`; +DROP TABLE IF EXISTS `inventory_check_items`; +DROP TABLE IF EXISTS `inventory_checks`; +DROP TABLE IF EXISTS `inventory_logs`; +DROP TABLE IF EXISTS `inventory`; +DROP TABLE IF EXISTS `stock_out_items`; +DROP TABLE IF EXISTS `stock_out_orders`; +DROP TABLE IF EXISTS `stock_in_items`; +DROP TABLE IF EXISTS `stock_in_orders`; diff --git a/backend/migrations/004_create_stock.up.sql b/backend/migrations/004_create_stock.up.sql new file mode 100644 index 0000000..bf9f978 --- /dev/null +++ b/backend/migrations/004_create_stock.up.sql @@ -0,0 +1,164 @@ +CREATE TABLE IF NOT EXISTS `stock_in_orders` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `order_no` VARCHAR(50) NOT NULL, + `type` VARCHAR(30) NOT NULL DEFAULT 'purchase', + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `reviewer_id` BIGINT UNSIGNED DEFAULT NULL, + `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', + `order_date` DATE NOT NULL, + `total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0, + `reviewed_at` DATETIME DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL, + `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`), + UNIQUE KEY `uk_order_no` (`hotel_id`, `order_no`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_order_date` (`order_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `stock_in_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL, + `unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0, + `total_price` DECIMAL(14,2) NOT NULL DEFAULT 0, + `batch_no` VARCHAR(50) DEFAULT NULL, + `expire_date` DATE DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `stock_out_orders` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `order_no` VARCHAR(50) NOT NULL, + `type` VARCHAR(30) NOT NULL DEFAULT 'sale', + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `reviewer_id` BIGINT UNSIGNED DEFAULT NULL, + `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', + `order_date` DATE NOT NULL, + `total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0, + `reviewed_at` DATETIME DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL, + `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`), + UNIQUE KEY `uk_order_no` (`hotel_id`, `order_no`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_order_date` (`order_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `stock_out_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL, + `unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0, + `total_price` DECIMAL(14,2) NOT NULL DEFAULT 0, + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `inventory` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hotel_wh_product` (`hotel_id`, `warehouse_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `inventory_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `direction` ENUM('in','out') NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL, + `qty_before` DECIMAL(12,3) NOT NULL, + `qty_after` DECIMAL(12,3) NOT NULL, + `ref_type` VARCHAR(30) NOT NULL, + `ref_id` BIGINT UNSIGNED NOT NULL, + `operator_id` BIGINT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_hotel_product` (`hotel_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `inventory_checks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `check_no` VARCHAR(50) NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('draft','completed') NOT NULL DEFAULT 'draft', + `check_date` DATE NOT NULL, + `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, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_check_no` (`hotel_id`, `check_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `inventory_check_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `check_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `system_qty` DECIMAL(12,3) NOT NULL, + `actual_qty` DECIMAL(12,3) NOT NULL, + `diff_qty` DECIMAL(12,3) GENERATED ALWAYS AS (`actual_qty` - `system_qty`) STORED, + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_check_id` (`check_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `finance_records` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL, + `type` ENUM('receivable','payable','receipt','payment') NOT NULL, + `amount` DECIMAL(14,2) NOT NULL, + `balance` DECIMAL(14,2) NOT NULL, + `ref_type` VARCHAR(30) DEFAULT NULL, + `ref_id` BIGINT UNSIGNED DEFAULT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `record_date` DATE NOT NULL, + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_record_date` (`record_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `number_rules` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `type` VARCHAR(30) NOT NULL, + `prefix` VARCHAR(20) DEFAULT '', + `current_no` INT NOT NULL DEFAULT 0, + `date_format` VARCHAR(20) DEFAULT 'YYYYMMDD', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hotel_type` (`hotel_id`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql new file mode 100644 index 0000000..f6222f5 --- /dev/null +++ b/backend/schema/schema.sql @@ -0,0 +1,377 @@ +-- ============================================================ +-- 酒店仓库管理系统 数据库 Schema +-- MySQL 8.0+ +-- 说明:所有业务表含 hotel_id 实现多租户隔离 +-- ============================================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ------------------------------------------------------------ +-- 酒店(租户) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `hotels` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL COMMENT '酒店名称', + `code` VARCHAR(50) NOT NULL COMMENT '酒店编号(唯一)', + `address` VARCHAR(255) DEFAULT NULL, + `phone` VARCHAR(30) DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL COMMENT '扩展字段', + `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`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='酒店(租户)'; + +-- ------------------------------------------------------------ +-- 用户(操作人员) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `username` VARCHAR(50) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL COMMENT 'bcrypt', + `real_name` VARCHAR(50) DEFAULT NULL, + `phone` VARCHAR(30) DEFAULT NULL, + `role` ENUM('admin','operator') NOT NULL DEFAULT 'operator', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `custom_fields` JSON 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`), + UNIQUE KEY `uk_hotel_username` (`hotel_id`, `username`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户'; + +-- ------------------------------------------------------------ +-- OAuth 第三方登录绑定 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `oauth_providers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `provider` ENUM('wechat','google','apple') NOT NULL, + `provider_user_id` VARCHAR(255) NOT NULL, + `access_token` TEXT DEFAULT NULL, + `refresh_token` TEXT DEFAULT NULL, + `expires_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_provider_uid` (`provider`, `provider_user_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OAuth第三方绑定'; + +-- ------------------------------------------------------------ +-- 许可证 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `licenses` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `license_key` VARCHAR(255) NOT NULL COMMENT '激活码', + `device_id` VARCHAR(255) DEFAULT NULL COMMENT '绑定设备ID', + `type` ENUM('trial','annual','lifetime') NOT NULL DEFAULT 'trial', + `expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `features` JSON DEFAULT NULL COMMENT '功能开关 {"finance":true}', + `activated_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_license_key` (`license_key`), + KEY `idx_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证'; + +-- ------------------------------------------------------------ +-- 商品分类 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `product_categories` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `parent_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '父分类,支持二级', + `sort_order` INT NOT NULL DEFAULT 0, + `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_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类'; + +-- ------------------------------------------------------------ +-- 商品 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `products` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL COMMENT '商品编号', + `barcode` VARCHAR(100) DEFAULT NULL COMMENT '条码', + `name` VARCHAR(200) NOT NULL COMMENT '商品名称', + `series` VARCHAR(100) DEFAULT NULL COMMENT '系列', + `spec` VARCHAR(100) DEFAULT NULL COMMENT '规格', + `unit` VARCHAR(20) DEFAULT NULL COMMENT '单位', + `category_id` BIGINT UNSIGNED DEFAULT NULL, + `brand` VARCHAR(100) DEFAULT NULL COMMENT '品牌', + `purchase_price` DECIMAL(12,2) DEFAULT NULL COMMENT '参考进价', + `sale_price` DECIMAL(12,2) DEFAULT NULL COMMENT '参考售价', + `min_stock` INT DEFAULT 0 COMMENT '库存预警值', + `custom_fields` JSON DEFAULT 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_hotel_id` (`hotel_id`), + KEY `idx_category` (`category_id`), + KEY `idx_deleted_at` (`deleted_at`), + FULLTEXT KEY `ft_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品'; + +-- ------------------------------------------------------------ +-- 仓库 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `warehouses` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `location` VARCHAR(200) DEFAULT NULL, + `is_default` TINYINT(1) NOT NULL DEFAULT 0, + `custom_fields` JSON 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_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库'; + +-- ------------------------------------------------------------ +-- 往来单位(供应商/客户) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `partners` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `code` VARCHAR(50) DEFAULT NULL, + `name` VARCHAR(200) NOT NULL, + `type` SET('supplier','customer') NOT NULL DEFAULT 'supplier' COMMENT '可同时是供应商和客户', + `contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人', + `phone` VARCHAR(30) DEFAULT NULL, + `address` VARCHAR(255) DEFAULT NULL, + `bank_account` VARCHAR(100) DEFAULT NULL COMMENT '银行账号', + `credit_limit` DECIMAL(12,2) DEFAULT NULL COMMENT '信用额度', + `balance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '往来余额(正=应收,负=应付)', + `custom_fields` JSON DEFAULT NULL, + `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_hotel_id` (`hotel_id`), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往来单位'; + +-- ------------------------------------------------------------ +-- 入库单 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `stock_in_orders` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `order_no` VARCHAR(50) NOT NULL COMMENT '入库单号', + `type` VARCHAR(30) NOT NULL DEFAULT 'purchase' COMMENT '入库类型: purchase采购/return退货/other其他', + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '供应商', + `operator_id` BIGINT UNSIGNED NOT NULL COMMENT '经办人', + `reviewer_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '审核人', + `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', + `order_date` DATE NOT NULL, + `total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0, + `reviewed_at` DATETIME DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL, + `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`), + UNIQUE KEY `uk_order_no` (`hotel_id`, `order_no`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_warehouse` (`warehouse_id`), + KEY `idx_order_date` (`order_date`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单'; + +-- ------------------------------------------------------------ +-- 入库单明细 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `stock_in_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL COMMENT '数量', + `unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '单价', + `total_price` DECIMAL(14,2) NOT NULL DEFAULT 0, + `batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号', + `expire_date` DATE DEFAULT NULL COMMENT '有效期', + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_hotel_product` (`hotel_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单明细'; + +-- ------------------------------------------------------------ +-- 出库单 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `stock_out_orders` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `order_no` VARCHAR(50) NOT NULL, + `type` VARCHAR(30) NOT NULL DEFAULT 'sale' COMMENT 'sale销售/return退货/other其他', + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '客户', + `operator_id` BIGINT UNSIGNED NOT NULL, + `reviewer_id` BIGINT UNSIGNED DEFAULT NULL, + `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', + `order_date` DATE NOT NULL, + `total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0, + `reviewed_at` DATETIME DEFAULT NULL, + `custom_fields` JSON DEFAULT NULL, + `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`), + UNIQUE KEY `uk_order_no` (`hotel_id`, `order_no`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_warehouse` (`warehouse_id`), + KEY `idx_order_date` (`order_date`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单'; + +-- ------------------------------------------------------------ +-- 出库单明细 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `stock_out_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL, + `unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0, + `total_price` DECIMAL(14,2) NOT NULL DEFAULT 0, + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_order_id` (`order_id`), + KEY `idx_hotel_product` (`hotel_id`, `product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细'; + +-- ------------------------------------------------------------ +-- 库存(实时) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `inventory` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT '当前库存', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hotel_wh_product` (`hotel_id`, `warehouse_id`, `product_id`), + KEY `idx_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时库存'; + +-- ------------------------------------------------------------ +-- 库存流水(出入库记录明细) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `inventory_logs` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `direction` ENUM('in','out') NOT NULL, + `quantity` DECIMAL(12,3) NOT NULL, + `qty_before` DECIMAL(12,3) NOT NULL COMMENT '变动前数量', + `qty_after` DECIMAL(12,3) NOT NULL COMMENT '变动后数量', + `ref_type` VARCHAR(30) NOT NULL COMMENT 'stock_in/stock_out/check', + `ref_id` BIGINT UNSIGNED NOT NULL COMMENT '关联单据ID', + `operator_id` BIGINT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_hotel_product` (`hotel_id`, `product_id`), + KEY `idx_ref` (`ref_type`, `ref_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水'; + +-- ------------------------------------------------------------ +-- 库存盘点 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `inventory_checks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `check_no` VARCHAR(50) NOT NULL, + `warehouse_id` BIGINT UNSIGNED NOT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `status` ENUM('draft','completed') NOT NULL DEFAULT 'draft', + `check_date` DATE NOT NULL, + `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, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_check_no` (`hotel_id`, `check_no`), + KEY `idx_hotel_id` (`hotel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存盘点单'; + +CREATE TABLE IF NOT EXISTS `inventory_check_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `check_id` BIGINT UNSIGNED NOT NULL, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `product_id` BIGINT UNSIGNED NOT NULL, + `system_qty` DECIMAL(12,3) NOT NULL COMMENT '系统数量', + `actual_qty` DECIMAL(12,3) NOT NULL COMMENT '实际盘点数量', + `diff_qty` DECIMAL(12,3) GENERATED ALWAYS AS (`actual_qty` - `system_qty`) STORED COMMENT '差异', + `remark` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_check_id` (`check_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='盘点明细'; + +-- ------------------------------------------------------------ +-- 财务流水 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `finance_records` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `partner_id` BIGINT UNSIGNED DEFAULT NULL, + `type` ENUM('receivable','payable','receipt','payment') NOT NULL COMMENT '应收/应付/收款/付款', + `amount` DECIMAL(14,2) NOT NULL, + `balance` DECIMAL(14,2) NOT NULL COMMENT '操作后余额', + `ref_type` VARCHAR(30) DEFAULT NULL COMMENT '关联单据类型', + `ref_id` BIGINT UNSIGNED DEFAULT NULL, + `operator_id` BIGINT UNSIGNED NOT NULL, + `record_date` DATE NOT NULL, + `custom_fields` JSON DEFAULT NULL, + `remark` VARCHAR(500) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_hotel_id` (`hotel_id`), + KEY `idx_partner` (`partner_id`), + KEY `idx_record_date` (`record_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='财务流水'; + +-- ------------------------------------------------------------ +-- 编号规则配置 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `number_rules` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hotel_id` BIGINT UNSIGNED NOT NULL, + `type` VARCHAR(30) NOT NULL COMMENT 'stock_in/stock_out/check', + `prefix` VARCHAR(20) DEFAULT '' COMMENT '前缀', + `current_no` INT NOT NULL DEFAULT 0 COMMENT '当前序号', + `date_format` VARCHAR(20) DEFAULT 'YYYYMMDD' COMMENT '日期格式', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_hotel_type` (`hotel_id`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单号规则'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..0b6d288 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,29 @@ +version: "3.9" + +services: + mysql: + image: mysql:8.0 + container_name: jiu_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: jiu_db + MYSQL_CHARSET: utf8mb4 + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ../backend/schema/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + adminer: + image: adminer + container_name: jiu_adminer + restart: unless-stopped + ports: + - "8888:8080" + depends_on: + - mysql + +volumes: + mysql_data: