Files
fastAPI/docs/plans/2026-05-11-shelf-registration.md
2026-05-11 13:08:05 +08:00

627 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上架登记模块 (Shelf Registration) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现 PRD v1.0 中定义的上架登记后端接口 `POST /CargoTrace/location`,支持总排号与货位号的绑定。
**Architecture:** FastAPI + SQLAlchemy (同步模式) 连接 SQL Server。三层结构API 路由层 → Service 业务层 → Model 数据层。使用 pydantic-settings 管理 .env 配置Alembic 管理数据库迁移(当前版本表已存在,仅做初始化)。
**Tech Stack:** Python 3.12+, FastAPI, SQLAlchemy 2.x (sync), pyodbc, pydantic-settings, python-dotenv
---
## 项目目录结构(最终目标)
```
apps/fastAPI/
├── .env # 数据库连接配置
├── .gitignore
├── requirements.txt
├── README.md
├── alembic.ini
├── alembic/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 应用入口
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # pydantic-settings 配置
│ │ └── database.py # SQLAlchemy engine & session
│ ├── models/
│ │ ├── __init__.py
│ │ └── finished_goods.py # 3 张表的 ORM 模型
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── location.py # 上架登记请求/响应 schema
│ │ └── common.py # 通用错误响应 schema
│ ├── api/
│ │ ├── __init__.py
│ │ └── v1/
│ │ ├── __init__.py
│ │ └── location.py # POST /CargoTrace/location
│ └── services/
│ ├── __init__.py
│ └── location_service.py # 上架登记业务逻辑
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 测试 fixtures (TestClient, mock DB)
│ └── test_location_api.py # 接口测试
```
---
### Task 1: 项目基础设施 — 配置与环境
**Files:**
- Create: `apps/fastAPI/.env`
- Create: `apps/fastAPI/.gitignore`
- Modify: `apps/fastAPI/requirements.txt`
- Create: `apps/fastAPI/app/core/__init__.py`
- Create: `apps/fastAPI/app/core/config.py`
- Create: `apps/fastAPI/app/core/database.py`
**Step 1: 更新 requirements.txt**
```
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
sqlalchemy>=2.0.0
pyodbc>=5.2.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
```
**Step 2: 创建 .env 文件**
```env
# SQL Server
SQL_SERVER_HOST=192.168.110.114
SQL_SERVER_PORT=1433
SQL_SERVER_DATABASE=CompanyDB
SQL_SERVER_USERNAME=peng
SQL_SERVER_PASSWORD=Cqbld123456.
SQL_SERVER_DRIVER={ODBC Driver 18 for SQL Server}
SQL_SERVER_TRUST_SERVER_CERTIFICATE=yes
```
**Step 3: 创建 .gitignore**
```
.venv/
__pycache__/
*.pyc
.env
*.egg-info/
dist/
build/
.pytest_cache/
```
**Step 4: 创建 app/core/config.py**
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SQL_SERVER_HOST: str
SQL_SERVER_PORT: int = 1433
SQL_SERVER_DATABASE: str
SQL_SERVER_USERNAME: str
SQL_SERVER_PASSWORD: str
SQL_SERVER_DRIVER: str = "{ODBC Driver 18 for SQL Server}"
SQL_SERVER_TRUST_SERVER_CERTIFICATE: str = "yes"
@property
def database_url(self) -> str:
return (
f"mssql+pyodbc://{self.SQL_SERVER_USERNAME}:{self.SQL_SERVER_PASSWORD}"
f"@{self.SQL_SERVER_HOST},{self.SQL_SERVER_PORT}/{self.SQL_SERVER_DATABASE}"
f"?driver={self.SQL_SERVER_DRIVER}"
f"&TrustServerCertificate={self.SQL_SERVER_TRUST_SERVER_CERTIFICATE}"
)
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
```
**Step 5: 创建 app/core/database.py**
```python
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
```
**Step 6: 验证数据库连接**
Run: `cd apps/fastAPI && source .venv/bin/activate && python -c "from app.core.database import engine; engine.connect(); print('DB OK')"`
Expected: `DB OK`
**Step 7: Commit**
```bash
git add .env .gitignore requirements.txt app/core/
git commit -m "feat: add project config and database connection"
```
---
### Task 2: ORM 模型 — 三张表映射
**Files:**
- Create: `apps/fastAPI/app/models/__init__.py`
- Create: `apps/fastAPI/app/models/finished_goods.py`
**Step 1: 创建 models/finished_goods.py**
```python
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Index, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
SCHEMA = "CargoTrace"
class FinishedGoodsLocation(Base):
__tablename__ = "finished_goods_location"
__table_args__ = (
Index("uk_zongpai_no", "zongpai_no", unique=True),
Index("idx_fgl_location_code", "location_code"),
{"schema": SCHEMA},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
location_code: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate()
)
class FinishedGoodsBox(Base):
__tablename__ = "finished_goods_box"
__table_args__ = (
Index("uk_paichan_box", "paichan_no", "box_no", unique=True),
Index("idx_fgb_paichan_no", "paichan_no"),
{"schema": SCHEMA},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
paichan_no: Mapped[str] = mapped_column(String(64), nullable=False)
box_no: Mapped[int] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate()
)
class FinishedGoodsBoxItem(Base):
__tablename__ = "finished_goods_box_item"
__table_args__ = (
Index("idx_fgbi_box_id", "box_id"),
Index("idx_fgbi_zongpai_no", "zongpai_no"),
{"schema": SCHEMA},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
box_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate()
)
```
**Step 2: 创建 models/__init__.py**
```python
from app.models.finished_goods import (
FinishedGoodsBox,
FinishedGoodsBoxItem,
FinishedGoodsLocation,
)
__all__ = [
"FinishedGoodsLocation",
"FinishedGoodsBox",
"FinishedGoodsBoxItem",
]
```
**Step 3: 验证模型可正常导入**
Run: `cd apps/fastAPI && python -c "from app.models import FinishedGoodsLocation; print('Models OK')"`
Expected: `Models OK`
**Step 4: Commit**
```bash
git add app/models/
git commit -m "feat: add ORM models for finished goods tables"
```
---
### Task 3: Pydantic Schemas — 请求/响应模型
**Files:**
- Create: `apps/fastAPI/app/schemas/__init__.py`
- Create: `apps/fastAPI/app/schemas/common.py`
- Create: `apps/fastAPI/app/schemas/location.py`
**Step 1: 创建 schemas/common.py — 通用错误响应**
```python
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error_code: str
message: str
detail: dict | None = None
```
**Step 2: 创建 schemas/location.py — 上架登记请求/响应**
```python
import re
from pydantic import BaseModel, field_validator
# 总排号正则: 两位年份 + 类型字母(B/C/T) + 流水号 或 两位年份 + 温度计(BW/CW) + 4位流水号
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
# 普通货位号: 区域货架-层-格 (全大写字母+数字, 横杠分隔)
LOCATION_NORMAL_PATTERN = re.compile(r"^[A-Z]+\d+-\d+-\d+$")
# 转运特殊货位号: TRANS-序号
LOCATION_TRANS_PATTERN = re.compile(r"^TRANS-\d+")
class LocationRequest(BaseModel):
zongpai_no: str
location_code: str
@field_validator("zongpai_no")
@classmethod
def validate_zongpai_no(cls, v: str) -> str:
v = v.strip().upper()
if not ZONGPAI_PATTERN.match(v):
raise ValueError("INVALID_ZONGPAI")
return v
@field_validator("location_code")
@classmethod
def validate_location_code(cls, v: str) -> str:
v = v.strip().upper()
if not LOCATION_NORMAL_PATTERN.match(v) and not LOCATION_TRANS_PATTERN.match(v):
raise ValueError("INVALID_LOCATION")
return v
class LocationResponse(BaseModel):
zongpai_no: str
location_code: str
created_at: str
class DuplicateLocationDetail(BaseModel):
existing_location: str
created_at: str
```
**Step 3: 创建 schemas/__init__.py**
```python
```
**Step 4: Commit**
```bash
git add app/schemas/
git commit -m "feat: add pydantic schemas for location API"
```
---
### Task 4: Service 层 — 上架登记业务逻辑
**Files:**
- Create: `apps/fastAPI/app/services/__init__.py`
- Create: `apps/fastAPI/app/services/location_service.py`
**Step 1: 创建 services/location_service.py**
```python
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.finished_goods import FinishedGoodsLocation
from app.schemas.location import LocationRequest
def register_location(db: Session, req: LocationRequest) -> FinishedGoodsLocation:
existing = (
db.query(FinishedGoodsLocation)
.filter(FinishedGoodsLocation.zongpai_no == req.zongpai_no)
.first()
)
if existing:
raise HTTPException(
status_code=409,
detail={
"error_code": "DUPLICATE_LOCATION",
"message": "该总排号已存在货位记录",
"detail": {
"existing_location": existing.location_code,
"created_at": existing.created_at.isoformat(),
},
},
)
record = FinishedGoodsLocation(
zongpai_no=req.zongpai_no,
location_code=req.location_code,
)
db.add(record)
db.commit()
db.refresh(record)
return record
```
**Step 2: 创建 services/__init__.py**
```python
```
**Step 3: Commit**
```bash
git add app/services/
git commit -m "feat: add location registration service"
```
---
### Task 5: API 路由层 — POST /CargoTrace/location
**Files:**
- Create: `apps/fastAPI/app/api/__init__.py`
- Create: `apps/fastAPI/app/api/v1/__init__.py`
- Create: `apps/fastAPI/app/api/v1/location.py`
- Modify: `apps/fastAPI/app/main.py`
**Step 1: 创建 api/v1/location.py**
```python
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.schemas.location import LocationRequest, LocationResponse
from app.services.location_service import register_location
router = APIRouter()
@router.post("/location", response_model=LocationResponse)
def create_location(req: LocationRequest, db: Session = Depends(get_db)):
record = register_location(db, req)
return LocationResponse(
zongpai_no=record.zongpai_no,
location_code=record.location_code,
created_at=record.created_at.isoformat(),
)
```
**Step 2: 更新 main.py — 挂载路由**
```python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.v1.location import router as location_router
app = FastAPI(title="CargoTrace API", version="0.1.0")
app.include_router(location_router, prefix="/CargoTrace")
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
error_code = str(exc)
status_map = {
"INVALID_ZONGPAI": 400,
"INVALID_LOCATION": 400,
}
status = status_map.get(error_code, 400)
return JSONResponse(
status_code=status,
content={
"error_code": error_code,
"message": str(exc),
},
)
@app.get("/")
async def root():
return {"message": "Welcome to CargoTrace API"}
```
**Step 3: 启动验证**
Run: `cd apps/fastAPI && uvicorn app.main:app --reload`
Expected: 服务启动成功,访问 `http://127.0.0.1:8000/docs` 可看到 Swagger UI
**Step 4: Commit**
```bash
git add app/api/ app/main.py
git commit -m "feat: add POST /CargoTrace/location endpoint"
```
---
### Task 6: 测试 — 接口集成测试
**Files:**
- Modify: `apps/fastAPI/requirements.txt` (添加测试依赖)
- Create: `apps/fastAPI/tests/__init__.py`
- Create: `apps/fastAPI/tests/conftest.py`
- Create: `apps/fastAPI/tests/test_location_api.py`
**Step 1: 添加测试依赖到 requirements.txt**
追加重定内容:
```
pytest>=8.0.0
httpx>=0.28.0
```
**Step 2: 创建 tests/conftest.py — 测试 fixtures使用真实数据库**
```python
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.main import app
from app.models.finished_goods import FinishedGoodsLocation
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def db():
session = SessionLocal()
yield session
session.close()
@pytest.fixture(autouse=True)
def cleanup_test_data(db: Session):
yield
# 清理测试数据:删除测试用总排号记录
test_zongpai_nos = ["26B999", "26C999", "26BW0999", "26T999"]
db.query(FinishedGoodsLocation).filter(
FinishedGoodsLocation.zongpai_no.in_(test_zongpai_nos)
).delete(synchronize_session=False)
db.commit()
```
**Step 3: 创建 tests/test_location_api.py**
```python
from fastapi.testclient import TestClient
def test_register_location_success(client: TestClient):
"""正常上架 — 应返回 200"""
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26B999", "location_code": "A01-02-03"},
)
assert resp.status_code == 200
data = resp.json()
assert data["zongpai_no"] == "26B999"
assert data["location_code"] == "A01-02-03"
def test_register_location_duplicate(client: TestClient):
"""重复上架 — 应返回 409"""
client.post(
"/CargoTrace/location",
json={"zongpai_no": "26C999", "location_code": "A01-02-03"},
)
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26C999", "location_code": "A02-01-01"},
)
assert resp.status_code == 409
data = resp.json()
assert data["detail"]["error_code"] == "DUPLICATE_LOCATION"
assert "existing_location" in data["detail"]["detail"]
def test_register_location_invalid_zongpai(client: TestClient):
"""无效总排号 — 应返回 400"""
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "INVALID", "location_code": "A01-02-03"},
)
assert resp.status_code == 422 # Pydantic validation error
def test_register_location_invalid_location(client: TestClient):
"""无效货位号 — 应返回 400"""
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26T999", "location_code": "bad-location"},
)
assert resp.status_code == 422
def test_register_location_trans_location(client: TestClient):
"""转运特殊货位 — 应返回 200"""
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26BW0999", "location_code": "TRANS-01"},
)
assert resp.status_code == 200
assert resp.json()["location_code"] == "TRANS-01"
```
**Step 4: 运行测试**
Run: `cd apps/fastAPI && pytest tests/ -v`
Expected: 5 tests passed
**Step 5: Commit**
```bash
git add requirements.txt tests/
git commit -m "test: add integration tests for location API"
```
---
## API 接口汇总
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/CargoTrace/location` | 上架登记 — 绑定总排号与货位号 |
### 错误码
| HTTP 状态码 | error_code | 含义 |
|-------------|-----------|------|
| 409 | `DUPLICATE_LOCATION` | 该总排号已存在货位记录 |
| 422 | `INVALID_ZONGPAI` | 总排号格式不合法 |
| 422 | `INVALID_LOCATION` | 货位号格式不合法 |
| 500 | `SERVER_ERROR` | 服务器内部错误 |