first commit

This commit is contained in:
jxw
2026-06-03 20:59:39 +08:00
commit 2f7309a45c
473 changed files with 81369 additions and 0 deletions

145
CLAUDE.md Executable file
View File

@@ -0,0 +1,145 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
企业供应链合同管理系统 (中尚鹏 / ZSP). Monorepo with a Vue 3 SPA frontend and Go REST API backend, managing contracts, deliveries, inventory, finance, purchasing, and reporting for a supply chain business.
### 目录结构
```
├── frontend/ # Vue 3 SPA 前端
├── backend/ # Go + Gin REST API 后端
├── docs/ # 项目文档
│ ├── api/ # API 文档
│ ├── deploy/ # 部署文档
│ ├── dev/ # 开发文档与需求分析
│ └── Git提交规范.md # Git 提交规范
├── scripts/ # 部署脚本
└── CLAUDE.md # Claude Code 配置文件
```
### 5 大业务模块21 个子菜单)
1. **合同管理**3 个子菜单):业务合同管理、中尚鹏合同管理、公司信息管理
2. **提货管理**5 个子菜单):我要提货、提货明细、库存信息、货权转移、其他出库
3. **财务管理**7 个子菜单):货代费用、杂费管理、服务费管理、发票管理、结算管理、对账单、利润明细
4. **采购管理**4 个子菜单):采购报单、采购运踪明细、国外供应商、采购预算
5. **查询&报表中心**5 个子菜单):合同执行情况、营业明细表、其他明细表、商品基础资料、其他报表
## Commands
### Frontend (frontend/)
```bash
pnpm dev # Start Vite dev server (port 5173, proxies /api to 127.0.0.1:8080)
pnpm build # Production build to frontend/dist/
pnpm typecheck # tsc --noEmit + vue-tsc --noEmit
pnpm lint:eslint # ESLint check
pnpm lint:prettier # Prettier check
pnpm lint # Run all linters
```
### Backend (backend/)
```bash
go run cmd/api/main.go # Start server (port 8080)
air -c .air.toml # Start with hot-reload
go test ./... # Run all tests
go test ./test/ # Run all handler tests (user + contract + delivery)
go build -o ./tmp/main ./cmd/api/main.go # Build binary
```
### Docker
```bash
docker-compose -f docker-compose.dev.yml up --build # Dev: backend + MySQL + Air
docker-compose --env-file .env.prod up -d # Production
```
## Architecture
### Frontend (Vue 3 + TypeScript + Vite + Pinia + Element Plus)
Based on [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin) Lite Edition (non-i18n). Key directories:
- `src/api/` — Axios-based API client modules, one file per domain (contract.ts, delivery.ts, finance.ts, etc.)
- `src/assets/` — Static assets (icons, images)
- `src/components/` — Reusable components (ReAuth, ReDialog, ReIcon, etc.)
- `src/config/` — App configuration
- `src/directives/` — Custom Vue directives (auth, copy, ripple, etc.)
- `src/layout/` — App shell with 3 layout modes (vertical/horizontal/mix)
- `src/router/modules/` — Route modules, one per feature domain, loaded dynamically via async routes
- `src/store/modules/` — Pinia stores (user, app, permission, settings, epTheme, multiTags)
- `src/style/` — Global styles (reset, index, sidebar, tailwind)
- `src/utils/` — Utility functions (HTTP client, auth, tree operations, etc.)
- `src/views/` — Page components by feature: login/, system/contract/, delivery/, finance/, purchase/, query-report/
### Backend (Go + Gin + GORM + MySQL 8.0)
Clean layered architecture:
- `internal/handler/` — HTTP request handling, parameter parsing, response writing
- `internal/service/` — Business logic orchestration
- `internal/repository/` — Data access via GORM queries
- `internal/model/` — GORM model structs with table tags (27 tables via AutoMigrate)
- `internal/dto/` — Request/response DTOs
- `internal/middleware/` — JWT auth middleware (enabled for all routes except /auth/* and static files)
- `internal/server/route.go` — All API route definitions (single source of truth)
API routes are under `/api/` prefix with per-domain route groups. JWT middleware is applied to all routes except `/auth/register`, `/auth/login`, and static file routes.
### API Route Groups
| Group | Prefix | Handler | Description |
|-------|--------|---------|-------------|
| Auth | `/api/auth/register`, `/api/auth/login` | UserHandler | Public: user registration/login |
| Users | `/api/users/:id` | UserHandler | Protected: user info |
| Contract | `/api/contract` | ContractHandler | Business contracts CRUD + folders + batches |
| ZSP Contract | `/api/zsp-contract` | ZSPContractHandler | CRUD + details + Excel import/export |
| Company | `/api/company` | CompanyHandler | Company info management |
| Delivery | `/api/apply-delivery` | DeliveryHandler | Delivery applications |
| Outbound | `/api/delivery-details` | DeliveryHandler | Outbound orders |
| Inventory | `/api/inventory` | DeliveryHandler | Warehouses/inbound/summary |
| Ownership | `/api/ownership-transfer` | DeliveryHandler | Ownership transfer CRUD |
| Finance | `/api/zsp-finances` | ZSPFinancesHandler | Freight/misc/service fees, costs, profits |
### Key patterns
- **Backend handler → service → repository** — Each layer calls the next. Handlers are thin; business logic lives in services. Tests use mocked service interfaces.
- **DTOs for API contracts** — Request/response types in `internal/dto/`, not raw models.
- **GORM AutoMigrate** — 27 models auto-migrated on startup; schema changes are additive.
- **Frontend API modules** — Each `src/api/*.ts` file exports typed functions that return Axios promises with the base response wrapper (`Result<T>`).
- **HTTP interceptors** — Auto-attach JWT Authorization header, handle token refresh, NProgress loading bar.
- **Async routes** — Routes are fetched dynamically post-login; each router module exports an array of route configs.
- **3 layout modes** — Adaptive vertical/horizontal/mix layouts with multi-tab + keep-alive caching.
- **No i18n** — All UI text is hardcoded in Chinese.
## Testing
- Backend tests live in `backend/test/` and use mocked service interfaces with `httptest.NewRecorder()`.
- Currently 24 tests covering user, contract, and delivery handlers.
- Frontend testing infrastructure is not yet configured.
- Run tests: `cd backend && go test ./test/ -v`
## Known Issues / Technical Debt
1. **Dual contract models** — Legacy `Contract` model coexists with new `ZSPContractFile`/`ZSPContractDetail` models.
2. **API URL inconsistency** — Some routes use `/list`, `/create`, `/update/:id` suffixes while others use direct REST patterns.
3. **Test coverage** — User, contract, and delivery handler tests exist (24 tests); finance and ZSP contract handlers still lack tests.
4. **Delivery handler** — Refactored to use service/repository layer (was directly using `*gorm.DB`). The `InventorySummary` method still accesses DB directly through `svc.DB()` for complex aggregate queries.
## Git Conventions
- **Commit format**: Conventional Commits with Chinese descriptions. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`. Scopes: `contract`, `finance`, `delivery`, `inventory`, `invoice`, `api`, `frontend`, `backend`.
- **Branch strategy**: `main` → production, `dev` → development, `feature/xxx` → features, `fix/xxx` → fixes.
- **Husky hooks**: commit-msg (commitlint) + pre-commit (lint-staged).
## Docs
Project documentation is in `docs/`:
- `docs/api/` — API documentation and frontend route docs
- `docs/deploy/` — Nginx configuration, Docker deployment, environment requirements
- `docs/dev/` — Requirements analysis, known issues, finance module pending questions
- `docs/Git提交规范.md` — Git commit standards (Conventional Commits)

36
backend/.air.toml Executable file
View File

@@ -0,0 +1,36 @@
# .air.toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/api/main.go"
bin = "tmp/main"
full_bin = "./tmp/main"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
log = "build-errors.log"
send_interrupt = true
stop_on_error = true
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[log]
time = false
[misc]
clean_on_exit = true
[watcher]
# 忽略的文件变化
ignore_list = ["vendor/*", "*.log", ".git/*", ".tmp/*", "node_modules/*"]
# 设置轮询间隔(适用于 Docker for Mac/Windows
poll = true
poll_interval = 500 # 毫秒

104
backend/.dockerignore Executable file
View File

@@ -0,0 +1,104 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
mysql_data/
.env.dev

10
backend/.env.dev Executable file
View File

@@ -0,0 +1,10 @@
# 开发环境配置
ENV=development
APP_NAME=myapp-dev
DB_HOST=mysql
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=myapp
# REDIS_HOST=redis
# REDIS_PORT=6379

13
backend/.env.prod Executable file
View File

@@ -0,0 +1,13 @@
# 生产环境配置(部署前请修改敏感信息)
ENV=production
APP_NAME=myapp
DB_HOST=mysql
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=myapp
# Docker Compose 变量(用于 MySQL 等服务的密码替换)
MYSQL_ROOT_PASSWORD=password
MYSQL_DATABASE=myapp
MYSQL_PASSWORD=password

5
backend/.gitignore vendored Executable file
View File

@@ -0,0 +1,5 @@
*.exe
/bin/
/.github/
/.continue/
/data/

27
backend/Dockerfile Executable file
View File

@@ -0,0 +1,27 @@
# 构建阶段
FROM golang:1.25.4-alpine3.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go env -w GO111MODULE=on
RUN go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api
# 运行阶段
FROM alpine:latest
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
COPY --from=builder /app/main .
COPY --from=builder /app/configs ./configs
# 数据目录(上传文件等),运行时挂载 volume
RUN mkdir -p data/contract/uploads data/contract/batch_archive data/ownership data/zsp-contract/files
EXPOSE 8080
ENTRYPOINT ["/app/main"]

27
backend/Dockerfile.dev Executable file
View File

@@ -0,0 +1,27 @@
# Dockerfile.dev
FROM golang:1.25.4-alpine3.22
# 安装必要的工具
RUN apk add --no-cache git curl bash
# 安装 airGo 1.21+ 版本)
RUN go install github.com/air-verse/air@latest
# 设置工作目录 - 注意这里用 /workspace不是 /app
WORKDIR /workspace
# 复制依赖文件
COPY go.mod go.sum ./
# 下载依赖到 GOPATH
RUN go mod download
# 暴露端口
EXPOSE 8080
# 设置环境变量让 go 命令能找到依赖
ENV GOPATH=/go
ENV PATH=$PATH:/go/bin
# 使用 air 启动应用
CMD ["air", "-c", ".air.toml"]

325
backend/README_CN.md Executable file
View File

@@ -0,0 +1,325 @@
# ZSP 后端管理系统
基于 Go + Gin + GORM + MySQL 构建的现代化合同管理与用户认证系统后端API。
## 📋 项目简介
ZSP 后端是一个用于供应链合同管理的企业级应用系统,提供用户认证、合同管理、文件存储等核心功能。系统采用微服务架构思想,具备高可扩展性和良好的开发体验。
### 主要特性
- 🔐 **JWT 用户认证与授权**
- 📄 **合同全生命周期管理**
- 🐳 **Docker 容器化开发环境**
- 🔄 **热重载开发体验** (Air)
- 📊 **MySQL 数据库支持**
- 🧪 **自动化数据库迁移**
- 🔧 **配置驱动架构**
## 🏗️ 技术栈
### 后端框架
- **Go 1.25.4** - 高性能编程语言
- **Gin** - 轻量级 Web 框架
- **GORM** - ORM 数据库工具
- **JWT** - 用户认证令牌
### 开发工具
- **Docker & Docker Compose** - 容器化开发环境
- **Air** - Go 应用热重载工具
- **MySQL 8.0** - 关系型数据库
### 配置管理
- **Viper** - 配置管理库
- **YAML 配置文件** - 结构化配置
## 📁 项目结构
```
zsp-backend/
├── cmd/api/ # 应用入口
│ └── main.go # 主程序入口
├── configs/ # 配置文件
│ └── config.dev.yaml # 开发环境配置
├── internal/ # 内部包
│ ├── handler/ # HTTP 处理器
│ │ ├── user_handler.go # 用户相关接口
│ │ └── contract_handler.go # 合同相关接口
│ ├── middleware/ # 中间件
│ │ └── auth.go # 认证中间件
│ ├── model/ # 数据模型
│ │ ├── user.go # 用户模型
│ │ ├── contract.go # 合同模型
│ │ └── error.go # 错误响应模型
│ ├── repository/ # 数据访问层
│ │ ├── user_repository.go
│ │ └── contract_repository.go
│ ├── service/ # 业务逻辑层
│ │ ├── user_service.go
│ │ └── contract_service.go
│ └── server/ # 服务器配置
│ └── router.go # 路由配置
├── pkg/ # 公共包
│ ├── config/ # 配置加载
│ └── database/ # 数据库连接
├── scripts/ # 脚本文件
│ └── init_database.sql # 数据库初始化脚本
├── migrations/ # 数据库迁移文件
├── test/ # 测试文件
├── web/ # 前端文件(可选)
├── deployments/ # 部署配置
├── docs/ # 文档
├── tmp/ # 临时文件
└── bin/ # 构建输出
```
## 🚀 快速开始
### 前提条件
- Docker & Docker Compose
- Go 1.25.4+ (可选,用于本地开发)
### 使用 Docker 开发环境(推荐)
1. **克隆项目**
```bash
git clone <仓库地址>
cd zsp-backend
```
2. **启动开发环境**
```bash
docker-compose -f docker-compose.dev.yml up --build
```
3. **访问应用**
- API 服务: http://localhost:8080
- MySQL 数据库: localhost:3306
### 本地开发(不使用 Docker
1. **安装依赖**
```bash
go mod download
```
2. **启动 MySQL 数据库**
```bash
# 使用 Docker 启动 MySQL
docker run --name mysql-dev -e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=myapp -p 3306:3306 -d mysql:8.0
```
3. **运行应用**
```bash
# 使用 Air 热重载
air -c .air.toml
# 或直接运行
go run cmd/api/main.go
```
## 🔧 配置说明
### 配置文件 (`configs/config.dev.yaml`)
```yaml
server:
port: 8080
mode: "debug"
read_timeout: 30
write_timeout: 30
database:
host: "mysql"
port: 3306
user: "root"
password: "password"
dbname: "myapp"
max_open_conns: 100
max_idle_conns: 10
reset_database: true
jwt:
secret: "your-jwt-secret-key"
expire_hours: 24
```
## 📖 API 接口文档
### 用户认证
#### 用户注册
```http
POST /api/v1/users/register
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123"
}
```
#### 用户登录
```http
POST /api/v1/users/login
Content-Type: application/json
{
"email": "test@example.com",
"password": "password123"
}
```
响应:
```json
{
"message": "Login successful",
"user": {
"id": 1,
"username": "testuser",
"email": "test@example.com"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
### 合同管理
#### 创建合同
```http
POST /api/v1/contracts
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"name": "采购合同",
"contract_type": "采购",
"contract_code": "CG2024001",
"buy_party": "买方公司",
"sell_party": "卖方公司",
"count_party": "计数方",
"unit_price": "1000.00",
"bl_number": "BL123456",
"delivery_time": "2024-12-31",
"pickup_time": "2024-12-30",
"packaging": "标准包装",
"remarks": "备注信息",
"file_path": "/uploads/contract.pdf"
}
```
#### 获取合同列表
```http
GET /api/v1/contracts
Authorization: Bearer <jwt-token>
```
## 🗄️ 数据模型
### 用户表 (users)
| 字段 | 类型 | 描述 |
|------|------|------|
| id | uint | 主键 |
| username | string(50) | 用户名,唯一 |
| email | string(100) | 邮箱,唯一 |
| password | string(255) | 密码哈希 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
### 合同表 (contracts)
| 字段 | 类型 | 描述 |
|------|------|------|
| id | uint | 主键 |
| name | string(100) | 合同名称 |
| contract_type | string(50) | 合同类型 |
| contract_code | string(50) | 合同编码 |
| buy_party | string(100) | 买方 |
| sell_party | string(100) | 卖方 |
| count_party | string(100) | 计数方 |
| unit_price | string(50) | 单价 |
| bl_number | string(50) | 提单号 |
| delivery_time | string(50) | 交付时间 |
| pickup_time | string(50) | 提货时间 |
| packaging | string(50) | 包装方式 |
| remarks | string(255) | 备注 |
| file_path | string(255) | 文件路径 |
## 🐳 Docker 开发环境
### 开发环境配置 (`docker-compose.dev.yml`)
- **应用服务**: Go + Air 热重载
- **数据库服务**: MySQL 8.0
- **网络配置**: 自定义网络 `app-network`
- **卷挂载**: 代码实时同步Go 模块缓存
## 🧪 测试
### 运行测试
```bash
# 运行所有测试
go test ./...
# 运行特定包测试
go test ./internal/handler
# 运行测试并显示覆盖率
go test -cover ./...
```
## 🔄 数据库迁移
### 自动迁移
在开发环境中,设置 `reset_database: true` 会自动执行数据库迁移。
### 手动迁移
1. 创建迁移文件在 `migrations/` 目录
2. 使用 GORM 的 `AutoMigrate` 或手动执行 SQL
## 📊 部署
### 生产环境构建
```bash
# 构建生产镜像
docker build -t zsp-backend-prod .
# 使用生产配置
docker run -p 8080:8080 \
-v /path/to/config.yaml:/app/config.yaml \
zsp-backend-prod
```
## 🛠️ 开发工具
### Air 热重载配置 (`.air.toml`)
- 自动检测文件变化并重新编译
- 排除测试文件和构建目录
- 自定义构建命令和输出目录
### 代码规范
- 使用 `go fmt` 格式化代码
- 遵循 Go 官方代码规范
- 使用有意义的包和函数命名
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/新功能`)
3. 提交更改 (`git commit -m '添加新功能'`)
4. 推送到分支 (`git push origin feature/新功能`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 📞 支持
如有问题或建议,请:
1. 查看 Issues
2. 提交新的 Issue
3. 联系项目维护者
---
**最后更新**: 2024年1月
**版本**: 1.0.0-dev

95
backend/cmd/api/main.go Executable file
View File

@@ -0,0 +1,95 @@
package main
import (
"log"
"os"
"os/signal"
"strconv"
"syscall"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/middleware"
"github.com/rovina/zsp-backend/internal/repository"
"github.com/rovina/zsp-backend/internal/server"
"github.com/rovina/zsp-backend/internal/service"
"github.com/rovina/zsp-backend/pkg/config"
"github.com/rovina/zsp-backend/pkg/database"
)
func main() {
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "./configs/config.dev.yaml"
}
cfg, err := config.Load(configPath)
if err != nil {
log.Fatal(err)
}
// 自动创建数据库
err = database.EnsureDatabase(&cfg.Database)
if err != nil {
log.Fatalf("create db failed: %v", err)
}
// 连接数据库
db, err := database.NewDB(&cfg.Database)
if err != nil {
log.Fatal(err)
}
// 自动建表
err = database.AutoMigrate(db)
if err != nil {
log.Fatalf("migrate failed: %v", err)
}
userRepo := repository.NewUserRepository(db)
contractRepo := repository.NewContractRepository(db)
contractFolderRepo := repository.NewContractFolderRepository(db)
contractBatchRepo := repository.NewContractBatchRepository(db)
zspContractRepo := repository.NewZSPContractRepository(db)
userService, err := service.NewUserService(userRepo, cfg.JWT.Secret)
if err != nil {
log.Fatalf("error in userService Create: %s", err.Error())
return
}
contractService, err := service.NewContractService(contractRepo)
if err != nil {
log.Fatalf("error in contractService Create: %s", err.Error())
return
}
contractFolderService := service.NewContractFolderService(contractFolderRepo)
contractBatchService := service.NewContractBatchService(contractBatchRepo)
zspContractService, err := service.NewZSPContractService(zspContractRepo)
if err != nil {
log.Fatalf("error in zspContractService Create: %s", err.Error())
return
}
zspFinancesService := service.NewZSPFinancesService(db)
companyRepo := repository.NewCompanyRepository(db)
companyService := service.NewCompanyService(companyRepo)
userHandler := handler.NewUserHandler(userService)
contractHandler := handler.NewContractHandler(contractService, contractFolderService, contractBatchService)
zspContractHandler := handler.NewZSPContractHandler(zspContractService)
zspFinancesHandler := handler.NewZSPFinancesHandler(zspFinancesService)
companyHandler := handler.NewCompanyHandler(companyService)
deliveryRepo := repository.NewDeliveryRepository(db)
deliveryService := service.NewDeliveryService(deliveryRepo)
deliveryHandler := handler.NewDeliveryHandler(deliveryService)
authMiddleware := middleware.AuthMiddleware(cfg.JWT.Secret)
router := server.SetupRouter(userHandler, contractHandler, zspContractHandler, zspFinancesHandler, companyHandler, deliveryHandler, authMiddleware)
go func() {
log.Printf("Server starting on port %d", cfg.Server.Port)
if err := router.Run(":" + strconv.Itoa(cfg.Server.Port)); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
}

20
backend/configs/config.dev.yaml Executable file
View File

@@ -0,0 +1,20 @@
# configs/config.yaml
server:
port: 8080
mode: "debug"
read_timeout: 30
write_timeout: 30
database:
host: "mysql"
port: 3306
user: "root"
password: "password"
dbname: "myapp"
max_open_conns: 100
max_idle_conns: 10
reset_database: false
jwt:
secret: "2Jkh4BdL6TVURttUEXlMeyD7rCLCkVvwdzPnddEIBRs="
expire_hours: 24

View File

@@ -0,0 +1,20 @@
# 生产环境配置
server:
port: 8080
mode: "release"
read_timeout: 30
write_timeout: 30
database:
host: "mysql"
port: 3306
user: "root"
password: "password"
dbname: "myapp"
max_open_conns: 100
max_idle_conns: 10
reset_database: false # 生产环境务必关闭,否则重启会清空所有数据
jwt:
secret: "2Jkh4BdL6TVURttUEXlMeyD7rCLCkVvwdzPnddEIBRs=" # 生产环境请更换为强密钥
expire_hours: 24

64
backend/docker-compose.dev.yml Executable file
View File

@@ -0,0 +1,64 @@
# 开发环境部署(支持热重载)
# 启动方式: docker-compose -f docker-compose.dev.yml up
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
volumes:
# 关键:将宿主机代码挂载到 /workspace而不是 /app
- .:/workspace
# 缓存 Go 模块到宿主机,避免重复下载
- go-mod-cache-dev:/go/pkg/mod
- go-build-cache-dev:/root/.cache/go-build
working_dir: /workspace # 设置容器工作目录
environment:
- GIN_MODE=debug
- GOFLAGS=-mod=readonly
# 设置用户为 root 避免权限问题(开发环境)
user: root
# 保持容器运行
stdin_open: true
tty: true
networks:
- app-network
env_file:
- ./.env.dev
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: myapp
MYSQL_PASSWORD: password
ports:
- "15782:3306"
volumes:
- mysql_data_dev:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ppassword"]
timeout: 20s
retries: 10
start_period: 30s
networks:
- app-network
volumes:
mysql_data_dev:
driver: local
go-mod-cache-dev:
driver: local
go-build-cache-dev:
driver: local
networks:
app-network:
driver: bridge

56
backend/docker-compose.yml Executable file
View File

@@ -0,0 +1,56 @@
# 生产环境部署
# 启动方式: docker-compose --env-file .env.prod up -d
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
working_dir: /app
environment:
- GIN_MODE=release
- CONFIG_PATH=/app/configs/config.prod.yaml
volumes:
# 持久化上传文件、合同等业务数据
- app_data:/app/data
- ./configs:/app/configs
- ./docker-entrypoint.sh:/docker-entrypoint.sh
stdin_open: true
tty: true
networks:
- app-network
env_file:
- ./.env.prod
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-myapp}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password}
ports:
- "15782:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-password}"]
timeout: 20s
retries: 10
start_period: 30s
networks:
- app-network
volumes:
mysql_data:
driver: local
app_data:
driver: local
networks:
app-network:
driver: bridge

5
backend/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -e
# 确保 data 目录volume 挂载点)对 appuser 可写
chown -R appuser:appuser /app/data 2>/dev/null || true
exec su-exec appuser /app/main "$@"

432
backend/docs/DEVELOPMENT.md Executable file
View File

@@ -0,0 +1,432 @@
# 开发指南
本文档提供 ZSP 后端系统的详细开发说明。
## 开发环境设置
### 1. 环境准备
#### 使用 Docker推荐
```bash
# 1. 克隆项目
git clone <repository-url>
cd zsp-backend
# 2. 复制环境变量文件
cp .env.dev.example .env.dev
# 3. 启动开发环境
docker-compose -f docker-compose.dev.yml up --build
# 4. 查看日志
docker-compose -f docker-compose.dev.yml logs -f app
```
#### 本地开发
```bash
# 1. 安装 Go 1.25.4+
# 2. 安装 Air
go install github.com/air-verse/air@latest
# 3. 启动 MySQL
docker run --name mysql-dev \
-e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=myapp \
-p 3306:3306 \
-d mysql:8.0
# 4. 运行应用
air -c .air.toml
```
### 2. 项目结构说明
```
internal/
├── handler/ # HTTP 请求处理器
├── middleware/ # 中间件(认证、日志等)
├── model/ # 数据模型定义
├── repository/ # 数据访问层
├── service/ # 业务逻辑层
└── server/ # 服务器配置和路由
```
## 开发流程
### 1. 添加新功能模块
#### 步骤 1: 创建数据模型
`internal/model/` 目录下创建新的模型文件:
```go
// internal/model/product.go
package model
type Product struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:100" json:"name"`
Description string `gorm:"size:255" json:"description"`
Price int `json:"price"`
// ... 其他字段
}
```
#### 步骤 2: 创建 Repository
`internal/repository/` 目录下创建数据访问层:
```go
// internal/repository/product_repository.go
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ProductRepository interface {
Create(product *model.Product) error
FindByID(id uint) (*model.Product, error)
FindAll() ([]model.Product, error)
Update(product *model.Product) error
Delete(id uint) error
}
type productRepository struct {
db *gorm.DB
}
func NewProductRepository(db *gorm.DB) ProductRepository {
return &productRepository{db: db}
}
// 实现接口方法...
```
#### 步骤 3: 创建 Service
`internal/service/` 目录下创建业务逻辑层:
```go
// internal/service/product_service.go
package service
import (
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
)
type ProductService interface {
CreateProduct(req *model.CreateProductRequest) (*model.Product, error)
GetProduct(id uint) (*model.Product, error)
ListProducts() ([]model.Product, error)
}
type productService struct {
repo repository.ProductRepository
}
func NewProductService(repo repository.ProductRepository) ProductService {
return &productService{repo: repo}
}
// 实现业务逻辑...
```
#### 步骤 4: 创建 Handler
`internal/handler/` 目录下创建 HTTP 处理器:
```go
// internal/handler/product_handler.go
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/service"
)
type ProductHandler struct {
service service.ProductService
}
func NewProductHandler(service service.ProductService) *ProductHandler {
return &ProductHandler{service: service}
}
func (h *ProductHandler) CreateProduct(c *gin.Context) {
var req model.CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
product, err := h.service.CreateProduct(&req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, product)
}
// 其他处理器方法...
```
#### 步骤 5: 注册路由
`internal/server/router.go` 中添加路由:
```go
// internal/server/router.go
func SetupRouter(userHandler *handler.UserHandler,
contractHandler *handler.ContractHandler,
productHandler *handler.ProductHandler, // 新增
authMiddleware gin.HandlerFunc) *gin.Engine {
router := gin.Default()
// 公共路由
public := router.Group("/api/v1")
{
public.POST("/users/register", userHandler.Register)
public.POST("/users/login", userHandler.Login)
}
// 需要认证的路由
authorized := router.Group("/api/v1")
authorized.Use(authMiddleware)
{
// 用户相关
authorized.GET("/users/profile", userHandler.GetProfile)
// 合同相关
authorized.POST("/contracts", contractHandler.CreateContract)
authorized.GET("/contracts", contractHandler.GetContracts)
// 产品相关(新增)
authorized.POST("/products", productHandler.CreateProduct)
authorized.GET("/products", productHandler.GetProducts)
authorized.GET("/products/:id", productHandler.GetProduct)
authorized.PUT("/products/:id", productHandler.UpdateProduct)
authorized.DELETE("/products/:id", productHandler.DeleteProduct)
}
return router
}
```
#### 步骤 6: 在主程序中初始化
`cmd/api/main.go` 中初始化新的组件:
```go
// cmd/api/main.go
func main() {
// ... 现有代码
// 初始化新的 Repository 和 Service
productRepo := repository.NewProductRepository(db)
productService, err := service.NewProductService(productRepo)
if err != nil {
log.Fatalf("error in productService Create: %s", err.Error())
return
}
// 创建 Handler
productHandler := handler.NewProductHandler(productService)
// 更新路由设置
router := server.SetupRouter(userHandler, contractHandler, productHandler, authMiddleware)
// ... 剩余代码
}
```
### 2. 数据库迁移
#### 自动迁移
在开发环境中,设置 `reset_database: true` 会自动执行 GORM 的 AutoMigrate。
#### 手动创建迁移文件
```bash
# 创建迁移目录
mkdir -p migrations
# 创建迁移文件
cat > migrations/001_create_products_table.sql << 'EOF'
CREATE TABLE IF NOT EXISTS products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description VARCHAR(255),
price INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
EOF
```
### 3. 测试开发
#### 单元测试
```go
// internal/service/product_service_test.go
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// 创建 Mock Repository
type mockProductRepository struct {
mock.Mock
}
func (m *mockProductRepository) Create(product *model.Product) error {
args := m.Called(product)
return args.Error(0)
}
func TestProductService_CreateProduct(t *testing.T) {
// 测试用例...
}
```
#### API 测试
使用 curl 或 Postman 测试 API
```bash
# 测试用户登录
curl -X POST http://localhost:8080/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"password123"}'
# 测试创建合同(需要认证)
curl -X POST http://localhost:8080/api/v1/contracts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{
"name": "测试合同",
"contract_type": "采购",
"contract_code": "TEST001"
}'
```
## 代码规范
### 1. 命名约定
- 包名:小写,单数形式
- 接口名:以 "er" 结尾(如 Repository, Handler
- 结构体名:首字母大写
- 变量名:驼峰式
### 2. 错误处理
```go
// 正确:返回错误
func GetUser(id uint) (*model.User, error) {
user, err := repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
// 错误panic 或忽略错误
func BadExample() {
user, _ := repo.FindByID(1) // 不要忽略错误
// 或
panic("something went wrong") // 不要使用 panic
}
```
### 3. 日志记录
```go
import "log"
// 使用结构化日志
log.Printf("User %d logged in", userID)
log.Printf("Failed to create contract: %v", err)
// 在生产环境中考虑使用更高级的日志库
```
## 调试技巧
### 1. 使用 Air 热重载
Air 会自动检测文件变化并重新编译。修改代码后无需手动重启。
### 2. 数据库调试
```bash
# 进入 MySQL 容器
docker exec -it zsp-backend-mysql-1 mysql -uroot -ppassword myapp
# 查看表结构
DESCRIBE users;
DESCRIBE contracts;
# 查询数据
SELECT * FROM users;
SELECT * FROM contracts;
```
### 3. 查看应用日志
```bash
# Docker 环境
docker-compose -f docker-compose.dev.yml logs -f app
# 查看特定服务的日志
docker-compose -f docker-compose.dev.yml logs app --tail=100
# 本地环境
tail -f tmp/air.log
```
## 常见问题
### 1. 数据库连接失败
**问题**: `Failed to connect to database`
**解决**:
1. 检查 MySQL 容器是否运行: `docker ps | grep mysql`
2. 检查连接配置: `configs/config.dev.yaml`
3. 检查网络: `docker network ls`
### 2. 热重载不工作
**问题**: Air 不检测文件变化
**解决**:
1. 检查 `.air.toml` 配置
2. 确保文件在挂载的卷中
3. 重启 Air: `docker-compose restart app`
### 3. 权限问题
**问题**: 文件写入权限错误
**解决**:
```bash
# 在容器内运行
docker exec -it zsp-backend-app-1 chmod -R 755 /workspace/uploads
```
## 性能优化建议
### 1. 数据库优化
- 为常用查询字段添加索引
- 使用连接池配置
- 避免 N+1 查询问题
### 2. API 优化
- 实现分页查询
- 使用缓存Redis
- 压缩响应数据
### 3. 内存优化
- 及时关闭数据库连接
- 使用连接池
- 避免内存泄漏
## 下一步
1. ✅ 完成基础框架搭建
2. ✅ 实现用户认证模块
3. ✅ 实现合同管理模块
4. 🔄 添加文件上传功能
5. 🔄 实现权限控制RBAC
6. 🔄 添加 API 文档Swagger
7. 🔄 实现监控和日志收集
8. 🔄 编写完整的测试套件

61
backend/go.mod Executable file
View File

@@ -0,0 +1,61 @@
module github.com/rovina/zsp-backend
go 1.25.4
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/spf13/viper v1.21.0
github.com/xuri/excelize/v2 v2.9.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
)
require (
github.com/bytedance/sonic v1.13.3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // 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.26.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2
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.2.10 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

141
backend/go.sum Executable file
View File

@@ -0,0 +1,141 @@
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
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.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
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.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
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/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
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/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/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.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
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.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

6
backend/internal/dto/error.go Executable file
View File

@@ -0,0 +1,6 @@
package dto
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}

18
backend/internal/dto/user.go Executable file
View File

@@ -0,0 +1,18 @@
package dto
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}

View File

@@ -0,0 +1,19 @@
package dto
type ZSPContractCreateRequest struct {
ContractNumber string `json:"contract_number" binding:"required"`
ContractType string `json:"contract_type" binding:"required"`
FolderID uint `json:"folder_id" binding:"required"`
SignDate string `json:"sign_date" binding:"required"`
CompanyName string `json:"company_name" binding:"required"`
Commodity string `json:"commodity" binding:"required"`
}
type ZSPContractUpdateRequest struct {
ContractNumber string `json:"contractNumber"`
CompanyName string `json:"companyName"`
Commodity string `json:"commodity"`
SignDate string `json:"signDate"`
ContractType string `json:"contractType"`
FolderID uint `json:"folderId"`
}

View File

@@ -0,0 +1,50 @@
package dto
// ZSPContractDetailCreateRequest 创建合同详情请求
type ZSPContractDetailCreateRequest struct {
ContractFileID uint `json:"contract_file_id" binding:"required"`
CommodityName string `json:"commodity_name" binding:"required"`
CommodityCode string `json:"commodity_code"`
TotalQuantity float64 `json:"total_quantity" binding:"required"`
Unit string `json:"unit"`
UnitPrice float64 `json:"unit_price" binding:"required"`
DeliveredQty float64 `json:"delivered_qty"`
BillOfLading string `json:"bill_of_lading"`
Remark string `json:"remark"`
}
// ZSPContractDetailUpdateRequest 更新合同详情请求
type ZSPContractDetailUpdateRequest struct {
ID uint `json:"id" binding:"required"`
CommodityName string `json:"commodity_name"`
CommodityCode string `json:"commodity_code"`
TotalQuantity float64 `json:"total_quantity"`
Unit string `json:"unit"`
UnitPrice float64 `json:"unit_price"`
DeliveredQty float64 `json:"delivered_qty"`
BillOfLading string `json:"bill_of_lading"`
Remark string `json:"remark"`
}
// ZSPContractDetailBatchCreateRequest 批量创建合同详情请求
type ZSPContractDetailBatchCreateRequest struct {
ContractFileID uint `json:"contract_file_id" binding:"required"`
Details []ZSPContractDetailCreateRequest `json:"details" binding:"required"`
}
// ZSPContractDetailResponse 合同详情响应
type ZSPContractDetailResponse struct {
ID uint `json:"id"`
ContractFileID uint `json:"contract_file_id"`
CommodityName string `json:"commodity_name"`
CommodityCode string `json:"commodity_code"`
TotalQuantity float64 `json:"total_quantity"`
Unit string `json:"unit"`
UnitPrice float64 `json:"unit_price"`
DeliveredQty float64 `json:"delivered_qty"`
PendingQty float64 `json:"pending_qty"`
BillOfLading string `json:"bill_of_lading"`
Remark string `json:"remark"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
}

View File

@@ -0,0 +1,101 @@
package dto
// ==================== 货代费用 ====================
type ZSPFreightChargesCreateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
ExpenseItem string `json:"expenseItem" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
Currency string `json:"currency"`
PaymentDate string `json:"paymentDate" binding:"required"`
FreightCompanyName string `json:"freightCompanyName" binding:"required"`
Remark string `json:"remark"`
}
type ZSPFreightChargesUpdateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
ExpenseItem string `json:"expenseItem"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PaymentDate string `json:"paymentDate"`
FreightCompanyName string `json:"freightCompanyName"`
Remark string `json:"remark"`
}
// ==================== 其他杂费 ====================
type ZSPMiscChargesCreateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
ExpenseItem string `json:"expenseItem" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
Currency string `json:"currency"`
PaymentDate string `json:"paymentDate" binding:"required"`
CompanyName string `json:"companyName"`
Remark string `json:"remark"`
}
type ZSPMiscChargesUpdateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
ExpenseItem string `json:"expenseItem"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PaymentDate string `json:"paymentDate"`
CompanyName string `json:"companyName"`
Remark string `json:"remark"`
}
// ==================== 服务费 ====================
type ZSPServiceFeesCreateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
Name string `json:"name" binding:"required"`
Amount float64 `json:"amount" binding:"required"`
Currency string `json:"currency"`
Date string `json:"date" binding:"required"`
Remark string `json:"remark"`
}
type ZSPServiceFeesUpdateRequest struct {
BillOfLading string `json:"billOfLading"`
ContractNumber string `json:"contractNumber"`
Name string `json:"name"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Date string `json:"date"`
Remark string `json:"remark"`
}
// ==================== 成本构成 ====================
type ZSPCostBreakdownCreateRequest struct {
BusinessID string `json:"businessId" binding:"required"`
BusinessType string `json:"businessType" binding:"required"`
}
type ZSPCostBreakdownUpdateRequest struct {
FreightChargeTotal float64 `json:"freightChargeTotal"`
MiscChargeTotal float64 `json:"miscChargeTotal"`
ServiceFeeTotal float64 `json:"serviceFeeTotal"`
}
// ==================== 利润记录 ====================
type ZSPProfitRecordCreateRequest struct {
BusinessID string `json:"businessId" binding:"required"`
BusinessType string `json:"businessType" binding:"required"`
Revenue float64 `json:"revenue" binding:"required"`
TotalCost float64 `json:"totalCost" binding:"required"`
RecordDate string `json:"recordDate" binding:"required"`
Remark string `json:"remark"`
}
type ZSPProfitRecordUpdateRequest struct {
Revenue float64 `json:"revenue"`
TotalCost float64 `json:"totalCost"`
Remark string `json:"remark"`
}

View File

@@ -0,0 +1,11 @@
package dto
type ZSPFolderCreateRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
type ZSPFolderUpdateRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}

View File

@@ -0,0 +1,176 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/service"
)
type CompanyHandler struct {
service service.CompanyService
}
func NewCompanyHandler(s service.CompanyService) *CompanyHandler {
return &CompanyHandler{service: s}
}
func (h *CompanyHandler) ListFolders(c *gin.Context) {
list, err := h.service.ListFolders()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *CompanyHandler) CreateFolder(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
f, err := h.service.CreateFolder(body.Name, body.Description, body.Category)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": f})
}
func (h *CompanyHandler) UpdateFolder(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if err := h.service.UpdateFolder(uint(id), body.Name, body.Description, body.Category); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "updated"})
}
func (h *CompanyHandler) DeleteFolder(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.service.DeleteFolder(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
func (h *CompanyHandler) ListFiles(c *gin.Context) {
var folderID *uint
if idStr := c.Query("folderId"); idStr != "" {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
uid := uint(id)
folderID = &uid
}
}
list, err := h.service.ListFiles(folderID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *CompanyHandler) UploadFiles(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "no files"})
return
}
var dataStr string
if v := form.Value["data"]; len(v) > 0 {
dataStr = v[0]
}
var data struct {
CompanyName string `json:"companyName"`
FileCategory string `json:"fileCategory"`
ExpireDate string `json:"expireDate"`
Description string `json:"description"`
FolderID string `json:"folderId"`
}
if dataStr != "" {
_ = json.Unmarshal([]byte(dataStr), &data)
}
var folderID *uint
if data.FolderID != "" {
if id, err := strconv.ParseUint(data.FolderID, 10, 32); err == nil {
uid := uint(id)
folderID = &uid
}
}
count, err := h.service.UploadFiles(folderID, data.CompanyName, data.FileCategory, data.ExpireDate, data.Description, files)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"uploadedCount": count}, "message": "uploaded"})
}
func (h *CompanyHandler) UpdateFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
var body struct {
CompanyName string `json:"companyName"`
FileCategory string `json:"fileCategory"`
ExpireDate string `json:"expireDate"`
Description string `json:"description"`
FolderID *uint `json:"folderId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if err := h.service.UpdateFile(uint(id), body.CompanyName, body.FileCategory, body.ExpireDate, body.Description, body.FolderID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "updated"})
}
func (h *CompanyHandler) DeleteFile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.service.DeleteFile(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}

View File

@@ -0,0 +1,482 @@
package handler
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
)
func (h *ContractHandler) List(c *gin.Context) {
contractType := c.Query("contractType")
list, err := h.service.List(contractType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *ContractHandler) Get(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
contract, err := h.service.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "contract not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": contract})
}
func (h *ContractHandler) Update(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
existing, err := h.service.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "contract not found"})
return
}
var body struct {
Name string `json:"name"`
ContractType string `json:"contractType"`
ContractCode string `json:"contractCode"`
BuyParty string `json:"buyParty"`
SellParty string `json:"sellParty"`
Commodity string `json:"commodity"`
Count float64 `json:"count"`
UnitPrice float64 `json:"unitPrice"`
BLNumber string `json:"blNumber"`
DeliveryTime string `json:"deliveryTime"`
PickUpTime string `json:"pickUpTime"`
Packaging string `json:"packaging"`
Remarks string `json:"remarks"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
existing.Name = body.Name
existing.ContractType = body.ContractType
existing.ContractCode = body.ContractCode
existing.BuyParty = body.BuyParty
existing.SellParty = body.SellParty
existing.Commodity = body.Commodity
existing.Count = body.Count
existing.UnitPrice = body.UnitPrice
existing.BLNumber = body.BLNumber
existing.DeliveryTime = body.DeliveryTime
existing.PickUpTime = body.PickUpTime
existing.Packaging = body.Packaging
existing.Remarks = body.Remarks
existing.UpdateTime = time.Now()
if err := h.service.Update(existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "updated"})
}
func (h *ContractHandler) UpdateWithFiles(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
formMap := make(map[string]string)
for k, v := range form.Value {
if len(v) > 0 {
formMap[k] = v[0]
}
}
keepFileIds := parseUintSlice(formMap["keepFileIds"])
newFileHeaders := form.File["files"]
if len(keepFileIds) == 0 && len(newFileHeaders) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "至少需要保留一个合同扫描件"})
return
}
var newFileModels []model.ContractFile
path := "./data/contract/uploads"
os.MkdirAll(path, os.ModePerm)
for _, fh := range newFileHeaders {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fh.Filename)
savePath := filepath.Join(path, filename)
if err := c.SaveUploadedFile(fh, savePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
newFileModels = append(newFileModels, model.ContractFile{
Name: fh.Filename,
URL: "uploads/" + filename,
})
}
if len(keepFileIds)+len(newFileModels) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "至少需要保留一个合同扫描件"})
return
}
if err := h.service.UpdateWithFiles(uint(id), keepFileIds, newFileModels, formMap); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "updated"})
}
func parseUintSlice(s string) []uint {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
var result []uint
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if v, err := strconv.ParseUint(p, 10, 32); err == nil {
result = append(result, uint(v))
}
}
return result
}
func (h *ContractHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.service.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
type ContractHandler struct {
service service.ContractService
folderService service.ContractFolderService
batchService service.ContractBatchService
}
func NewContractHandler(
service service.ContractService,
folderService service.ContractFolderService,
batchService service.ContractBatchService,
) *ContractHandler {
return &ContractHandler{
service: service,
folderService: folderService,
batchService: batchService,
}
}
func (h *ContractHandler) Upload(c *gin.Context) {
var contract model.Contract
contract.Name = c.PostForm("name")
contract.ContractType = c.PostForm("contractType")
contract.ContractCode = c.PostForm("contractCode")
contract.BuyParty = c.PostForm("buyParty")
contract.SellParty = c.PostForm("sellParty")
contract.Commodity = c.PostForm("commodity")
contract.BLNumber = c.PostForm("BLNumber")
contract.DeliveryTime = c.PostForm("deliveryTime")
contract.PickUpTime = c.PostForm("pickUpTime")
contract.Packaging = c.PostForm("packaging")
contract.Remarks = c.PostForm("remarks")
if countStr := c.PostForm("count"); countStr != "" {
if v, err := strconv.ParseFloat(countStr, 64); err == nil {
contract.Count = v
}
}
if priceStr := c.PostForm("unitPrice"); priceStr != "" {
if v, err := strconv.ParseFloat(priceStr, 64); err == nil {
contract.UnitPrice = v
}
}
contract.CreateTime = time.Now()
contract.UpdateTime = time.Now()
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "no files"})
return
}
path := "./data/contract/uploads"
os.MkdirAll(path, os.ModePerm)
var fileModels []model.ContractFile
for _, file := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
savePath := filepath.Join(path, filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
fileModels = append(fileModels, model.ContractFile{
Name: file.Filename,
URL: "uploads/" + filename,
})
}
if err := h.service.CreateContract(&contract, fileModels); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "upload success",
})
}
func (h *ContractHandler) BatchUpload(c *gin.Context) {
path := c.PostForm("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid form"})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no files"})
return
}
saved, err := h.service.BatchSaveFiles(path, files)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"count": saved,
})
}
// ========== 批量归档文件夹 ==========
func (h *ContractHandler) ListFolders(c *gin.Context) {
list, err := h.folderService.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *ContractHandler) CreateFolder(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
f, err := h.folderService.Create(body.Name, body.Description)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": f})
}
func (h *ContractHandler) GetFolder(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
f, err := h.folderService.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": f})
}
func (h *ContractHandler) UpdateFolder(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if err := h.folderService.Update(uint(id), body.Name, body.Description); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "updated"})
}
func (h *ContractHandler) DeleteFolder(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.folderService.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
// ========== 批量上传记录 ==========
func (h *ContractHandler) ListBatches(c *gin.Context) {
var folderID *uint
if idStr := c.Query("folderId"); idStr != "" {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
uid := uint(id)
folderID = &uid
}
}
list, err := h.batchService.List(folderID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *ContractHandler) CreateBatch(c *gin.Context) {
folderIDStr := c.PostForm("folderId")
batchName := c.PostForm("batchName")
remark := c.PostForm("remark")
if folderIDStr == "" || batchName == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "folderId and batchName required"})
return
}
folderID, err := strconv.ParseUint(folderIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid folderId"})
return
}
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "no files"})
return
}
path := "./data/contract/batch_archive"
os.MkdirAll(path, os.ModePerm)
var fileModels []model.ContractBatchFile
for _, file := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
savePath := filepath.Join(path, filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
fileModels = append(fileModels, model.ContractBatchFile{
Name: file.Filename,
URL: "batch_archive/" + filename,
FileSize: file.Size,
})
}
b, err := h.batchService.Create(uint(folderID), batchName, remark, fileModels)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": b, "message": "created"})
}
func (h *ContractHandler) GetBatch(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
b, err := h.batchService.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": b})
}
func (h *ContractHandler) DeleteBatch(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.batchService.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}

View File

@@ -0,0 +1,530 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
"github.com/rovina/zsp-backend/pkg/utils"
)
type DeliveryHandler struct {
svc service.DeliveryService
}
func NewDeliveryHandler(svc service.DeliveryService) *DeliveryHandler {
return &DeliveryHandler{svc: svc}
}
// ========== 我要提货 /api/apply-delivery ==========
func (h *DeliveryHandler) ListApply(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
orderNumber := c.Query("orderNumber")
blNumber := c.Query("blNumber")
status := c.Query("status")
list, total, err := h.svc.ListApply( page, pageSize, orderNumber, blNumber, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"items": list, "total": total, "page": page, "pageSize": pageSize}})
}
func (h *DeliveryHandler) GetApply(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetApplyByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) CreateApply(c *gin.Context) {
var body model.DeliveryApply
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
body.OrderNumber = fmt.Sprintf("DA%d", time.Now().UnixNano()/1e6)
body.Status = "pending"
body.CreateTime = time.Now()
body.UpdateTime = time.Now()
if err := h.svc.CreateApply( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"id": body.ID, "orderNumber": body.OrderNumber}})
}
func (h *DeliveryHandler) UpdateApply(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetApplyByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
if err := c.ShouldBindJSON(e); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
e.UpdateTime = time.Now()
if err := h.svc.UpdateApply( e); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) CancelApply(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetApplyByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
e.Status = "cancelled"
e.UpdateTime = time.Now()
if err := h.svc.UpdateApply( e); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) DeleteApply(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.svc.DeleteApply( uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
// ========== 提货明细 /api/delivery-details ==========
func (h *DeliveryHandler) ListOutbound(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
outboundNumber := c.Query("outboundNumber")
blNumber := c.Query("blNumber")
status := c.Query("outboundStatus")
list, total, err := h.svc.ListOutbound( page, pageSize, outboundNumber, blNumber, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"items": list, "total": total, "page": page, "pageSize": pageSize}})
}
func (h *DeliveryHandler) GetOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetOutboundByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) CreateOutbound(c *gin.Context) {
var body model.DeliveryOutbound
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
body.OutboundNumber = fmt.Sprintf("OB%d", time.Now().UnixNano()/1e6)
body.OutboundStatus = "pending"
body.ReceiptStatus = "pending"
body.CreateTime = time.Now()
body.UpdateTime = time.Now()
if err := h.svc.CreateOutbound( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
}
func (h *DeliveryHandler) UpdateOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetOutboundByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
if err := c.ShouldBindJSON(e); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
e.UpdateTime = time.Now()
if err := h.svc.UpdateOutbound( e); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) DeleteOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.svc.DeleteOutbound( uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
// ========== 库存 /api/inventory ==========
func (h *DeliveryHandler) ListWarehouses(c *gin.Context) {
list, err := h.svc.ListWarehouses()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func (h *DeliveryHandler) CreateWarehouse(c *gin.Context) {
var body model.Warehouse
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
body.Status = "active"
body.CreateTime = time.Now()
body.UpdateTime = time.Now()
if err := h.svc.CreateWarehouse( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
}
func (h *DeliveryHandler) UpdateWarehouse(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetWarehouseByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
if err := c.ShouldBindJSON(e); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
e.UpdateTime = time.Now()
if err := h.svc.UpdateWarehouse( e); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) DeleteWarehouse(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.svc.DeleteWarehouse( uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
func (h *DeliveryHandler) ListInbound(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
warehouse := c.Query("warehouse")
commodity := c.Query("commodity")
list, total, err := h.svc.ListInbound( page, pageSize, warehouse, commodity)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"items": list, "total": total, "page": page, "pageSize": pageSize}})
}
func (h *DeliveryHandler) CreateInbound(c *gin.Context) {
var body model.InventoryInbound
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
body.InboundNumber = fmt.Sprintf("IN%d", time.Now().UnixNano()/1e6)
body.CreateTime = time.Now()
body.UpdateTime = time.Now()
if err := h.svc.CreateInbound( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
}
func (h *DeliveryHandler) UpdateInbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetInboundByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
if err := c.ShouldBindJSON(e); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
e.UpdateTime = time.Now()
if err := h.svc.UpdateInbound( e); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) DeleteInbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.svc.DeleteInbound( uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}
// InventorySummary 库存汇总:入库 - 出库 - 货权转移 = 库存
func (h *DeliveryHandler) InventorySummary(c *gin.Context) {
warehouse := c.Query("warehouse")
commodity := c.Query("commodity")
type row struct {
Warehouse string `json:"warehouse"`
Commodity string `json:"commodity"`
TotalInbound float64 `json:"totalInbound"`
TotalOutbound float64 `json:"totalOutbound"`
TotalTransfer float64 `json:"totalTransfer"`
CurrentStock float64 `json:"currentStock"`
Unit string `json:"unit"`
}
var inSum []struct {
Warehouse string
Commodity string
Sum float64
}
qIn := h.svc.DB().Model(&model.InventoryInbound{}).Select("warehouse, commodity, SUM(inbound_weight) as sum").Group("warehouse, commodity")
if warehouse != "" {
qIn = qIn.Where("warehouse = ?", warehouse)
}
if commodity != "" {
qIn = qIn.Where("commodity LIKE ?", "%"+commodity+"%")
}
qIn.Scan(&inSum)
var outSum []struct {
Warehouse string
Commodity string
Sum float64
}
qOut := h.svc.DB().Model(&model.DeliveryOutbound{}).Select("warehouse, commodity, SUM(quantity) as sum").Group("warehouse, commodity")
if warehouse != "" {
qOut = qOut.Where("warehouse = ?", warehouse)
}
if commodity != "" {
qOut = qOut.Where("commodity LIKE ?", "%"+commodity+"%")
}
qOut.Scan(&outSum)
var trSum []struct {
Warehouse string
Commodity string
Sum float64
}
qTr := h.svc.DB().Model(&model.OwnershipTransfer{}).
Select("ownership_transfers.warehouse, ownership_transfers.commodity, COALESCE((SELECT SUM(quantity) FROM ownership_transfer_bls WHERE transfer_id = ownership_transfers.id), 0) as sum").
Group("ownership_transfers.warehouse, ownership_transfers.commodity")
if warehouse != "" {
qTr = qTr.Where("warehouse = ?", warehouse)
}
if commodity != "" {
qTr = qTr.Where("commodity LIKE ?", "%"+commodity+"%")
}
qTr.Scan(&trSum)
m := make(map[string]*row)
for _, r := range inSum {
key := r.Warehouse + "|" + r.Commodity
m[key] = &row{Warehouse: r.Warehouse, Commodity: r.Commodity, TotalInbound: r.Sum, Unit: "吨"}
}
for _, r := range outSum {
key := r.Warehouse + "|" + r.Commodity
if m[key] == nil {
m[key] = &row{Warehouse: r.Warehouse, Commodity: r.Commodity, Unit: "吨"}
}
m[key].TotalOutbound = r.Sum
}
for _, r := range trSum {
key := r.Warehouse + "|" + r.Commodity
if m[key] == nil {
m[key] = &row{Warehouse: r.Warehouse, Commodity: r.Commodity, Unit: "吨"}
}
m[key].TotalTransfer = r.Sum
}
var result []row
for _, v := range m {
v.CurrentStock = v.TotalInbound - v.TotalOutbound - v.TotalTransfer
result = append(result, *v)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// ========== 货权转移 /api/ownership-transfer ==========
func (h *DeliveryHandler) ListOwnership(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
transferNumber := c.Query("transferNumber")
blNumber := c.Query("blNumber")
status := c.Query("status")
list, total, err := h.svc.ListOwnership( page, pageSize, transferNumber, blNumber, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"items": list, "total": total}})
}
func (h *DeliveryHandler) GetOwnership(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
e, err := h.svc.GetOwnershipByID( uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": e})
}
func (h *DeliveryHandler) CreateOwnership(c *gin.Context) {
var body model.OwnershipTransfer
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
body.TransferNumber = fmt.Sprintf("OT%d", time.Now().UnixNano()/1e6)
body.Status = "draft"
body.CreateTime = time.Now()
body.UpdateTime = time.Now()
if err := h.svc.CreateOwnership( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
}
func (h *DeliveryHandler) CreateOwnershipWithFiles(c *gin.Context) {
transferDate := c.PostForm("transferDate")
commodity := c.PostForm("commodity")
warehouse := c.PostForm("warehouse")
transferor := c.PostForm("transferor")
transferee := c.PostForm("transferee")
remark := c.PostForm("remark")
// Parse dynamic BL items from JSON string field "blItems"
var blItems []model.OwnershipTransferBL
if blItemsJSON := c.PostForm("blItems"); blItemsJSON != "" {
json.Unmarshal([]byte(blItemsJSON), &blItems)
}
body := model.OwnershipTransfer{
TransferNumber: fmt.Sprintf("OT%d", time.Now().UnixNano()/1e6),
TransferDate: transferDate,
Commodity: commodity,
Warehouse: warehouse,
Transferor: transferor,
Transferee: transferee,
Remark: remark,
BLItems: blItems,
Status: "draft",
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := h.svc.CreateOwnership( &body); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
form, _ := c.MultipartForm()
files := form.File["files"]
basePath := filepath.Join(".", "data", "ownership")
if len(files) > 0 {
_ = os.MkdirAll(basePath, os.ModePerm)
for _, fh := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fh.Filename)
savePath := filepath.Join(basePath, filename)
if err := utils.SaveUploadedFile(fh, savePath); err != nil {
continue
}
fileRec := model.OwnershipTransferFile{TransferID: body.ID, Name: fh.Filename, URL: "ownership/" + filename}
_ = h.svc.CreateOwnershipFiles( []model.OwnershipTransferFile{fileRec})
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
}
func (h *DeliveryHandler) DeleteOwnership(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
return
}
if err := h.svc.DeleteOwnership( uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "deleted"})
}

View File

@@ -0,0 +1,76 @@
// internal/handler/user_handler.go
package handler
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/service"
)
type UserHandler struct {
userService service.UserService
}
func NewUserHandler(userService service.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
func (h *UserHandler) Register(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("bind json error: %s", err.Error())})
return
}
user, err := h.userService.Register(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User registered successfully",
"user": user,
})
}
func (h *UserHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, token, err := h.userService.Login(&req)
fmt.Println(user)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Login successful",
"user": user,
"token": token,
})
}
func (h *UserHandler) GetUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
user, err := h.userService.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
}

View File

@@ -0,0 +1,539 @@
package handler
import (
"bytes"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/service"
"github.com/xuri/excelize/v2"
)
type ZSPContractHandler struct {
service service.ZSPContractService
}
func NewZSPContractHandler(service service.ZSPContractService) *ZSPContractHandler {
return &ZSPContractHandler{service: service}
}
// zspContracts.POST("/folders", zspContractHandler.CreateFolder)
// zspContracts.GET("/folders", zspContractHandler.GetFolders)
// zspContracts.GET("/folders/:id", zspContractHandler.GetFolder)
// zspContracts.DELETE("/folders/:id", zspContractHandler.DeleteFolder)
// zspContracts.POST("/files", zspContractHandler.UploadFile)
// zspContracts.GET("/files/:id", zspContractHandler.GetFile)
// zspContracts.GET("/files", zspContractHandler.ListFiles)
// zspContracts.DELETE("/files/:id", zspContractHandler.DeleteFile)
func (h *ZSPContractHandler) CreateFolder(c *gin.Context) {
var req dto.ZSPFolderCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateFolder(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "folder created"})
}
func (h *ZSPContractHandler) GetFolders(c *gin.Context) {
folders, err := h.service.GetFolders()
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "folders retrieved", "data": folders})
}
func (h *ZSPContractHandler) GetFolder(c *gin.Context) {
folderID := c.Param("id")
// 这里可以调用 service 获取单个文件夹的详情
folder, err := h.service.GetFolderById(folderID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "folder retrieved", "data": folder})
}
func (h *ZSPContractHandler) DeleteFolder(c *gin.Context) {
folderID := c.Param("id")
err := h.service.DeleteFolderById(folderID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "folder deleted"})
}
func (h *ZSPContractHandler) UpdateFolder(c *gin.Context) {
folderID := c.Param("id")
var req dto.ZSPFolderUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
// 这里可以调用 service 更新文件夹的信息
err := h.service.UpdateFolderById(folderID, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "folder updated"})
}
func (h *ZSPContractHandler) UploadContract(c *gin.Context) {
var req dto.ZSPContractCreateRequest
// FormData 方式上传文件,参数在 form 中
req.Commodity = c.PostForm("commodity")
req.SignDate = c.PostForm("signDate")
req.ContractType = c.PostForm("contractType")
folderIDStr := c.PostForm("folderId")
if folderIDStr == "" {
c.JSON(400, gin.H{"success": false, "message": "请选择合同文件夹"})
return
}
folderID, err := strconv.ParseUint(folderIDStr, 10, 64)
if err != nil {
c.JSON(400, gin.H{"success": false, "message": "invalid folderId"})
return
}
req.FolderID = uint(folderID)
req.ContractNumber = c.PostForm("contractNumber")
req.CompanyName = c.PostForm("companyName")
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"success": false, "message": "file is required"})
return
}
err = h.service.UploadContract(&req, file)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract uploaded"})
}
func (h *ZSPContractHandler) GetContract(c *gin.Context) {
contractID := c.Param("id")
// 这里可以调用 service 获取单个合同的详情
contract, err := h.service.GetContractById(contractID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract retrieved", "data": contract})
}
func (h *ZSPContractHandler) ListContracts(c *gin.Context) {
folderID := c.Query("folderId")
contracts, err := h.service.ListContracts(folderID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contracts retrieved", "data": contracts})
}
func (h *ZSPContractHandler) DeleteContract(c *gin.Context) {
contractID := c.Param("id")
err := h.service.DeleteContractById(contractID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract deleted"})
}
func (h *ZSPContractHandler) UpdateContract(c *gin.Context) {
contractID := c.Param("id")
var req dto.ZSPContractUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateContractById(contractID, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract updated"})
}
// ============ 合同详情相关Handler ============
// GetContractWithDetails 获取合同及其详情列表
func (h *ZSPContractHandler) GetContractWithDetails(c *gin.Context) {
contractID := c.Param("id")
contract, err := h.service.GetContractWithDetails(contractID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract with details retrieved", "data": contract})
}
// ListContractsWithDetails 获取合同列表(包含详情)
func (h *ZSPContractHandler) ListContractsWithDetails(c *gin.Context) {
folderID := c.Query("folderId")
contracts, err := h.service.ListContractsWithDetails(folderID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contracts with details retrieved", "data": contracts})
}
// CreateContractDetail 创建合同详情
func (h *ZSPContractHandler) CreateContractDetail(c *gin.Context) {
var req dto.ZSPContractDetailCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateContractDetail(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract detail created"})
}
// UpdateContractDetail 更新合同详情
func (h *ZSPContractHandler) UpdateContractDetail(c *gin.Context) {
var req dto.ZSPContractDetailUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateContractDetail(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract detail updated"})
}
// GetContractDetail 获取合同详情
func (h *ZSPContractHandler) GetContractDetail(c *gin.Context) {
detailID := c.Param("id")
detail, err := h.service.GetContractDetailById(detailID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract detail retrieved", "data": detail})
}
// ListContractDetails 获取合同详情列表
func (h *ZSPContractHandler) ListContractDetails(c *gin.Context) {
contractFileID := c.Param("contractFileId")
details, err := h.service.ListContractDetails(contractFileID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract details retrieved", "data": details})
}
// DeleteContractDetail 删除合同详情
func (h *ZSPContractHandler) DeleteContractDetail(c *gin.Context) {
detailID := c.Param("id")
err := h.service.DeleteContractDetailById(detailID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "contract detail deleted"})
}
// BatchCreateContractDetails 批量创建合同详情
func (h *ZSPContractHandler) BatchCreateContractDetails(c *gin.Context) {
var req dto.ZSPContractDetailBatchCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.BatchCreateContractDetails(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": fmt.Sprintf("%d contract details created", len(req.Details))})
}
// ImportContractDetailsFromExcel 从Excel导入合同详情
func (h *ZSPContractHandler) ImportContractDetailsFromExcel(c *gin.Context) {
contractFileIDStr := c.PostForm("contractFileId")
if contractFileIDStr == "" {
c.JSON(400, gin.H{"success": false, "message": "contractFileId is required"})
return
}
contractFileID, err := strconv.ParseUint(contractFileIDStr, 10, 64)
if err != nil {
c.JSON(400, gin.H{"success": false, "message": "invalid contractFileId"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"success": false, "message": "file is required"})
return
}
// 打开上传的Excel文件
src, err := file.Open()
if err != nil {
c.JSON(500, gin.H{"success": false, "message": fmt.Sprintf("failed to open file: %v", err)})
return
}
defer src.Close()
// 使用excelize读取Excel文件
f, err := excelize.OpenReader(src)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": fmt.Sprintf("failed to read Excel file: %v", err)})
return
}
defer f.Close()
// 获取第一个sheet的名称
sheets := f.GetSheetList()
if len(sheets) == 0 {
c.JSON(400, gin.H{"success": false, "message": "Excel file is empty"})
return
}
sheetName := sheets[0]
// 读取所有行数据
rows, err := f.GetRows(sheetName)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": fmt.Sprintf("failed to read rows: %v", err)})
return
}
// 检查是否有数据
if len(rows) <= 1 {
c.JSON(400, gin.H{"success": false, "message": "Excel file has no data rows"})
return
}
// 解析数据(假设第一行是表头)
var details []dto.ZSPContractDetailCreateRequest
for i, row := range rows {
// 跳过表头
if i == 0 {
continue
}
// 确保至少有基本列
if len(row) < 6 {
continue
}
detail := dto.ZSPContractDetailCreateRequest{
ContractFileID: uint(contractFileID),
}
// 商品名称 (第1列)
detail.CommodityName = strings.TrimSpace(row[0])
// 单位 (第2列)
detail.Unit = strings.TrimSpace(row[1])
if detail.Unit == "" {
detail.Unit = "吨"
}
// 总量 (第3列)
if row[2] != "" {
detail.TotalQuantity, err = strconv.ParseFloat(strings.TrimSpace(row[2]), 64)
if err != nil {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("invalid total quantity at row %d: %v", i+1, err)})
return
}
}
// 单价 (第4列)
if row[3] != "" {
detail.UnitPrice, err = strconv.ParseFloat(strings.TrimSpace(row[3]), 64)
if err != nil {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("invalid unit price at row %d: %v", i+1, err)})
return
}
}
// 已提货数量 (第5列)
if row[4] != "" {
detail.DeliveredQty, err = strconv.ParseFloat(strings.TrimSpace(row[4]), 64)
if err != nil {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("invalid delivered quantity at row %d: %v", i+1, err)})
return
}
}
// 提单号 (第6列)
if len(row) > 5 {
detail.BillOfLading = strings.TrimSpace(row[5])
}
// 备注 (第7列)
if len(row) > 6 {
detail.Remark = strings.TrimSpace(row[6])
}
// 验证必填字段
if detail.CommodityName == "" {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("commodity name is required at row %d", i+1)})
return
}
if detail.TotalQuantity == 0 {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("total quantity is required at row %d", i+1)})
return
}
if detail.UnitPrice == 0 {
c.JSON(400, gin.H{"success": false, "message": fmt.Sprintf("unit price is required at row %d", i+1)})
return
}
details = append(details, detail)
}
// 批量创建合同详情
if len(details) == 0 {
c.JSON(400, gin.H{"success": false, "message": "no valid data found in Excel file"})
return
}
batchReq := &dto.ZSPContractDetailBatchCreateRequest{
ContractFileID: uint(contractFileID),
Details: details,
}
err = h.service.BatchCreateContractDetails(batchReq)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": fmt.Sprintf("failed to create contract details: %v", err)})
return
}
c.JSON(200, gin.H{"success": true, "message": fmt.Sprintf("successfully imported %d contract details", len(details))})
}
// ExportContractDetailsToExcel 导出合同详情到Excel
func (h *ZSPContractHandler) ExportContractDetailsToExcel(c *gin.Context) {
contractFileID := c.Param("id")
details, err := h.service.ListContractDetails(contractFileID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
// 创建新的Excel文件
f := excelize.NewFile()
sheetName := "Sheet1"
// 设置表头
headers := []string{"商品名称", "单位", "总量", "单价", "已提货数量", "应提货数量", "提单号", "备注"}
for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
f.SetCellValue(sheetName, cell, header)
// 设置表头样式(加粗、背景色)
style, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#E0E0E0"},
Pattern: 1,
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
})
f.SetCellStyle(sheetName, cell, cell, style)
}
// 填充数据
for i, detail := range details {
row := i + 2
// 商品名称
cell, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheetName, cell, detail.CommodityName)
// 单位
cell, _ = excelize.CoordinatesToCellName(2, row)
f.SetCellValue(sheetName, cell, detail.Unit)
// 总量
cell, _ = excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheetName, cell, detail.TotalQuantity)
// 单价
cell, _ = excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheetName, cell, detail.UnitPrice)
// 已提货数量
cell, _ = excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheetName, cell, detail.DeliveredQty)
// 应提货数量
cell, _ = excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheetName, cell, detail.PendingQty)
// 提单号
cell, _ = excelize.CoordinatesToCellName(7, row)
f.SetCellValue(sheetName, cell, detail.BillOfLading)
// 备注
cell, _ = excelize.CoordinatesToCellName(8, row)
f.SetCellValue(sheetName, cell, detail.Remark)
}
// 设置列宽
f.SetColWidth(sheetName, "A", "A", 20) // 商品名称
f.SetColWidth(sheetName, "B", "B", 10) // 单位
f.SetColWidth(sheetName, "C", "C", 12) // 总量
f.SetColWidth(sheetName, "D", "D", 12) // 单价
f.SetColWidth(sheetName, "E", "E", 12) // 已提货数量
f.SetColWidth(sheetName, "F", "F", 12) // 应提货数量
f.SetColWidth(sheetName, "G", "G", 20) // 提单号
f.SetColWidth(sheetName, "H", "H", 30) // 备注
// 使用buffer保存Excel数据
var buf bytes.Buffer
err = f.Write(&buf)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": fmt.Sprintf("failed to write Excel buffer: %v", err)})
return
}
// 关闭文件
f.Close()
// 设置响应头
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=contract_details_%s.xlsx", contractFileID))
// 返回Excel文件
c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
}

View File

@@ -0,0 +1,318 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/service"
)
type ZSPFinancesHandler struct {
service service.ZSPFinancesService
}
func NewZSPFinancesHandler(service service.ZSPFinancesService) *ZSPFinancesHandler {
return &ZSPFinancesHandler{service: service}
}
// ============ 货代费用 ============
func (h *ZSPFinancesHandler) CreateFreightCharge(c *gin.Context) {
var req dto.ZSPFreightChargesCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateFreightCharge(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "freight charge created"})
}
func (h *ZSPFinancesHandler) GetFreightCharge(c *gin.Context) {
id := c.Param("id")
charge, err := h.service.GetFreightCharge(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "freight charge retrieved", "data": charge})
}
func (h *ZSPFinancesHandler) ListFreightCharges(c *gin.Context) {
charges, err := h.service.ListFreightCharges()
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "freight charges retrieved", "data": charges})
}
func (h *ZSPFinancesHandler) UpdateFreightCharge(c *gin.Context) {
id := c.Param("id")
var req dto.ZSPFreightChargesUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateFreightCharge(id, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "freight charge updated"})
}
func (h *ZSPFinancesHandler) DeleteFreightCharge(c *gin.Context) {
id := c.Param("id")
err := h.service.DeleteFreightCharge(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "freight charge deleted"})
}
// ============ 杂费 ============
func (h *ZSPFinancesHandler) CreateMiscCharge(c *gin.Context) {
var req dto.ZSPMiscChargesCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateMiscCharge(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "misc charge created"})
}
func (h *ZSPFinancesHandler) GetMiscCharge(c *gin.Context) {
id := c.Param("id")
charge, err := h.service.GetMiscCharge(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "misc charge retrieved", "data": charge})
}
func (h *ZSPFinancesHandler) ListMiscCharges(c *gin.Context) {
charges, err := h.service.ListMiscCharges()
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "misc charges retrieved", "data": charges})
}
func (h *ZSPFinancesHandler) UpdateMiscCharge(c *gin.Context) {
id := c.Param("id")
var req dto.ZSPMiscChargesUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateMiscCharge(id, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "misc charge updated"})
}
func (h *ZSPFinancesHandler) DeleteMiscCharge(c *gin.Context) {
id := c.Param("id")
err := h.service.DeleteMiscCharge(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "misc charge deleted"})
}
// ============ 服务费 ============
func (h *ZSPFinancesHandler) CreateServiceFee(c *gin.Context) {
var req dto.ZSPServiceFeesCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateServiceFee(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "service fee created"})
}
func (h *ZSPFinancesHandler) GetServiceFee(c *gin.Context) {
id := c.Param("id")
fee, err := h.service.GetServiceFee(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "service fee retrieved", "data": fee})
}
func (h *ZSPFinancesHandler) ListServiceFees(c *gin.Context) {
fees, err := h.service.ListServiceFees()
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "service fees retrieved", "data": fees})
}
func (h *ZSPFinancesHandler) UpdateServiceFee(c *gin.Context) {
id := c.Param("id")
var req dto.ZSPServiceFeesUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateServiceFee(id, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "service fee updated"})
}
func (h *ZSPFinancesHandler) DeleteServiceFee(c *gin.Context) {
id := c.Param("id")
err := h.service.DeleteServiceFee(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "service fee deleted"})
}
// ============ 成本构成 ============
func (h *ZSPFinancesHandler) CreateCostBreakdown(c *gin.Context) {
var req dto.ZSPCostBreakdownCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateCostBreakdown(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "cost breakdown created"})
}
func (h *ZSPFinancesHandler) GetCostBreakdown(c *gin.Context) {
id := c.Param("id")
breakdown, err := h.service.GetCostBreakdown(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "cost breakdown retrieved", "data": breakdown})
}
func (h *ZSPFinancesHandler) ListCostBreakdowns(c *gin.Context) {
businessID := c.Query("businessId")
breakdowns, err := h.service.ListCostBreakdowns(businessID)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "cost breakdowns retrieved", "data": breakdowns})
}
func (h *ZSPFinancesHandler) UpdateCostBreakdown(c *gin.Context) {
id := c.Param("id")
var req dto.ZSPCostBreakdownUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateCostBreakdown(id, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "cost breakdown updated"})
}
func (h *ZSPFinancesHandler) DeleteCostBreakdown(c *gin.Context) {
id := c.Param("id")
err := h.service.DeleteCostBreakdown(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "cost breakdown deleted"})
}
// ============ 利润记录 ============
func (h *ZSPFinancesHandler) CreateProfitRecord(c *gin.Context) {
var req dto.ZSPProfitRecordCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.CreateProfitRecord(&req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "profit record created"})
}
func (h *ZSPFinancesHandler) GetProfitRecord(c *gin.Context) {
id := c.Param("id")
record, err := h.service.GetProfitRecord(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "profit record retrieved", "data": record})
}
func (h *ZSPFinancesHandler) ListProfitRecords(c *gin.Context) {
filters := make(map[string]string)
if c.Query("businessId") != "" {
filters["businessId"] = c.Query("businessId")
}
if c.Query("businessType") != "" {
filters["businessType"] = c.Query("businessType")
}
records, err := h.service.ListProfitRecords(filters)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "profit records retrieved", "data": records})
}
func (h *ZSPFinancesHandler) UpdateProfitRecord(c *gin.Context) {
id := c.Param("id")
var req dto.ZSPProfitRecordUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "message": err.Error()})
return
}
err := h.service.UpdateProfitRecord(id, &req)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "profit record updated"})
}
func (h *ZSPFinancesHandler) DeleteProfitRecord(c *gin.Context) {
id := c.Param("id")
err := h.service.DeleteProfitRecord(id)
if err != nil {
c.JSON(500, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "message": "profit record deleted"})
}

View File

@@ -0,0 +1,48 @@
// internal/middleware/auth.go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
)
type Claims struct {
UserID uint `json:"user_id"`
jwt.RegisteredClaims
}
func AuthMiddleware(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token is required"})
c.Abort()
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("userID", claims.ID) // 从token中解析出的用户ID
c.Next()
}
}

View File

@@ -0,0 +1,30 @@
package model
import "time"
// CompanyFolder 公司信息文件夹
type CompanyFolder struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
Category string `gorm:"size:50" json:"category"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
Files []CompanyFile `gorm:"foreignKey:FolderID" json:"files"`
}
// CompanyFile 公司文件(营业执照、开票信息等)
type CompanyFile struct {
ID uint `gorm:"primaryKey" json:"id"`
FolderID uint `gorm:"index" json:"folderId"`
FileName string `gorm:"size:255;not null" json:"fileName"`
FileType string `gorm:"size:50" json:"fileType"`
FileSize int64 `json:"fileSize"`
UploadTime time.Time `json:"uploadTime"`
CompanyName string `gorm:"size:100" json:"companyName"`
FileCategory string `gorm:"size:50" json:"fileCategory"` // license, tax, organization, bank, invoice, charter, legal, other
ExpireDate *time.Time `json:"expireDate"`
Description string `gorm:"size:500" json:"description"`
FileURL string `gorm:"size:500" json:"fileUrl"`
}

View File

@@ -0,0 +1,25 @@
package model
import "time"
type Contract struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:100" json:"name"`
ContractType string `gorm:"size:50" json:"contractType"`
ContractCode string `gorm:"size:50" json:"contractCode"`
BuyParty string `gorm:"size:100" json:"buyParty"`
SellParty string `gorm:"size:100" json:"sellParty"`
Commodity string `gorm:"size:100" json:"commodity"`
Count float64 `gorm:"type:decimal(16,4);default:0" json:"count"`
UnitPrice float64 `gorm:"type:decimal(16,4);default:0" json:"unitPrice"`
BLNumber string `gorm:"size:500" json:"blNumber"`
DeliveryTime string `gorm:"size:50" json:"deliveryTime"`
PickUpTime string `gorm:"size:50" json:"pickUpTime"`
Packaging string `gorm:"size:50" json:"packaging"`
Remarks string `gorm:"size:255" json:"remarks"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
Files []ContractFile `gorm:"foreignKey:ContractID" json:"files"`
}

View File

@@ -0,0 +1,8 @@
package model
type ContractFile struct {
ID uint `gorm:"primarykey" json:"id"`
ContractID uint `gorm:"index" json:"contractId"`
Name string `gorm:"size:255" json:"name"`
URL string `gorm:"size:500" json:"url"`
}

View File

@@ -0,0 +1,33 @@
package model
import "time"
// ContractFolder 业务合同批量归档文件夹(与 Contract 共用体系,用于归档批量上传)
type ContractFolder struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// ContractBatch 一次批量上传记录(归属某文件夹)
type ContractBatch struct {
ID uint `gorm:"primaryKey" json:"id"`
FolderID uint `gorm:"index;not null" json:"folderId"`
BatchName string `gorm:"size:200;not null" json:"batchName"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
Files []ContractBatchFile `gorm:"foreignKey:BatchID" json:"files"`
}
// ContractBatchFile 批量上传中的单个文件
type ContractBatchFile struct {
ID uint `gorm:"primaryKey" json:"id"`
BatchID uint `gorm:"index;not null" json:"batchId"`
Name string `gorm:"size:255;not null" json:"name"`
URL string `gorm:"size:500;not null" json:"url"`
FileSize int64 `gorm:"default:0" json:"fileSize"`
}

View File

@@ -0,0 +1,115 @@
package model
import "time"
// DeliveryApply 我要提货 - 提货委托
type DeliveryApply struct {
ID uint `gorm:"primaryKey" json:"id"`
OrderNumber string `gorm:"size:50;uniqueIndex" json:"orderNumber"`
BLNumber string `gorm:"size:100" json:"blNumber"`
BLNumber2 string `gorm:"size:100" json:"blNumber2"`
LicensePlate string `gorm:"size:20" json:"licensePlate"`
DriverName string `gorm:"size:50" json:"driverName"`
DriverPhone string `gorm:"size:20" json:"driverPhone"`
DriverIdCard string `gorm:"size:30" json:"driverIdCard"`
Commodity string `gorm:"size:50" json:"commodity"`
Quantity float64 `gorm:"type:decimal(16,4)" json:"quantity"`
Unit string `gorm:"size:20" json:"unit"`
DeliveryDate string `gorm:"size:20" json:"deliveryDate"`
DeliveryAddress string `gorm:"size:200" json:"deliveryAddress"`
Warehouse string `gorm:"size:100" json:"warehouse"`
Status string `gorm:"size:20;default:pending" json:"status"` // pending, approved, in_progress, completed, cancelled
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// DeliveryOutbound 提货明细 - 出库单
type DeliveryOutbound struct {
ID uint `gorm:"primaryKey" json:"id"`
OutboundNumber string `gorm:"size:50;uniqueIndex" json:"outboundNumber"`
DeliveryOrderNumber string `gorm:"size:50" json:"deliveryOrderNumber"`
BLNumber string `gorm:"size:100" json:"blNumber"`
LicensePlate string `gorm:"size:20" json:"licensePlate"`
DriverName string `gorm:"size:50" json:"driverName"`
DriverPhone string `gorm:"size:20" json:"driverPhone"`
Commodity string `gorm:"size:50" json:"commodity"`
CommodityCode string `gorm:"size:30" json:"commodityCode"`
Quantity float64 `gorm:"type:decimal(16,4)" json:"quantity"`
Unit string `gorm:"size:20" json:"unit"`
OutboundDate string `gorm:"size:20" json:"outboundDate"`
Warehouse string `gorm:"size:100" json:"warehouse"`
Operator string `gorm:"size:50" json:"operator"`
OutboundStatus string `gorm:"size:20;default:pending" json:"outboundStatus"`
ReceiptStatus string `gorm:"size:20;default:pending" json:"receiptStatus"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// Warehouse 仓库
type Warehouse struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"size:30;uniqueIndex" json:"code"`
Name string `gorm:"size:100" json:"name"`
Address string `gorm:"size:200" json:"address"`
ContactPerson string `gorm:"size:50" json:"contactPerson"`
ContactPhone string `gorm:"size:20" json:"contactPhone"`
Status string `gorm:"size:20;default:active" json:"status"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// InventoryInbound 入库单
type InventoryInbound struct {
ID uint `gorm:"primaryKey" json:"id"`
InboundNumber string `gorm:"size:50;uniqueIndex" json:"inboundNumber"`
Warehouse string `gorm:"size:100" json:"warehouse"`
Commodity string `gorm:"size:50" json:"commodity"`
CommodityCode string `gorm:"size:30" json:"commodityCode"`
BLNumber string `gorm:"size:100" json:"blNumber"`
BLWeight float64 `gorm:"type:decimal(16,4)" json:"blWeight"`
InboundWeight float64 `gorm:"type:decimal(16,4)" json:"inboundWeight"`
Owner string `gorm:"size:100" json:"owner"`
ContractNumber string `gorm:"size:50" json:"contractNumber"`
InboundDate string `gorm:"size:20" json:"inboundDate"`
Operator string `gorm:"size:50" json:"operator"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// OwnershipTransfer 货权转移
type OwnershipTransfer struct {
ID uint `gorm:"primaryKey" json:"id"`
TransferNumber string `gorm:"size:50;uniqueIndex" json:"transferNumber"`
TransferDate string `gorm:"size:20" json:"transferDate"`
Commodity string `gorm:"size:50" json:"commodity"`
Warehouse string `gorm:"size:100" json:"warehouse"`
Transferor string `gorm:"size:100" json:"transferor"`
Transferee string `gorm:"size:100" json:"transferee"`
Status string `gorm:"size:20;default:draft" json:"status"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
BLItems []OwnershipTransferBL `gorm:"foreignKey:TransferID" json:"blItems"`
Files []OwnershipTransferFile `gorm:"foreignKey:TransferID" json:"fileList"`
}
// OwnershipTransferBL 货权转移 - 提单明细(支持动态多条)
type OwnershipTransferBL struct {
ID uint `gorm:"primaryKey" json:"id"`
TransferID uint `gorm:"index;not null" json:"transferId"`
BLNumber string `gorm:"size:100" json:"blNumber"`
Quantity float64 `gorm:"type:decimal(16,4)" json:"quantity"`
}
// OwnershipTransferFile 货权转移附件
type OwnershipTransferFile struct {
ID uint `gorm:"primaryKey" json:"id"`
TransferID uint `gorm:"index" json:"transferId"`
Name string `gorm:"size:255" json:"name"`
URL string `gorm:"size:500" json:"url"`
}

14
backend/internal/model/user.go Executable file
View File

@@ -0,0 +1,14 @@
package model
import (
"time"
)
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;size:50" json:"username"`
Email string `gorm:"uniqueIndex;size:100" json:"email"`
Password string `gorm:"size:255" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,99 @@
package model
import (
"time"
"gorm.io/gorm"
)
// {
// id: "1",
// fileName: "上游合同-铜矿采购-2024-001.pdf",
// fileType: "pdf",
// fileSize: "2.3MB",
// uploadTime: "2024-01-20",
// folderId: "1",
// contractNumber: "SC2024001",
// companyName: "中尚鹏贸易有限公司",
// commodity: "铜矿",
// signDate: "2024-01-15",
// contractType: "upstream"
// }
type ZSPContractFolder struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100" json:"name"`
Count int `json:"count"`
CreateTime time.Time `json:"createTime"`
Description string `gorm:"size:255" json:"description"`
Files []ZSPContractFile `gorm:"foreignKey:FolderID" json:"files"`
}
type ZSPContractFile struct {
ID uint `gorm:"primaryKey" json:"id"`
FileName string `gorm:"size:255" json:"fileName"`
FileURL string `gorm:"size:500" json:"fileUrl"`
FileType string `gorm:"size:50" json:"fileType"`
FileSize int64 `json:"fileSize"`
UploadTime time.Time `json:"uploadTime"`
FolderID uint `gorm:"index" json:"folderId"`
Folder ZSPContractFolder `gorm:"foreignKey:FolderID" json:"folder"`
ContractNumber string `gorm:"size:50" json:"contractNumber"`
CompanyName string `gorm:"size:100" json:"companyName"`
Commodity string `gorm:"size:100" json:"commodity"`
SignDate time.Time `json:"signDate"`
ContractType string `gorm:"size:50" json:"contractType"`
// 合同详情
Details []ZSPContractDetail `gorm:"foreignKey:ContractFileID" json:"details"`
}
// ZSPContractDetail 合同详情表(商品明细)
type ZSPContractDetail struct {
ID uint `gorm:"primaryKey" json:"id"`
ContractFileID uint `gorm:"index;not null" json:"contractFileId"`
ContractFile ZSPContractFile `gorm:"foreignKey:ContractFileID" json:"contractFile"`
// 商品信息
CommodityName string `gorm:"size:100;not null" json:"commodityName"` // 商品名称
CommodityCode string `gorm:"size:50" json:"commodityCode"` // 商品编码
// 数量信息
TotalQuantity float64 `gorm:"not null" json:"totalQuantity"` // 合同总量
Unit string `gorm:"size:20;default:'吨'" json:"unit"` // 单位
// 价格信息
UnitPrice float64 `gorm:"not null" json:"unitPrice"` // 单价
// 提货信息
DeliveredQty float64 `gorm:"default:0" json:"deliveredQty"` // 已提货数量
PendingQty float64 `gorm:"default:0" json:"pendingQty"` // 应提货数量(自动计算 = 总量 - 已提货)
// 提单信息
BillOfLading string `gorm:"size:100" json:"billOfLading"` // 提单号
// 备注
Remark string `gorm:"size:500" json:"remark"`
// 时间戳
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// BeforeCreate GORM钩子在创建前自动计算应提货数量
func (d *ZSPContractDetail) BeforeCreate(tx *gorm.DB) error {
d.PendingQty = d.TotalQuantity - d.DeliveredQty
d.CreateTime = time.Now()
d.UpdateTime = time.Now()
return nil
}
// BeforeUpdate GORM钩子在更新前自动计算应提货数量
func (d *ZSPContractDetail) BeforeUpdate(tx *gorm.DB) error {
d.PendingQty = d.TotalQuantity - d.DeliveredQty
d.UpdateTime = time.Now()
return nil
}

View File

@@ -0,0 +1,77 @@
package model
import "time"
// ZSPFreightCharges 货代费用表
type ZSPFreightCharges struct {
ID uint `gorm:"primaryKey" json:"id"`
BillOfLading string `gorm:"size:100;index" json:"billOfLading"`
ContractNumber string `gorm:"size:100;index" json:"contractNumber"`
ExpenseItem string `gorm:"size:200;not null" json:"expenseItem"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
PaymentDate time.Time `gorm:"not null" json:"paymentDate"`
FreightCompanyName string `gorm:"size:200;not null" json:"freightCompanyName"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// ZSPMiscCharges 其他杂费表
type ZSPMiscCharges struct {
ID uint `gorm:"primaryKey" json:"id"`
BillOfLading string `gorm:"size:100;index" json:"billOfLading"`
ContractNumber string `gorm:"size:100;index" json:"contractNumber"`
ExpenseItem string `gorm:"size:200;not null" json:"expenseItem"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
PaymentDate time.Time `gorm:"not null" json:"paymentDate"`
CompanyName string `gorm:"size:200" json:"companyName"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// ZSPServiceFees 服务费表
type ZSPServiceFees struct {
ID uint `gorm:"primaryKey" json:"id"`
BillOfLading string `gorm:"size:100;index" json:"billOfLading"`
ContractNumber string `gorm:"size:100;index" json:"contractNumber"`
Name string `gorm:"size:100;not null" json:"name"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
Date time.Time `gorm:"not null" json:"date"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// ZSPCostBreakdown 成本构成明细表
type ZSPCostBreakdown struct {
ID uint `gorm:"primaryKey" json:"id"`
BusinessID string `gorm:"size:100;not null;index" json:"businessId"`
BusinessType string `gorm:"size:50;not null;index" json:"businessType"`
TotalCost float64 `gorm:"type:decimal(16,4);not null" json:"totalCost"`
FreightChargeTotal float64 `gorm:"type:decimal(16,4);default:0" json:"freightChargeTotal"`
MiscChargeTotal float64 `gorm:"type:decimal(16,4);default:0" json:"miscChargeTotal"`
ServiceFeeTotal float64 `gorm:"type:decimal(16,4);default:0" json:"serviceFeeTotal"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}
// ZSPProfitRecord 利润记录表
type ZSPProfitRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BusinessID string `gorm:"size:100;not null;index" json:"businessId"`
BusinessType string `gorm:"size:50;not null;index" json:"businessType"`
Revenue float64 `gorm:"type:decimal(16,4);not null" json:"revenue"`
TotalCost float64 `gorm:"type:decimal(16,4);not null" json:"totalCost"`
Profit float64 `gorm:"type:decimal(16,4);not null" json:"profit"`
ProfitRate float64 `gorm:"type:decimal(8,4)" json:"profitRate"`
RecordDate time.Time `gorm:"not null" json:"recordDate"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"`
}

View File

@@ -0,0 +1,63 @@
package model
import "time"
// ZSPUpstreamInvoice 上游发票表
type ZSPUpstreamInvoice struct {
ID uint `gorm:"primaryKey" json:"id"`
InvoiceNumber string `gorm:"size:100;uniqueIndex;not null" json:"invoice_number"`
ContractNumber string `gorm:"size:100;index" json:"contract_number"`
BillOfLading string `gorm:"size:100;index" json:"bill_of_lading"`
SupplierName string `gorm:"size:200;not null" json:"supplier_name"`
Commodity string `gorm:"size:100" json:"commodity"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
TaxAmount float64 `gorm:"type:decimal(16,4);default:0" json:"tax_amount"`
TotalAmount float64 `gorm:"type:decimal(16,4);not null" json:"total_amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
InvoiceDate time.Time `gorm:"not null" json:"invoice_date"`
Status string `gorm:"size:20;default:'pending'" json:"status"`
FilePath string `gorm:"size:500" json:"file_path"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
// ZSPDownstreamInvoice 下游发票表
type ZSPDownstreamInvoice struct {
ID uint `gorm:"primaryKey" json:"id"`
InvoiceNumber string `gorm:"size:100;uniqueIndex;not null" json:"invoice_number"`
ContractNumber string `gorm:"size:100;index" json:"contract_number"`
BillOfLading string `gorm:"size:100;index" json:"bill_of_lading"`
CustomerName string `gorm:"size:200;not null" json:"customer_name"`
Commodity string `gorm:"size:100" json:"commodity"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
TaxAmount float64 `gorm:"type:decimal(16,4);default:0" json:"tax_amount"`
TotalAmount float64 `gorm:"type:decimal(16,4);not null" json:"total_amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
InvoiceDate time.Time `gorm:"not null" json:"invoice_date"`
Status string `gorm:"size:20;default:'pending'" json:"status"`
FilePath string `gorm:"size:500" json:"file_path"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
// ZSPFreightMiscInvoice 货代杂费发票表
type ZSPFreightMiscInvoice struct {
ID uint `gorm:"primaryKey" json:"id"`
InvoiceNumber string `gorm:"size:100;uniqueIndex;not null" json:"invoice_number"`
ContractNumber string `gorm:"size:100;index" json:"contract_number"`
BillOfLading string `gorm:"size:100;index" json:"bill_of_lading"`
CompanyName string `gorm:"size:200;not null" json:"company_name"`
ExpenseItem string `gorm:"size:200" json:"expense_item"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
TaxAmount float64 `gorm:"type:decimal(16,4);default:0" json:"tax_amount"`
TotalAmount float64 `gorm:"type:decimal(16,4);not null" json:"total_amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
InvoiceDate time.Time `gorm:"not null" json:"invoice_date"`
Status string `gorm:"size:20;default:'pending'" json:"status"`
FilePath string `gorm:"size:500" json:"file_path"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}

View File

@@ -0,0 +1,42 @@
package model
import "time"
// ZSPSettlement 结算单表
type ZSPSettlement struct {
ID uint `gorm:"primaryKey" json:"id"`
SettlementNumber string `gorm:"size:100;uniqueIndex;not null" json:"settlement_number"`
ContractNumber string `gorm:"size:100;index;not null" json:"contract_number"`
ContractType string `gorm:"size:50" json:"contract_type"`
CompanyName string `gorm:"size:200;not null" json:"company_name"`
Commodity string `gorm:"size:100" json:"commodity"`
Quantity float64 `gorm:"type:decimal(16,4)" json:"quantity"`
UnitPrice float64 `gorm:"type:decimal(16,4)" json:"unit_price"`
Amount float64 `gorm:"type:decimal(16,4);not null" json:"amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
SettlementDate time.Time `gorm:"not null" json:"settlement_date"`
Status string `gorm:"size:20;default:'pending'" json:"status"`
FilePath string `gorm:"size:500" json:"file_path"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
// ZSPReconciliation 对账单表
type ZSPReconciliation struct {
ID uint `gorm:"primaryKey" json:"id"`
ReconciliationNumber string `gorm:"size:100;uniqueIndex;not null" json:"reconciliation_number"`
ContractNumber string `gorm:"size:100;index" json:"contract_number"`
CompanyName string `gorm:"size:200;not null" json:"company_name"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
TotalAmount float64 `gorm:"type:decimal(16,4)" json:"total_amount"`
PaidAmount float64 `gorm:"type:decimal(16,4)" json:"paid_amount"`
UnpaidAmount float64 `gorm:"type:decimal(16,4)" json:"unpaid_amount"`
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
Status string `gorm:"size:20;default:'draft'" json:"status"`
FilePath string `gorm:"size:500" json:"file_path"`
Remark string `gorm:"size:500" json:"remark"`
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}

View File

@@ -0,0 +1,86 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type CompanyRepository interface {
CreateFolder(f *model.CompanyFolder) error
ListFolders() ([]model.CompanyFolder, error)
GetFolderByID(id uint) (*model.CompanyFolder, error)
UpdateFolder(f *model.CompanyFolder) error
DeleteFolder(id uint) error
CreateFile(f *model.CompanyFile) error
ListFiles(folderID *uint) ([]model.CompanyFile, error)
GetFileByID(id uint) (*model.CompanyFile, error)
UpdateFile(f *model.CompanyFile) error
DeleteFile(id uint) error
}
type companyRepository struct {
db *gorm.DB
}
func NewCompanyRepository(db *gorm.DB) CompanyRepository {
return &companyRepository{db: db}
}
func (r *companyRepository) CreateFolder(f *model.CompanyFolder) error {
return r.db.Create(f).Error
}
func (r *companyRepository) ListFolders() ([]model.CompanyFolder, error) {
var list []model.CompanyFolder
err := r.db.Order("create_time DESC").Find(&list).Error
return list, err
}
func (r *companyRepository) GetFolderByID(id uint) (*model.CompanyFolder, error) {
var f model.CompanyFolder
err := r.db.First(&f, id).Error
if err != nil {
return nil, err
}
return &f, nil
}
func (r *companyRepository) UpdateFolder(f *model.CompanyFolder) error {
return r.db.Save(f).Error
}
func (r *companyRepository) DeleteFolder(id uint) error {
return r.db.Delete(&model.CompanyFolder{}, id).Error
}
func (r *companyRepository) CreateFile(f *model.CompanyFile) error {
return r.db.Create(f).Error
}
func (r *companyRepository) ListFiles(folderID *uint) ([]model.CompanyFile, error) {
var list []model.CompanyFile
q := r.db.Order("upload_time DESC")
if folderID != nil && *folderID > 0 {
q = q.Where("folder_id = ?", *folderID)
}
err := q.Find(&list).Error
return list, err
}
func (r *companyRepository) GetFileByID(id uint) (*model.CompanyFile, error) {
var f model.CompanyFile
err := r.db.First(&f, id).Error
if err != nil {
return nil, err
}
return &f, nil
}
func (r *companyRepository) UpdateFile(f *model.CompanyFile) error {
return r.db.Save(f).Error
}
func (r *companyRepository) DeleteFile(id uint) error {
return r.db.Delete(&model.CompanyFile{}, id).Error
}

View File

@@ -0,0 +1,65 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ContractBatchRepository interface {
List(folderID *uint) ([]model.ContractBatch, error)
GetByID(id uint) (*model.ContractBatch, error)
Create(b *model.ContractBatch, files []model.ContractBatchFile) error
Delete(id uint) error
}
type contractBatchRepository struct {
db *gorm.DB
}
func NewContractBatchRepository(db *gorm.DB) ContractBatchRepository {
return &contractBatchRepository{db: db}
}
func (r *contractBatchRepository) List(folderID *uint) ([]model.ContractBatch, error) {
var list []model.ContractBatch
query := r.db.Model(&model.ContractBatch{}).Order("create_time DESC")
if folderID != nil && *folderID > 0 {
query = query.Where("folder_id = ?", *folderID)
}
err := query.Preload("Files").Find(&list).Error
return list, err
}
func (r *contractBatchRepository) GetByID(id uint) (*model.ContractBatch, error) {
var b model.ContractBatch
if err := r.db.Preload("Files").First(&b, id).Error; err != nil {
return nil, err
}
return &b, nil
}
func (r *contractBatchRepository) Create(b *model.ContractBatch, files []model.ContractBatchFile) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(b).Error; err != nil {
return err
}
for i := range files {
files[i].BatchID = b.ID
}
if len(files) > 0 {
if err := tx.Create(&files).Error; err != nil {
return err
}
}
return nil
})
}
func (r *contractBatchRepository) Delete(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("batch_id = ?", id).Delete(&model.ContractBatchFile{}).Error; err != nil {
return err
}
return tx.Delete(&model.ContractBatch{}, id).Error
})
}

View File

@@ -0,0 +1,48 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ContractFolderRepository interface {
List() ([]model.ContractFolder, error)
GetByID(id uint) (*model.ContractFolder, error)
Create(f *model.ContractFolder) error
Update(f *model.ContractFolder) error
Delete(id uint) error
}
type contractFolderRepository struct {
db *gorm.DB
}
func NewContractFolderRepository(db *gorm.DB) ContractFolderRepository {
return &contractFolderRepository{db: db}
}
func (r *contractFolderRepository) List() ([]model.ContractFolder, error) {
var list []model.ContractFolder
err := r.db.Order("create_time DESC").Find(&list).Error
return list, err
}
func (r *contractFolderRepository) GetByID(id uint) (*model.ContractFolder, error) {
var f model.ContractFolder
if err := r.db.First(&f, id).Error; err != nil {
return nil, err
}
return &f, nil
}
func (r *contractFolderRepository) Create(f *model.ContractFolder) error {
return r.db.Create(f).Error
}
func (r *contractFolderRepository) Update(f *model.ContractFolder) error {
return r.db.Save(f).Error
}
func (r *contractFolderRepository) Delete(id uint) error {
return r.db.Delete(&model.ContractFolder{}, id).Error
}

View File

@@ -0,0 +1,120 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ContractRepository interface {
CreateWithFiles(contract *model.Contract, files []model.ContractFile) error
CreateFiles(files []model.ContractFile) error
BatchInsert(files []model.ContractFile) error
List(contractType string) ([]model.Contract, error)
GetByID(id uint) (*model.Contract, error)
Update(contract *model.Contract) error
Delete(id uint) error
DeleteFilesByContractIDExcept(contractID uint, keepIDs []uint) error
UpdateWithFiles(contract *model.Contract, keepFileIDs []uint, newFiles []model.ContractFile) error
}
type contractRepository struct {
db *gorm.DB
}
func NewContractRepository(db *gorm.DB) ContractRepository {
return &contractRepository{db: db}
}
func (r *contractRepository) CreateFiles(files []model.ContractFile) error {
return r.db.Create(&files).Error
}
func (r *contractRepository) CreateWithFiles(contract *model.Contract, files []model.ContractFile) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(contract).Error; err != nil {
return err
}
for i := range files {
files[i].ContractID = contract.ID
}
if len(files) > 0 {
if err := tx.Create(&files).Error; err != nil {
return err
}
}
return nil
})
}
func (r *contractRepository) BatchInsert(files []model.ContractFile) error {
return r.db.Create(&files).Error
}
func (r *contractRepository) List(contractType string) ([]model.Contract, error) {
var list []model.Contract
query := r.db.Model(&model.Contract{}).Order("create_time DESC")
if contractType != "" {
query = query.Where("contract_type = ?", contractType)
}
err := query.Preload("Files").Find(&list).Error
return list, err
}
func (r *contractRepository) GetByID(id uint) (*model.Contract, error) {
var c model.Contract
err := r.db.Preload("Files").First(&c, id).Error
if err != nil {
return nil, err
}
return &c, nil
}
func (r *contractRepository) Update(contract *model.Contract) error {
return r.db.Save(contract).Error
}
func (r *contractRepository) Delete(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("contract_id = ?", id).Delete(&model.ContractFile{}).Error; err != nil {
return err
}
return tx.Delete(&model.Contract{}, id).Error
})
}
func (r *contractRepository) DeleteFilesByContractIDExcept(contractID uint, keepIDs []uint) error {
if len(keepIDs) == 0 {
return r.db.Where("contract_id = ?", contractID).Delete(&model.ContractFile{}).Error
}
return r.db.Where("contract_id = ? AND id NOT IN ?", contractID, keepIDs).Delete(&model.ContractFile{}).Error
}
func (r *contractRepository) UpdateWithFiles(contract *model.Contract, keepFileIDs []uint, newFiles []model.ContractFile) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(contract).Error; err != nil {
return err
}
if len(keepFileIDs) == 0 {
if err := tx.Where("contract_id = ?", contract.ID).Delete(&model.ContractFile{}).Error; err != nil {
return err
}
} else {
if err := tx.Where("contract_id = ? AND id NOT IN ?", contract.ID, keepFileIDs).Delete(&model.ContractFile{}).Error; err != nil {
return err
}
}
for i := range newFiles {
newFiles[i].ContractID = contract.ID
}
if len(newFiles) > 0 {
if err := tx.Create(&newFiles).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,269 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
// ----- 我要提货 -----
func CreateDeliveryApply(db *gorm.DB, e *model.DeliveryApply) error { return db.Create(e).Error }
func ListDeliveryApply(db *gorm.DB, page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error) {
var list []model.DeliveryApply
q := db.Model(&model.DeliveryApply{})
if orderNumber != "" {
q = q.Where("order_number LIKE ?", "%"+orderNumber+"%")
}
if blNumber != "" {
q = q.Where("bl_number LIKE ?", "%"+blNumber+"%")
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if pageSize <= 0 {
pageSize = 10
}
err := q.Order("create_time DESC").Offset(offset).Limit(pageSize).Find(&list).Error
return list, total, err
}
func GetDeliveryApplyByID(db *gorm.DB, id uint) (*model.DeliveryApply, error) {
var e model.DeliveryApply
if err := db.First(&e, id).Error; err != nil {
return nil, err
}
return &e, nil
}
func UpdateDeliveryApply(db *gorm.DB, e *model.DeliveryApply) error { return db.Save(e).Error }
func DeleteDeliveryApply(db *gorm.DB, id uint) error { return db.Delete(&model.DeliveryApply{}, id).Error }
// ----- 提货明细/出库单 -----
func CreateDeliveryOutbound(db *gorm.DB, e *model.DeliveryOutbound) error { return db.Create(e).Error }
func ListDeliveryOutbound(db *gorm.DB, page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error) {
var list []model.DeliveryOutbound
q := db.Model(&model.DeliveryOutbound{})
if outboundNumber != "" {
q = q.Where("outbound_number LIKE ?", "%"+outboundNumber+"%")
}
if blNumber != "" {
q = q.Where("bl_number LIKE ?", "%"+blNumber+"%")
}
if status != "" {
q = q.Where("outbound_status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if pageSize <= 0 {
pageSize = 10
}
err := q.Order("create_time DESC").Offset(offset).Limit(pageSize).Find(&list).Error
return list, total, err
}
func GetDeliveryOutboundByID(db *gorm.DB, id uint) (*model.DeliveryOutbound, error) {
var e model.DeliveryOutbound
if err := db.First(&e, id).Error; err != nil {
return nil, err
}
return &e, nil
}
func UpdateDeliveryOutbound(db *gorm.DB, e *model.DeliveryOutbound) error { return db.Save(e).Error }
func DeleteDeliveryOutbound(db *gorm.DB, id uint) error { return db.Delete(&model.DeliveryOutbound{}, id).Error }
// ----- 仓库 -----
func CreateWarehouse(db *gorm.DB, e *model.Warehouse) error { return db.Create(e).Error }
func ListWarehouses(db *gorm.DB) ([]model.Warehouse, error) { var list []model.Warehouse; err := db.Order("create_time DESC").Find(&list).Error; return list, err }
func GetWarehouseByID(db *gorm.DB, id uint) (*model.Warehouse, error) {
var e model.Warehouse
if err := db.First(&e, id).Error; err != nil {
return nil, err
}
return &e, nil
}
func UpdateWarehouse(db *gorm.DB, e *model.Warehouse) error { return db.Save(e).Error }
func DeleteWarehouse(db *gorm.DB, id uint) error { return db.Delete(&model.Warehouse{}, id).Error }
// ----- 入库单 -----
func CreateInventoryInbound(db *gorm.DB, e *model.InventoryInbound) error { return db.Create(e).Error }
func ListInventoryInbound(db *gorm.DB, page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error) {
var list []model.InventoryInbound
q := db.Model(&model.InventoryInbound{})
if warehouse != "" {
q = q.Where("warehouse = ?", warehouse)
}
if commodity != "" {
q = q.Where("commodity LIKE ?", "%"+commodity+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if pageSize <= 0 {
pageSize = 10
}
err := q.Order("create_time DESC").Offset(offset).Limit(pageSize).Find(&list).Error
return list, total, err
}
func GetInventoryInboundByID(db *gorm.DB, id uint) (*model.InventoryInbound, error) {
var e model.InventoryInbound
if err := db.First(&e, id).Error; err != nil {
return nil, err
}
return &e, nil
}
func UpdateInventoryInbound(db *gorm.DB, e *model.InventoryInbound) error { return db.Save(e).Error }
func DeleteInventoryInbound(db *gorm.DB, id uint) error { return db.Delete(&model.InventoryInbound{}, id).Error }
// ----- 货权转移 -----
func CreateOwnershipTransfer(db *gorm.DB, e *model.OwnershipTransfer) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(e).Error; err != nil {
return err
}
// Set TransferID for BL items after main record is created
for i := range e.BLItems {
e.BLItems[i].TransferID = e.ID
}
if len(e.BLItems) > 0 {
return tx.Create(&e.BLItems).Error
}
return nil
})
}
func ListOwnershipTransfer(db *gorm.DB, page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error) {
var list []model.OwnershipTransfer
q := db.Model(&model.OwnershipTransfer{})
if transferNumber != "" {
q = q.Where("transfer_number LIKE ?", "%"+transferNumber+"%")
}
if blNumber != "" {
q = q.Where("id IN (SELECT transfer_id FROM ownership_transfer_bls WHERE bl_number LIKE ?)", "%"+blNumber+"%")
}
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
if pageSize <= 0 {
pageSize = 10
}
err := q.Preload("Files").Preload("BLItems").Order("create_time DESC").Offset(offset).Limit(pageSize).Find(&list).Error
return list, total, err
}
func GetOwnershipTransferByID(db *gorm.DB, id uint) (*model.OwnershipTransfer, error) {
var e model.OwnershipTransfer
if err := db.Preload("Files").Preload("BLItems").First(&e, id).Error; err != nil {
return nil, err
}
return &e, nil
}
func UpdateOwnershipTransfer(db *gorm.DB, e *model.OwnershipTransfer) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(e).Error; err != nil {
return err
}
// Replace BL items: delete old, insert new
if err := tx.Where("transfer_id = ?", e.ID).Delete(&model.OwnershipTransferBL{}).Error; err != nil {
return err
}
for i := range e.BLItems {
e.BLItems[i].ID = 0
e.BLItems[i].TransferID = e.ID
}
if len(e.BLItems) > 0 {
return tx.Create(&e.BLItems).Error
}
return nil
})
}
func DeleteOwnershipTransfer(db *gorm.DB, id uint) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("transfer_id = ?", id).Delete(&model.OwnershipTransferFile{}).Error; err != nil {
return err
}
if err := tx.Where("transfer_id = ?", id).Delete(&model.OwnershipTransferBL{}).Error; err != nil {
return err
}
return tx.Delete(&model.OwnershipTransfer{}, id).Error
})
}
func CreateOwnershipTransferFiles(db *gorm.DB, files []model.OwnershipTransferFile) error {
if len(files) == 0 {
return nil
}
return db.Create(&files).Error
}
// DeliveryRepository wraps *gorm.DB and exposes all delivery-related repository methods
type DeliveryRepository struct {
db *gorm.DB
}
func NewDeliveryRepository(db *gorm.DB) *DeliveryRepository {
return &DeliveryRepository{db: db}
}
func (r *DeliveryRepository) CreateDeliveryApply(e *model.DeliveryApply) error { return CreateDeliveryApply(r.db, e) }
func (r *DeliveryRepository) ListDeliveryApply(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error) {
return ListDeliveryApply(r.db, page, pageSize, orderNumber, blNumber, status)
}
func (r *DeliveryRepository) GetDeliveryApplyByID(id uint) (*model.DeliveryApply, error) { return GetDeliveryApplyByID(r.db, id) }
func (r *DeliveryRepository) UpdateDeliveryApply(e *model.DeliveryApply) error { return UpdateDeliveryApply(r.db, e) }
func (r *DeliveryRepository) DeleteDeliveryApply(id uint) error { return DeleteDeliveryApply(r.db, id) }
func (r *DeliveryRepository) CreateDeliveryOutbound(e *model.DeliveryOutbound) error { return CreateDeliveryOutbound(r.db, e) }
func (r *DeliveryRepository) ListDeliveryOutbound(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error) {
return ListDeliveryOutbound(r.db, page, pageSize, outboundNumber, blNumber, status)
}
func (r *DeliveryRepository) GetDeliveryOutboundByID(id uint) (*model.DeliveryOutbound, error) { return GetDeliveryOutboundByID(r.db, id) }
func (r *DeliveryRepository) UpdateDeliveryOutbound(e *model.DeliveryOutbound) error { return UpdateDeliveryOutbound(r.db, e) }
func (r *DeliveryRepository) DeleteDeliveryOutbound(id uint) error { return DeleteDeliveryOutbound(r.db, id) }
func (r *DeliveryRepository) CreateWarehouse(e *model.Warehouse) error { return CreateWarehouse(r.db, e) }
func (r *DeliveryRepository) ListWarehouses() ([]model.Warehouse, error) { return ListWarehouses(r.db) }
func (r *DeliveryRepository) GetWarehouseByID(id uint) (*model.Warehouse, error) { return GetWarehouseByID(r.db, id) }
func (r *DeliveryRepository) UpdateWarehouse(e *model.Warehouse) error { return UpdateWarehouse(r.db, e) }
func (r *DeliveryRepository) DeleteWarehouse(id uint) error { return DeleteWarehouse(r.db, id) }
func (r *DeliveryRepository) CreateInventoryInbound(e *model.InventoryInbound) error { return CreateInventoryInbound(r.db, e) }
func (r *DeliveryRepository) ListInventoryInbound(page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error) {
return ListInventoryInbound(r.db, page, pageSize, warehouse, commodity)
}
func (r *DeliveryRepository) GetInventoryInboundByID(id uint) (*model.InventoryInbound, error) { return GetInventoryInboundByID(r.db, id) }
func (r *DeliveryRepository) UpdateInventoryInbound(e *model.InventoryInbound) error { return UpdateInventoryInbound(r.db, e) }
func (r *DeliveryRepository) DeleteInventoryInbound(id uint) error { return DeleteInventoryInbound(r.db, id) }
func (r *DeliveryRepository) CreateOwnershipTransfer(e *model.OwnershipTransfer) error { return CreateOwnershipTransfer(r.db, e) }
func (r *DeliveryRepository) ListOwnershipTransfer(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error) {
return ListOwnershipTransfer(r.db, page, pageSize, transferNumber, blNumber, status)
}
func (r *DeliveryRepository) GetOwnershipTransferByID(id uint) (*model.OwnershipTransfer, error) { return GetOwnershipTransferByID(r.db, id) }
func (r *DeliveryRepository) UpdateOwnershipTransfer(e *model.OwnershipTransfer) error { return UpdateOwnershipTransfer(r.db, e) }
func (r *DeliveryRepository) DeleteOwnershipTransfer(id uint) error { return DeleteOwnershipTransfer(r.db, id) }
func (r *DeliveryRepository) CreateOwnershipTransferFiles(files []model.OwnershipTransferFile) error {
return CreateOwnershipTransferFiles(r.db, files)
}
// DB returns the underlying *gorm.DB for complex queries (e.g. InventorySummary)
func (r *DeliveryRepository) DB() *gorm.DB { return r.db }

View File

@@ -0,0 +1,66 @@
// internal/repository/user_repository.go
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type UserRepository interface {
Create(user *model.User) error
FindByID(id uint) (*model.User, error)
FindByEmail(email string) (*model.User, error)
FindByUsername(username string) (*model.User, error)
Update(user *model.User) error
Delete(id uint) error
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *model.User) error {
return r.db.Create(user).Error
}
func (r *userRepository) FindByID(id uint) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
return &user, err
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *userRepository) FindByEmail(email string) (*model.User, error) {
var user model.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *userRepository) Update(user *model.User) error {
return r.db.Save(user).Error
}
func (r *userRepository) Delete(id uint) error {
return r.db.Delete(&model.User{}, id).Error
}

View File

@@ -0,0 +1,129 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ZSPContractRepository interface {
CreateFolder(folder *model.ZSPContractFolder) error
GetFolders() ([]model.ZSPContractFolder, error)
GetFolderById(folderID string) (*model.ZSPContractFolder, error)
DeleteFolderById(folderID string) error
UpdateFolder(folder *model.ZSPContractFolder) error
CreateContract(contract *model.ZSPContractFile) error
GetContractById(contractID string) (*model.ZSPContractFile, error)
ListContracts(folderID string) ([]model.ZSPContractFile, error)
ListContractsWithDetails(folderID string) ([]model.ZSPContractFile, error)
DeleteContractById(contractID string) error
UpdateContract(contract *model.ZSPContractFile) error
// 合同详情相关
CreateContractDetail(detail *model.ZSPContractDetail) error
GetContractDetailById(detailID string) (*model.ZSPContractDetail, error)
ListContractDetails(contractFileID string) ([]model.ZSPContractDetail, error)
UpdateContractDetail(detail *model.ZSPContractDetail) error
DeleteContractDetailById(detailID string) error
BatchCreateContractDetails(details []model.ZSPContractDetail) error
}
type zspContractRepository struct {
db *gorm.DB
}
func NewZSPContractRepository(db *gorm.DB) ZSPContractRepository {
return &zspContractRepository{db: db}
}
func (r *zspContractRepository) CreateFolder(folder *model.ZSPContractFolder) error {
// 这里可以根据实际需求创建一个 Folder 模型并保存到数据库
return r.db.Create(folder).Error
}
func (r *zspContractRepository) GetFolders() ([]model.ZSPContractFolder, error) {
var folders []model.ZSPContractFolder
err := r.db.Find(&folders).Error
return folders, err
}
func (r *zspContractRepository) GetFolderById(folderID string) (*model.ZSPContractFolder, error) {
var folder model.ZSPContractFolder
err := r.db.First(&folder, folderID).Error
return &folder, err
}
func (r *zspContractRepository) DeleteFolderById(folderID string) error {
return r.db.Delete(&model.ZSPContractFolder{}, folderID).Error
}
func (r *zspContractRepository) UpdateFolder(folder *model.ZSPContractFolder) error {
return r.db.Save(folder).Error
}
func (r *zspContractRepository) CreateContract(contract *model.ZSPContractFile) error {
return r.db.Create(contract).Error
}
func (r *zspContractRepository) GetContractById(contractID string) (*model.ZSPContractFile, error) {
var contract model.ZSPContractFile
err := r.db.First(&contract, contractID).Error
return &contract, err
}
func (r *zspContractRepository) ListContracts(folderID string) ([]model.ZSPContractFile, error) {
var contracts []model.ZSPContractFile
err := r.db.Where("folder_id = ?", folderID).Find(&contracts).Error
return contracts, err
}
func (r *zspContractRepository) DeleteContractById(contractID string) error {
return r.db.Delete(&model.ZSPContractFile{}, contractID).Error
}
func (r *zspContractRepository) UpdateContract(contract *model.ZSPContractFile) error {
return r.db.Save(contract).Error
}
func (r *zspContractRepository) ListContractsWithDetails(folderID string) ([]model.ZSPContractFile, error) {
var contracts []model.ZSPContractFile
query := r.db.Preload("Details")
if folderID != "" {
query = query.Where("folder_id = ?", folderID)
}
err := query.Find(&contracts).Error
return contracts, err
}
// ============ 合同详情相关方法 ============
func (r *zspContractRepository) CreateContractDetail(detail *model.ZSPContractDetail) error {
return r.db.Create(detail).Error
}
func (r *zspContractRepository) GetContractDetailById(detailID string) (*model.ZSPContractDetail, error) {
var detail model.ZSPContractDetail
err := r.db.First(&detail, detailID).Error
return &detail, err
}
func (r *zspContractRepository) ListContractDetails(contractFileID string) ([]model.ZSPContractDetail, error) {
var details []model.ZSPContractDetail
err := r.db.Where("contract_file_id = ?", contractFileID).Find(&details).Error
return details, err
}
func (r *zspContractRepository) UpdateContractDetail(detail *model.ZSPContractDetail) error {
return r.db.Save(detail).Error
}
func (r *zspContractRepository) DeleteContractDetailById(detailID string) error {
return r.db.Delete(&model.ZSPContractDetail{}, detailID).Error
}
func (r *zspContractRepository) BatchCreateContractDetails(details []model.ZSPContractDetail) error {
if len(details) == 0 {
return nil
}
return r.db.Create(&details).Error
}

View File

@@ -0,0 +1,182 @@
package repository
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
type ZSPFinancesRepository interface {
CreateFreightCharge(charge *model.ZSPFreightCharges) error
GetFreightCharge(id string) (*model.ZSPFreightCharges, error)
ListFreightCharges() ([]model.ZSPFreightCharges, error)
UpdateFreightCharge(charge *model.ZSPFreightCharges) error
DeleteFreightCharge(id string) error
CreateMiscCharge(charge *model.ZSPMiscCharges) error
GetMiscCharge(id string) (*model.ZSPMiscCharges, error)
ListMiscCharges() ([]model.ZSPMiscCharges, error)
UpdateMiscCharge(charge *model.ZSPMiscCharges) error
DeleteMiscCharge(id string) error
CreateServiceFee(fee *model.ZSPServiceFees) error
GetServiceFee(id string) (*model.ZSPServiceFees, error)
ListServiceFees() ([]model.ZSPServiceFees, error)
UpdateServiceFee(fee *model.ZSPServiceFees) error
DeleteServiceFee(id string) error
CreateCostBreakdown(breakdown *model.ZSPCostBreakdown) error
GetCostBreakdown(id string) (*model.ZSPCostBreakdown, error)
ListCostBreakdowns(businessID string) ([]model.ZSPCostBreakdown, error)
UpdateCostBreakdown(breakdown *model.ZSPCostBreakdown) error
DeleteCostBreakdown(id string) error
CreateProfitRecord(record *model.ZSPProfitRecord) error
GetProfitRecord(id string) (*model.ZSPProfitRecord, error)
ListProfitRecords(filters map[string]string) ([]model.ZSPProfitRecord, error)
UpdateProfitRecord(id string, req *model.ZSPProfitRecord) error
DeleteProfitRecord(id string) error
}
type zspFinancesRepository struct {
db *gorm.DB
}
func NewZSPFinancesRepository(db *gorm.DB) ZSPFinancesRepository {
return &zspFinancesRepository{db: db}
}
// ============ 货代费用 ============
func (r *zspFinancesRepository) CreateFreightCharge(charge *model.ZSPFreightCharges) error {
return r.db.Create(charge).Error
}
func (r *zspFinancesRepository) GetFreightCharge(id string) (*model.ZSPFreightCharges, error) {
var charge model.ZSPFreightCharges
err := r.db.First(&charge, id).Error
return &charge, err
}
func (r *zspFinancesRepository) ListFreightCharges() ([]model.ZSPFreightCharges, error) {
var charges []model.ZSPFreightCharges
err := r.db.Order("create_time desc").Find(&charges).Error
return charges, err
}
func (r *zspFinancesRepository) UpdateFreightCharge(charge *model.ZSPFreightCharges) error {
return r.db.Save(charge).Error
}
func (r *zspFinancesRepository) DeleteFreightCharge(id string) error {
return r.db.Delete(&model.ZSPFreightCharges{}, id).Error
}
// ============ 杂费 ============
func (r *zspFinancesRepository) CreateMiscCharge(charge *model.ZSPMiscCharges) error {
return r.db.Create(charge).Error
}
func (r *zspFinancesRepository) GetMiscCharge(id string) (*model.ZSPMiscCharges, error) {
var charge model.ZSPMiscCharges
err := r.db.First(&charge, id).Error
return &charge, err
}
func (r *zspFinancesRepository) ListMiscCharges() ([]model.ZSPMiscCharges, error) {
var charges []model.ZSPMiscCharges
err := r.db.Order("create_time desc").Find(&charges).Error
return charges, err
}
func (r *zspFinancesRepository) UpdateMiscCharge(charge *model.ZSPMiscCharges) error {
return r.db.Save(charge).Error
}
func (r *zspFinancesRepository) DeleteMiscCharge(id string) error {
return r.db.Delete(&model.ZSPMiscCharges{}, id).Error
}
// ============ 服务费 ============
func (r *zspFinancesRepository) CreateServiceFee(fee *model.ZSPServiceFees) error {
return r.db.Create(fee).Error
}
func (r *zspFinancesRepository) GetServiceFee(id string) (*model.ZSPServiceFees, error) {
var fee model.ZSPServiceFees
err := r.db.First(&fee, id).Error
return &fee, err
}
func (r *zspFinancesRepository) ListServiceFees() ([]model.ZSPServiceFees, error) {
var fees []model.ZSPServiceFees
err := r.db.Order("create_time desc").Find(&fees).Error
return fees, err
}
func (r *zspFinancesRepository) UpdateServiceFee(fee *model.ZSPServiceFees) error {
return r.db.Save(fee).Error
}
func (r *zspFinancesRepository) DeleteServiceFee(id string) error {
return r.db.Delete(&model.ZSPServiceFees{}, id).Error
}
// ============ 成本构成 ============
func (r *zspFinancesRepository) CreateCostBreakdown(breakdown *model.ZSPCostBreakdown) error {
return r.db.Create(breakdown).Error
}
func (r *zspFinancesRepository) GetCostBreakdown(id string) (*model.ZSPCostBreakdown, error) {
var breakdown model.ZSPCostBreakdown
err := r.db.First(&breakdown, id).Error
return &breakdown, err
}
func (r *zspFinancesRepository) ListCostBreakdowns(businessID string) ([]model.ZSPCostBreakdown, error) {
var breakdowns []model.ZSPCostBreakdown
query := r.db.Model(&model.ZSPCostBreakdown{})
if businessID != "" {
query = query.Where("business_id = ?", businessID)
}
err := query.Order("create_time desc").Find(&breakdowns).Error
return breakdowns, err
}
func (r *zspFinancesRepository) UpdateCostBreakdown(breakdown *model.ZSPCostBreakdown) error {
return r.db.Save(breakdown).Error
}
func (r *zspFinancesRepository) DeleteCostBreakdown(id string) error {
return r.db.Delete(&model.ZSPCostBreakdown{}, id).Error
}
// ============ 利润记录 ============
func (r *zspFinancesRepository) CreateProfitRecord(record *model.ZSPProfitRecord) error {
return r.db.Create(record).Error
}
func (r *zspFinancesRepository) GetProfitRecord(id string) (*model.ZSPProfitRecord, error) {
var record model.ZSPProfitRecord
err := r.db.First(&record, id).Error
return &record, err
}
func (r *zspFinancesRepository) ListProfitRecords(filters map[string]string) ([]model.ZSPProfitRecord, error) {
var records []model.ZSPProfitRecord
query := r.db.Model(&model.ZSPProfitRecord{})
if filters["businessId"] != "" {
query = query.Where("business_id = ?", filters["businessId"])
}
if filters["businessType"] != "" {
query = query.Where("business_type = ?", filters["businessType"])
}
err := query.Order("create_time desc").Find(&records).Error
return records, err
}
func (r *zspFinancesRepository) UpdateProfitRecord(id string, req *model.ZSPProfitRecord) error {
return r.db.Save(req).Error
}
func (r *zspFinancesRepository) DeleteProfitRecord(id string) error {
return r.db.Delete(&model.ZSPProfitRecord{}, id).Error
}

190
backend/internal/server/route.go Executable file
View File

@@ -0,0 +1,190 @@
package server
import (
"path/filepath"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/handler"
)
func SetupRouter(userHandler *handler.UserHandler,
contractHandler *handler.ContractHandler,
zspContractHandler *handler.ZSPContractHandler,
zspFinancesHandler *handler.ZSPFinancesHandler,
companyHandler *handler.CompanyHandler,
deliveryHandler *handler.DeliveryHandler,
authMiddleware gin.HandlerFunc,
) *gin.Engine {
router := gin.Default()
router.Use(gin.Logger())
router.Use(gin.Recovery())
// 允许跨域,并放行 Authorization 等请求头(解决前端直连 8080 时的 CORS 报错)
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * 3600,
}))
// user routes
api := router.Group("/api")
{
router.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
auth := api.Group("/auth")
{
auth.POST("/register", userHandler.Register)
auth.POST("/login", userHandler.Login)
}
// 静态文件资源(公开访问,供前端预览/下载)
api.Static("/contract-files", filepath.Join(".", "data", "contract"))
api.Static("/zsp-contract-files", filepath.Join(".", "data", "zsp-contract"))
api.Static("/company-files", filepath.Join(".", "data", "company"))
// 以下所有路由需要 JWT 认证
authorized := api.Group("/")
authorized.Use(authMiddleware)
{
users := authorized.Group("/users")
{
users.GET("/:id", userHandler.GetUser)
}
contracts := authorized.Group("/contract")
{
contracts.GET("", contractHandler.List)
contracts.POST("/upload", contractHandler.Upload)
contracts.POST("/batch-upload", contractHandler.BatchUpload)
contracts.GET("/folders", contractHandler.ListFolders)
contracts.POST("/folders", contractHandler.CreateFolder)
contracts.GET("/folders/:id", contractHandler.GetFolder)
contracts.PUT("/folders/:id", contractHandler.UpdateFolder)
contracts.DELETE("/folders/:id", contractHandler.DeleteFolder)
contracts.GET("/batches", contractHandler.ListBatches)
contracts.POST("/batches", contractHandler.CreateBatch)
contracts.GET("/batches/:id", contractHandler.GetBatch)
contracts.DELETE("/batches/:id", contractHandler.DeleteBatch)
contracts.POST("/:id/update-files", contractHandler.UpdateWithFiles)
contracts.GET("/:id", contractHandler.Get)
contracts.PUT("/:id", contractHandler.Update)
contracts.DELETE("/:id", contractHandler.Delete)
}
zspContracts := authorized.Group("/zsp-contract")
{
zspContracts.POST("/folders", zspContractHandler.CreateFolder)
zspContracts.GET("/folders", zspContractHandler.GetFolders)
zspContracts.GET("/folders/:id", zspContractHandler.GetFolder)
zspContracts.DELETE("/folders/:id", zspContractHandler.DeleteFolder)
zspContracts.PUT("/folders/:id", zspContractHandler.UpdateFolder)
zspContracts.POST("/contracts", zspContractHandler.UploadContract)
zspContracts.GET("/contracts/:id", zspContractHandler.GetContractWithDetails)
zspContracts.GET("/contracts", zspContractHandler.ListContractsWithDetails)
zspContracts.PUT("/contracts/:id", zspContractHandler.UpdateContract)
zspContracts.DELETE("/contracts/:id", zspContractHandler.DeleteContract)
zspContracts.POST("/contracts/:id/details", zspContractHandler.CreateContractDetail)
zspContracts.GET("/contracts/:id/details", zspContractHandler.ListContractDetails)
zspContracts.POST("/details/batch", zspContractHandler.BatchCreateContractDetails)
zspContracts.GET("/details/:id", zspContractHandler.GetContractDetail)
zspContracts.PUT("/details/:id", zspContractHandler.UpdateContractDetail)
zspContracts.DELETE("/details/:id", zspContractHandler.DeleteContractDetail)
zspContracts.POST("/contracts/:id/details/import", zspContractHandler.ImportContractDetailsFromExcel)
zspContracts.GET("/contracts/:id/details/export", zspContractHandler.ExportContractDetailsToExcel)
}
company := authorized.Group("/company")
{
company.GET("/folders", companyHandler.ListFolders)
company.POST("/folders", companyHandler.CreateFolder)
company.PUT("/folders/:id", companyHandler.UpdateFolder)
company.DELETE("/folders/:id", companyHandler.DeleteFolder)
company.GET("/files", companyHandler.ListFiles)
company.POST("/files/upload", companyHandler.UploadFiles)
company.PUT("/files/:id", companyHandler.UpdateFile)
company.DELETE("/files/:id", companyHandler.DeleteFile)
}
// 提货管理 (RESTful)
applyDelivery := authorized.Group("/apply-delivery")
{
applyDelivery.GET("", deliveryHandler.ListApply)
applyDelivery.GET("/:id", deliveryHandler.GetApply)
applyDelivery.POST("", deliveryHandler.CreateApply)
applyDelivery.PUT("/:id", deliveryHandler.UpdateApply)
applyDelivery.PUT("/:id/cancel", deliveryHandler.CancelApply)
applyDelivery.DELETE("/:id", deliveryHandler.DeleteApply)
}
deliveryDetails := authorized.Group("/delivery-details")
{
deliveryDetails.GET("", deliveryHandler.ListOutbound)
deliveryDetails.GET("/:id", deliveryHandler.GetOutbound)
deliveryDetails.POST("", deliveryHandler.CreateOutbound)
deliveryDetails.PUT("/:id", deliveryHandler.UpdateOutbound)
deliveryDetails.DELETE("/:id", deliveryHandler.DeleteOutbound)
}
inventory := authorized.Group("/inventory")
{
inventory.GET("/warehouses", deliveryHandler.ListWarehouses)
inventory.POST("/warehouses", deliveryHandler.CreateWarehouse)
inventory.PUT("/warehouses/:id", deliveryHandler.UpdateWarehouse)
inventory.DELETE("/warehouses/:id", deliveryHandler.DeleteWarehouse)
inventory.GET("/inbound", deliveryHandler.ListInbound)
inventory.POST("/inbound", deliveryHandler.CreateInbound)
inventory.PUT("/inbound/:id", deliveryHandler.UpdateInbound)
inventory.DELETE("/inbound/:id", deliveryHandler.DeleteInbound)
inventory.GET("/summary", deliveryHandler.InventorySummary)
}
ownershipTransfer := authorized.Group("/ownership-transfer")
{
ownershipTransfer.GET("", deliveryHandler.ListOwnership)
ownershipTransfer.GET("/:id", deliveryHandler.GetOwnership)
ownershipTransfer.POST("", deliveryHandler.CreateOwnership)
ownershipTransfer.POST("/with-files", deliveryHandler.CreateOwnershipWithFiles)
ownershipTransfer.DELETE("/:id", deliveryHandler.DeleteOwnership)
}
zspFinances := authorized.Group("/zsp-finances")
{
// 货代费用
zspFinances.POST("/freight-charges", zspFinancesHandler.CreateFreightCharge)
zspFinances.GET("/freight-charges/:id", zspFinancesHandler.GetFreightCharge)
zspFinances.GET("/freight-charges", zspFinancesHandler.ListFreightCharges)
zspFinances.PUT("/freight-charges/:id", zspFinancesHandler.UpdateFreightCharge)
zspFinances.DELETE("/freight-charges/:id", zspFinancesHandler.DeleteFreightCharge)
// 杂费
zspFinances.POST("/misc-charges", zspFinancesHandler.CreateMiscCharge)
zspFinances.GET("/misc-charges/:id", zspFinancesHandler.GetMiscCharge)
zspFinances.GET("/misc-charges", zspFinancesHandler.ListMiscCharges)
zspFinances.PUT("/misc-charges/:id", zspFinancesHandler.UpdateMiscCharge)
zspFinances.DELETE("/misc-charges/:id", zspFinancesHandler.DeleteMiscCharge)
// 服务费
zspFinances.POST("/service-fees", zspFinancesHandler.CreateServiceFee)
zspFinances.GET("/service-fees/:id", zspFinancesHandler.GetServiceFee)
zspFinances.GET("/service-fees", zspFinancesHandler.ListServiceFees)
zspFinances.PUT("/service-fees/:id", zspFinancesHandler.UpdateServiceFee)
zspFinances.DELETE("/service-fees/:id", zspFinancesHandler.DeleteServiceFee)
// 成本构成
zspFinances.POST("/cost-breakdowns", zspFinancesHandler.CreateCostBreakdown)
zspFinances.GET("/cost-breakdowns/:id", zspFinancesHandler.GetCostBreakdown)
zspFinances.GET("/cost-breakdowns", zspFinancesHandler.ListCostBreakdowns)
zspFinances.PUT("/cost-breakdowns/:id", zspFinancesHandler.UpdateCostBreakdown)
zspFinances.DELETE("/cost-breakdowns/:id", zspFinancesHandler.DeleteCostBreakdown)
// 利润记录
zspFinances.POST("/profit-records", zspFinancesHandler.CreateProfitRecord)
zspFinances.GET("/profit-records/:id", zspFinancesHandler.GetProfitRecord)
zspFinances.GET("/profit-records", zspFinancesHandler.ListProfitRecords)
zspFinances.PUT("/profit-records/:id", zspFinancesHandler.UpdateProfitRecord)
zspFinances.DELETE("/profit-records/:id", zspFinancesHandler.DeleteProfitRecord)
}
} // end authorized
} // end api
return router
}

View File

@@ -0,0 +1,156 @@
package service
import (
"fmt"
"mime/multipart"
"os"
"path/filepath"
"time"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
"github.com/rovina/zsp-backend/pkg/utils"
)
type CompanyService interface {
CreateFolder(name, description, category string) (*model.CompanyFolder, error)
ListFolders() ([]model.CompanyFolder, error)
GetFolderByID(id uint) (*model.CompanyFolder, error)
UpdateFolder(id uint, name, description, category string) error
DeleteFolder(id uint) error
UploadFiles(folderID *uint, companyName, fileCategory, expireDate, description string, files []*multipart.FileHeader) (int, error)
ListFiles(folderID *uint) ([]model.CompanyFile, error)
GetFileByID(id uint) (*model.CompanyFile, error)
UpdateFile(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error
DeleteFile(id uint) error
}
type companyService struct {
repo repository.CompanyRepository
}
func NewCompanyService(repo repository.CompanyRepository) CompanyService {
return &companyService{repo: repo}
}
func (s *companyService) CreateFolder(name, description, category string) (*model.CompanyFolder, error) {
f := &model.CompanyFolder{
Name: name,
Description: description,
Category: category,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := s.repo.CreateFolder(f); err != nil {
return nil, err
}
return f, nil
}
func (s *companyService) ListFolders() ([]model.CompanyFolder, error) {
return s.repo.ListFolders()
}
func (s *companyService) GetFolderByID(id uint) (*model.CompanyFolder, error) {
return s.repo.GetFolderByID(id)
}
func (s *companyService) UpdateFolder(id uint, name, description, category string) error {
f, err := s.repo.GetFolderByID(id)
if err != nil {
return err
}
f.Name = name
f.Description = description
f.Category = category
f.UpdateTime = time.Now()
return s.repo.UpdateFolder(f)
}
func (s *companyService) DeleteFolder(id uint) error {
return s.repo.DeleteFolder(id)
}
func (s *companyService) UploadFiles(folderID *uint, companyName, fileCategory, expireDate, description string, files []*multipart.FileHeader) (int, error) {
basePath := "./data/company/files"
if err := os.MkdirAll(basePath, os.ModePerm); err != nil {
return 0, err
}
var expire *time.Time
if expireDate != "" {
if t, err := time.Parse("2006-01-02", expireDate); err == nil {
expire = &t
}
}
count := 0
for _, fh := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fh.Filename)
savePath := filepath.Join(basePath, filename)
if err := utils.SaveUploadedFile(fh, savePath); err != nil {
return count, err
}
ext := filepath.Ext(fh.Filename)
if len(ext) > 0 {
ext = ext[1:]
}
file := &model.CompanyFile{
FolderID: 0,
FileName: fh.Filename,
FileType: ext,
FileSize: fh.Size,
UploadTime: time.Now(),
CompanyName: companyName,
FileCategory: fileCategory,
ExpireDate: expire,
Description: description,
FileURL: "files/" + filename,
}
if folderID != nil && *folderID > 0 {
file.FolderID = *folderID
}
if err := s.repo.CreateFile(file); err != nil {
return count, err
}
count++
}
return count, nil
}
func (s *companyService) ListFiles(folderID *uint) ([]model.CompanyFile, error) {
return s.repo.ListFiles(folderID)
}
func (s *companyService) GetFileByID(id uint) (*model.CompanyFile, error) {
return s.repo.GetFileByID(id)
}
func (s *companyService) UpdateFile(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error {
f, err := s.repo.GetFileByID(id)
if err != nil {
return err
}
f.CompanyName = companyName
f.FileCategory = fileCategory
f.Description = description
if expireDate != "" {
if t, err := time.Parse("2006-01-02", expireDate); err == nil {
f.ExpireDate = &t
}
} else {
f.ExpireDate = nil
}
if folderID != nil {
f.FolderID = *folderID
}
return s.repo.UpdateFile(f)
}
func (s *companyService) DeleteFile(id uint) error {
return s.repo.DeleteFile(id)
}

View File

@@ -0,0 +1,49 @@
package service
import (
"time"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
)
type ContractBatchService interface {
List(folderID *uint) ([]model.ContractBatch, error)
GetByID(id uint) (*model.ContractBatch, error)
Create(folderID uint, batchName, remark string, files []model.ContractBatchFile) (*model.ContractBatch, error)
Delete(id uint) error
}
type contractBatchService struct {
repo repository.ContractBatchRepository
}
func NewContractBatchService(repo repository.ContractBatchRepository) ContractBatchService {
return &contractBatchService{repo: repo}
}
func (s *contractBatchService) List(folderID *uint) ([]model.ContractBatch, error) {
return s.repo.List(folderID)
}
func (s *contractBatchService) GetByID(id uint) (*model.ContractBatch, error) {
return s.repo.GetByID(id)
}
func (s *contractBatchService) Create(folderID uint, batchName, remark string, files []model.ContractBatchFile) (*model.ContractBatch, error) {
b := &model.ContractBatch{
FolderID: folderID,
BatchName: batchName,
Remark: remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := s.repo.Create(b, files); err != nil {
return nil, err
}
return s.repo.GetByID(b.ID)
}
func (s *contractBatchService) Delete(id uint) error {
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,60 @@
package service
import (
"time"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
)
type ContractFolderService interface {
List() ([]model.ContractFolder, error)
GetByID(id uint) (*model.ContractFolder, error)
Create(name, description string) (*model.ContractFolder, error)
Update(id uint, name, description string) error
Delete(id uint) error
}
type contractFolderService struct {
repo repository.ContractFolderRepository
}
func NewContractFolderService(repo repository.ContractFolderRepository) ContractFolderService {
return &contractFolderService{repo: repo}
}
func (s *contractFolderService) List() ([]model.ContractFolder, error) {
return s.repo.List()
}
func (s *contractFolderService) GetByID(id uint) (*model.ContractFolder, error) {
return s.repo.GetByID(id)
}
func (s *contractFolderService) Create(name, description string) (*model.ContractFolder, error) {
f := &model.ContractFolder{
Name: name,
Description: description,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := s.repo.Create(f); err != nil {
return nil, err
}
return f, nil
}
func (s *contractFolderService) Update(id uint, name, description string) error {
f, err := s.repo.GetByID(id)
if err != nil {
return err
}
f.Name = name
f.Description = description
f.UpdateTime = time.Now()
return s.repo.Update(f)
}
func (s *contractFolderService) Delete(id uint) error {
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,152 @@
package service
import (
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strconv"
"time"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
)
type ContractService interface {
CreateContract(contract *model.Contract, files []model.ContractFile) error
SaveFiles(files []model.ContractFile) error
BatchSaveFiles(
basePath string,
files []*multipart.FileHeader,
) (int, error)
List(contractType string) ([]model.Contract, error)
GetByID(id uint) (*model.Contract, error)
Update(contract *model.Contract) error
UpdateWithFiles(id uint, keepFileIDs []uint, newFileModels []model.ContractFile, form map[string]string) error
Delete(id uint) error
}
type contractService struct {
repo repository.ContractRepository
}
func NewContractService(repo repository.ContractRepository) (ContractService, error) {
return &contractService{repo: repo}, nil
}
func (s *contractService) CreateContract(contract *model.Contract, files []model.ContractFile) error {
return s.repo.CreateWithFiles(contract, files)
}
func (s *contractService) SaveFiles(files []model.ContractFile) error {
return s.repo.CreateFiles(files)
}
func (s *contractService) BatchSaveFiles(
basePath string,
files []*multipart.FileHeader,
) (int, error) {
// 相对路径根目录
root := filepath.Join("./data/contract/batch", basePath)
if err := os.MkdirAll(root, os.ModePerm); err != nil {
return 0, err
}
count := 0
for _, file := range files {
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
savePath := filepath.Join(root, filename)
if err := saveUploadedFile(file, savePath); err != nil {
return count, err
}
count++
}
return count, nil
}
func (s *contractService) List(contractType string) ([]model.Contract, error) {
return s.repo.List(contractType)
}
func (s *contractService) GetByID(id uint) (*model.Contract, error) {
return s.repo.GetByID(id)
}
func (s *contractService) Update(contract *model.Contract) error {
return s.repo.Update(contract)
}
func (s *contractService) UpdateWithFiles(id uint, keepFileIDs []uint, newFileModels []model.ContractFile, form map[string]string) error {
contract, err := s.repo.GetByID(id)
if err != nil {
return err
}
// 更新合同元信息
get := func(k string) string { return form[k] }
if v := get("name"); v != "" {
contract.Name = v
}
if v := get("contractType"); v != "" {
contract.ContractType = v
}
if v := get("contractCode"); v != "" {
contract.ContractCode = v
}
if v := get("buyParty"); v != "" {
contract.BuyParty = v
}
if v := get("sellParty"); v != "" {
contract.SellParty = v
}
if v := get("commodity"); v != "" {
contract.Commodity = v
}
if v := get("count"); v != "" {
if n, e := strconv.ParseFloat(v, 64); e == nil {
contract.Count = n
}
}
if v := get("unitPrice"); v != "" {
if n, e := strconv.ParseFloat(v, 64); e == nil {
contract.UnitPrice = n
}
}
if v := get("blNumber"); v != "" {
contract.BLNumber = v
}
contract.DeliveryTime = get("deliveryTime")
contract.PickUpTime = get("pickUpTime")
contract.Packaging = get("packaging")
contract.Remarks = get("remarks")
contract.UpdateTime = time.Now()
return s.repo.UpdateWithFiles(contract, keepFileIDs, newFileModels)
}
func (s *contractService) Delete(id uint) error {
return s.repo.Delete(id)
}
func saveUploadedFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}

View File

@@ -0,0 +1,153 @@
package service
import (
"time"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
"gorm.io/gorm"
)
type DeliveryService interface {
// 我要提货
ListApply(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error)
GetApplyByID(id uint) (*model.DeliveryApply, error)
CreateApply(e *model.DeliveryApply) error
UpdateApply(e *model.DeliveryApply) error
DeleteApply(id uint) error
// 出库单
ListOutbound(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error)
GetOutboundByID(id uint) (*model.DeliveryOutbound, error)
CreateOutbound(e *model.DeliveryOutbound) error
UpdateOutbound(e *model.DeliveryOutbound) error
DeleteOutbound(id uint) error
// 仓库
ListWarehouses() ([]model.Warehouse, error)
GetWarehouseByID(id uint) (*model.Warehouse, error)
CreateWarehouse(e *model.Warehouse) error
UpdateWarehouse(e *model.Warehouse) error
DeleteWarehouse(id uint) error
// 入库
ListInbound(page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error)
GetInboundByID(id uint) (*model.InventoryInbound, error)
CreateInbound(e *model.InventoryInbound) error
UpdateInbound(e *model.InventoryInbound) error
DeleteInbound(id uint) error
// 货权转移
ListOwnership(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error)
GetOwnershipByID(id uint) (*model.OwnershipTransfer, error)
CreateOwnership(e *model.OwnershipTransfer) error
DeleteOwnership(id uint) error
CreateOwnershipFiles(files []model.OwnershipTransferFile) error
// DB for complex queries (InventorySummary)
DB() *gorm.DB
}
type deliveryService struct {
repo *repository.DeliveryRepository
}
func NewDeliveryService(repo *repository.DeliveryRepository) DeliveryService {
return &deliveryService{repo: repo}
}
func (s *deliveryService) DB() *gorm.DB { return s.repo.DB() }
func (s *deliveryService) ListApply(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error) {
return s.repo.ListDeliveryApply(page, pageSize, orderNumber, blNumber, status)
}
func (s *deliveryService) GetApplyByID(id uint) (*model.DeliveryApply, error) {
return s.repo.GetDeliveryApplyByID(id)
}
func (s *deliveryService) CreateApply(e *model.DeliveryApply) error {
e.CreateTime = time.Now()
e.UpdateTime = time.Now()
return s.repo.CreateDeliveryApply(e)
}
func (s *deliveryService) UpdateApply(e *model.DeliveryApply) error {
e.UpdateTime = time.Now()
return s.repo.UpdateDeliveryApply(e)
}
func (s *deliveryService) DeleteApply(id uint) error {
return s.repo.DeleteDeliveryApply(id)
}
func (s *deliveryService) ListOutbound(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error) {
return s.repo.ListDeliveryOutbound(page, pageSize, outboundNumber, blNumber, status)
}
func (s *deliveryService) GetOutboundByID(id uint) (*model.DeliveryOutbound, error) {
return s.repo.GetDeliveryOutboundByID(id)
}
func (s *deliveryService) CreateOutbound(e *model.DeliveryOutbound) error {
e.CreateTime = time.Now()
e.UpdateTime = time.Now()
return s.repo.CreateDeliveryOutbound(e)
}
func (s *deliveryService) UpdateOutbound(e *model.DeliveryOutbound) error {
e.UpdateTime = time.Now()
return s.repo.UpdateDeliveryOutbound(e)
}
func (s *deliveryService) DeleteOutbound(id uint) error {
return s.repo.DeleteDeliveryOutbound(id)
}
func (s *deliveryService) ListWarehouses() ([]model.Warehouse, error) {
return s.repo.ListWarehouses()
}
func (s *deliveryService) GetWarehouseByID(id uint) (*model.Warehouse, error) {
return s.repo.GetWarehouseByID(id)
}
func (s *deliveryService) CreateWarehouse(e *model.Warehouse) error {
e.CreateTime = time.Now()
e.UpdateTime = time.Now()
return s.repo.CreateWarehouse(e)
}
func (s *deliveryService) UpdateWarehouse(e *model.Warehouse) error {
e.UpdateTime = time.Now()
return s.repo.UpdateWarehouse(e)
}
func (s *deliveryService) DeleteWarehouse(id uint) error {
return s.repo.DeleteWarehouse(id)
}
func (s *deliveryService) ListInbound(page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error) {
return s.repo.ListInventoryInbound(page, pageSize, warehouse, commodity)
}
func (s *deliveryService) GetInboundByID(id uint) (*model.InventoryInbound, error) {
return s.repo.GetInventoryInboundByID(id)
}
func (s *deliveryService) CreateInbound(e *model.InventoryInbound) error {
e.CreateTime = time.Now()
e.UpdateTime = time.Now()
return s.repo.CreateInventoryInbound(e)
}
func (s *deliveryService) UpdateInbound(e *model.InventoryInbound) error {
e.UpdateTime = time.Now()
return s.repo.UpdateInventoryInbound(e)
}
func (s *deliveryService) DeleteInbound(id uint) error {
return s.repo.DeleteInventoryInbound(id)
}
func (s *deliveryService) ListOwnership(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error) {
return s.repo.ListOwnershipTransfer(page, pageSize, transferNumber, blNumber, status)
}
func (s *deliveryService) GetOwnershipByID(id uint) (*model.OwnershipTransfer, error) {
return s.repo.GetOwnershipTransferByID(id)
}
func (s *deliveryService) CreateOwnership(e *model.OwnershipTransfer) error {
e.CreateTime = time.Now()
e.UpdateTime = time.Now()
return s.repo.CreateOwnershipTransfer(e)
}
func (s *deliveryService) DeleteOwnership(id uint) error {
return s.repo.DeleteOwnershipTransfer(id)
}
func (s *deliveryService) CreateOwnershipFiles(files []model.OwnershipTransferFile) error {
return s.repo.CreateOwnershipTransferFiles(files)
}

View File

@@ -0,0 +1,127 @@
// internal/service/user_service.go
package service
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
"golang.org/x/crypto/bcrypt"
)
type Claims struct {
UserID uint `json:"user_id"`
jwt.RegisteredClaims
}
type UserService interface {
Register(req *dto.CreateUserRequest) (*dto.UserResponse, error)
Login(req *dto.LoginRequest) (*dto.UserResponse, string, error)
GetUserByID(id uint) (*dto.UserResponse, error)
}
type userService struct {
userRepo repository.UserRepository
jwtSecret []byte // 改为字节切片
}
func NewUserService(userRepo repository.UserRepository, jwtSecretStr string) (*userService, error) {
// 直接使用字符串作为密钥,不再解析 PEM
return &userService{
userRepo: userRepo,
jwtSecret: []byte(jwtSecretStr),
}, nil
}
func (s *userService) Register(req *dto.CreateUserRequest) (*dto.UserResponse, error) {
// 检查邮箱是否已存在
existingUser, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
return nil, fmt.Errorf("database error: %v", err)
}
if existingUser != nil {
return nil, errors.New("email already exists")
}
existingUser, err = s.userRepo.FindByUsername(req.Username)
if err != nil {
return nil, fmt.Errorf("database error: %v", err)
}
if existingUser != nil {
return nil, errors.New("username already exists")
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &model.User{
Username: req.Username,
Email: req.Email,
Password: string(hashedPassword),
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return &dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
}, nil
}
func (s *userService) Login(req *dto.LoginRequest) (*dto.UserResponse, string, error) {
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
return nil, "", errors.New("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, "", errors.New("invalid credentials")
}
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "zsp-backend",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(s.jwtSecret)
if err != nil {
return nil, "", err
}
return &dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
}, tokenString, nil
}
func (s *userService) GetUserByID(id uint) (*dto.UserResponse, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
return &dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
}, nil
}

View File

@@ -0,0 +1,271 @@
package service
import (
"fmt"
"mime/multipart"
"os"
"path"
"strings"
"time"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
)
type ZSPContractService interface {
CreateFolder(req *dto.ZSPFolderCreateRequest) error
GetFolders() ([]model.ZSPContractFolder, error)
GetFolderById(folderID string) (*model.ZSPContractFolder, error)
DeleteFolderById(folderID string) error
UpdateFolderById(folderID string, req *dto.ZSPFolderUpdateRequest) error
UploadContract(req *dto.ZSPContractCreateRequest, fileHeader *multipart.FileHeader) error
GetContractById(contractID string) (*model.ZSPContractFile, error)
GetContractWithDetails(contractID string) (*model.ZSPContractFile, error)
ListContracts(folderID string) ([]model.ZSPContractFile, error)
ListContractsWithDetails(folderID string) ([]model.ZSPContractFile, error)
DeleteContractById(contractID string) error
UpdateContract(contract *model.ZSPContractFile) error
UpdateContractById(contractID string, req *dto.ZSPContractUpdateRequest) error
// 合同详情相关
CreateContractDetail(req *dto.ZSPContractDetailCreateRequest) error
UpdateContractDetail(req *dto.ZSPContractDetailUpdateRequest) error
GetContractDetailById(detailID string) (*model.ZSPContractDetail, error)
ListContractDetails(contractFileID string) ([]model.ZSPContractDetail, error)
DeleteContractDetailById(detailID string) error
BatchCreateContractDetails(req *dto.ZSPContractDetailBatchCreateRequest) error
}
type zspContractService struct {
repo repository.ZSPContractRepository
}
func NewZSPContractService(repo repository.ZSPContractRepository) (ZSPContractService, error) {
return &zspContractService{repo: repo}, nil
}
func (s *zspContractService) CreateFolder(req *dto.ZSPFolderCreateRequest) error {
var folder model.ZSPContractFolder
folder.Name = req.Name
folder.Description = req.Description
folder.Count = 0
folder.CreateTime = time.Now()
return s.repo.CreateFolder(&folder)
}
func (s *zspContractService) GetFolders() ([]model.ZSPContractFolder, error) {
folders, err := s.repo.GetFolders()
if err != nil {
return nil, err
}
return folders, nil
}
func (s *zspContractService) GetFolderById(folderID string) (*model.ZSPContractFolder, error) {
return s.repo.GetFolderById(folderID)
}
func (s *zspContractService) DeleteFolderById(folderID string) error {
return s.repo.DeleteFolderById(folderID)
}
func (s *zspContractService) UpdateFolderById(folderID string, req *dto.ZSPFolderUpdateRequest) error {
folder, err := s.repo.GetFolderById(folderID)
if err != nil {
return err
}
folder.Name = req.Name
folder.Description = req.Description
return s.repo.UpdateFolder(folder)
}
func (s *zspContractService) UploadContract(req *dto.ZSPContractCreateRequest, fileHeader *multipart.FileHeader) error {
timeNow := time.Now()
var contract model.ZSPContractFile
contract.FolderID = req.FolderID
contract.Commodity = req.Commodity
contract.CompanyName = req.CompanyName
contract.ContractNumber = req.ContractNumber
contract.ContractType = req.ContractType
contract.FileName = fileHeader.Filename
// 提取扩展名便于前端展示(如 pdf、jpg
if ext := path.Ext(fileHeader.Filename); ext != "" {
contract.FileType = strings.TrimPrefix(strings.ToLower(ext), ".")
} else {
contract.FileType = fileHeader.Header.Get("Content-Type")
}
if req.SignDate == "" {
return fmt.Errorf("签订日期为必填项")
}
signDate, err := time.Parse("2006-01-02", req.SignDate)
if err != nil {
return fmt.Errorf("签订日期格式不正确,请使用 YYYY-MM-DD: %w", err)
}
contract.SignDate = signDate
contract.UploadTime = timeNow
// 确保目录存在后保存文件
saveDir := "./data/zsp-contract/files"
if err := os.MkdirAll(saveDir, os.ModePerm); err != nil {
return fmt.Errorf("create upload dir: %w", err)
}
storedName := fmt.Sprintf("%d_%s", timeNow.UnixNano(), fileHeader.Filename)
savePath := path.Join(saveDir, storedName)
if err := saveUploadedFile(fileHeader, savePath); err != nil {
return err
}
contract.FileURL = "files/" + storedName
contract.FileSize = fileHeader.Size
return s.repo.CreateContract(&contract)
}
func (s *zspContractService) GetContractById(contractID string) (*model.ZSPContractFile, error) {
return s.repo.GetContractById(contractID)
}
func (s *zspContractService) ListContracts(folderID string) ([]model.ZSPContractFile, error) {
return s.repo.ListContracts(folderID)
}
func (s *zspContractService) DeleteContractById(contractID string) error {
return s.repo.DeleteContractById(contractID)
}
func (s *zspContractService) UpdateContract(contract *model.ZSPContractFile) error {
return s.repo.UpdateContract(contract)
}
func (s *zspContractService) UpdateContractById(contractID string, req *dto.ZSPContractUpdateRequest) error {
contract, err := s.repo.GetContractById(contractID)
if err != nil {
return err
}
if req.ContractNumber != "" {
contract.ContractNumber = req.ContractNumber
}
if req.CompanyName != "" {
contract.CompanyName = req.CompanyName
}
if req.Commodity != "" {
contract.Commodity = req.Commodity
}
if req.ContractType != "" {
contract.ContractType = req.ContractType
}
if req.FolderID > 0 {
contract.FolderID = req.FolderID
}
if req.SignDate != "" {
signDate, err := time.Parse("2006-01-02", req.SignDate)
if err != nil {
return fmt.Errorf("签订日期格式不正确: %w", err)
}
contract.SignDate = signDate
}
return s.repo.UpdateContract(contract)
}
func (s *zspContractService) GetContractWithDetails(contractID string) (*model.ZSPContractFile, error) {
contract, err := s.repo.GetContractById(contractID)
if err != nil {
return nil, err
}
// 获取详情列表
details, err := s.repo.ListContractDetails(contractID)
if err != nil {
return nil, err
}
contract.Details = details
return contract, nil
}
func (s *zspContractService) ListContractsWithDetails(folderID string) ([]model.ZSPContractFile, error) {
return s.repo.ListContractsWithDetails(folderID)
}
// ============ 合同详情相关方法 ============
func (s *zspContractService) CreateContractDetail(req *dto.ZSPContractDetailCreateRequest) error {
var detail model.ZSPContractDetail
detail.ContractFileID = req.ContractFileID
detail.CommodityName = req.CommodityName
detail.CommodityCode = req.CommodityCode
detail.TotalQuantity = req.TotalQuantity
detail.Unit = req.Unit
detail.UnitPrice = req.UnitPrice
detail.DeliveredQty = req.DeliveredQty
detail.BillOfLading = req.BillOfLading
detail.Remark = req.Remark
// PendingQty 会在 BeforeCreate 钩子中自动计算
return s.repo.CreateContractDetail(&detail)
}
func (s *zspContractService) UpdateContractDetail(req *dto.ZSPContractDetailUpdateRequest) error {
detail, err := s.repo.GetContractDetailById(fmt.Sprintf("%d", req.ID))
if err != nil {
return err
}
if req.CommodityName != "" {
detail.CommodityName = req.CommodityName
}
if req.CommodityCode != "" {
detail.CommodityCode = req.CommodityCode
}
if req.TotalQuantity > 0 {
detail.TotalQuantity = req.TotalQuantity
}
if req.Unit != "" {
detail.Unit = req.Unit
}
if req.UnitPrice > 0 {
detail.UnitPrice = req.UnitPrice
}
// DeliveredQty 可以为 0所以需要特别处理
detail.DeliveredQty = req.DeliveredQty
if req.BillOfLading != "" {
detail.BillOfLading = req.BillOfLading
}
detail.Remark = req.Remark
// PendingQty 会在 BeforeUpdate 钩子中自动计算
return s.repo.UpdateContractDetail(detail)
}
func (s *zspContractService) GetContractDetailById(detailID string) (*model.ZSPContractDetail, error) {
return s.repo.GetContractDetailById(detailID)
}
func (s *zspContractService) ListContractDetails(contractFileID string) ([]model.ZSPContractDetail, error) {
return s.repo.ListContractDetails(contractFileID)
}
func (s *zspContractService) DeleteContractDetailById(detailID string) error {
return s.repo.DeleteContractDetailById(detailID)
}
func (s *zspContractService) BatchCreateContractDetails(req *dto.ZSPContractDetailBatchCreateRequest) error {
details := make([]model.ZSPContractDetail, 0, len(req.Details))
for _, detailReq := range req.Details {
detail := model.ZSPContractDetail{
ContractFileID: req.ContractFileID,
CommodityName: detailReq.CommodityName,
CommodityCode: detailReq.CommodityCode,
TotalQuantity: detailReq.TotalQuantity,
Unit: detailReq.Unit,
UnitPrice: detailReq.UnitPrice,
DeliveredQty: detailReq.DeliveredQty,
BillOfLading: detailReq.BillOfLading,
Remark: detailReq.Remark,
}
// PendingQty 会在 BeforeCreate 钩子中自动计算
details = append(details, detail)
}
return s.repo.BatchCreateContractDetails(details)
}

View File

@@ -0,0 +1,335 @@
package service
import (
"time"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/repository"
"gorm.io/gorm"
)
type ZSPFinancesService interface {
CreateFreightCharge(req *dto.ZSPFreightChargesCreateRequest) error
GetFreightCharge(id string) (*model.ZSPFreightCharges, error)
ListFreightCharges() ([]model.ZSPFreightCharges, error)
UpdateFreightCharge(id string, req *dto.ZSPFreightChargesUpdateRequest) error
DeleteFreightCharge(id string) error
CreateMiscCharge(req *dto.ZSPMiscChargesCreateRequest) error
GetMiscCharge(id string) (*model.ZSPMiscCharges, error)
ListMiscCharges() ([]model.ZSPMiscCharges, error)
UpdateMiscCharge(id string, req *dto.ZSPMiscChargesUpdateRequest) error
DeleteMiscCharge(id string) error
CreateServiceFee(req *dto.ZSPServiceFeesCreateRequest) error
GetServiceFee(id string) (*model.ZSPServiceFees, error)
ListServiceFees() ([]model.ZSPServiceFees, error)
UpdateServiceFee(id string, req *dto.ZSPServiceFeesUpdateRequest) error
DeleteServiceFee(id string) error
CreateCostBreakdown(req *dto.ZSPCostBreakdownCreateRequest) error
GetCostBreakdown(id string) (*model.ZSPCostBreakdown, error)
ListCostBreakdowns(businessID string) ([]model.ZSPCostBreakdown, error)
UpdateCostBreakdown(id string, req *dto.ZSPCostBreakdownUpdateRequest) error
DeleteCostBreakdown(id string) error
CreateProfitRecord(req *dto.ZSPProfitRecordCreateRequest) error
GetProfitRecord(id string) (*model.ZSPProfitRecord, error)
ListProfitRecords(filters map[string]string) ([]model.ZSPProfitRecord, error)
UpdateProfitRecord(id string, req *dto.ZSPProfitRecordUpdateRequest) error
DeleteProfitRecord(id string) error
}
type zspFinancesService struct {
repo repository.ZSPFinancesRepository
}
func NewZSPFinancesService(db *gorm.DB) ZSPFinancesService {
return &zspFinancesService{repo: repository.NewZSPFinancesRepository(db)}
}
// ============ 货代费用 ============
func (s *zspFinancesService) CreateFreightCharge(req *dto.ZSPFreightChargesCreateRequest) error {
currency := req.Currency
if currency == "" {
currency = "CNY"
}
charge := model.ZSPFreightCharges{
BillOfLading: req.BillOfLading,
ContractNumber: req.ContractNumber,
ExpenseItem: req.ExpenseItem,
Amount: req.Amount,
Currency: currency,
PaymentDate: parseDate(req.PaymentDate),
FreightCompanyName: req.FreightCompanyName,
Remark: req.Remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
return s.repo.CreateFreightCharge(&charge)
}
func (s *zspFinancesService) GetFreightCharge(id string) (*model.ZSPFreightCharges, error) {
return s.repo.GetFreightCharge(id)
}
func (s *zspFinancesService) ListFreightCharges() ([]model.ZSPFreightCharges, error) {
return s.repo.ListFreightCharges()
}
func (s *zspFinancesService) UpdateFreightCharge(id string, req *dto.ZSPFreightChargesUpdateRequest) error {
charge, err := s.repo.GetFreightCharge(id)
if err != nil {
return err
}
if req.BillOfLading != "" {
charge.BillOfLading = req.BillOfLading
}
if req.ContractNumber != "" {
charge.ContractNumber = req.ContractNumber
}
if req.ExpenseItem != "" {
charge.ExpenseItem = req.ExpenseItem
}
if req.Amount != 0 {
charge.Amount = req.Amount
}
if req.Currency != "" {
charge.Currency = req.Currency
}
if req.PaymentDate != "" {
charge.PaymentDate = parseDate(req.PaymentDate)
}
if req.FreightCompanyName != "" {
charge.FreightCompanyName = req.FreightCompanyName
}
charge.Remark = req.Remark
charge.UpdateTime = time.Now()
return s.repo.UpdateFreightCharge(charge)
}
func (s *zspFinancesService) DeleteFreightCharge(id string) error {
return s.repo.DeleteFreightCharge(id)
}
// ============ 杂费 ============
func (s *zspFinancesService) CreateMiscCharge(req *dto.ZSPMiscChargesCreateRequest) error {
currency := req.Currency
if currency == "" {
currency = "CNY"
}
charge := model.ZSPMiscCharges{
BillOfLading: req.BillOfLading,
ContractNumber: req.ContractNumber,
ExpenseItem: req.ExpenseItem,
Amount: req.Amount,
Currency: currency,
PaymentDate: parseDate(req.PaymentDate),
CompanyName: req.CompanyName,
Remark: req.Remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
return s.repo.CreateMiscCharge(&charge)
}
func (s *zspFinancesService) GetMiscCharge(id string) (*model.ZSPMiscCharges, error) {
return s.repo.GetMiscCharge(id)
}
func (s *zspFinancesService) ListMiscCharges() ([]model.ZSPMiscCharges, error) {
return s.repo.ListMiscCharges()
}
func (s *zspFinancesService) UpdateMiscCharge(id string, req *dto.ZSPMiscChargesUpdateRequest) error {
charge, err := s.repo.GetMiscCharge(id)
if err != nil {
return err
}
if req.BillOfLading != "" {
charge.BillOfLading = req.BillOfLading
}
if req.ContractNumber != "" {
charge.ContractNumber = req.ContractNumber
}
if req.ExpenseItem != "" {
charge.ExpenseItem = req.ExpenseItem
}
if req.Amount != 0 {
charge.Amount = req.Amount
}
if req.Currency != "" {
charge.Currency = req.Currency
}
if req.PaymentDate != "" {
charge.PaymentDate = parseDate(req.PaymentDate)
}
if req.CompanyName != "" {
charge.CompanyName = req.CompanyName
}
charge.Remark = req.Remark
charge.UpdateTime = time.Now()
return s.repo.UpdateMiscCharge(charge)
}
func (s *zspFinancesService) DeleteMiscCharge(id string) error {
return s.repo.DeleteMiscCharge(id)
}
// ============ 服务费 ============
func (s *zspFinancesService) CreateServiceFee(req *dto.ZSPServiceFeesCreateRequest) error {
currency := req.Currency
if currency == "" {
currency = "CNY"
}
fee := model.ZSPServiceFees{
BillOfLading: req.BillOfLading,
ContractNumber: req.ContractNumber,
Name: req.Name,
Amount: req.Amount,
Currency: currency,
Date: parseDate(req.Date),
Remark: req.Remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
return s.repo.CreateServiceFee(&fee)
}
func (s *zspFinancesService) GetServiceFee(id string) (*model.ZSPServiceFees, error) {
return s.repo.GetServiceFee(id)
}
func (s *zspFinancesService) ListServiceFees() ([]model.ZSPServiceFees, error) {
return s.repo.ListServiceFees()
}
func (s *zspFinancesService) UpdateServiceFee(id string, req *dto.ZSPServiceFeesUpdateRequest) error {
fee, err := s.repo.GetServiceFee(id)
if err != nil {
return err
}
if req.BillOfLading != "" {
fee.BillOfLading = req.BillOfLading
}
if req.ContractNumber != "" {
fee.ContractNumber = req.ContractNumber
}
if req.Name != "" {
fee.Name = req.Name
}
if req.Amount != 0 {
fee.Amount = req.Amount
}
if req.Currency != "" {
fee.Currency = req.Currency
}
if req.Date != "" {
fee.Date = parseDate(req.Date)
}
fee.Remark = req.Remark
fee.UpdateTime = time.Now()
return s.repo.UpdateServiceFee(fee)
}
func (s *zspFinancesService) DeleteServiceFee(id string) error {
return s.repo.DeleteServiceFee(id)
}
// ============ 成本构成 ============
func (s *zspFinancesService) CreateCostBreakdown(req *dto.ZSPCostBreakdownCreateRequest) error {
breakdown := model.ZSPCostBreakdown{
BusinessID: req.BusinessID,
BusinessType: req.BusinessType,
TotalCost: 0,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
return s.repo.CreateCostBreakdown(&breakdown)
}
func (s *zspFinancesService) GetCostBreakdown(id string) (*model.ZSPCostBreakdown, error) {
return s.repo.GetCostBreakdown(id)
}
func (s *zspFinancesService) ListCostBreakdowns(businessID string) ([]model.ZSPCostBreakdown, error) {
return s.repo.ListCostBreakdowns(businessID)
}
func (s *zspFinancesService) UpdateCostBreakdown(id string, req *dto.ZSPCostBreakdownUpdateRequest) error {
breakdown, err := s.repo.GetCostBreakdown(id)
if err != nil {
return err
}
breakdown.FreightChargeTotal = req.FreightChargeTotal
breakdown.MiscChargeTotal = req.MiscChargeTotal
breakdown.ServiceFeeTotal = req.ServiceFeeTotal
breakdown.TotalCost = req.FreightChargeTotal + req.MiscChargeTotal + req.ServiceFeeTotal
breakdown.UpdateTime = time.Now()
return s.repo.UpdateCostBreakdown(breakdown)
}
func (s *zspFinancesService) DeleteCostBreakdown(id string) error {
return s.repo.DeleteCostBreakdown(id)
}
// ============ 利润记录 ============
func (s *zspFinancesService) CreateProfitRecord(req *dto.ZSPProfitRecordCreateRequest) error {
record := model.ZSPProfitRecord{
BusinessID: req.BusinessID,
BusinessType: req.BusinessType,
Revenue: req.Revenue,
TotalCost: req.TotalCost,
Profit: req.Revenue - req.TotalCost,
ProfitRate: calculateProfitRate(req.Revenue, req.TotalCost),
RecordDate: parseDate(req.RecordDate),
Remark: req.Remark,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
return s.repo.CreateProfitRecord(&record)
}
func (s *zspFinancesService) GetProfitRecord(id string) (*model.ZSPProfitRecord, error) {
return s.repo.GetProfitRecord(id)
}
func (s *zspFinancesService) ListProfitRecords(filters map[string]string) ([]model.ZSPProfitRecord, error) {
return s.repo.ListProfitRecords(filters)
}
func (s *zspFinancesService) UpdateProfitRecord(id string, req *dto.ZSPProfitRecordUpdateRequest) error {
record, err := s.repo.GetProfitRecord(id)
if err != nil {
return err
}
record.Revenue = req.Revenue
record.TotalCost = req.TotalCost
record.Profit = req.Revenue - req.TotalCost
record.ProfitRate = calculateProfitRate(req.Revenue, req.TotalCost)
record.Remark = req.Remark
record.UpdateTime = time.Now()
return s.repo.UpdateProfitRecord(id, record)
}
func (s *zspFinancesService) DeleteProfitRecord(id string) error {
return s.repo.DeleteProfitRecord(id)
}
// ============ 辅助函数 ============
func parseDate(dateStr string) time.Time {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return time.Time{}
}
return t
}
func calculateProfitRate(revenue, totalCost float64) float64 {
if revenue == 0 {
return 0
}
return (revenue - totalCost) / revenue * 100
}

51
backend/pkg/config/config.go Executable file
View File

@@ -0,0 +1,51 @@
package config
import (
"log"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
ReadTimeout int `mapstructure:"read_timeout"`
WriteTimeout int `mapstructure:"write_timeout"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ResetDatabase bool `mapstructure:"reset_database"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
func Load(configPath string) (*Config, error) {
viper.SetConfigFile(configPath)
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, err
}
log.Printf("Config loaded successfully from %s", configPath)
return &config, nil
}

30
backend/pkg/database/gorm.go Executable file
View File

@@ -0,0 +1,30 @@
package database
import (
"fmt"
"github.com/rovina/zsp-backend/pkg/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, err
}
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
return db, nil
}

57
backend/pkg/database/init.go Executable file
View File

@@ -0,0 +1,57 @@
package database
import (
"database/sql"
"fmt"
"log"
"github.com/rovina/zsp-backend/pkg/config"
)
func CheckDatabaseConnection(cfg *config.DatabaseConfig) error {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
log.Println("start conn db:", dsn)
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
return db.Ping()
}
func EnsureDatabase(cfg *config.DatabaseConfig) error {
// 不指定数据库连接
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
// 创建数据库
if cfg.ResetDatabase {
sql := fmt.Sprintf("DROP DATABASE IF EXISTS `%s`;", cfg.DBName)
_, err = db.Exec(sql)
if err != nil {
return err
}
}
sql := fmt.Sprintf(
"CREATE DATABASE IF NOT EXISTS `%s` DEFAULT CHARSET utf8mb4;",
cfg.DBName,
)
_, err = db.Exec(sql)
if err != nil {
return err
}
log.Println("Database ensured:", cfg.DBName)
return nil
}

39
backend/pkg/database/migrate.go Executable file
View File

@@ -0,0 +1,39 @@
package database
import (
"github.com/rovina/zsp-backend/internal/model"
"gorm.io/gorm"
)
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{},
&model.Contract{},
&model.ContractFile{},
&model.ZSPContractFolder{},
&model.ZSPContractFile{},
&model.ZSPContractDetail{},
&model.ZSPFreightCharges{},
&model.ZSPMiscCharges{},
&model.ZSPServiceFees{},
&model.ZSPCostBreakdown{},
&model.ZSPProfitRecord{},
&model.ZSPUpstreamInvoice{},
&model.ZSPDownstreamInvoice{},
&model.ZSPFreightMiscInvoice{},
&model.ZSPSettlement{},
&model.ZSPReconciliation{},
&model.ContractFolder{},
&model.ContractBatch{},
&model.ContractBatchFile{},
&model.CompanyFolder{},
&model.CompanyFile{},
&model.DeliveryApply{},
&model.DeliveryOutbound{},
&model.Warehouse{},
&model.InventoryInbound{},
&model.OwnershipTransfer{},
&model.OwnershipTransferBL{},
&model.OwnershipTransferFile{},
)
}

45
backend/pkg/utils/file.go Executable file
View File

@@ -0,0 +1,45 @@
package utils
import (
"fmt"
"io"
"mime/multipart"
"os"
)
func FormatFileSize(i int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
switch {
case i >= TB:
return fmt.Sprintf("%.2fTB", float64(i)/float64(TB))
case i >= GB:
return fmt.Sprintf("%.2fGB", float64(i)/float64(GB))
case i >= MB:
return fmt.Sprintf("%.2fMB", float64(i)/float64(MB))
case i >= KB:
return fmt.Sprintf("%.2fKB", float64(i)/float64(KB))
default:
return fmt.Sprintf("%dB", i)
}
}
// SaveUploadedFile 保存 multipart 上传的文件到指定路径
func SaveUploadedFile(fh *multipart.FileHeader, dst string) error {
src, err := fh.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}

366
backend/readme.md Executable file
View File

@@ -0,0 +1,366 @@
# ZSP 后端管理系统
基于 Go + Gin + GORM + MySQL 构建的现代化合同管理与用户认证系统后端API。
## 📋 项目概述
ZSP 后端是一个用于供应链合同管理的企业级应用系统,提供用户认证、合同管理、文件存储等核心功能。系统采用微服务架构思想,具备高可扩展性和良好的开发体验。
### 主要特性
- 🔐 **JWT 用户认证与授权**
- 📄 **合同全生命周期管理**
- 🐳 **Docker 容器化开发环境**
- 🔄 **热重载开发体验** (Air)
- 📊 **MySQL 数据库支持**
- 🧪 **自动化数据库迁移**
- 🔧 **配置驱动架构**
## 🏗️ 技术栈
### 后端框架
- **Go 1.25.4** - 高性能编程语言
- **Gin** - 轻量级 Web 框架
- **GORM** - ORM 数据库工具
- **JWT** - 用户认证令牌
### 开发工具
- **Docker & Docker Compose** - 容器化开发环境
- **Air** - Go 应用热重载工具
- **MySQL 8.0** - 关系型数据库
### 配置管理
- **Viper** - 配置管理库
- **YAML 配置文件** - 结构化配置
## 📁 项目结构
```
zsp-backend/
├── cmd/api/ # 应用入口
│ └── main.go # 主程序入口
├── configs/ # 配置文件
│ └── config.dev.yaml # 开发环境配置
├── internal/ # 内部包
│ ├── handler/ # HTTP 处理器
│ │ ├── user_handler.go # 用户相关接口
│ │ └── contract_handler.go # 合同相关接口
│ ├── middleware/ # 中间件
│ │ └── auth.go # 认证中间件
│ ├── model/ # 数据模型
│ │ ├── user.go # 用户模型
│ │ ├── contract.go # 合同模型
│ │ └── error.go # 错误响应模型
│ ├── repository/ # 数据访问层
│ │ ├── user_repository.go
│ │ └── contract_repository.go
│ ├── service/ # 业务逻辑层
│ │ ├── user_service.go
│ │ └── contract_service.go
│ └── server/ # 服务器配置
│ └── router.go # 路由配置
├── pkg/ # 公共包
│ ├── config/ # 配置加载
│ └── database/ # 数据库连接
├── scripts/ # 脚本文件
│ └── init_database.sql # 数据库初始化脚本
├── migrations/ # 数据库迁移文件
├── test/ # 测试文件
├── web/ # 前端文件(可选)
├── deployments/ # 部署配置
├── docs/ # 文档
├── tmp/ # 临时文件
└── bin/ # 构建输出
```
## 🚀 快速开始
### 前提条件
- Docker & Docker Compose
- Go 1.25.4+ (可选,用于本地开发)
### 使用 Docker 开发环境(推荐)
1. **克隆项目**
```bash
git clone <repository-url>
cd zsp-backend
```
2. **启动开发环境**
```bash
docker-compose -f docker-compose.dev.yml up --build
```
3. **访问应用**
- API 服务: http://localhost:8080
- MySQL 数据库: localhost:3306
### 本地开发(不使用 Docker
1. **安装依赖**
```bash
go mod download
```
2. **启动 MySQL 数据库**
```bash
# 使用 Docker 启动 MySQL
docker run --name mysql-dev -e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=myapp -p 3306:3306 -d mysql:8.0
```
3. **运行应用**
```bash
# 使用 Air 热重载
air -c .air.toml
# 或直接运行
go run cmd/api/main.go
```
## 🔧 配置说明
### 环境变量
创建 `.env.dev` 文件(参考 `.env.dev.example`
```env
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=myapp
# JWT 配置
JWT_SECRET=your-secret-key
JWT_EXPIRE_HOURS=24
```
### 配置文件 (`configs/config.dev.yaml`)
```yaml
server:
port: 8080
mode: "debug"
read_timeout: 30
write_timeout: 30
database:
host: "mysql"
port: 3306
user: "root"
password: "password"
dbname: "myapp"
max_open_conns: 100
max_idle_conns: 10
reset_database: true
jwt:
secret: "your-jwt-secret-key"
expire_hours: 24
```
## 📖 API 文档
### 用户认证
#### 用户注册
```http
POST /api/v1/users/register
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123"
}
```
#### 用户登录
```http
POST /api/v1/users/login
Content-Type: application/json
{
"email": "test@example.com",
"password": "password123"
}
```
响应:
```json
{
"message": "Login successful",
"user": {
"id": 1,
"username": "testuser",
"email": "test@example.com"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
### 合同管理
#### 创建合同
```http
POST /api/v1/contracts
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"name": "采购合同",
"contract_type": "采购",
"contract_code": "CG2024001",
"buy_party": "买方公司",
"sell_party": "卖方公司",
"count_party": "计数方",
"unit_price": "1000.00",
"bl_number": "BL123456",
"delivery_time": "2024-12-31",
"pickup_time": "2024-12-30",
"packaging": "标准包装",
"remarks": "备注信息",
"file_path": "/uploads/contract.pdf"
}
```
#### 获取合同列表
```http
GET /api/v1/contracts
Authorization: Bearer <jwt-token>
```
## 🗄️ 数据模型
### 用户表 (users)
| 字段 | 类型 | 描述 |
|------|------|------|
| id | uint | 主键 |
| username | string(50) | 用户名,唯一 |
| email | string(100) | 邮箱,唯一 |
| password | string(255) | 密码哈希 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
### 合同表 (contracts)
| 字段 | 类型 | 描述 |
|------|------|------|
| id | uint | 主键 |
| name | string(100) | 合同名称 |
| contract_type | string(50) | 合同类型 |
| contract_code | string(50) | 合同编码 |
| buy_party | string(100) | 买方 |
| sell_party | string(100) | 卖方 |
| count_party | string(100) | 计数方 |
| unit_price | string(50) | 单价 |
| bl_number | string(50) | 提单号 |
| delivery_time | string(50) | 交付时间 |
| pickup_time | string(50) | 提货时间 |
| packaging | string(50) | 包装方式 |
| remarks | string(255) | 备注 |
| file_path | string(255) | 文件路径 |
## 🐳 Docker 开发环境
### 开发环境配置 (`docker-compose.dev.yml`)
- **应用服务**: Go + Air 热重载
- **数据库服务**: MySQL 8.0
- **网络配置**: 自定义网络 `app-network`
- **卷挂载**: 代码实时同步Go 模块缓存
### 构建自定义镜像
```bash
# 构建开发镜像
docker build -f Dockerfile.dev -t zsp-backend-dev .
# 运行容器
docker run -p 8080:8080 -v $(pwd):/workspace zsp-backend-dev
```
## 🧪 测试
### 运行测试
```bash
# 运行所有测试
go test ./...
# 运行特定包测试
go test ./internal/handler
# 运行测试并显示覆盖率
go test -cover ./...
```
### 测试数据库
测试环境使用独立的数据库 `myapp_test`,通过初始化脚本自动创建。
## 🔄 数据库迁移
### 自动迁移
在开发环境中,设置 `reset_database: true` 会自动执行数据库迁移:
```go
if cfg.Database.ResetDatabase {
err = database.AutoMigrate(db)
// ...
}
```
### 手动迁移
1. 创建迁移文件在 `migrations/` 目录
2. 使用 GORM 的 `AutoMigrate` 或手动执行 SQL
## 📊 部署
### 生产环境构建
```bash
# 构建生产镜像
docker build -t zsp-backend-prod .
# 使用生产配置
docker run -p 8080:8080 \
-v /path/to/config.yaml:/app/config.yaml \
zsp-backend-prod
```
### 环境变量配置
生产环境建议使用环境变量覆盖配置文件:
```bash
export DB_HOST=production-db
export DB_PASSWORD=secure-password
export JWT_SECRET=production-secret
```
## 🛠️ 开发工具
### Air 热重载配置 (`.air.toml`)
- 自动检测文件变化并重新编译
- 排除测试文件和构建目录
- 自定义构建命令和输出目录
### 代码规范
- 使用 `go fmt` 格式化代码
- 遵循 Go 官方代码规范
- 使用有意义的包和函数命名
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 📞 支持
如有问题或建议,请:
1. 查看 [Issues](https://github.com/rovina/zsp-backend/issues)
2. 提交新的 Issue
3. 联系项目维护者
---
**最后更新**: 2024年1月
**版本**: 1.0.0-dev

View File

@@ -0,0 +1,11 @@
CREATE DATABASE IF NOT EXISTS `myapp_dev` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS `myapp_test` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
--- 创建专用用户
CREATE USER IF NOT EXISTS 'myapp_user'@'%' IDENTIFIED BY 'devpassword';
GRANT ALL PRIVILEGES ON `myapp_dev`.* TO 'myapp_user'@'%';
GRANT ALL PRIVILEGES ON `myapp_test`.* TO 'myapp_user'@'%';
FLUSH PRIVILEGES;
USE `myapp_dev`;

View File

@@ -0,0 +1,496 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
)
// mockCompanyService implements service.CompanyService for testing
type mockCompanyService struct {
createFolderFn func(name, description, category string) (*model.CompanyFolder, error)
listFoldersFn func() ([]model.CompanyFolder, error)
getFolderByIDFn func(id uint) (*model.CompanyFolder, error)
updateFolderFn func(id uint, name, description, category string) error
deleteFolderFn func(id uint) error
uploadFilesFn func(folderID *uint, companyName, fileCategory, expireDate, description string, files []*multipart.FileHeader) (int, error)
listFilesFn func(folderID *uint) ([]model.CompanyFile, error)
getFileByIDFn func(id uint) (*model.CompanyFile, error)
updateFileFn func(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error
deleteFileFn func(id uint) error
}
func (m *mockCompanyService) CreateFolder(name, description, category string) (*model.CompanyFolder, error) {
return m.createFolderFn(name, description, category)
}
func (m *mockCompanyService) ListFolders() ([]model.CompanyFolder, error) {
return m.listFoldersFn()
}
func (m *mockCompanyService) GetFolderByID(id uint) (*model.CompanyFolder, error) {
return m.getFolderByIDFn(id)
}
func (m *mockCompanyService) UpdateFolder(id uint, name, description, category string) error {
return m.updateFolderFn(id, name, description, category)
}
func (m *mockCompanyService) DeleteFolder(id uint) error {
return m.deleteFolderFn(id)
}
func (m *mockCompanyService) UploadFiles(folderID *uint, companyName, fileCategory, expireDate, description string, files []*multipart.FileHeader) (int, error) {
return m.uploadFilesFn(folderID, companyName, fileCategory, expireDate, description, files)
}
func (m *mockCompanyService) ListFiles(folderID *uint) ([]model.CompanyFile, error) {
return m.listFilesFn(folderID)
}
func (m *mockCompanyService) GetFileByID(id uint) (*model.CompanyFile, error) {
return m.getFileByIDFn(id)
}
func (m *mockCompanyService) UpdateFile(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error {
return m.updateFileFn(id, companyName, fileCategory, expireDate, description, folderID)
}
func (m *mockCompanyService) DeleteFile(id uint) error {
return m.deleteFileFn(id)
}
var _ service.CompanyService = (*mockCompanyService)(nil)
func setupCompanyRouter(h *handler.CompanyHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
company := api.Group("/company")
{
company.GET("/folders", h.ListFolders)
company.POST("/folders", h.CreateFolder)
company.PUT("/folders/:id", h.UpdateFolder)
company.DELETE("/folders/:id", h.DeleteFolder)
company.GET("/files", h.ListFiles)
company.POST("/files/upload", h.UploadFiles)
company.PUT("/files/:id", h.UpdateFile)
company.DELETE("/files/:id", h.DeleteFile)
}
}
return r
}
// ========== Folder Tests ==========
func TestCompanyListFolders_Success(t *testing.T) {
mock := &mockCompanyService{
listFoldersFn: func() ([]model.CompanyFolder, error) {
return []model.CompanyFolder{
{ID: 1, Name: "营业执照文件夹", Category: "license"},
{ID: 2, Name: "开票信息文件夹", Category: "invoice"},
}, nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/company/folders", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestCompanyListFolders_Error(t *testing.T) {
mock := &mockCompanyService{
listFoldersFn: func() ([]model.CompanyFolder, error) {
return nil, errors.New("database error")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/company/folders", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestCompanyCreateFolder_Success(t *testing.T) {
mock := &mockCompanyService{
createFolderFn: func(name, description, category string) (*model.CompanyFolder, error) {
return &model.CompanyFolder{ID: 1, Name: name, Description: description, Category: category}, nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"name":"测试文件夹","description":"测试描述","category":"license"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/company/folders", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestCompanyCreateFolder_BadRequest(t *testing.T) {
mock := &mockCompanyService{
createFolderFn: func(name, description, category string) (*model.CompanyFolder, error) {
return nil, nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/company/folders", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing required field, got %d", w.Code)
}
}
func TestCompanyCreateFolder_Error(t *testing.T) {
mock := &mockCompanyService{
createFolderFn: func(name, description, category string) (*model.CompanyFolder, error) {
return nil, errors.New("folder already exists")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"name":"测试文件夹","description":"测试描述","category":"license"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/company/folders", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestCompanyUpdateFolder_Success(t *testing.T) {
mock := &mockCompanyService{
updateFolderFn: func(id uint, name, description, category string) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"name":"更新文件夹","description":"更新描述","category":"invoice"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/folders/1", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCompanyUpdateFolder_InvalidID(t *testing.T) {
mock := &mockCompanyService{
updateFolderFn: func(id uint, name, description, category string) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"name":"更新文件夹","description":"更新描述","category":"invoice"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/folders/invalid", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid id, got %d", w.Code)
}
}
func TestCompanyUpdateFolder_Error(t *testing.T) {
mock := &mockCompanyService{
updateFolderFn: func(id uint, name, description, category string) error {
return errors.New("folder not found")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"name":"更新文件夹","description":"更新描述","category":"invoice"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/folders/999", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestCompanyDeleteFolder_Success(t *testing.T) {
mock := &mockCompanyService{
deleteFolderFn: func(id uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/folders/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCompanyDeleteFolder_InvalidID(t *testing.T) {
mock := &mockCompanyService{
deleteFolderFn: func(id uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/folders/invalid", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid id, got %d", w.Code)
}
}
func TestCompanyDeleteFolder_Error(t *testing.T) {
mock := &mockCompanyService{
deleteFolderFn: func(id uint) error {
return errors.New("folder not found")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/folders/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
// ========== File Tests ==========
func TestCompanyListFiles_Success(t *testing.T) {
mock := &mockCompanyService{
listFilesFn: func(folderID *uint) ([]model.CompanyFile, error) {
return []model.CompanyFile{
{ID: 1, FileName: "营业执照.pdf", CompanyName: "公司A", FileCategory: "license"},
{ID: 2, FileName: "开票信息.pdf", CompanyName: "公司A", FileCategory: "invoice"},
}, nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/company/files", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestCompanyListFiles_WithFolderID(t *testing.T) {
mock := &mockCompanyService{
listFilesFn: func(folderID *uint) ([]model.CompanyFile, error) {
if folderID != nil && *folderID != 1 {
t.Errorf("expected folderID 1, got %v", folderID)
}
return []model.CompanyFile{
{ID: 1, FileName: "营业执照.pdf", FolderID: 1},
}, nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/company/files?folderId=1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCompanyListFiles_Error(t *testing.T) {
mock := &mockCompanyService{
listFilesFn: func(folderID *uint) ([]model.CompanyFile, error) {
return nil, errors.New("database error")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/company/files", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestCompanyUpdateFile_Success(t *testing.T) {
mock := &mockCompanyService{
updateFileFn: func(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"companyName":"公司B","fileCategory":"license","expireDate":"2025-12-31","description":"更新描述"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/files/1", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCompanyUpdateFile_InvalidID(t *testing.T) {
mock := &mockCompanyService{
updateFileFn: func(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"companyName":"公司B","fileCategory":"license"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/files/invalid", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid id, got %d", w.Code)
}
}
func TestCompanyUpdateFile_Error(t *testing.T) {
mock := &mockCompanyService{
updateFileFn: func(id uint, companyName, fileCategory, expireDate, description string, folderID *uint) error {
return errors.New("file not found")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
body := `{"companyName":"公司B","fileCategory":"license"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/company/files/999", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestCompanyDeleteFile_Success(t *testing.T) {
mock := &mockCompanyService{
deleteFileFn: func(id uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/files/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestCompanyDeleteFile_InvalidID(t *testing.T) {
mock := &mockCompanyService{
deleteFileFn: func(id uint) error {
return nil
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/files/invalid", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid id, got %d", w.Code)
}
}
func TestCompanyDeleteFile_Error(t *testing.T) {
mock := &mockCompanyService{
deleteFileFn: func(id uint) error {
return errors.New("file not found")
},
}
h := handler.NewCompanyHandler(mock)
router := setupCompanyRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/company/files/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}

View File

@@ -0,0 +1,216 @@
package test
import (
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/model"
)
// ----- mock contract services -----
type mockContractService struct {
listFn func(contractType string) ([]model.Contract, error)
getByIDFn func(id uint) (*model.Contract, error)
}
func (m *mockContractService) CreateContract(contract *model.Contract, files []model.ContractFile) error { return nil }
func (m *mockContractService) SaveFiles(files []model.ContractFile) error { return nil }
func (m *mockContractService) BatchSaveFiles(basePath string, files []*multipart.FileHeader) (int, error) { return 0, nil }
func (m *mockContractService) List(contractType string) ([]model.Contract, error) { return m.listFn(contractType) }
func (m *mockContractService) GetByID(id uint) (*model.Contract, error) { return m.getByIDFn(id) }
func (m *mockContractService) Update(contract *model.Contract) error { return nil }
func (m *mockContractService) UpdateWithFiles(id uint, keepFileIDs []uint, newFileModels []model.ContractFile, form map[string]string) error {
return nil
}
func (m *mockContractService) Delete(id uint) error { return nil }
type mockContractFolderService struct {
listFn func() ([]model.ContractFolder, error)
createFn func(name, description string) (*model.ContractFolder, error)
getByIDFn func(id uint) (*model.ContractFolder, error)
updateFn func(id uint, name, description string) error
deleteFn func(id uint) error
}
func (m *mockContractFolderService) List() ([]model.ContractFolder, error) { return m.listFn() }
func (m *mockContractFolderService) Create(name, description string) (*model.ContractFolder, error) { return m.createFn(name, description) }
func (m *mockContractFolderService) GetByID(id uint) (*model.ContractFolder, error) { return m.getByIDFn(id) }
func (m *mockContractFolderService) Update(id uint, name, description string) error { return m.updateFn(id, name, description) }
func (m *mockContractFolderService) Delete(id uint) error { return m.deleteFn(id) }
type mockContractBatchService struct {
listFn func(folderID *uint) ([]model.ContractBatch, error)
getByIDFn func(id uint) (*model.ContractBatch, error)
deleteFn func(id uint) error
}
func (m *mockContractBatchService) List(folderID *uint) ([]model.ContractBatch, error) { return m.listFn(folderID) }
func (m *mockContractBatchService) GetByID(id uint) (*model.ContractBatch, error) { return m.getByIDFn(id) }
func (m *mockContractBatchService) Create(folderID uint, batchName, remark string, files []model.ContractBatchFile) (*model.ContractBatch, error) {
return nil, nil
}
func (m *mockContractBatchService) Delete(id uint) error { return m.deleteFn(id) }
func setupContractRouter(h *handler.ContractHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
contracts := api.Group("/contract")
{
contracts.GET("", h.List)
contracts.GET("/folders", h.ListFolders)
contracts.POST("/folders", h.CreateFolder)
contracts.GET("/folders/:id", h.GetFolder)
contracts.PUT("/folders/:id", h.UpdateFolder)
contracts.DELETE("/folders/:id", h.DeleteFolder)
contracts.GET("/:id", h.Get)
}
}
return r
}
func TestContractList_Success(t *testing.T) {
mockContract := &mockContractService{
listFn: func(contractType string) ([]model.Contract, error) {
return []model.Contract{
{ID: 1, Name: "合同1", ContractCode: "C001"},
{ID: 2, Name: "合同2", ContractCode: "C002"},
}, nil
},
}
mockFolder := &mockContractFolderService{
listFn: func() ([]model.ContractFolder, error) { return nil, nil },
}
mockBatch := &mockContractBatchService{
listFn: func(folderID *uint) ([]model.ContractBatch, error) { return nil, nil },
}
h := handler.NewContractHandler(mockContract, mockFolder, mockBatch)
router := setupContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/contract", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestContractGet_Success(t *testing.T) {
mockContract := &mockContractService{
getByIDFn: func(id uint) (*model.Contract, error) {
return &model.Contract{ID: 1, Name: "合同1", ContractCode: "C001"}, nil
},
}
mockFolder := &mockContractFolderService{
listFn: func() ([]model.ContractFolder, error) { return nil, nil },
}
mockBatch := &mockContractBatchService{
listFn: func(folderID *uint) ([]model.ContractBatch, error) { return nil, nil },
}
h := handler.NewContractHandler(mockContract, mockFolder, mockBatch)
router := setupContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/contract/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestContractGet_NotFound(t *testing.T) {
mockContract := &mockContractService{
getByIDFn: func(id uint) (*model.Contract, error) {
return nil, errors.New("contract not found")
},
}
mockFolder := &mockContractFolderService{
listFn: func() ([]model.ContractFolder, error) { return nil, nil },
}
mockBatch := &mockContractBatchService{
listFn: func(folderID *uint) ([]model.ContractBatch, error) { return nil, nil },
}
h := handler.NewContractHandler(mockContract, mockFolder, mockBatch)
router := setupContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/contract/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
func TestContractCreateFolder_Success(t *testing.T) {
mockContract := &mockContractService{}
mockFolder := &mockContractFolderService{
createFn: func(name, description string) (*model.ContractFolder, error) {
return &model.ContractFolder{ID: 1, Name: name, Description: description}, nil
},
}
mockBatch := &mockContractBatchService{}
h := handler.NewContractHandler(mockContract, mockFolder, mockBatch)
router := setupContractRouter(h)
body := `{"name":"测试文件夹","description":"测试描述"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/contract/folders", nil)
req.Body = http.NoBody
_ = body // body is unused in this simple test; the handler uses ShouldBindJSON
router.ServeHTTP(w, req)
// Without valid JSON body, this should return 400
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing body, got %d", w.Code)
}
}
func TestContractListFolders_Success(t *testing.T) {
mockContract := &mockContractService{}
mockFolder := &mockContractFolderService{
listFn: func() ([]model.ContractFolder, error) {
return []model.ContractFolder{
{ID: 1, Name: "文件夹1"},
{ID: 2, Name: "文件夹2"},
}, nil
},
}
mockBatch := &mockContractBatchService{}
h := handler.NewContractHandler(mockContract, mockFolder, mockBatch)
router := setupContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/contract/folders", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}

View File

@@ -0,0 +1,375 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
"gorm.io/gorm"
)
// mockDeliveryService implements service.DeliveryService for testing
type mockDeliveryService struct {
listApplyFn func(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error)
getApplyByIDFn func(id uint) (*model.DeliveryApply, error)
createApplyFn func(e *model.DeliveryApply) error
deleteApplyFn func(id uint) error
listOutboundFn func(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error)
getOutboundByIDFn func(id uint) (*model.DeliveryOutbound, error)
createOutboundFn func(e *model.DeliveryOutbound) error
deleteOutboundFn func(id uint) error
listWarehousesFn func() ([]model.Warehouse, error)
createWarehouseFn func(e *model.Warehouse) error
deleteWarehouseFn func(id uint) error
listInboundFn func(page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error)
createInboundFn func(e *model.InventoryInbound) error
deleteInboundFn func(id uint) error
listOwnershipFn func(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error)
deleteOwnershipFn func(id uint) error
dbFn func() *gorm.DB
}
func (m *mockDeliveryService) ListApply(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error) {
return m.listApplyFn(page, pageSize, orderNumber, blNumber, status)
}
func (m *mockDeliveryService) GetApplyByID(id uint) (*model.DeliveryApply, error) { return m.getApplyByIDFn(id) }
func (m *mockDeliveryService) CreateApply(e *model.DeliveryApply) error { return m.createApplyFn(e) }
func (m *mockDeliveryService) UpdateApply(e *model.DeliveryApply) error { return nil }
func (m *mockDeliveryService) DeleteApply(id uint) error { return m.deleteApplyFn(id) }
func (m *mockDeliveryService) ListOutbound(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error) {
return m.listOutboundFn(page, pageSize, outboundNumber, blNumber, status)
}
func (m *mockDeliveryService) GetOutboundByID(id uint) (*model.DeliveryOutbound, error) { return m.getOutboundByIDFn(id) }
func (m *mockDeliveryService) CreateOutbound(e *model.DeliveryOutbound) error { return m.createOutboundFn(e) }
func (m *mockDeliveryService) UpdateOutbound(e *model.DeliveryOutbound) error { return nil }
func (m *mockDeliveryService) DeleteOutbound(id uint) error { return m.deleteOutboundFn(id) }
func (m *mockDeliveryService) ListWarehouses() ([]model.Warehouse, error) { return m.listWarehousesFn() }
func (m *mockDeliveryService) GetWarehouseByID(id uint) (*model.Warehouse, error) { return nil, nil }
func (m *mockDeliveryService) CreateWarehouse(e *model.Warehouse) error { return m.createWarehouseFn(e) }
func (m *mockDeliveryService) UpdateWarehouse(e *model.Warehouse) error { return nil }
func (m *mockDeliveryService) DeleteWarehouse(id uint) error { return m.deleteWarehouseFn(id) }
func (m *mockDeliveryService) ListInbound(page, pageSize int, warehouse, commodity string) ([]model.InventoryInbound, int64, error) {
return m.listInboundFn(page, pageSize, warehouse, commodity)
}
func (m *mockDeliveryService) GetInboundByID(id uint) (*model.InventoryInbound, error) { return nil, nil }
func (m *mockDeliveryService) CreateInbound(e *model.InventoryInbound) error { return m.createInboundFn(e) }
func (m *mockDeliveryService) UpdateInbound(e *model.InventoryInbound) error { return nil }
func (m *mockDeliveryService) DeleteInbound(id uint) error { return m.deleteInboundFn(id) }
func (m *mockDeliveryService) ListOwnership(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error) {
return m.listOwnershipFn(page, pageSize, transferNumber, blNumber, status)
}
func (m *mockDeliveryService) GetOwnershipByID(id uint) (*model.OwnershipTransfer, error) { return nil, nil }
func (m *mockDeliveryService) CreateOwnership(e *model.OwnershipTransfer) error { return nil }
func (m *mockDeliveryService) DeleteOwnership(id uint) error { return m.deleteOwnershipFn(id) }
func (m *mockDeliveryService) CreateOwnershipFiles(files []model.OwnershipTransferFile) error { return nil }
func (m *mockDeliveryService) DB() *gorm.DB { return nil }
var _ service.DeliveryService = (*mockDeliveryService)(nil)
func setupDeliveryRouter(h *handler.DeliveryHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
applyDelivery := api.Group("/apply-delivery")
{
applyDelivery.GET("", h.ListApply)
applyDelivery.POST("", h.CreateApply)
applyDelivery.DELETE("/:id", h.DeleteApply)
}
deliveryDetails := api.Group("/delivery-details")
{
deliveryDetails.GET("", h.ListOutbound)
deliveryDetails.POST("", h.CreateOutbound)
deliveryDetails.DELETE("/:id", h.DeleteOutbound)
}
inventory := api.Group("/inventory")
{
inventory.GET("/warehouses", h.ListWarehouses)
inventory.POST("/warehouses", h.CreateWarehouse)
inventory.DELETE("/warehouses/:id", h.DeleteWarehouse)
inventory.POST("/inbound", h.CreateInbound)
inventory.DELETE("/inbound/:id", h.DeleteInbound)
}
ownershipTransfer := api.Group("/ownership-transfer")
{
ownershipTransfer.GET("", h.ListOwnership)
ownershipTransfer.DELETE("/:id", h.DeleteOwnership)
}
}
return r
}
// ========== 我要提货 /api/apply-delivery ==========
func TestDeliveryListApply_Success(t *testing.T) {
mock := &mockDeliveryService{
listApplyFn: func(page, pageSize int, orderNumber, blNumber, status string) ([]model.DeliveryApply, int64, error) {
return []model.DeliveryApply{
{ID: 1, OrderNumber: "DA001", Status: "pending"},
{ID: 2, OrderNumber: "DA002", Status: "approved"},
}, 2, nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/apply-delivery?page=1&pageSize=10", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestDeliveryCreateApply_Success(t *testing.T) {
mock := &mockDeliveryService{
createApplyFn: func(e *model.DeliveryApply) error {
e.ID = 1
e.OrderNumber = "DA1234567890"
return nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
body := `{"blNumber":"BL001","commodity":"燕麦","quantity":100}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/apply-delivery", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryDeleteApply_Success(t *testing.T) {
mock := &mockDeliveryService{
deleteApplyFn: func(id uint) error { return nil },
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/apply-delivery/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryDeleteApply_NotFound(t *testing.T) {
mock := &mockDeliveryService{
deleteApplyFn: func(id uint) error { return errors.New("not found") },
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/apply-delivery/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
// ========== 提货明细 /api/delivery-details ==========
func TestDeliveryListOutbound_Success(t *testing.T) {
mock := &mockDeliveryService{
listOutboundFn: func(page, pageSize int, outboundNumber, blNumber, status string) ([]model.DeliveryOutbound, int64, error) {
return []model.DeliveryOutbound{
{ID: 1, OutboundNumber: "OB001", OutboundStatus: "completed"},
}, 1, nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/delivery-details?page=1&pageSize=10", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryCreateOutbound_Success(t *testing.T) {
mock := &mockDeliveryService{
createOutboundFn: func(e *model.DeliveryOutbound) error {
e.ID = 1
e.OutboundNumber = "OB1234567890"
return nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
body := `{"blNumber":"BL001","warehouse":"A库","quantity":50}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/delivery-details", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryDeleteOutbound_Success(t *testing.T) {
mock := &mockDeliveryService{
deleteOutboundFn: func(id uint) error { return nil },
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/delivery-details/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== 库存 /api/inventory ==========
func TestDeliveryListWarehouses_Success(t *testing.T) {
mock := &mockDeliveryService{
listWarehousesFn: func() ([]model.Warehouse, error) {
return []model.Warehouse{
{ID: 1, Name: "A库", Status: "active"},
{ID: 2, Name: "B库", Status: "active"},
}, nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/inventory/warehouses", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryCreateWarehouse_Success(t *testing.T) {
mock := &mockDeliveryService{
createWarehouseFn: func(e *model.Warehouse) error {
e.ID = 1
return nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
body := `{"name":"C库","location":"广州"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/inventory/warehouses", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryDeleteWarehouse_Success(t *testing.T) {
mock := &mockDeliveryService{
deleteWarehouseFn: func(id uint) error { return nil },
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/inventory/warehouses/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryCreateInbound_Success(t *testing.T) {
mock := &mockDeliveryService{
createInboundFn: func(e *model.InventoryInbound) error {
e.ID = 1
e.InboundNumber = "IN1234567890"
return nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
body := `{"warehouse":"A库","commodity":"燕麦","inboundWeight":1000}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/inventory/inbound", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== 货权转移 /api/ownership-transfer ==========
func TestDeliveryListOwnership_Success(t *testing.T) {
mock := &mockDeliveryService{
listOwnershipFn: func(page, pageSize int, transferNumber, blNumber, status string) ([]model.OwnershipTransfer, int64, error) {
return []model.OwnershipTransfer{
{ID: 1, TransferNumber: "OT001", Status: "draft"},
}, 1, nil
},
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/ownership-transfer?page=1&pageSize=10", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestDeliveryDeleteOwnership_Success(t *testing.T) {
mock := &mockDeliveryService{
deleteOwnershipFn: func(id uint) error { return nil },
}
h := handler.NewDeliveryHandler(mock)
router := setupDeliveryRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/ownership-transfer/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}

177
backend/test/user_handler_test.go Executable file
View File

@@ -0,0 +1,177 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/handler"
)
// mockUserService implements service.UserService for testing
type mockUserService struct {
registerFn func(req *dto.CreateUserRequest) (*dto.UserResponse, error)
loginFn func(req *dto.LoginRequest) (*dto.UserResponse, string, error)
getUserByIDFn func(id uint) (*dto.UserResponse, error)
}
func (m *mockUserService) Register(req *dto.CreateUserRequest) (*dto.UserResponse, error) {
return m.registerFn(req)
}
func (m *mockUserService) Login(req *dto.LoginRequest) (*dto.UserResponse, string, error) {
return m.loginFn(req)
}
func (m *mockUserService) GetUserByID(id uint) (*dto.UserResponse, error) {
return m.getUserByIDFn(id)
}
func setupUserRouter(h *handler.UserHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
auth := api.Group("/auth")
{
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
}
users := api.Group("/users")
{
users.GET("/:id", h.GetUser)
}
}
return r
}
func TestUserRegister_Success(t *testing.T) {
mock := &mockUserService{
registerFn: func(req *dto.CreateUserRequest) (*dto.UserResponse, error) {
return &dto.UserResponse{ID: 1, Username: "testuser", Email: "test@example.com"}, nil
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
body, _ := json.Marshal(dto.CreateUserRequest{Username: "testuser", Email: "test@example.com", Password: "password123"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["message"] != "User registered successfully" {
t.Errorf("unexpected message: %v", resp["message"])
}
}
func TestUserRegister_DuplicateEmail(t *testing.T) {
mock := &mockUserService{
registerFn: func(req *dto.CreateUserRequest) (*dto.UserResponse, error) {
return nil, errors.New("email already exists")
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
body, _ := json.Marshal(dto.CreateUserRequest{Username: "testuser", Email: "existing@example.com", Password: "password123"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestUserLogin_Success(t *testing.T) {
mock := &mockUserService{
loginFn: func(req *dto.LoginRequest) (*dto.UserResponse, string, error) {
return &dto.UserResponse{ID: 1, Username: "testuser", Email: "test@example.com"}, "test-token", nil
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
body, _ := json.Marshal(dto.LoginRequest{Email: "test@example.com", Password: "password123"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["token"] != "test-token" {
t.Errorf("expected token 'test-token', got %v", resp["token"])
}
}
func TestUserLogin_InvalidCredentials(t *testing.T) {
mock := &mockUserService{
loginFn: func(req *dto.LoginRequest) (*dto.UserResponse, string, error) {
return nil, "", errors.New("invalid credentials")
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
body, _ := json.Marshal(dto.LoginRequest{Email: "wrong@example.com", Password: "wrong"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestUserGet_Success(t *testing.T) {
mock := &mockUserService{
getUserByIDFn: func(id uint) (*dto.UserResponse, error) {
return &dto.UserResponse{ID: 1, Username: "testuser", Email: "test@example.com"}, nil
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/users/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestUserGet_NotFound(t *testing.T) {
mock := &mockUserService{
getUserByIDFn: func(id uint) (*dto.UserResponse, error) {
return nil, errors.New("user not found")
},
}
h := handler.NewUserHandler(mock)
router := setupUserRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/users/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}

View File

@@ -0,0 +1,539 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
"mime/multipart"
)
// mockZSPContractService implements service.ZSPContractService for testing
type mockZSPContractService struct {
createFolderFn func(req *dto.ZSPFolderCreateRequest) error
getFoldersFn func() ([]model.ZSPContractFolder, error)
getFolderByIdFn func(folderID string) (*model.ZSPContractFolder, error)
deleteFolderByIdFn func(folderID string) error
updateFolderByIdFn func(folderID string, req *dto.ZSPFolderUpdateRequest) error
uploadContractFn func(req *dto.ZSPContractCreateRequest, fileHeader *multipart.FileHeader) error
getContractByIdFn func(contractID string) (*model.ZSPContractFile, error)
getContractWithDetailsFn func(contractID string) (*model.ZSPContractFile, error)
listContractsFn func(folderID string) ([]model.ZSPContractFile, error)
listContractsWithDetailsFn func(folderID string) ([]model.ZSPContractFile, error)
deleteContractByIdFn func(contractID string) error
updateContractByIdFn func(contractID string, req *dto.ZSPContractUpdateRequest) error
createContractDetailFn func(req *dto.ZSPContractDetailCreateRequest) error
updateContractDetailFn func(req *dto.ZSPContractDetailUpdateRequest) error
getContractDetailByIdFn func(detailID string) (*model.ZSPContractDetail, error)
listContractDetailsFn func(contractFileID string) ([]model.ZSPContractDetail, error)
deleteContractDetailByIdFn func(detailID string) error
batchCreateContractDetailsFn func(req *dto.ZSPContractDetailBatchCreateRequest) error
}
func (m *mockZSPContractService) CreateFolder(req *dto.ZSPFolderCreateRequest) error {
return m.createFolderFn(req)
}
func (m *mockZSPContractService) GetFolders() ([]model.ZSPContractFolder, error) {
return m.getFoldersFn()
}
func (m *mockZSPContractService) GetFolderById(folderID string) (*model.ZSPContractFolder, error) {
return m.getFolderByIdFn(folderID)
}
func (m *mockZSPContractService) DeleteFolderById(folderID string) error {
return m.deleteFolderByIdFn(folderID)
}
func (m *mockZSPContractService) UpdateFolderById(folderID string, req *dto.ZSPFolderUpdateRequest) error {
return m.updateFolderByIdFn(folderID, req)
}
func (m *mockZSPContractService) UploadContract(req *dto.ZSPContractCreateRequest, fileHeader *multipart.FileHeader) error {
return m.uploadContractFn(req, fileHeader)
}
func (m *mockZSPContractService) GetContractById(contractID string) (*model.ZSPContractFile, error) {
return m.getContractByIdFn(contractID)
}
func (m *mockZSPContractService) GetContractWithDetails(contractID string) (*model.ZSPContractFile, error) {
return m.getContractWithDetailsFn(contractID)
}
func (m *mockZSPContractService) ListContracts(folderID string) ([]model.ZSPContractFile, error) {
return m.listContractsFn(folderID)
}
func (m *mockZSPContractService) ListContractsWithDetails(folderID string) ([]model.ZSPContractFile, error) {
return m.listContractsWithDetailsFn(folderID)
}
func (m *mockZSPContractService) DeleteContractById(contractID string) error {
return m.deleteContractByIdFn(contractID)
}
func (m *mockZSPContractService) UpdateContract(contract *model.ZSPContractFile) error {
return nil
}
func (m *mockZSPContractService) UpdateContractById(contractID string, req *dto.ZSPContractUpdateRequest) error {
return m.updateContractByIdFn(contractID, req)
}
func (m *mockZSPContractService) CreateContractDetail(req *dto.ZSPContractDetailCreateRequest) error {
return m.createContractDetailFn(req)
}
func (m *mockZSPContractService) UpdateContractDetail(req *dto.ZSPContractDetailUpdateRequest) error {
return m.updateContractDetailFn(req)
}
func (m *mockZSPContractService) GetContractDetailById(detailID string) (*model.ZSPContractDetail, error) {
return m.getContractDetailByIdFn(detailID)
}
func (m *mockZSPContractService) ListContractDetails(contractFileID string) ([]model.ZSPContractDetail, error) {
return m.listContractDetailsFn(contractFileID)
}
func (m *mockZSPContractService) DeleteContractDetailById(detailID string) error {
return m.deleteContractDetailByIdFn(detailID)
}
func (m *mockZSPContractService) BatchCreateContractDetails(req *dto.ZSPContractDetailBatchCreateRequest) error {
return m.batchCreateContractDetailsFn(req)
}
var _ service.ZSPContractService = (*mockZSPContractService)(nil)
func setupZSPContractRouter(h *handler.ZSPContractHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
zsp := api.Group("/zsp-contract")
{
zsp.POST("/folders", h.CreateFolder)
zsp.GET("/folders", h.GetFolders)
zsp.GET("/folders/:id", h.GetFolder)
zsp.DELETE("/folders/:id", h.DeleteFolder)
zsp.PUT("/folders/:id", h.UpdateFolder)
zsp.POST("/contracts", h.UploadContract)
zsp.GET("/contracts/:id", h.GetContractWithDetails)
zsp.GET("/contracts", h.ListContractsWithDetails)
zsp.PUT("/contracts/:id", h.UpdateContract)
zsp.DELETE("/contracts/:id", h.DeleteContract)
zsp.POST("/contracts/:id/details", h.CreateContractDetail)
zsp.GET("/contracts/:id/details", h.ListContractDetails)
zsp.POST("/details/batch", h.BatchCreateContractDetails)
zsp.GET("/details/:id", h.GetContractDetail)
zsp.PUT("/details/:id", h.UpdateContractDetail)
zsp.DELETE("/details/:id", h.DeleteContractDetail)
}
}
return r
}
// ========== Folder Tests ==========
func TestZSPContractCreateFolder_Success(t *testing.T) {
mock := &mockZSPContractService{
createFolderFn: func(req *dto.ZSPFolderCreateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
body, _ := json.Marshal(dto.ZSPFolderCreateRequest{Name: "test folder", Description: "test desc"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-contract/folders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPContractCreateFolder_BadRequest(t *testing.T) {
mock := &mockZSPContractService{
createFolderFn: func(req *dto.ZSPFolderCreateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-contract/folders", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing required field, got %d", w.Code)
}
}
func TestZSPContractGetFolders_Success(t *testing.T) {
mock := &mockZSPContractService{
getFoldersFn: func() ([]model.ZSPContractFolder, error) {
return []model.ZSPContractFolder{
{ID: 1, Name: "folder1"},
{ID: 2, Name: "folder2"},
}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/folders", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPContractGetFolders_Error(t *testing.T) {
mock := &mockZSPContractService{
getFoldersFn: func() ([]model.ZSPContractFolder, error) {
return nil, errors.New("database error")
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/folders", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestZSPContractGetFolder_Success(t *testing.T) {
mock := &mockZSPContractService{
getFolderByIdFn: func(folderID string) (*model.ZSPContractFolder, error) {
return &model.ZSPContractFolder{ID: 1, Name: "test folder"}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/folders/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractDeleteFolder_Success(t *testing.T) {
mock := &mockZSPContractService{
deleteFolderByIdFn: func(folderID string) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-contract/folders/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractDeleteFolder_Error(t *testing.T) {
mock := &mockZSPContractService{
deleteFolderByIdFn: func(folderID string) error {
return errors.New("folder not found")
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-contract/folders/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestZSPContractUpdateFolder_Success(t *testing.T) {
mock := &mockZSPContractService{
updateFolderByIdFn: func(folderID string, req *dto.ZSPFolderUpdateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
body, _ := json.Marshal(dto.ZSPFolderUpdateRequest{Name: "updated name", Description: "updated desc"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/zsp-contract/folders/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== Contract Tests ==========
func TestZSPContractListContracts_Success(t *testing.T) {
mock := &mockZSPContractService{
listContractsWithDetailsFn: func(folderID string) ([]model.ZSPContractFile, error) {
return []model.ZSPContractFile{
{ID: 1, ContractNumber: "C001", CompanyName: "公司A"},
{ID: 2, ContractNumber: "C002", CompanyName: "公司B"},
}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/contracts?folderId=1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPContractGetContract_Success(t *testing.T) {
mock := &mockZSPContractService{
getContractWithDetailsFn: func(contractID string) (*model.ZSPContractFile, error) {
return &model.ZSPContractFile{ID: 1, ContractNumber: "C001", CompanyName: "公司A"}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/contracts/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractGetContract_Error(t *testing.T) {
mock := &mockZSPContractService{
getContractWithDetailsFn: func(contractID string) (*model.ZSPContractFile, error) {
return nil, errors.New("contract not found")
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/contracts/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestZSPContractDeleteContract_Success(t *testing.T) {
mock := &mockZSPContractService{
deleteContractByIdFn: func(contractID string) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-contract/contracts/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractUpdateContract_Success(t *testing.T) {
mock := &mockZSPContractService{
updateContractByIdFn: func(contractID string, req *dto.ZSPContractUpdateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
body, _ := json.Marshal(dto.ZSPContractUpdateRequest{ContractNumber: "C001-Updated"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/zsp-contract/contracts/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== Contract Detail Tests ==========
func TestZSPContractCreateContractDetail_Success(t *testing.T) {
mock := &mockZSPContractService{
createContractDetailFn: func(req *dto.ZSPContractDetailCreateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
body, _ := json.Marshal(dto.ZSPContractDetailCreateRequest{
ContractFileID: 1,
CommodityName: "燕麦",
TotalQuantity: 100,
UnitPrice: 500.0,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-contract/contracts/1/details", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractListContractDetails_Success(t *testing.T) {
mock := &mockZSPContractService{
listContractDetailsFn: func(contractFileID string) ([]model.ZSPContractDetail, error) {
return []model.ZSPContractDetail{
{ID: 1, CommodityName: "燕麦", TotalQuantity: 100},
{ID: 2, CommodityName: "大豆", TotalQuantity: 200},
}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/contracts/1/details", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPContractGetContractDetail_Success(t *testing.T) {
mock := &mockZSPContractService{
getContractDetailByIdFn: func(detailID string) (*model.ZSPContractDetail, error) {
return &model.ZSPContractDetail{ID: 1, CommodityName: "燕麦", TotalQuantity: 100}, nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-contract/details/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractDeleteContractDetail_Success(t *testing.T) {
mock := &mockZSPContractService{
deleteContractDetailByIdFn: func(detailID string) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-contract/details/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractUpdateContractDetail_Success(t *testing.T) {
mock := &mockZSPContractService{
updateContractDetailFn: func(req *dto.ZSPContractDetailUpdateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
body, _ := json.Marshal(dto.ZSPContractDetailUpdateRequest{
ID: 1,
CommodityName: "燕麦-更新",
TotalQuantity: 150,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/zsp-contract/details/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPContractBatchCreateContractDetails_Success(t *testing.T) {
mock := &mockZSPContractService{
batchCreateContractDetailsFn: func(req *dto.ZSPContractDetailBatchCreateRequest) error {
return nil
},
}
h := handler.NewZSPContractHandler(mock)
router := setupZSPContractRouter(h)
details := []dto.ZSPContractDetailCreateRequest{
{ContractFileID: 1, CommodityName: "燕麦", TotalQuantity: 100, UnitPrice: 500.0},
{ContractFileID: 1, CommodityName: "大豆", TotalQuantity: 200, UnitPrice: 400.0},
}
body, _ := json.Marshal(dto.ZSPContractDetailBatchCreateRequest{
ContractFileID: 1,
Details: details,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-contract/details/batch", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}

View File

@@ -0,0 +1,676 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/rovina/zsp-backend/internal/dto"
"github.com/rovina/zsp-backend/internal/handler"
"github.com/rovina/zsp-backend/internal/model"
"github.com/rovina/zsp-backend/internal/service"
"time"
)
// mockZSPFinancesService implements service.ZSPFinancesService for testing
type mockZSPFinancesService struct {
createFreightChargeFn func(req *dto.ZSPFreightChargesCreateRequest) error
getFreightChargeFn func(id string) (*model.ZSPFreightCharges, error)
listFreightChargesFn func() ([]model.ZSPFreightCharges, error)
updateFreightChargeFn func(id string, req *dto.ZSPFreightChargesUpdateRequest) error
deleteFreightChargeFn func(id string) error
createMiscChargeFn func(req *dto.ZSPMiscChargesCreateRequest) error
getMiscChargeFn func(id string) (*model.ZSPMiscCharges, error)
listMiscChargesFn func() ([]model.ZSPMiscCharges, error)
updateMiscChargeFn func(id string, req *dto.ZSPMiscChargesUpdateRequest) error
deleteMiscChargeFn func(id string) error
createServiceFeeFn func(req *dto.ZSPServiceFeesCreateRequest) error
getServiceFeeFn func(id string) (*model.ZSPServiceFees, error)
listServiceFeesFn func() ([]model.ZSPServiceFees, error)
updateServiceFeeFn func(id string, req *dto.ZSPServiceFeesUpdateRequest) error
deleteServiceFeeFn func(id string) error
createCostBreakdownFn func(req *dto.ZSPCostBreakdownCreateRequest) error
getCostBreakdownFn func(id string) (*model.ZSPCostBreakdown, error)
listCostBreakdownsFn func(businessID string) ([]model.ZSPCostBreakdown, error)
updateCostBreakdownFn func(id string, req *dto.ZSPCostBreakdownUpdateRequest) error
deleteCostBreakdownFn func(id string) error
createProfitRecordFn func(req *dto.ZSPProfitRecordCreateRequest) error
getProfitRecordFn func(id string) (*model.ZSPProfitRecord, error)
listProfitRecordsFn func(filters map[string]string) ([]model.ZSPProfitRecord, error)
updateProfitRecordFn func(id string, req *dto.ZSPProfitRecordUpdateRequest) error
deleteProfitRecordFn func(id string) error
}
func (m *mockZSPFinancesService) CreateFreightCharge(req *dto.ZSPFreightChargesCreateRequest) error {
return m.createFreightChargeFn(req)
}
func (m *mockZSPFinancesService) GetFreightCharge(id string) (*model.ZSPFreightCharges, error) {
return m.getFreightChargeFn(id)
}
func (m *mockZSPFinancesService) ListFreightCharges() ([]model.ZSPFreightCharges, error) {
return m.listFreightChargesFn()
}
func (m *mockZSPFinancesService) UpdateFreightCharge(id string, req *dto.ZSPFreightChargesUpdateRequest) error {
return m.updateFreightChargeFn(id, req)
}
func (m *mockZSPFinancesService) DeleteFreightCharge(id string) error {
return m.deleteFreightChargeFn(id)
}
func (m *mockZSPFinancesService) CreateMiscCharge(req *dto.ZSPMiscChargesCreateRequest) error {
return m.createMiscChargeFn(req)
}
func (m *mockZSPFinancesService) GetMiscCharge(id string) (*model.ZSPMiscCharges, error) {
return m.getMiscChargeFn(id)
}
func (m *mockZSPFinancesService) ListMiscCharges() ([]model.ZSPMiscCharges, error) {
return m.listMiscChargesFn()
}
func (m *mockZSPFinancesService) UpdateMiscCharge(id string, req *dto.ZSPMiscChargesUpdateRequest) error {
return m.updateMiscChargeFn(id, req)
}
func (m *mockZSPFinancesService) DeleteMiscCharge(id string) error {
return m.deleteMiscChargeFn(id)
}
func (m *mockZSPFinancesService) CreateServiceFee(req *dto.ZSPServiceFeesCreateRequest) error {
return m.createServiceFeeFn(req)
}
func (m *mockZSPFinancesService) GetServiceFee(id string) (*model.ZSPServiceFees, error) {
return m.getServiceFeeFn(id)
}
func (m *mockZSPFinancesService) ListServiceFees() ([]model.ZSPServiceFees, error) {
return m.listServiceFeesFn()
}
func (m *mockZSPFinancesService) UpdateServiceFee(id string, req *dto.ZSPServiceFeesUpdateRequest) error {
return m.updateServiceFeeFn(id, req)
}
func (m *mockZSPFinancesService) DeleteServiceFee(id string) error {
return m.deleteServiceFeeFn(id)
}
func (m *mockZSPFinancesService) CreateCostBreakdown(req *dto.ZSPCostBreakdownCreateRequest) error {
return m.createCostBreakdownFn(req)
}
func (m *mockZSPFinancesService) GetCostBreakdown(id string) (*model.ZSPCostBreakdown, error) {
return m.getCostBreakdownFn(id)
}
func (m *mockZSPFinancesService) ListCostBreakdowns(businessID string) ([]model.ZSPCostBreakdown, error) {
return m.listCostBreakdownsFn(businessID)
}
func (m *mockZSPFinancesService) UpdateCostBreakdown(id string, req *dto.ZSPCostBreakdownUpdateRequest) error {
return m.updateCostBreakdownFn(id, req)
}
func (m *mockZSPFinancesService) DeleteCostBreakdown(id string) error {
return m.deleteCostBreakdownFn(id)
}
func (m *mockZSPFinancesService) CreateProfitRecord(req *dto.ZSPProfitRecordCreateRequest) error {
return m.createProfitRecordFn(req)
}
func (m *mockZSPFinancesService) GetProfitRecord(id string) (*model.ZSPProfitRecord, error) {
return m.getProfitRecordFn(id)
}
func (m *mockZSPFinancesService) ListProfitRecords(filters map[string]string) ([]model.ZSPProfitRecord, error) {
return m.listProfitRecordsFn(filters)
}
func (m *mockZSPFinancesService) UpdateProfitRecord(id string, req *dto.ZSPProfitRecordUpdateRequest) error {
return m.updateProfitRecordFn(id, req)
}
func (m *mockZSPFinancesService) DeleteProfitRecord(id string) error {
return m.deleteProfitRecordFn(id)
}
var _ service.ZSPFinancesService = (*mockZSPFinancesService)(nil)
func setupZSPFinancesRouter(h *handler.ZSPFinancesHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
{
finances := api.Group("/zsp-finances")
{
// Freight charges
finances.POST("/freight-charges", h.CreateFreightCharge)
finances.GET("/freight-charges/:id", h.GetFreightCharge)
finances.GET("/freight-charges", h.ListFreightCharges)
finances.PUT("/freight-charges/:id", h.UpdateFreightCharge)
finances.DELETE("/freight-charges/:id", h.DeleteFreightCharge)
// Misc charges
finances.POST("/misc-charges", h.CreateMiscCharge)
finances.GET("/misc-charges/:id", h.GetMiscCharge)
finances.GET("/misc-charges", h.ListMiscCharges)
finances.PUT("/misc-charges/:id", h.UpdateMiscCharge)
finances.DELETE("/misc-charges/:id", h.DeleteMiscCharge)
// Service fees
finances.POST("/service-fees", h.CreateServiceFee)
finances.GET("/service-fees/:id", h.GetServiceFee)
finances.GET("/service-fees", h.ListServiceFees)
finances.PUT("/service-fees/:id", h.UpdateServiceFee)
finances.DELETE("/service-fees/:id", h.DeleteServiceFee)
// Cost breakdowns
finances.POST("/cost-breakdowns", h.CreateCostBreakdown)
finances.GET("/cost-breakdowns/:id", h.GetCostBreakdown)
finances.GET("/cost-breakdowns", h.ListCostBreakdowns)
finances.PUT("/cost-breakdowns/:id", h.UpdateCostBreakdown)
finances.DELETE("/cost-breakdowns/:id", h.DeleteCostBreakdown)
// Profit records
finances.POST("/profit-records", h.CreateProfitRecord)
finances.GET("/profit-records/:id", h.GetProfitRecord)
finances.GET("/profit-records", h.ListProfitRecords)
finances.PUT("/profit-records/:id", h.UpdateProfitRecord)
finances.DELETE("/profit-records/:id", h.DeleteProfitRecord)
}
}
return r
}
// ========== Freight Charges Tests ==========
func TestZSPFinancesCreateFreightCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
createFreightChargeFn: func(req *dto.ZSPFreightChargesCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPFreightChargesCreateRequest{
ExpenseItem: "运费",
Amount: 1000.0,
PaymentDate: "2024-01-15",
FreightCompanyName: "货代公司A",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/freight-charges", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPFinancesCreateFreightCharge_BadRequest(t *testing.T) {
mock := &mockZSPFinancesService{
createFreightChargeFn: func(req *dto.ZSPFreightChargesCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/freight-charges", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing required fields, got %d", w.Code)
}
}
func TestZSPFinancesGetFreightCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
getFreightChargeFn: func(id string) (*model.ZSPFreightCharges, error) {
return &model.ZSPFreightCharges{
ID: 1,
ExpenseItem: "运费",
Amount: 1000.0,
FreightCompanyName: "货代公司A",
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/freight-charges/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesListFreightCharges_Success(t *testing.T) {
mock := &mockZSPFinancesService{
listFreightChargesFn: func() ([]model.ZSPFreightCharges, error) {
return []model.ZSPFreightCharges{
{ID: 1, ExpenseItem: "运费", Amount: 1000.0},
{ID: 2, ExpenseItem: "港杂费", Amount: 500.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/freight-charges", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPFinancesUpdateFreightCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
updateFreightChargeFn: func(id string, req *dto.ZSPFreightChargesUpdateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPFreightChargesUpdateRequest{
Amount: 1500.0,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/zsp-finances/freight-charges/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteFreightCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
deleteFreightChargeFn: func(id string) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/freight-charges/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteFreightCharge_Error(t *testing.T) {
mock := &mockZSPFinancesService{
deleteFreightChargeFn: func(id string) error {
return errors.New("record not found")
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/freight-charges/999", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
// ========== Misc Charges Tests ==========
func TestZSPFinancesCreateMiscCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
createMiscChargeFn: func(req *dto.ZSPMiscChargesCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPMiscChargesCreateRequest{
ExpenseItem: "报关费",
Amount: 200.0,
PaymentDate: "2024-01-15",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/misc-charges", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesListMiscCharges_Success(t *testing.T) {
mock := &mockZSPFinancesService{
listMiscChargesFn: func() ([]model.ZSPMiscCharges, error) {
return []model.ZSPMiscCharges{
{ID: 1, ExpenseItem: "报关费", Amount: 200.0},
{ID: 2, ExpenseItem: "仓储费", Amount: 300.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/misc-charges", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteMiscCharge_Success(t *testing.T) {
mock := &mockZSPFinancesService{
deleteMiscChargeFn: func(id string) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/misc-charges/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== Service Fees Tests ==========
func TestZSPFinancesCreateServiceFee_Success(t *testing.T) {
mock := &mockZSPFinancesService{
createServiceFeeFn: func(req *dto.ZSPServiceFeesCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPServiceFeesCreateRequest{
Name: "代理服务费",
Amount: 500.0,
Date: "2024-01-15",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/service-fees", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesListServiceFees_Success(t *testing.T) {
mock := &mockZSPFinancesService{
listServiceFeesFn: func() ([]model.ZSPServiceFees, error) {
return []model.ZSPServiceFees{
{ID: 1, Name: "代理服务费", Amount: 500.0},
{ID: 2, Name: "咨询费", Amount: 300.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/service-fees", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteServiceFee_Success(t *testing.T) {
mock := &mockZSPFinancesService{
deleteServiceFeeFn: func(id string) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/service-fees/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== Cost Breakdown Tests ==========
func TestZSPFinancesCreateCostBreakdown_Success(t *testing.T) {
mock := &mockZSPFinancesService{
createCostBreakdownFn: func(req *dto.ZSPCostBreakdownCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPCostBreakdownCreateRequest{
BusinessID: "B001",
BusinessType: "contract",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/cost-breakdowns", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesListCostBreakdowns_Success(t *testing.T) {
mock := &mockZSPFinancesService{
listCostBreakdownsFn: func(businessID string) ([]model.ZSPCostBreakdown, error) {
return []model.ZSPCostBreakdown{
{ID: 1, BusinessID: "B001", BusinessType: "contract", TotalCost: 1000.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/cost-breakdowns?businessId=B001", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteCostBreakdown_Success(t *testing.T) {
mock := &mockZSPFinancesService{
deleteCostBreakdownFn: func(id string) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/cost-breakdowns/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
// ========== Profit Record Tests ==========
func TestZSPFinancesCreateProfitRecord_Success(t *testing.T) {
mock := &mockZSPFinancesService{
createProfitRecordFn: func(req *dto.ZSPProfitRecordCreateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPProfitRecordCreateRequest{
BusinessID: "B001",
BusinessType: "contract",
Revenue: 2000.0,
TotalCost: 1500.0,
RecordDate: "2024-01-15",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/zsp-finances/profit-records", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesGetProfitRecord_Success(t *testing.T) {
mock := &mockZSPFinancesService{
getProfitRecordFn: func(id string) (*model.ZSPProfitRecord, error) {
return &model.ZSPProfitRecord{
ID: 1,
BusinessID: "B001",
BusinessType: "contract",
Revenue: 2000.0,
TotalCost: 1500.0,
Profit: 500.0,
RecordDate: time.Now(),
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/profit-records/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesListProfitRecords_Success(t *testing.T) {
mock := &mockZSPFinancesService{
listProfitRecordsFn: func(filters map[string]string) ([]model.ZSPProfitRecord, error) {
return []model.ZSPProfitRecord{
{ID: 1, BusinessID: "B001", Revenue: 2000.0, Profit: 500.0},
{ID: 2, BusinessID: "B002", Revenue: 3000.0, Profit: 800.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/profit-records", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp["success"] != true {
t.Errorf("expected success true, got %v", resp["success"])
}
}
func TestZSPFinancesListProfitRecords_WithFilters(t *testing.T) {
mock := &mockZSPFinancesService{
listProfitRecordsFn: func(filters map[string]string) ([]model.ZSPProfitRecord, error) {
if filters["businessId"] != "B001" {
t.Errorf("expected businessId filter B001, got %v", filters["businessId"])
}
return []model.ZSPProfitRecord{
{ID: 1, BusinessID: "B001", Revenue: 2000.0, Profit: 500.0},
}, nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/zsp-finances/profit-records?businessId=B001&businessType=contract", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesUpdateProfitRecord_Success(t *testing.T) {
mock := &mockZSPFinancesService{
updateProfitRecordFn: func(id string, req *dto.ZSPProfitRecordUpdateRequest) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
body, _ := json.Marshal(dto.ZSPProfitRecordUpdateRequest{
Revenue: 2500.0,
TotalCost: 1600.0,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/zsp-finances/profit-records/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}
func TestZSPFinancesDeleteProfitRecord_Success(t *testing.T) {
mock := &mockZSPFinancesService{
deleteProfitRecordFn: func(id string) error {
return nil
},
}
h := handler.NewZSPFinancesHandler(mock)
router := setupZSPFinancesRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/zsp-finances/profit-records/1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
}

114
docs/Git提交规范.md Executable file
View File

@@ -0,0 +1,114 @@
# Git 提交规范文档
> 本文档规范项目中的 Git 提交信息格式,便于团队协作与版本追溯。
---
## 一、提交信息格式
采用 **Conventional Commits** 规范,格式如下:
```
<类型>[可选范围]: <简短描述>
[可选的详细说明]
[可选的脚注]
```
### 1.1 类型type
| 类型 | 说明 |
| ---------- | ------------------------ |
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更README、说明等 |
| `style` | 代码格式(空格、缩进等,不影响逻辑) |
| `refactor` | 重构( neither feat nor fix |
| `perf` | 性能优化 |
| `test` | 测试相关 |
| `chore` | 构建、工具、依赖等 |
### 1.2 可选范围scope
标明影响模块,便于检索,例如:
- `contract` - 合同管理
- `finance` - 财务管理
- `delivery` - 提货管理
- `inventory` - 库存
- `invoice` - 发票
- `api` - 后端 API
- `frontend` - 前端
- `backend` - 后端
---
## 二、提交示例
### 2.1 新功能
```
feat(contract): 新增合同批量上传功能
feat(finance): 结算管理支持导出 Excel
```
### 2.2 修复
```
fix(api): 修复合同查询接口分页参数错误
fix(frontend): 修复发票列表筛选不生效
```
### 2.3 文档
```
docs: 补充财务管理模块待确认问题
docs(api): 更新合同管理 API 文档
```
### 2.4 重构与优化
```
refactor(invoice): 抽取发票上传公共逻辑
perf(contract): 优化合同列表查询性能
```
### 2.5 其他
```
style: 统一前端代码缩进
chore: 升级 Go 依赖版本
test(finance): 补充结算单单元测试
```
---
## 三、书写建议
1. **标题**:简洁明了,控制在 50 字符内;使用中文,动词开头。
2. **一条提交做一件事**:功能、修复、格式尽量分开提交。
3. **说明可省略**:简单变更可不写正文,复杂逻辑建议补充说明。
4. **关联需求/问题**:如有 Jira/飞书等,可在脚注中写 `Refs: #123`
---
## 四、反例(应避免)
| 不良示例 | 问题说明 |
| ---------------- | ---------------------- |
| `update` | 类型不明确,描述含糊 |
| `修改了一些东西` | 没有说明修改了什么 |
| `fix bug` | 未说明修复的是哪个 bug |
| `WIP` | 未完成工作不宜直接合并 |
---
## 五、工作流简要
- **main**:生产分支,稳定版本
- **dev**:开发主分支
- **feature/xxx**:功能开发分支
- **fix/xxx**:缺陷修复分支
提交前请先在 `dev` 或对应 feature 分支上开发,完成后再合并到目标分支。

70
docs/api/前端路由文档.md Executable file
View File

@@ -0,0 +1,70 @@
# 中尚鹏管理系统 - 前端路由文档
```
作者: rovina
最近修订: 2026/3/2
```
---
## 一、路由结构概览
前端基于 Vue 3 + Vue Router采用扁平化二级路由结构。路由模块位于 `frontend/pure-admin-thin/src/router/modules/`
---
## 二、合同管理模块路由
| 路径 | 名称 | 组件 | 说明 |
|------|------|------|------|
| `/system/contract` | 合同管理 | - | 父级重定向到业务合同管理 |
| `/system/contract/add-contract/index` | AddContract | AddContract.vue | **业务合同管理**:单合同录入、批量上传归档 |
| `/system/contract/zhongshangpeng/index` | ZhongshangpengContract | ZhongshangpengContract.vue | **中尚鹏合同管理**:公司合同文件夹、合同文件、详情表格 |
| `/system/contract/company-info/index` | CompanyInfo | CompanyInfo.vue | **公司信息管理** |
### 2.1 业务合同管理AddContract
- **单合同管理**:左侧按合同类型筛选(全部/采购/上游/下游),右侧合同列表,支持新增、编辑、删除
- **批量上传**:左侧批量归档文件夹,右侧批量记录列表,支持新建文件夹、批量上传文件
### 2.2 中尚鹏合同管理ZhongshangpengContract
- 文件夹归类、合同上传、合同详情(商品/数量/单价/已提货/未提货等)
- Excel 导入导出合同详情
- **前后端已对接**:数据从 `/api/zsp-contract` 接口加载
### 2.3 公司信息管理CompanyInfo
- 管理公司相关文件(营业执照、税务登记证、开票信息等)
- 文件夹分类、文件上传、查看、下载、删除
- **前后端已对接**:数据从 `/api/company` 接口加载
---
## 三、其他模块路由(简要)
| 模块 | 路径前缀 | 说明 |
|------|----------|------|
| 首页 | `/`, `/welcome` | 首页 |
| 提货管理 | `/system/delivery/*` | 我要提货、提货明细、库存、货权转移 |
| 财务管理 | `/system/finance/*` | 货代费用、杂费、服务费、成本构成、利润、结算、发票、对账单 |
| 查询报表 | `/system/query-report/*` | 合同执行、营业明细、其它明细、商品基础资料 |
| 采购管理 | `/system/purchase/*` | 采购报单、采购运踪、国外供应商、采购预算 |
| 更新看板 | `/system/changelog/index` | **更新内容看板**:展示系统更新记录 |
---
## 四、路由配置说明
- 路由定义:`src/router/modules/contract.ts`
- 自动导入:`src/router/index.ts` 通过 `import.meta.glob` 自动加载 `modules/**/*.ts`(排除 remaining.ts
- 菜单渲染:`constantMenus` 用于侧边栏菜单展示
---
## 五、API 请求前缀
合同相关接口统一使用 `/api` 前缀,经 Vite 代理转发至后端:
- 开发环境:`/api/*``http://127.0.0.1:8080/api/*`
- 生产环境:需配置 Nginx 或同类反向代理将 `/api` 转发至后端服务

View File

@@ -0,0 +1,306 @@
# 中尚鹏管理系统 - 后端 API 文档(合同管理 + 提货管理)
```
作者: rovina
最近修订: 2026/3/2
说明: 含合同管理、中尚鹏合同、公司信息、提货管理(我要提货/提货明细/库存/货权转移)。
```
---
## 一、基础说明
- **Base URL**`http://{host}:8080/api`
- **数据格式**JSON除文件上传为 multipart/form-data
- **通用响应**`{ "success": boolean, "message": string, "data"?: T }`
- **认证方式**:除 `/auth/login``/auth/register` 及静态文件路由外,所有 API 需在请求头中携带 `Authorization: Bearer <token>`JWT token 从登录接口获取)
---
## 二、业务合同管理 API/api/contract
### 2.1 单合同 CRUD
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/contract` | 获取合同列表 |
| GET | `/contract/:id` | 获取单个合同详情 |
| POST | `/contract/upload` | 上传合同(表单 + 文件) |
| PUT | `/contract/:id` | 更新合同(仅表单字段) |
| DELETE | `/contract/:id` | 删除合同 |
#### GET /contract
**Query 参数**`contractType`可选1=采购 2=上游 3=下游
**响应示例**
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "合同名称",
"contractType": "1",
"contractCode": "HT001",
"buyParty": "甲方",
"sellParty": "乙方",
"commodity": "商品",
"count": 100,
"unitPrice": 10.5,
"blNumber": "BL001;BL002",
"deliveryTime": "",
"pickUpTime": "",
"packaging": "",
"remarks": "",
"createTime": "2026-03-02T10:00:00Z",
"updateTime": "2026-03-02T10:00:00Z",
"files": [
{ "id": 1, "name": "合同.pdf", "url": "uploads/123_合同.pdf" }
]
}
]
}
```
**文件 URL 说明**`url` 为相对路径,完整访问地址为 `GET /api/contract-files/{url}`,例如 `/api/contract-files/uploads/123_合同.pdf`
#### POST /contract/upload
**Content-Type**`multipart/form-data`
**表单字段**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 是 | 合同名称 |
| contractType | string | 是 | 1/2/3 |
| contractCode | string | 是 | 合同编号 |
| buyParty | string | 是 | 甲方 |
| sellParty | string | 是 | 乙方 |
| commodity | string | 是 | 商品 |
| count | string | 是 | 数量 |
| unitPrice | string | 是 | 单价 |
| BLNumber | string | 是 | 提单号,最多 15 个,分号分隔 |
| deliveryTime | string | 否 | 交货时间 |
| pickUpTime | string | 否 | 提货时间 |
| packaging | string | 否 | 包装 |
| remarks | string | 否 | 备注 |
| files | File[] | 是 | 合同扫描件PDF/图片单文件≤10MB |
---
### 2.2 批量归档(文件夹 + 批量记录)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/contract/folders` | 获取批量归档文件夹列表 |
| POST | `/contract/folders` | 新建文件夹 |
| GET | `/contract/folders/:id` | 获取单个文件夹 |
| PUT | `/contract/folders/:id` | 更新文件夹 |
| DELETE | `/contract/folders/:id` | 删除文件夹 |
| GET | `/contract/batches` | 获取批量记录列表 |
| POST | `/contract/batches` | 批量上传 |
| GET | `/contract/batches/:id` | 获取批量记录详情 |
| DELETE | `/contract/batches/:id` | 删除批量记录 |
#### POST /contract/folders
**请求体**
```json
{
"name": "文件夹名称",
"description": "描述(可选)"
}
```
#### GET /contract/batches
**Query 参数**`folderId`(可选):按文件夹筛选
#### POST /contract/batches
**Content-Type**`multipart/form-data`
**表单字段**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| folderId | string | 是 | 归档文件夹 ID |
| batchName | string | 是 | 批次名称 |
| remark | string | 否 | 备注 |
| files | File[] | 是 | 批量文件 |
---
### 2.3 合同文件静态资源(供前端预览/下载)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/contract-files/uploads/{filename}` | 单合同附件 |
| GET | `/api/contract-files/batch_archive/{filename}` | 批量归档文件 |
| GET | `/api/zsp-contract-files/files/{filename}` | 中尚鹏合同文件 |
---
## 三、中尚鹏合同管理 API/api/zsp-contract
与业务合同管理为两套独立体系,用于公司合同(上游/下游/外商采购/货代物流等)。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/zsp-contract/folders` | 获取合同文件夹列表 |
| POST | `/zsp-contract/folders` | 创建文件夹 |
| GET | `/zsp-contract/folders/:id` | 获取单个文件夹 |
| PUT | `/zsp-contract/folders/:id` | 更新文件夹 |
| DELETE | `/zsp-contract/folders/:id` | 删除文件夹 |
| GET | `/zsp-contract/contracts` | 获取合同列表(含详情,可选 folderId |
| POST | `/zsp-contract/contracts` | 上传合同文件 |
| GET | `/zsp-contract/contracts/:id` | 获取单个合同详情 |
| DELETE | `/zsp-contract/contracts/:id` | 删除合同 |
| POST | `/zsp-contract/contracts/:id/details` | 创建合同详情 |
| GET | `/zsp-contract/contracts/:id/details` | 获取合同详情列表 |
| PUT | `/zsp-contract/details/:id` | 更新合同详情 |
| DELETE | `/zsp-contract/details/:id` | 删除合同详情 |
| POST | `/zsp-contract/details/batch` | 批量创建合同详情 |
| POST | `/zsp-contract/contracts/:id/details/import` | Excel 导入合同详情 |
| GET | `/zsp-contract/contracts/:id/details/export` | 导出合同详情到 Excel |
---
## 四、公司信息管理 API/api/company
管理公司相关文件(营业执照、开票信息等)。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/company/folders` | 获取文件夹列表 |
| POST | `/company/folders` | 创建文件夹 |
| PUT | `/company/folders/:id` | 更新文件夹 |
| DELETE | `/company/folders/:id` | 删除文件夹 |
| GET | `/company/files` | 获取文件列表(可选 folderId |
| POST | `/company/files/upload` | 上传公司文件 |
| DELETE | `/company/files/:id` | 删除文件 |
#### POST /company/files/upload
**Content-Type**`multipart/form-data`
**表单字段**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| files | File[] | 是 | 文件列表 |
| data | string | 否 | JSON 字符串,含 companyName、fileCategory、expireDate、description、folderId |
**文件访问**`GET /api/company-files/{fileUrl}`,其中 fileUrl 为接口返回的 fileUrl 字段(如 `files/xxx.pdf`)。
---
## 五、提货管理 API
提货管理包含四块:**我要提货**(提货委托)、**提货明细**(出库单)、**库存**(仓库/入库单/库存汇总)、**货权转移**。以下路径均需在 Base URL 下加 `/api` 前缀(与合同等接口一致),且需登录态。
### 5.1 我要提货(/api/apply-delivery
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/apply-delivery/list` | 分页列表 |
| GET | `/apply-delivery/detail/:id` | 单条详情 |
| POST | `/apply-delivery/create` | 新增 |
| PUT | `/apply-delivery/update/:id` | 更新 |
| PUT | `/apply-delivery/cancel/:id` | 取消(状态改为 cancelled |
| DELETE | `/apply-delivery/:id` | 删除 |
**GET /apply-delivery/list**
Query`page``pageSize``orderNumber``blNumber``status`(可选)。
**响应**`data: { items: DeliveryApply[], total, page, pageSize }`
单条字段id, orderNumber, blNumber, licensePlate, driverName, driverPhone, driverIdCard, commodity, quantity, unit, deliveryDate, deliveryAddress, warehouse, status, remark, createTime, updateTime。
statuspending / approved / in_progress / completed / cancelled。
**POST /apply-delivery/create**
BodyJSONblNumber, licensePlate, driverName, driverPhone, driverIdCard, commodity, quantity, unit, deliveryDate, deliveryAddress, warehouse, remark。
服务端自动生成 orderNumber、status=pending、createTime/updateTime。
---
### 5.2 提货明细(/api/delivery-details
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/delivery-details/list` | 分页列表 |
| GET | `/delivery-details/detail/:id` | 单条详情 |
| POST | `/delivery-details/create` | 新增出库单 |
| PUT | `/delivery-details/update/:id` | 更新 |
| DELETE | `/delivery-details/:id` | 删除 |
**GET /delivery-details/list**
Query`page``pageSize``outboundNumber``blNumber``outboundStatus`(可选)。
**响应**`data: { items: DeliveryOutbound[], total, page, pageSize }`
单条字段id, outboundNumber, deliveryOrderNumber, blNumber, licensePlate, driverName, driverPhone, commodity, commodityCode, quantity, unit, outboundDate, warehouse, operator, outboundStatus, receiptStatus, remark, createTime, updateTime。
---
### 5.3 库存(/api/inventory
#### 仓库
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/inventory/warehouses` | 仓库列表(不分页) |
| POST | `/inventory/warehouses/create` | 新增仓库 |
| PUT | `/inventory/warehouses/update/:id` | 更新 |
| DELETE | `/inventory/warehouses/delete/:id` | 删除 |
仓库字段id, code, name, address, contactPerson, contactPhone, status, remark, createTime, updateTime。
#### 入库单
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/inventory/inbound/list` | 分页列表 |
| POST | `/inventory/inbound/create` | 新增入库单 |
| PUT | `/inventory/inbound/update/:id` | 更新 |
| DELETE | `/inventory/inbound/delete/:id` | 删除 |
**GET /inventory/inbound/list**
Query`page``pageSize``warehouse``commodity`(可选)。
入库单字段id, inboundNumber, warehouse, commodity, commodityCode, blNumber, blWeight, inboundWeight, owner, contractNumber, inboundDate, operator, remark, createTime, updateTime。
#### 库存汇总
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/inventory/summary` | 按仓库+商品汇总:入库 - 出库 - 货权转移 = 当前库存 |
Query`warehouse``commodity`(可选)。
响应:`data` 为汇总列表,含仓库、商品、入库量、出库量、货权转移量、当前库存等。
---
### 5.4 货权转移(/api/ownership-transfer
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/ownership-transfer/list` | 分页列表 |
| GET | `/ownership-transfer/detail/:id` | 单条详情(含附件列表) |
| POST | `/ownership-transfer/create` | 新增(仅表单) |
| POST | `/ownership-transfer/create-with-files` | 新增(表单 + 附件) |
| DELETE | `/ownership-transfer/:id` | 删除 |
**GET /ownership-transfer/list**
Query分页及筛选如 transferNumber, blNumber, status, transferor, transferee, startDate, endDate 等,以实际后端为准)。
**POST /ownership-transfer/create-with-files**
Content-Type`multipart/form-data`
表单字段transferDate, blNumber, quantity, commodity, warehouse, transferor, transferee, remarkfiles附件。
货权转移单字段id, transferNumber, transferDate, blNumber, quantity, commodity, warehouse, transferor, transferee, status, remark, fileList, createTime, updateTime。
附件存于服务端 `./data/ownership/`,返回的 fileList 中 url 为相对路径(如 `ownership/xxx`)。若需通过 HTTP 访问,可配置静态路由(如 `/api/ownership-files/` 指向该目录),并在文档中说明。
---
## 六、错误码与状态
- `200`:成功
- `400`:请求参数错误
- `404`:资源不存在
- `500`:服务器内部错误

77
docs/deploy/nginx.conf Executable file
View File

@@ -0,0 +1,77 @@
# 中尚鹏管理系统 - Nginx 生产配置
# 放置于 /etc/nginx/conf.d/zsp.conf 或 /etc/nginx/sites-enabled/zsp
#
# 前置条件:
# 1. 前端构建产物位于 /var/www/zsp/dist
# 2. 后端服务运行于 127.0.0.1:8080
# 3. 后端静态文件目录 /var/www/zsp/data 可读
server {
listen 80;
server_name your-domain.com;
# 前端静态资源
root /var/www/zsp/dist;
index index.html;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1024;
# 前端 SPA 路由:所有非文件/非 API 请求返回 index.html
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 大文件上传支持(合同附件等)
client_max_body_size 100m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 健康检查端点
location /health {
proxy_pass http://127.0.0.1:8080/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# 静态文件缓存(合同附件、公司文件等)
location /api/contract-files/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
location /api/zsp-contract-files/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
location /api/company-files/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

160
docs/deploy/部署说明.md Executable file
View File

@@ -0,0 +1,160 @@
# 中尚鹏管理系统 - 部署说明
```
作者: rovina
最近修订: 2026/3/2
```
---
## 一、环境要求
| 组件 | 版本 |
|------|------|
| Node.js | 18+ |
| pnpm | 8+ |
| Go | 1.21+ |
| MySQL | 8.0+ |
---
## 二、后端部署
### 2.1 配置文件
复制并修改 `backend/configs/config.dev.yaml`
```yaml
server:
port: 8080
mode: "release" # 生产环境建议 release
read_timeout: 30
write_timeout: 30
database:
host: "127.0.0.1" # 或 MySQL 服务地址
port: 3306
user: "root"
password: "your_password"
dbname: "myapp"
max_open_conns: 100
max_idle_conns: 10
reset_database: false # 生产务必 false
jwt:
secret: "your_jwt_secret"
expire_hours: 24
```
### 2.2 启动
```bash
cd backend
# 指定配置文件(可选,默认 ./configs/config.dev.yaml
export CONFIG_PATH=./configs/config.prod.yaml
go run ./cmd/api
# 或编译后运行
go build -o zsp-api ./cmd/api && ./zsp-api
```
### 2.3 数据目录
- 单合同附件:`./data/contract/uploads/`
- 批量归档:`./data/contract/batch_archive/`
首次运行会自动创建,需确保进程有写权限。
---
## 三、前端部署
### 3.1 开发环境
```bash
cd frontend
pnpm install
pnpm dev
```
前端默认端口 8848可改 `.env.development` 中 VITE_PORTAPI 请求 `/api/*` 会代理到 `http://127.0.0.1:8080`
### 3.2 生产构建
```bash
cd frontend
pnpm install
pnpm build
```
产物在 `dist/`,需配合 Nginx 等 Web 服务器部署。
### 3.3 为什么打包部署后访问不到 8080 的数据?
打包后前端是**静态资源**,在**用户浏览器**里运行。请求会发到「打开页面时的那台服务器的同源地址」:
- 开发时:页面来自 `http://127.0.0.1:5173`Vite 把 `/api` 代理到本机 8080所以能访问。
- 部署后:页面来自 `http://你的服务器:80`,浏览器只会把 `/api` 发到 `http://你的服务器:80/api`**不会**自动发到 8080。
因此必须让**提供前端页面的 Web 服务器**(如 Nginx`/api` 反向代理到本机 8080或后端容器。同时前端已统一用**相对路径** `/api/xxx`,不再写死 `127.0.0.1:8080`,这样部署后即可通过同源访问到后端。
### 3.4 Nginx 配置
完整配置文件见 `docs/deploy/nginx.conf`,包含:
- SPA 路由重写(`try_files $uri /index.html`
- API 反向代理(`/api``127.0.0.1:8080`
- 大文件上传支持(`client_max_body_size 100m`
- 静态文件缓存(合同附件 7 天缓存)
- Gzip 压缩
---
## 四、Docker 部署(可选)
### 4.1 后端 Dockerfile 示例
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o zsp-api ./cmd/api
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/zsp-api .
COPY --from=builder /app/configs ./configs
RUN mkdir -p data/contract/uploads data/contract/batch_archive
EXPOSE 8080
CMD ["./zsp-api"]
```
### 4.2 运行
```bash
# 需挂载配置与数据目录
docker run -d -p 8080:8080 \
-v $(pwd)/configs:/app/configs \
-v $(pwd)/data:/app/data \
-e CONFIG_PATH=/app/configs/config.prod.yaml \
zsp-backend
```
---
## 五、健康检查
- 后端:`GET http://{host}:8080/health` 返回 `{"status":"ok"}`
- 合同 API`GET http://{host}:8080/api/contract` 需返回 JSON
---
## 六、常见问题
1. **跨域**:后端已启用 CORS生产环境若前后端同域可无需额外配置。
2. **文件访问**:合同文件通过 `/api/contract-files/{path}` 访问,确保 Nginx 将 `/api` 转发至后端。
3. **数据库**:首次部署需确保 MySQL 已创建 `myapp` 库,表由 GORM AutoMigrate 自动创建。
4. **JWT 认证**:除 `/api/auth/login``/api/auth/register` 外,所有 API 均需 JWT 认证。前端需在登录后存储 token并在后续请求中通过 `Authorization: Bearer <token>` 头传递。token 有效期为 24 小时。

15
docs/dev/Talk.md Executable file
View File

@@ -0,0 +1,15 @@
# 开发要求
和甲方沟通过程中的内容
他们其实诉求就是他们跟单人的诉求是这样的因为我们比如说现在手上有1000吨货那么这批货客户是要分很长时间才会提完的可能就几十吨或者100吨慢慢的提每一次那么在第一个合同管理栏他。少写了比如说除了上传合同之外然后去做就空出来一个excel表格。这个Excel表格呢他们就手动录入合同里面的一些数据信息数量啊单价呀已经提货的数量啊然后这些东西。然后系统呢它那个excel表格就会自动生成海应提货就是未提的货物对应那个合同有多少吨就会显示出来。然后显示出来之后呢合同管理这一栏就跟第四个查询系统这一块儿。就是要有关联起来比如说关联就是关联合同编号或者是商品信息或者是提单号这样。
他这个大框架改的,我刚刚电话沟通了,我算是明白了一些,但是就是明天,我让他们上午再去做最后的就是确定下来,因为。就是这个主菜单,肯定是后面不能再改的。然后呢,二级菜单我们每一次做二级菜单,比如说合同管理这一栏要实现什么,在做之前我们再沟通一次。就是我觉得这样就也免得就是。就就麻烦你改来改去,因为我虽然不懂这个东西,但我知道可能大框架他们有变动,那等于你前面的可能就都白做了,你要重新再来。
1、会涉及到多处表格内部分数据关联后生成新的表格数据。2、比如在利润表中的“成本“栏是根据”货代费用+杂费”表格中数据以及利润表中单独设的“服务费”相加而成。但是能不能实现点“成本”金额自动显示出这个成本总金额的构成比如“货代费20万杂费12万”。
问题:现在前端部分有两个问题:第一个:"营业明细表"的具体内容和字段是什么?是按年/月/合同维度汇总的报表?还是某个甲方提供的 Excel 模板?
第二个:合同执行情况中"差款"的计算规则是什么?是 合同金额 已结算金额?还是 合同金额 银行流水中已收金额?数据分别来自哪些表?
甲方回答:第一个,汇总的报表。
第二个,等于收(付)款金额-提货数量*提货单价(提货数量需手填,单价可应用合同单价)第一个数据来自于“营业明细表”。第二个数据来自于“提货明细”这个表格。

View File

@@ -0,0 +1,220 @@
# 中尚鹏管理系统 - 当前程序不足分析
本文档基于对现有前端代码与《项目开发要求文档》的对照,整理当前实现的不足与待确认点。
> **更新说明**:以下问题已在后续迭代中修复:
> - JWT 认证中间件已启用(`backend/internal/server/route.go`),所有业务 API 需携带 `Authorization: Bearer <token>` 头
> - `reset_database` 已设为 `false`(配置文件和部署说明均已更新),重启不再清空数据库
> - Handler 层测试已编写(`backend/test/`),覆盖用户注册/登录/查询、合同列表/查询/文件夹管理
> - Delivery Handler 已重构为 Handler → Service → Repository 三层架构,不再直接使用 `*gorm.DB`
---
## 一、与需求文档的菜单对照
| 需求文档模块 | 需求三级菜单概要 | 当前前端情况 |
|------------------------|--------------------------------------|--------------|
| 合同管理 | 新增合同 / 中尚鹏合同管理 / 公司信息 | 路由与页面均有 |
| 提货管理 | 我要提货 / 提货明细 / 库存 / 货权转移 / 其它 | 路由与页面均有 |
| 财务管理 | 结算 / 发票 / 利润明细 / 货代杂费+其它 / 对账单 | 路由与页面均有 |
| 查询+报表中心 | 合同执行 / 营业明细表 / 其它明细表 / 商品基础资料 / 其它 | 路由与页面均有 |
| 采购管理 | 采购报单 / 采购运踪 / 国外供应商 / 采购预算 | 路由与页面均有 |
| 权限管理 | 1 / 2需求未展开 | 现有 permission 页面/按钮 |
---
## 二、功能与业务层面的不足
### 2.1 合同管理
- **新增合同**
- 已实现:单合同录入、批量上传、提单号最多 15 个校验。
- 不足/待确认:需求中“录入合同内容中相关数据的表格(会涉及简单的公式,我们会给出)”是否已按贵方提供的公式在表格中实现?若未提供公式,当前表格是否有明确占位或说明?
- **中尚鹏合同管理**
- 已实现:文件夹归类、上传、按合同编号/公司名/商品/提单号等搜索。
- 不足:需求要求“通过文件名包含商品、公司名、合同编号”进行搜索。当前是否对上传文件的**文件名**做解析并写入可搜索字段?若未解析文件名,则与“通过三要素搜索对应合同”的预期存在差距。
- **公司信息管理**
- 已存在页面,需确认:是否仅做“公司相关文件存储(如营业执照、开票信息)”即可,有无额外字段或审批流程要求。
### 2.2 提货管理
- **我要提货**
- 已实现:新增/变更/取消提货委托,以及模板下载等。
- 不足/待确认:提货单是否已完全按贵方提供的**提货单模板**(含车牌号、提单号、司机信息、数量、商品品类等)生成与展示?模板若后续变更,是否有统一维护方式?
- **提货明细**
- 需求:新增出库单、查看出库明细、收货确认单,且“录入数据的表格(后期我们给模板)”。
- 不足:当前是否已接入“后期给的模板”?若未接入,表格结构与字段是否已与业务方对齐?
- **库存信息**
- 需求公式:**入库表格中数量 新增出库单中数量 货权转移数量 = 库存数量**。
- 不足:该公式是否在**前端展示与后端计算**中都已严格按此逻辑实现?库存明细是否按“仓库+商品+货主”等维度正确汇总?
- **货权转移**
- 需求字段:货转日期、提单号、数量、商品、库方、货权出让方、货权接收方等。
- 不足:当前“货权转移”相关表格与表单字段是否已全部包含上述字段,且与扫描件录入需求一致?
### 2.3 财务管理
- **结算管理**
- 需求:新增结算单(导出下游合同结算单)、按单号查询、上传已结算单备份。
- 不足:导出下游合同结算单的**数据来源与模板**是否已定稿?上传备份后的检索与展示是否满足“按单号查询”的完整流程?
- **发票管理**
- 需求:上游发票、下游发票、货代杂费发票的上传及“对应表格录入发票对应信息”。
- 不足:发票表格的字段是否与后续“待开发票情况”“已开票明细表”等统计完全一致,便于关联与报表使用?
- **利润明细**
- 需求:“根据我们提供的 excel 表格创建一个表格(需要关联其他表格中数据 + 设置好栏目以及函数,后续自行手动录入)”。
- 不足:当前利润明细页是否已**关联合同/结算/发票/货代杂费等其它表数据**?栏目与公式是否已按贵方提供的 Excel 定稿?若 Excel 尚未提供,当前页面是占位还是已有临时结构?
- **货代费用 + 其他杂费**
- 需求字段:提单号、上游合同编号、费用项目、金额、付款日期、货代/物流公司名。
- 不足:当前表单与列表是否已完全覆盖上述字段,并与“利润明细”“历史清关成本”等可关联?
- **对账单**
- 需求文档中“5.对账单”无具体内容描述。
- 不足:对账单的业务范围、字段、与结算/发票的关系是否已有口头或补充文档说明?当前页面若存在,是否仅为占位?
### 2.4 查询 + 报表中心
- **查询上下游客户合同执行情况**
- 需求:按合同编号/公司名/商品/提单号/日期查询,且**可查询到**
- 该合同**剩余未提数量**(与“新增合同”表格关联);
- **差款**及**待开发票情况**(与“发票管理”关联);
- 支持**上传 Excel 表格(银行流水)**。
- 不足:
- 当前“合同执行”页的**付款状态**为前端写死的 `"unpaid"`,并未与真实结算/发票数据关联,无法体现真实“差款”与“待开发票”。
- “剩余未提数量”是否已与“新增合同”及出库/货权转移数据打通并一致?
- “上传银行流水”功能是否已实现?若已实现,银行流水与合同、收付款的关联规则是否明确?
- **营业明细表**
- 需求:① 2025 年中尚鹏业务明细表。
- 不足:当前“营业明细表”路由对应的是 **BusinessDetails.vue**,从代码看更偏向“合同文件/营业相关合同”的列表与搜索与需求中的“2025 年业务明细表”可能不是同一张表。需确认:**营业明细表** 究竟是“按年的业务汇总表”,还是“合同维度明细表”,以便与菜单命名和实现对齐。
- **其它明细表**
- 需求:① 已开票明细表 ② 货代杂费明细表 ③ 历史清关成本明细表。
- 不足当前“其它明细表”为单一列表OtherDetails未在菜单或页面内拆分为上述**三个子表**。三个表的字段与数据来源是否已定义?是否需要在同一页用 Tab 区分,还是独立三个页面?
- **商品基础资料**
- 需求:燕麦/大麦/豌豆/亚麻籽/葵粕/菜籽粕等,可批量上传扫描件或文档。
- 不足:当前是否已按“商品类型”或“品类”做分类归档与展示?批量上传后的检索方式是否明确?
### 2.5 采购管理
- **采购报单、采购运踪明细、国外供应商**
- 不足:需求为“批量上传扫描件 + 录入扫描件内容的表格”或“根据所提供的 excel 在系统中创建表格”。当前各页是否已接入贵方提供的**模板/Excel 结构**?若未提供,当前表格是否为暂定结构?
- **采购预算**
- 需求:“已有单独的预算测算系统,最后按该预算系统功能等添加到我们系统中(这个最后有时间慢慢弄)”。
- 不足:当前采购预算页是占位还是已有部分对接?与“单独预算系统”的对接方式(单点登录、接口、嵌入等)是否有初步方案?
### 2.6 权限管理
- 需求文档仅写“1”“2”无具体说明。
- 不足:当前为通用“页面权限 + 按钮权限”结构。贵方是否需要**角色名、数据范围(如按公司/部门)、审批流**等具体需求?若有,需补充说明以便后续设计。
---
## 三、技术与实现层面的不足
### 3.1 API 与后端
- **双套 API 基址**
- 合同模块(如 `contract/upload``contract/batch-upload`)通过 `baseUrlApi` 直接写死 `http://127.0.0.1:8080/api/`
- 其余模块(如中尚鹏合同、财务、提货等)通过 `@/utils/http` 走相对路径(通常为 `/api`),在开发环境会经 Vite 代理到 127.0.0.1:8080。
- 不足:基址不统一,不利于环境切换(开发/测试/生产)与后续维护;若后端实际未部署在 8080 或路径不同,合同相关请求易出错。
- **后端存在性**
- 当前仓库内未见后端工程目录,仅前端调用 8080 接口。
- 不足:需确认后端是否独立仓库或独立服务、接口文档是否齐全、现有前端调用的接口是否已全部实现并联调通过。
### 3.2 功能未完成或占位
- **合同执行情况**
- 导出功能为“导出功能开发中”提示,未实现真实导出。
- **差款与待开发票**
- 未与结算、发票、银行流水等数据打通,无法展示真实“差款”与“待开发票情况”。
- **银行流水**
- 需求提到“上传 excel 表格(银行流水)”,当前代码中未检索到“银行流水”相关实现,需确认是否未做或位于其他模块。
### 3.3 数据关联与一致性
- 需求多处强调“关联其他表格”“设置好栏目以及函数”,例如:
- 利润明细 关联 合同/结算/发票/货代等;
- 合同执行 关联 新增合同、发票管理;
- 库存 = 入库 出库 货权转移。
- 不足:当前实现中,这些**跨模块的数据关联与计算**是否已在后端或前端全部落地并校验一致,需要逐项对照需求做确认与测试。
---
## 四、小结
- **菜单与路由**:与需求文档的一、二、三级菜单基本对应,但“营业明细表”与“其它明细表”的页面职责与需求描述需要对齐。
- **业务逻辑**:合同执行中的“差款”“待开发票”“剩余未提数量”“银行流水”未按需求打通;利润明细、库存公式、对账单、采购/提货模板等依赖贵方提供的表格或公式,需确认是否已提供并已实现。
- **技术**API 基址不统一、后端与接口实现情况不透明、部分导出与上传功能仍为占位或未实现。
建议在进入下一阶段开发前,先根据下文《需求确认问题清单》与业务方确认,再制定实现与联调计划。
---
## 五、需求确认问题清单(请产品/业务方确认)
以下问题基于《项目开发要求文档》整理,便于明确开发范围与优先级,**请逐项确认或补充**。
### 合同管理
1. **新增合同**:需求中提到“会涉及简单的公式,我们会给出”。公式是否已提供?若已提供,请说明存放位置或文档名;若未提供,当前表格中的计算逻辑是否按临时规则处理即可?
2. **中尚鹏合同管理**:搜索是否必须基于**上传文件的文件名**解析出“商品、公司名、合同编号”?
- 若是,文件名命名规范是什么(例如:`商品_公司名_合同编号.pdf`)?是否需要在系统中做“文件名解析并写入可搜索字段”的功能?
3. **公司信息管理**:除“上传扫描件 + 文件夹归类”外,是否需要维护公司主数据(如公司名、税号、开户行等)?还是仅文件存储即可?
### 提货管理
4. **我要提货**:提货单模板(含车牌号、提单号、司机信息、数量、商品品类等)是否已定稿?若已提供,请说明文件位置;若会变更,变更后由谁在系统中更新?
5. **提货明细**:需求写“录入数据的表格(后期我们给模板)”。模板是否已提供?若未提供,当前表格字段是否可作为正式版使用?
6. **库存信息**:公式“入库 出库 货权转移 = 库存”是否必须按**仓库 + 商品 + 货主(及合同)**等维度精确到最细粒度?是否有“只按仓库汇总”等简化展示需求?
7. **货权转移**:除需求列出的字段外,是否还有必填或必展示字段?已生效的货权转移函“上传”与“新增货权转移函”是两条独立流程还是需要关联?
### 财务管理
8. **结算管理**:“导出下游合同结算单”的数据来源是哪些表?是否有固定 Excel 模板?若有,请提供模板或字段说明。
9. **发票管理**:上游/下游/货代杂费发票的“对应表格”字段是否已定稿?是否需要与“已开票明细表”“待开发票情况”完全一致以便关联?
10. **利润明细**:需求写“根据我们提供的 excel 表格创建……关联其他表格中数据 + 设置好栏目以及函数”。
- Excel 模板是否已提供?若已提供,请说明位置。
- “其他表格”具体指哪些(合同、结算、发票、货代费用、清关成本等)?关联规则(按合同号、提单号、日期等)是什么?
11. **货代费用 + 其他杂费**:与“利润明细”“历史清关成本明细表”的关联方式是否已明确(例如通过提单号、合同编号、日期)?
12. **对账单**:对账单的业务含义、使用场景、字段与数据来源是否已有说明?若有补充文档或样例,请提供或简述。
### 查询 + 报表中心
13. **合同执行情况**
- “差款”的定义是什么(例如:合同金额 已收款项)?数据来源是结算单、银行流水还是其它?
- “待开发票情况”是指:已结算但未开票的金额/列表?数据来源是否与发票管理表一致?
- “上传银行流水”的 Excel 格式是否已定?上传后是否需要解析并自动匹配到合同/收付款,还是仅作附件存档?
14. **营业明细表**“2025 年中尚鹏业务明细表”与当前菜单中的“营业明细表”是否同一张表?若否,请说明需求中的这张表应展示哪些维度(按合同、按客户、按商品、按月份等)。
15. **其它明细表**:已开票明细表、货代杂费明细表、历史清关成本明细表:
- 三张表是独立页面还是同一页面下三个 Tab/子表?
- 每张表的字段与数据来源是否已定义?历史清关成本的数据从哪里录入或导入?
### 采购管理
16. **采购报单 / 采购运踪明细 / 国外供应商**:各模块的“我们提供的 excel”或“扫描件内容表格”是否已提供若未提供是否同意以当前系统中的表格结构为初版后续再按模板调整
17. **采购预算**与“单独的预算测算系统”的对接方式是否有初步方案例如链接跳转、单点登录、接口拉取数据、iframe 嵌入等)?优先级是否确认为“最后有时间再弄”?
### 权限管理
18. 需求中“权限管理 1、2”具体指什么是否需要角色管理、数据权限按公司/部门)、审批流、操作日志等?若有,请列出清单或提供简单说明。
### 其它
19. 是否有未在《项目开发要求文档》中体现的隐藏需求或口头约定(例如:某表需要审批后才能生效、某报表需要定时发送邮件等)?
20. 后端服务与接口:当前后端是否已有独立项目或部署地址?前端调用的接口是否有完整文档(如 Swagger/Word若部分接口尚未实现请标明模块或接口路径便于排期。
---
*文档生成后请根据实际业务与最新需求增删改,本分析不修改任何代码。*

View File

@@ -0,0 +1,85 @@
# 财务管理模块 - 待确认问题清单
> 在实现财务管理前后端前,请逐条回答或标注「暂不考虑」。回答完成后再补充前后端代码。
---
## 一、结算管理
**1.1** 需求写「新增结算单(导出下游合同结算单)」:
「导出下游合同结算单」是指:**(a)** 从系统中已有的下游合同数据(如中尚鹏合同/业务合同里选一份合同一键生成并下载一份结算单文档PDF/Excel**(b)** 还是仅在前端提供「结算单表单」,手动录入后保存,再支持导出为文件?**(c)** 其他方式?
**1.2** 「根据单号查询」:结算单号规则是否由系统自动生成(如 `STL2026030001`)?还是允许用户手动填写单号?
**1.3** 「上传结算单(已交易且结算完毕的,主要为了备份)」:
上传的结算单是否需要和「已录入的结算单记录」关联(例如上传后关联到某条结算单号)?还是仅作为独立附件存储,按文件夹/时间归类即可?
**1.4** 结算单「录入扫描件内容数据的表格」除当前模型已有字段(结算单号、合同编号、合同类型、公司名、商品、数量、单价、金额、结算日期、状态、附件路径、备注)外,是否还需要增加或删减字段?若有,请列出。
---
## 二、发票管理
**2.1** 需求为「上游发票(上传)、下游发票(上传)、货代杂费发票(上传)」且已确认三张独立表。
当前三张表均含:发票号、合同编号、提单号、金额/税额/价税合计、开票日期、状态、附件路径、备注;上游多了「供应商名」、下游多了「客户名」、货代杂费多了「公司名」「费用项目」。
是否还需要其他字段(如税率、发票类型、购方/销方税号等)?
**2.2** 发票的「合同编号」是否需要与系统内已有合同(业务合同/中尚鹏合同)做下拉关联选择?还是自由文本即可?
**2.3** 上传发票后,附件存储方式是否与「合同管理/公司信息」一致:按文件夹归类 + 记录文件路径到该条发票记录的 `file_path`
---
## 三、利润明细
**3.1** 需求为「2025年业务利润表」「关联其他表格中数据 + 设置好栏目以及函数,后续自行手动录入」。
当前理解:收入来自合同金额,成本 = 货代费用 + 杂费 + 服务费,利润 = 收入 成本。
请确认:**(a)** 利润表是按「年度」一张总表(如 2025 年一整年),**(b)** 还是按「合同/业务单」逐条有对应利润记录?**(c)** 或两者都要(既有年度汇总,又有单笔明细)?
**3.2** 「栏目以及函数」除「利润 = 收入 成本」、以及可能的「利润率 = 利润/收入」外,是否还有其它公式或统计维度(如按商品、按公司汇总)?若甲方尚未提供,是否先只做「收入、成本、利润、利润率」四栏?
**3.3** 利润明细的数据来源:**(a)** 完全由后端根据合同、货代/杂费/服务费自动汇总计算,前端只读展示;**(b)** 支持在前端表格中手动改写部分单元格(覆盖自动结果);**(c)** 以手动录入为主,仅部分字段可从其他表带出。请选一种或说明组合方式。
---
## 四、货代费用 + 杂费(与菜单结构)
**4.1** 需求表里写「4.货代费用+其他杂费」合并为一条二级菜单,但当前前端是「货代费用」「杂费管理」两个并列子菜单。
是否保持现有两个子菜单(货代费用、杂费管理)不变?还是希望合并为一个「货代费用与杂费」页面,用 Tab 或筛选区分「货代」与「杂费」?
**4.2** 货代费用、杂费、服务费三者是否都需要「支持上传附件」(如发票扫描件)?当前货代/杂费/服务费模型是否需增加 `file_path` 或类似字段?
---
## 五、对账单
**5.1** 需求仅写「5.对账单」,无具体说明。当前后端有 `ZSPReconciliation` 模型(对账单号、合同编号、公司名、账期起止、总金额、已付、未付、状态、附件路径等)。
请确认对账单的业务含义:**(a)** 与「结算单」一一对应或一对多(如多笔结算汇总成一张对账单)?**(b)** 与结算单无关,是独立与客户/供应商的阶段性对账?**(c)** 其他(请简要说明)。
**5.2** 对账单的「总金额 / 已付 / 未付」是否由系统根据结算单或付款记录自动汇总?还是完全手动录入?
**5.3** 对账单是否也需要「上传已确认的对账单扫描件」作为备份?若需要,是否与结算单一样按「记录 + 附件路径」即可?
---
## 六、接口与路由约定
**6.1** 结算、发票、对账单的后端 API 是否统一放在现有 `/api/zsp-finances/` 下,例如:
- 结算:`/api/zsp-finances/settlements`CRUD
- 上游/下游/货代杂费发票:`/api/zsp-finances/upstream-invoices``downstream-invoices``freight-misc-invoices`
- 对账单:`/api/zsp-finances/reconciliations`
是否同意?若有不同路径规范请说明。
**6.2** 财务相关列表接口是否需要分页、按结算单号/合同号/公司名/日期范围等筛选?若需要,请列出各模块的常用筛选条件。
---
## 七、其他
**7.1** 财务管理各子模块(结算、发票、利润、货代、杂费、服务费、对账单)的菜单顺序是否与当前前端路由一致(结算 → 发票 → 利润明细 → 货代费用 → 杂费管理 → 服务费管理 → 对账单)?如需调整请说明。
**7.2** 是否有「财务数据不可删除、仅可作废/红冲」等合规要求?若有,哪些表需要支持状态为「作废」而不是物理删除?
---
*回答方式:可在每题后直接写答案,或另起文档写「题号 + 答案」。完成后告知,再开始补充前后端实现。*

299
docs/dev/项目开发文档.md Executable file
View File

@@ -0,0 +1,299 @@
# 项目开发文档
```
作者:rovina
最近修订日期:2026/3/2
```
---
## 一、项目技术栈概览
| 层级 | 技术 | 说明 |
| --- | --------------------------------- | --------------------- |
| 前端 | Vue 3 + TypeScript + Element Plus | 基于 pure-admin-thin 模板 |
| 后端 | Go + Gin + GORM | RESTful APIMySQL 存储 |
| 数据库 | MySQL 8 (Docker) | 数据库名 `myapp_dev` |
---
## 二、后端已实现的路由与模型
### 2.1 已有后端路由(`route.go`
```
# 认证
POST /api/auth/register
POST /api/auth/login
GET /api/users/:id
# 合同上传旧版Contract 模型)
POST /api/contract/upload
POST /api/contract/batch-upload
# 中尚鹏合同管理ZSPContract 模型)
POST /api/zsp-contract/folders
GET /api/zsp-contract/folders
GET /api/zsp-contract/folders/:id
PUT /api/zsp-contract/folders/:id
DELETE /api/zsp-contract/folders/:id
POST /api/zsp-contract/contracts (上传合同文件)
GET /api/zsp-contract/contracts (含详情列表)
GET /api/zsp-contract/contracts/:id (含详情)
DELETE /api/zsp-contract/contracts/:id
POST /api/zsp-contract/contracts/:id/details
GET /api/zsp-contract/contracts/:id/details
POST /api/zsp-contract/details/batch
GET /api/zsp-contract/details/:id
PUT /api/zsp-contract/details/:id
DELETE /api/zsp-contract/details/:id
POST /api/zsp-contract/contracts/:id/details/import Excel导入
GET /api/zsp-contract/contracts/:id/details/export Excel导出
# 财务管理ZSPFinances 模型)
CRUD /api/zsp-finances/freight-charges (货代费用)
CRUD /api/zsp-finances/misc-charges (杂费)
CRUD /api/zsp-finances/service-fees (服务费)
CRUD /api/zsp-finances/cost-breakdowns (成本构成)
CRUD /api/zsp-finances/profit-records (利润记录)
```
### 2.2 已有后端数据模型
| 模型 | 说明 | AutoMigrate |
| ------------------- | -------------------------- | --------------------- |
| `User` | 用户表 | 是 |
| `Contract` | 合同(旧版,仅名称/编号/甲乙方等基础字段) | 是 |
| `ContractFile` | 合同附件(旧版) | 是 |
| `ZSPContractFolder` | 合同文件夹 | 是 |
| `ZSPContractFile` | 合同文件(含合同编号/公司/商品/签订日期/类型) | 是 |
| `ZSPContractDetail` | 合同详情(商品/数量/单价/已提货/应提货/提单号) | **否,未加入 AutoMigrate** |
| `ZSPFreightCharges` | 货代费用 | **否,未加入 AutoMigrate** |
| `ZSPMiscCharges` | 杂费 | **否,未加入 AutoMigrate** |
| `ZSPServiceFees` | 服务费 | **否,未加入 AutoMigrate** |
| `ZSPCostBreakdown` | 成本构成(关联货代/杂费/服务费) | **否,未加入 AutoMigrate** |
| `ZSPProfitRecord` | 利润记录(关联成本构成) | **否,未加入 AutoMigrate** |
### 2.3 后端完全缺失的模块(前端有 API 定义但后端无路由/模型)
| 前端 API 文件 | 调用路径前缀 | 后端状态 |
| ---------------------- | ----------------------- | ----------------------------------- |
| `applyDelivery.ts` | `/apply-delivery/`* | 无路由、无模型、无 handler |
| `deliveryDetails.ts` | `/delivery-details/`* | 无路由、无模型、无 handler |
| `inventory.ts` | `/inventory/*` | 无路由、无模型、无 handler |
| `ownershipTrAns1fer.ts` | `/ownership-trAns1fer/*` | 无路由、无模型、无 handler且前端大部分 API 函数被注释) |
| `company.ts` | `/company/*` | 无路由、无模型、无 handler |
| 结算管理 | 未知 | 无前端 API 文件、无后端 |
| 发票管理 | 未知 | 无前端 API 文件、无后端 |
| 采购管理 | 未知 | 无前端 API 文件、无后端 |
| 查询报表 | 复用 zspContract API | 无独立后端 |
---
## 三、已发现的技术问题
### 3.1 AutoMigrate 不完整
`pkg/database/migrate.go` 只注册了 5 个模型,缺少 `ZSPContractDetail` 及所有财务相关模型(共 6 个),后端启动后这些表不会自动创建。
### 3.2 双套合同模型
后端同时存在 `Contract`(旧版)和 `ZSPContractFile`(新版)两套合同模型:
- `Contract` 字段Name/ContractType/ContractCode/BuyParty/SellParty/Count/UnitPrice/BLNumber 等,且 Count、UnitPrice 均为 string 类型。
- `ZSPContractFile` + `ZSPContractDetail`:更完整,有数值类型的 TotalQuantity/UnitPrice/DeliveredQty/PendingQty。
前端 `AddContract.vue` 调用旧版 `contract/upload`;中尚鹏合同管理、合同执行等调用新版 `zsp-contract/*`
### 3.3 Auth 中间件已注释
`route.go``authorized.Use(authMiddleware)` 被注释,所有接口无鉴权保护。
### 3.4 API 基址不统一
- `contract.ts`:用 `baseUrlApi()` 写死 `http://127.0.0.1:8080/api/`,不走 Vite 代理。
- 其余 API`@/utils/http`,走 Vite 代理 `/api` -> `127.0.0.1:8080`
### 3.5 财务模型字段与需求不匹配
需求中"货代费用"要求字段为:**提单号、上游合同编号、费用项目、金额、付款日期、货代/物流公司名**。
当前后端 `ZSPFreightCharges` 模型仅有Name、Amount、Currency、Date、Remark**缺少提单号、上游合同编号、货代公司名等关键业务字段**。
---
## 四、需要你确认的问题
> 以下问题分为 **业务理解** 和 **技术决策** 两类,请逐条回答或标注"暂不考虑"。
### A. 合同管理
**A1.** 旧版 `Contract` 模型和新版 `ZSPContractFile + ZSPContractDetail` 模型都在使用中。你的计划是:
- (a) 废弃旧版,把"新增合同"页面也改用新版模型?
- (b) 两套并存,各管各的?
- (c) 其他方案?
**Ans1: b新增合同部分要注意区分中尚鹏合同管理和新增合同是两套并行的合同管理在业务需求端新增合同其实应该是合同管理或者叫做业务合同管理建议改掉然后中尚鹏合同管理只用负责公司相关的合同就行**
**A2.** 旧版 `Contract``Count`(数量)和 `UnitPrice`(单价)字段是 `string` 类型,无法直接参与数值计算。如果保留旧版,是否需要改成数值类型?
**Ans1: 我害怕有浮点计算风险,如果你能解决这个问题也未尝不可。**
**A3.** Talk 中提到"合同管理里空一个 Excel 表格,手动录入合同数据(数量、单价、已提货数量等),系统自动计算未提货数量"。这个"Excel 表格"是否就是当前 `ZSPContractDetail`(合同详情)表的逐行录入形式?还是甲方期望一个**真正可编辑的类 Excel 界面**(如可拖拽选区、复制粘贴公式等 SpreadSheet 组件)?
**Ans1: 甲方可能希望的是一个表的逐行录入形式类似csv格式的前端显示但是有并发修改的需求**
**A4.** 甲方说"公式后续会给出"。目前后端 `ZSPContractDetail.BeforeCreate` 只做了一个公式:`PendingQty = TotalQuantity - DeliveredQty`。除此之外是否还有其他公式需要实现?如果甲方还没给,是否先只保留这一个?
**Ans1: 甲方还没给**
**A5.** 中尚鹏合同管理的搜索:甲方说"我们会备注好文件名包含商品、公司名、合同编号"。当前后端 `ZSPContractFile` 模型已有独立的 `ContractNumber``CompanyName``Commodity` 字段。那么搜索逻辑是:
- (a) 上传时手动填写这三个字段,搜索时按字段查?(当前实现)
- (b) 从文件名自动解析出这三个字段?如果是,文件名格式是什么?
**Ans1: (a)方法**
### B. 提货管理
**B1.** 提货管理(我要提货、提货明细、库存、货权转移)的后端完全没有实现。前端已定义了 API 类型和调用路径。开发顺序是否按 `我要提货 → 提货明细 → 库存 → 货权转移` 依次实现?
**Ans1: 是的**
**B2.** "我要提货"需求中说"根据我们提供的提货单模板创建"。模板是否已提供?如果提供了,请告诉我文件位置;如果没有,是否按当前前端 `ApplyDelivery.vue` 的字段结构先做?
**Ans1: 目前还没提供,这个部分先不做**
**B3.** 库存计算公式:`库存 = 入库重 出库数量 货权转移数量`。这个计算在后端做API 返回已算好的库存值),还是前端拿到三个数字自己算?
**Ans1: 这个后端来算吧**
**B4.** 货权转移前端 `ownershipTrAns1fer.ts` 中大部分 API 函数被注释了create、print、export、delete、upload只保留了 `getOwnershipList`。这些注释掉的功能是否都要实现?
**Ans1: 可能是Ai生成的内容可信度不高你按照自己的想法来**
**B5.** 需求中"货权转移"要求字段包含**库方**warehouse。这里的"库方"是否就是"仓库管理"中已维护的仓库列表?即两者共用同一套仓库数据?
**Ans1: 公司是一个对接上游公司和下游公司的业务,从上游公司拿货给下游公司这样的,库方一个指的是仓库管理中的仓库列表**
### C. 财务管理
**C1.** 当前后端的 `ZSPFreightCharges`(货代费用)模型字段很少(仅 name/amount/currency/date/remark需求要求的"提单号、上游合同编号、费用项目、金额、付款日期、货代/物流公司名"基本都缺。是否需要重新设计这个模型的字段?
**Ans1: 是的,重新设计**
**C2.** Talk 中提到"利润表中的成本栏 = 货代费用 + 杂费 + 服务费,且点击成本金额能显示构成明细(如货代费 20 万、杂费 12 万)"。当前后端已有 `ZSPCostBreakdown` 模型关联了三种费用。这个设计方向是否正确?是否需要调整?
**Ans1: 是的,重新设计**
**C3.** 利润明细需要"关联其他表格中数据"。Talk 中提到的"其他表格",你认为具体是指哪些?我目前的理解是:
- 收入 ← 来自合同金额ZSPContractDetail.TotalQuantity × UnitPrice
- 成本 ← 来自货代费用 + 杂费 + 服务费ZSPCostBreakdown
- 利润 = 收入 成本
- 这个理解对吗?还是收入来源不同(比如来自结算单)?
**Ans1: 应该是对的**
**C4.** 结算管理、发票管理、对账单:前端目前没有独立的 API 文件,后端也没有实现。这三个模块的优先级如何?是否排在合同和提货之后?
**Ans1: 需要你来实现**
**C5.** 发票管理中"上游/下游/货代杂费发票",这三类发票是否共用一个数据表(通过 type 字段区分),还是需要独立三张表?
**Ans1: 独立三张表**
### D. 查询 + 报表中心
**D1.** 合同执行情况中"差款"的计算规则是什么?是 `合同金额 已结算金额`?还是 `合同金额 银行流水中已收金额`?数据分别来自哪些表?
**Ans1: 先不做这一部分**
**D2.** "待开发票情况"的计算规则是什么?是 `已结算金额 已开票金额`
**Ans1: 是**
**D3.** "上传银行流水":上传后是否需要**解析 Excel 内容并自动匹配到合同**还是仅当附件保存人工查看如果要解析Excel 的列格式是什么?
**Ans1: 仅当附件保存,人工查看**
**D4.** "其它明细表"下有三个子表:已开票明细表、货代杂费明细表、历史清关成本明细表。当前前端只有一个 `OtherDetails.vue`。是在一个页面用 Tab 切换三个子表,还是拆成三个独立页面/路由?
**Ans1: 用 Tab 切换三个子表**
**D5.** "营业明细表"的具体内容和字段是什么?是按年/月/合同维度汇总的报表?还是某个甲方提供的 Excel 模板?
**Ans1: 先不做这一部分**
### E. 采购管理
**E1.** 采购管理的四个子模块(采购报单、采购运踪明细、国外供应商、采购预算)目前前端有路由和页面,但前端 API 只有 `PurchaseOrder.vue` 有部分内容,后端完全没有。这些模块的开发优先级是否排在合同/提货/财务之后?
**Ans1: 这是之前用AI生成的前端内容可信度并不高**
**E2.** 采购预算需求说"已有单独的预算测算系统"。对接方式你倾向哪种链接跳转、iframe 嵌入、还是接口拉数据?还是说现阶段完全不做?
**Ans1: 完全不做**
### F. 权限管理
**F1.** 需求中"权限管理 1、2"没有展开。当前系统是否只需要最基础的**角色 + 菜单权限**(如管理员看全部、普通用户看部分菜单)?还是需要更复杂的**数据权限**(如只能看自己创建的合同)或**审批流**
**Ans1: 只需要最基础的**
**F2.** 当前后端 Auth 中间件已注释。是否需要在这一阶段启用 JWT 鉴权?还是等功能都做完再加?
**Ans1: 等所有的内容做完了再加吧**
### G. 整体开发计划
**G1.** 你期望的模块开发顺序是什么?我建议按依赖关系排序:
1. 合同管理(基础数据,其他模块依赖合同编号/提单号)
2. 提货管理(依赖合同)
3. 库存信息(依赖入库/出库/货权转移)
4. 财务管理(依赖合同、提货数据)
5. 查询报表(依赖以上所有数据)
6. 采购管理(相对独立)
7. 权限管理(最后统一加)
这个顺序你是否同意?或者有其他优先级安排?
**Ans1: 同意**
**G2.** 甲方 Talk 中说"主菜单后面不能再改,二级菜单每次做之前再沟通一次"。是否意味着我每做完一个二级菜单就要等你确认后再做下一个?
**Ans1: 不需要**
**G3.** 前端页面大部分已经有了 UI 和模拟逻辑。后续开发的重心是否主要在**后端 API 实现 + 前后端联调**,前端 UI 改动较少?
**Ans1: 前端UI的改动现在我对新增合同这个菜单并不满意建议修改为业务合同管理然后前端UI做的和中尚鹏合同管理一样这些**
---
## 五、Ans 修改完成备注
以下为根据你的回复已完成的修改及代码位置说明。
**Solve A1两套合同并存 + 业务合同管理命名)**
- 完成:菜单「新增合同」改为「业务合同管理」;业务合同管理仅负责业务合同,中尚鹏合同管理负责公司相关合同。
- 参考位置:`frontend/pure-admin-thin/src/router/modules/contract.ts``meta.title` 改为「业务合同管理」;`frontend/pure-admin-thin/src/views/system/contract/AddContract.vue` 页面标题与描述改为「业务合同管理」「管理业务合同信息、上传合同相关文件、录入合同数据」。
**Solve A2Contract 数量/单价改为数值并避免浮点风险)**
- 完成:旧版 `Contract``Count``UnitPrice` 改为 `decimal(16,4)` 数值类型,后端解析表单字符串为 float64。
- 参考位置:`backend/go-backend/internal/model/contract.go``Count``UnitPrice` 定义为 `float64``gorm:"type:decimal(16,4)"``backend/go-backend/internal/handler/contract_handler.go``strconv.ParseFloat` 解析并赋值。
**Solve A3、A4、A5**
- 当前保留合同详情逐行录入形式ZSPContractDetail公式仅保留 `PendingQty = TotalQuantity - DeliveredQty`;搜索按手动填写的合同编号/公司名/商品字段查询。无新增代码改动,逻辑维持现状。
**Solve C1货代费用模型重新设计**
- 完成:`ZSPFreightCharges` 增加提单号、上游合同编号、费用项目、金额、付款日期、货代/物流公司名等字段。
- 参考位置:`backend/go-backend/internal/model/zsp_freight_charges.go``ZSPFreightCharges` 结构体;`internal/dto/zsp_finances.go` 中 Create/Update 请求体;`internal/service/zsp_finances_service.go``internal/repository/zsp_finances.go``internal/handler/zsp_finances_handler.go` 对应 CRUD前端 `frontend/pure-admin-thin/src/api/zspFinances.ts` 类型与接口;`frontend/pure-admin-thin/src/views/system/finance/FreightCharges.vue` 表格与表单字段。
**Solve C2成本构成重新设计**
- 完成:`ZSPCostBreakdown` 改为按金额汇总设计,包含 `FreightChargeTotal``MiscChargeTotal``ServiceFeeTotal``TotalCost`,不再用外键关联单条费用记录。
- 参考位置:`backend/go-backend/internal/model/zsp_freight_charges.go``ZSPCostBreakdown``internal/dto/zsp_finances.go` 中成本构成 DTOservice/repository 中成本构成的计算与更新逻辑;前端 `zspFinances.ts``ZSPCostBreakdown` 及相关 API。
**Solve C3**
- 理解已确认:收入来自合同金额,成本=货代+杂费+服务费,利润=收入−成本。当前后端利润记录与成本构成已按此逻辑实现,无需额外改动。
**Solve C4结算/发票/对账单需实现)**
- 完成:后端新增结算、对账单及三张发票表模型并加入迁移;前端新增对应 API 文件。后端路由与完整 CRUD 待后续补全。
- 参考位置:`backend/go-backend/internal/model/zsp_invoice.go`(上游/下游/货代杂费发票);`backend/go-backend/internal/model/zsp_settlement.go`(结算、对账单);`backend/go-backend/pkg/database/migrate.go` 中 AutoMigrate 新增上述模型;`frontend/pure-admin-thin/src/api/settlement.ts``invoice.ts``reconciliation.ts`
**Solve C5发票独立三张表**
- 完成:已建 `ZSPUpstreamInvoice``ZSPDownstreamInvoice``ZSPFreightMiscInvoice` 三张独立表及对应模型与迁移。
- 参考位置:`backend/go-backend/internal/model/zsp_invoice.go`
**Solve 其他(杂费/服务费字段、API 基址、AutoMigrate**
- 杂费:`ZSPMiscCharges` 增加提单号、合同编号、费用项目、付款日期、公司名等,见 `zsp_freight_charges.go`、DTO、`MiscCharges.vue`
- 服务费:`ZSPServiceFees` 增加提单号、合同编号,见 `zsp_freight_charges.go`、DTO、`ServiceFees.vue`
- API 基址统一:业务合同上传改用 `@/utils/http`,走 Vite 代理。参考 `frontend/pure-admin-thin/src/api/contract.ts``submitContract``batchUploadContracts` 使用 `http.request`)。
- AutoMigrate 补全:`ZSPContractDetail`、所有财务相关模型、三张发票表、结算、对账单已加入。参考 `backend/go-backend/pkg/database/migrate.go`
*以上为当前已完成的修改与对应代码位置;提货/库存/货权转移、结算与发票的后端路由及利润明细页与新成本结构的联调等,按开发计划后续迭代。*

View File

@@ -0,0 +1,28 @@
| 中尚鹏管理系统 | 主菜单 | 二级菜单 | 三级菜单 | 内容 | 需要实现的功能 |
| -------------- | ------------- | ---------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| | 合同管理 | 1.新增合同 | ①新增合同信息②上传合同相关文件 | 合同类型,合同编号,商品,数量,单价,卖方,买方,提单号<一个上游合同可关联的提单号最多15个>(体现在一个表格中);上传合同扫描附件(上传扫描件的文件夹,可以后续新建多个文件夹作为合同归类)。(非必选:提货时间·交货时间·包装(散装/吨袋)·备注) | 可批量上传合同+有一个录入合同内容中相关数据的表格(会涉及简单的公式,我们会给出) |
| | 合同管理 | 2.中尚鹏合同管理 | ①上游合同②下游合同③外商采购合同④货代物流协议⑤其它合同 | 上传合同扫描附件(上传扫描件的文件夹,可以后续新建多个文件夹作为合同归类);提供按照签订时间/合同编号/公司名 的搜索功能(我们会备注好文件名包含商品、公司名、合同编号,我们就通过三个要素搜索对应的合同) | |
| | 合同管理 | 3.公司信息管理 | 主要是公司相关文件存储,营业执照开票信息等 | 上传合同扫描附件(上传扫描件的文件夹,可以后续新建多个文件夹作为合同归类) | |
| | 提货管理 | 1.我要提货 | ①新增提货委托②变更提货委托③取消提货委托④ | 根据我们提供提货单模板创建在系统内,需要新增提货单时,我们手动录入可变信息内容(车牌号、提单号、司机信息、数量、商品品类) | |
| | 提货管理 | 2.提货明细 | ①新增出库单②查看出库明细③收货确认单 | 录入数据的表格(后期我们给模板) | |
| | 提货管理 | 3.库存信息 | ①新增入库单(入库单上传+录入表格)②库存明细(入库表格中数量-新增出库单中数量-货权转移数量=库存数量)③仓库管理(需要录入几个目前固定存放货物的仓库名,且后续可能会增加新的仓库名) | 批量上传扫描件的文件夹;录入扫描件内容数据的表格。表格内容:入库单:仓库,商品,提单号,提单重,入库重,货主,对应上游合同编号。可选项(仓库地址,包装) | |
| | 提货管理 | 4.货权转移 | ①新增货权转移函②上传货权转移函(已生效的)③查询/导出货转函 | 批量上传扫描件的文件夹;录入货权转移函数据的表格内容:货转日期,提单号,数量,商品,库方,货权出让方,货权接收方等 | |
| | 提货管理 | 5.其它 | | | |
| | 财务管理 | 1.结算管理 | ①新增结算单(导出下游合同结算单)②结算单根据单号查询③上传结算单(已交易且结算完毕的,主要为了备份) | 批量上传扫描件的文件夹;录入扫描件内容数据的表格 | |
| | 财务管理 | 2.发票管理 | ①上游发票(上传)②下游发票(上传)③货代杂费发票(上传) | 上传文件;对应表格录入发票对应信息 | |
| | 财务管理 | 3.利润明细 | ①2025年业务利润表 | 根据我们提供的excel表格创建一个表格需要关联其他表格中数据+设置好栏目以及函数,后续自行手动录入) | |
| | 财务管理 | 4.货代费用+其他杂费 | ①新增货代费用/其它费用 | 货代费用/其它费用:提单号,上游合同编号,费用项目,金额,付款日期,货代/物流公司名 | |
| | 财务管理 | 5.对账单 | | | |
| | 查询+报表中心 | 1.查询上下游客户合同执行情况 | ①查询上下游客户合同执行(可按照合同编号/公司名/商品/提单号/日期 查询②上传excel表格银行流水 | 查询上下游客户货款及提货进度 | 可查询到该合同剩余未提数量(与二级菜单中新增合同表格关联)及差款及待开发票情况(与二级菜单中发票管理关联) |
| | 查询+报表中心 | 2.营业明细表 | ①2025年中尚鹏业务明细表 | | |
| | 查询+报表中心 | 3.其它明细表 | ①已开票明细表②货代杂费明细表③历史清关成本明细表 | | |
| | 查询+报表中心 | 4.商品基础资料 | 燕麦/大麦/豌豆/亚麻籽/葵粕/菜籽粕 | 可批量上传扫描件or文档即可 | |
| | 查询+报表中心 | 5.其它 | | | |
| | 采购管理 | 1.采购报单 | ①新增采购报单②变更采购报单 | 批量上传扫描件+录入扫描件中内容的表格 | |
| | 采购管理 | 2.采购运踪明细 | 采购进度明细表(相当于一个共享表格) | 根据我们所提供的excel在系统中创建一个表格 | |
| | 采购管理 | 3.国外供应商 | 国外供应商明细表 | 可批量上传扫描件or文档即可 | |
| | 采购管理 | 4.采购预算 | | 已有单独的预算测算系统,最后按照该预算系统功能等添加到我们系统中(这个最后有时间慢慢弄,不着急) | |
| | 权限管理 | 1 | | | |
| | 权限管理 | 2 | | | |
| | | | | | |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,274 @@
---
name: 前端 API 测试基础设施
description: 为 Vue3+TypeScript 前端建立 Vitest 测试框架Mock Axios 测试 11 个 API 模块
created: 2026-04-25
---
# 前端 API 测试基础设施设计文档
## 一、背景与目标
### 当前状态
- 前端基于 Vue 3 + TypeScript + Vite + Axios
- 11 个 API 模块 (`src/api/*.ts`) 封装 HTTP 请求
- **测试完全缺失**:无 vitest 配置,无 .spec/.test 文件
### 目标
建立前端 API 层测试基础设施,确保:
1. API 请求参数/响应类型正确
2. 错误处理逻辑覆盖
3. 前后端契约一致性验证
4. CI/CD 集成准备
## 二、技术选型
| 项目 | 选择 | 原因 |
|------|------|------|
| 测试框架 | Vitest | Vite 生态原生支持配置简单ESM 原生 |
| Mock 工具 | vi.mock (Vitest 内置) | 无额外依赖Axios mock 简洁 |
| 断言库 | Vitest expect | 内置,支持 TypeScript 类型推断 |
| 覆盖率 | @vitest/coverage-v8 | Istanbul 覆盖率报告 |
## 三、架构设计
### 目录结构
```
frontend/
├── vitest.config.ts # Vitest 配置文件
├── src/api/
│ ├── __tests__/ # API 测试目录
│ │ ├── contract.spec.ts # 合同 API 测试
│ │ ├── zspContract.spec.ts # 中尚鹏合同
│ │ ├── zspFinances.spec.ts # 财务模块
│ │ ├── applyDelivery.spec.ts # 提货申请
│ │ ├── deliveryDetails.spec.ts
│ │ ├── inventory.spec.ts
│ │ ├── ownershipTransfer.spec.ts
│ │ ├── company.spec.ts
│ │ ├── user.spec.ts
│ │ ├── invoice.spec.ts
│ │ ├── settlement.spec.ts
│ │ └── reconciliation.spec.ts
│ └── test-utils.ts # Mock 工具函数
└── package.json # 新增 vitest 相关依赖
```
### Vitest 配置
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
include: ['src/api/__tests__/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/api/**/*.ts'],
exclude: ['src/api/__tests__/**', 'src/api/test-utils.ts']
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
```
### Mock 工具函数
```typescript
// src/api/test-utils.ts
import { vi } from 'vitest'
import { http } from '@/utils/http'
/**
* Mock http.request 方法
* @param response 模拟响应数据
* @param status HTTP 状态码 (默认 200)
*/
export function mockHttpRequest(response: any, status = 200) {
vi.spyOn(http, 'request').mockResolvedValue({
success: true,
data: response,
status
})
}
/**
* Mock HTTP 错误响应
* @param message 错误信息
* @param status HTTP 状态码
*/
export function mockHttpError(message: string, status: number) {
vi.spyOn(http, 'request').mockRejectedValue({
success: false,
message,
status
})
}
/**
* 清除所有 mock
*/
export function clearMocks() {
vi.restoreAllMocks()
}
```
## 四、测试模式
每个 API 模块测试遵循统一模式:
### 4.1 请求成功测试
```typescript
describe('getContractList', () => {
it('应返回合同列表', async () => {
const mockData = [{ id: 1, name: '合同1' }]
mockHttpRequest(mockData)
const result = await getContractList()
expect(result.success).toBe(true)
expect(result.data).toEqual(mockData)
})
})
```
### 4.2 参数验证测试
```typescript
it('带 contractType 参数应正确传递', async () => {
mockHttpRequest([])
await getContractList({ contractType: 'business' })
expect(http.request).toHaveBeenCalledWith('get', '/api/contract', {
params: { contractType: 'business' }
})
})
```
### 4.3 错误处理测试
```typescript
describe('错误处理', () => {
it('401 未授权应抛出错误', async () => {
mockHttpError('Unauthorized', 401)
await expect(getContractList()).rejects.toMatchObject({
status: 401
})
})
it('500 服务器错误应抛出错误', async () => {
mockHttpError('Internal Server Error', 500)
await expect(getContractList()).rejects.toMatchObject({
status: 500
})
})
})
```
### 4.4 类型验证
```typescript
it('响应应符合 BusinessContract 类型', async () => {
const mockData: BusinessContract[] = [
{
id: 1,
name: '测试合同',
contractType: 'business',
contractCode: 'C001',
// ... 其他必填字段
}
]
mockHttpRequest(mockData)
const result = await getContractList()
// TypeScript 编译时类型检查,运行时验证结构
expect(result.data[0]).toHaveProperty('id')
expect(result.data[0]).toHaveProperty('name')
})
```
## 五、API 模块测试清单
| 模块 | 文件 | 主要 API 函数 | 预估测试数 |
|------|------|---------------|------------|
| Contract | `contract.spec.ts` | getContractList, getContract, submitContract, batchUploadContracts, updateContract, deleteContract + folders/batches | 15-20 |
| ZSP Contract | `zspContract.spec.ts` | getFolders, getContracts, uploadContract, createDetail, importExcel, exportExcel | 10-15 |
| ZSP Finances | `zspFinances.spec.ts` | CRUD for freight/misc/service/cost/profit | 12-15 |
| Apply Delivery | `applyDelivery.spec.ts` | list, get, create, update, cancel, delete | 10 |
| Delivery Details | `deliveryDetails.spec.ts` | list, get, create, update, delete | 8 |
| Inventory | `inventory.spec.ts` | warehouses, inbound, summary | 8 |
| Ownership Transfer | `ownershipTransfer.spec.ts` | list, get, create, createWithFiles, delete | 8 |
| Company | `company.spec.ts` | folders, files CRUD | 8 |
| User | `user.spec.ts` | login, register, getUser, refreshToken | 8 |
| Invoice | `invoice.spec.ts` | 上游/下游/货代发票 CRUD | 10 |
| Settlement | `settlement.spec.ts` | CRUD | 6 |
| Reconciliation | `reconciliation.spec.ts` | 对账单 CRUD | 6 |
**总计预估90-110 个测试用例**
## 六、执行命令
```bash
# 安装依赖
pnpm add -D vitest @vitest/coverage-v8 jsdom
# 运行测试
pnpm test
# 运行覆盖率
pnpm test:coverage
# 监听模式
pnpm test:watch
```
### package.json 新增脚本
```json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
```
## 七、CI/CD 集成预留
后续可添加 GitHub Actions 或其他 CI 配置:
```yaml
# .github/workflows/test.yml (预留)
- name: Run frontend tests
run: cd frontend && pnpm test:coverage
```
## 八、验收标准
1. 所有 11 个 API 模块有对应 `.spec.ts` 文件
2. 测试覆盖率 > 80%API 模块代码行覆盖)
3. 所有测试通过,无 TypeScript 编译错误
4. `pnpm test` 命令可用
5. 覆盖率报告生成 (`coverage/index.html`)
## 九、后续扩展方向
- 组件测试 (`src/components/`)
- 页面集成测试 (`src/views/`)
- E2E 测试 (Playwright)
- 真实后端集成测试层

4
frontend/.browserslistrc Executable file
View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

55
frontend/.dockerignore Executable file
View File

@@ -0,0 +1,55 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
node_modules
.DS_Store
dist
dist-ssr
*.local
.eslintcache
report.html
yarn.lock
npm-debug.log*
.pnpm-error.log*
.pnpm-debug.log
tests/**/coverage/
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
tsconfig.tsbuildinfo

14
frontend/.editorconfig Executable file
View File

@@ -0,0 +1,14 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

5
frontend/.env Executable file
View File

@@ -0,0 +1,5 @@
# 平台本地运行端口号
VITE_PORT = 12345
# 是否隐藏首页 隐藏 true 不隐藏 false 勿删除VITE_HIDE_HOME只需在.env文件配置
VITE_HIDE_HOME = true

8
frontend/.env.development Executable file
View File

@@ -0,0 +1,8 @@
# 平台本地运行端口号(若 12345 无权限可改用 5173
VITE_PORT = 5173
# 开发环境读取配置文件路径
VITE_PUBLIC_PATH = /
# 开发环境路由历史模式Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数"
VITE_ROUTER_HISTORY = "hash"

17
frontend/.env.production Executable file
View File

@@ -0,0 +1,17 @@
# 线上环境平台打包路径
VITE_PUBLIC_PATH = /
# 线上环境路由历史模式Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数"
VITE_ROUTER_HISTORY = "hash"
# 是否在打包时使用cdn替换本地库 替换 true 不替换 false
VITE_CDN = false
# 是否启用gzip压缩或brotli压缩分两种情况删除原始文件和不删除原始文件
# 压缩时不删除原始文件的配置gzip、brotli、both同时开启 gzip 与 brotli 压缩、none不开启压缩默认
# 压缩时删除原始文件的配置gzip-clear、brotli-clear、both-clear同时开启 gzip 与 brotli 压缩、none不开启压缩默认
VITE_COMPRESSION = "none"
# 生产环境 API 地址(可选)。不填则默认请求「当前访问的域名:8080」前后端同机时无需 Nginx 代理 /api
# 示例VITE_API_BASE_URL = http://129.204.148.31:8080
# VITE_API_BASE_URL =

16
frontend/.env.staging Executable file
View File

@@ -0,0 +1,16 @@
# 预发布也需要生产环境的行为
# https://cn.vitejs.dev/guide/env-and-mode.html#modes
# NODE_ENV = development
VITE_PUBLIC_PATH = /
# 预发布环境路由历史模式Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数"
VITE_ROUTER_HISTORY = "hash"
# 是否在打包时使用cdn替换本地库 替换 true 不替换 false
VITE_CDN = true
# 是否启用gzip压缩或brotli压缩分两种情况删除原始文件和不删除原始文件
# 压缩时不删除原始文件的配置gzip、brotli、both同时开启 gzip 与 brotli 压缩、none不开启压缩默认
# 压缩时删除原始文件的配置gzip-clear、brotli-clear、both-clear同时开启 gzip 与 brotli 压缩、none不开启压缩默认
VITE_COMPRESSION = "none"

22
frontend/.gitignore vendored Executable file
View File

@@ -0,0 +1,22 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.eslintcache
report.html
vite.config.*.timestamp*
yarn.lock
npm-debug.log*
.pnpm-error.log*
.pnpm-debug.log
tests/**/coverage/
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
tsconfig.tsbuildinfo

8
frontend/.husky/commit-msg Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
# shellcheck source=./_/husky.sh
. "$(dirname "$0")/_/husky.sh"
PATH="/usr/local/bin:$PATH"
npx --no-install commitlint --edit "$1"

9
frontend/.husky/common.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
command_exists () {
command -v "$1" >/dev/null 2>&1
}
# Workaround for Windows 10, Git Bash and Pnpm
if command_exists winpty && test -t 1; then
exec < /dev/tty
fi

10
frontend/.husky/pre-commit Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/common.sh"
[ -n "$CI" ] && exit 0
PATH="/usr/local/bin:$PATH"
# Perform lint check on files in the staging area through .lintstagedrc configuration
pnpm exec lint-staged

20
frontend/.lintstagedrc Executable file
View File

@@ -0,0 +1,20 @@
{
"*.{js,jsx,ts,tsx}": [
"prettier --cache --ignore-unknown --write",
"eslint --cache --fix"
],
"{!(package)*.json,*.code-snippets,.!({browserslist,npm,nvm})*rc}": [
"prettier --cache --write--parser json"
],
"package.json": ["prettier --cache --write"],
"*.vue": [
"prettier --write",
"eslint --cache --fix",
"stylelint --fix --allow-empty-input"
],
"*.{css,scss,html}": [
"prettier --cache --ignore-unknown --write",
"stylelint --fix --allow-empty-input"
],
"*.md": ["prettier --cache --ignore-unknown --write"]
}

11
frontend/.markdownlint.json Executable file
View File

@@ -0,0 +1,11 @@
{
"default": true,
"MD003": false,
"MD033": false,
"MD013": false,
"MD001": false,
"MD025": false,
"MD024": false,
"MD007": { "indent": 4 },
"no-hard-tabs": false
}

4
frontend/.npmrc Executable file
View File

@@ -0,0 +1,4 @@
shell-emulator=true
shamefully-hoist=true
enable-pre-post-scripts=false
strict-peer-dependencies=false

1
frontend/.nvmrc Executable file
View File

@@ -0,0 +1 @@
v22.14.0

9
frontend/.prettierrc.js Executable file
View File

@@ -0,0 +1,9 @@
// @ts-check
/** @type {import("prettier").Config} */
export default {
bracketSpacing: true,
singleQuote: false,
arrowParens: "avoid",
trailingComma: "none"
};

4
frontend/.stylelintignore Executable file
View File

@@ -0,0 +1,4 @@
/dist/*
/public/*
public/*
src/style/reset.scss

Some files were not shown because too many files have changed in this diff Show More