25 Commits

Author SHA1 Message Date
Misaka
eec0fd3189 feat: add temp storage shelf (B prefix) support
Add _is_temp_storage_location() helper and temp_stored status to
_location_status(). Refactor register_location() state transitions to
allow normal→temp shelf changes while blocking temp→normal and temp→temp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 22:48:28 +08:00
Misaka_Company
797a125d81 docs: add CLAUDE.md with branch strategy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:36:09 +08:00
Misaka_Company
d9e6d38466 feat(location): add paicha overview endpoint 2026-05-15 08:45:18 +08:00
Misaka_Company
5a7e88c3c9 feat(box): simplify boxing mode API 2026-05-14 12:29:33 +08:00
Misaka_Company
bd926050c2 feat(box): include item total quantity 2026-05-13 15:14:14 +08:00
Misaka_Company
afc540005c feat: support off-shelf location registration 2026-05-13 12:43:17 +08:00
Misaka_Company
905988bed8 feat: support editable boxing assignments 2026-05-13 11:22:49 +08:00
Misaka_Company
53abead657 chore: ignore agent workspace 2026-05-13 09:52:23 +08:00
Misaka_Company
7fe1fe7ef5 feat(box): include work order numbers in box info 2026-05-13 09:48:08 +08:00
Misaka_Company
25982b12bf feat: add one-to-one box mode with duplicate bind check
Add box_mode field to BoxSaveRequest and DuplicateZongpaiBindError
exception. In one-to-one mode, prevent the same zongpai from being
packed into multiple boxes, returning 409 with existing box info.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 15:44:00 +08:00
Misaka_Company
ee2b2f8f25 feat: add PostgreSQL database backend support
Add multi-database support allowing selection between SQL Server and
PostgreSQL via database.active config. Changes include dialect-aware
SQL generation, cross-database timestamp functions, PostgreSQL connection
URL builder, and psycopg dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 12:33:48 +08:00
Misaka_Company
4b8c502a91 chore: add settings.yaml template file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 09:58:12 +08:00
Misaka_Company
b1e369e9f3 chore: remove settings.yaml from tracking and add to .gitignore
Contains database credentials, should not be versioned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 09:56:54 +08:00
Misaka_Company
7305ad9ae6 refactor: migrate configuration from .env to YAML
Migrate FastAPI configuration from environment files (.env) to YAML
using pydantic-yaml library for better readability and maintainability.

Changes:
- Add pydantic-yaml dependency
- Create config/ module with Settings, SqlServerConfig classes
- Add config/settings.yaml for database configuration
- Update all imports from app.core.config to config.settings
- Add error handling for config loading on startup
- Remove old .env and app/core/config.py
- Update README with YAML configuration documentation
- Add test coverage for config loading

Test results:
- All 15 tests passing
- 88% code coverage maintained

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:50:40 +08:00
Misaka_Company
fb759ebe33 docs: update README with YAML configuration instructions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:47:10 +08:00
Misaka_Company
b9e13105e1 chore: ignore local YAML config overrides
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:45:06 +08:00
Misaka_Company
70cb6759ea refactor: remove old .env and config.py files
Remove deprecated app/core/config.py as configuration is now managed
through the new YAML-based config module (app/config/).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:44:03 +08:00
Misaka_Company
f94a18fee7 feat: add config error handling on startup
Add error handling for configuration loading at application startup.
If the config file is missing or invalid, the app will exit with
a clear error message instead of crashing later.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:42:45 +08:00
Misaka_Company
a6873ae2ee fix: resolve YAML config path relative to project root
Ensure config/settings.yaml can be found when running from any working
directory by resolving the path relative to the settings.py file location
rather than the current working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:40:46 +08:00
Misaka_Company
8292de7747 refactor: update database.py to use new config module
Update import statement from app.core.config to config.settings as part
of YAML configuration migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:37:52 +08:00
Misaka_Company
2f73dd07b5 test: add configuration loading tests
- Add test_config.py with tests for load_settings function
- Add test_config.yaml fixture with test database configuration
- Tests cover: successful loading, file not found error, database URL generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:36:30 +08:00
Misaka_Company
7478b7176f feat: add YAML configuration module with pydantic-yaml
- Created config directory with settings.py, __init__.py, and settings.yaml
- Implemented SqlServerConfig and DatabaseConfig classes for SQL Server connections
- Added Settings class with database_url property for SQLAlchemy URL construction
- Uses parse_yaml_raw_as for UTF-8 safe YAML loading on Windows
- Configuration validated successfully via import test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:35:03 +08:00
Misaka_Company
33fee72e3f deps: add pydantic-yaml for YAML configuration support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:32:53 +08:00
Misaka_Company
71d261af26 docs: add YAML configuration implementation plan
Add detailed step-by-step implementation plan for migrating
from .env to YAML configuration using pydantic-yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:26:26 +08:00
Misaka_Company
01423ccccc docs: add YAML configuration design document
Add design document for migrating from .env to YAML configuration
using pydantic-yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:25:46 +08:00
25 changed files with 1973 additions and 62 deletions

6
.gitignore vendored
View File

@@ -7,3 +7,9 @@ dist/
build/ build/
.pytest_cache/ .pytest_cache/
.claude/ .claude/
.agents/
# Config with secrets
config/settings.yaml
config/settings.local.yaml
.runtime

7
CLAUDE.md Normal file
View File

@@ -0,0 +1,7 @@
# fastapi CLAUDE.md
## Branch Strategy
- The main branch is `master`, always kept in a releasable state
- All new feature development must be done on a `dev` branch, created from `master`
- After verification, merge `dev` back into `master`

View File

@@ -11,3 +11,35 @@ source .venv/bin/activate # Linux/Mac
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
## Configuration
Configuration is managed via YAML files in the `config/` directory.
### Configuration File
Edit `config/settings.yaml` to configure your environment:
```yaml
database:
active: sql_server # sql_server or postgresql
sql_server:
host: your-host
port: 1433
database: your-database
username: your-username
password: your-password
postgresql:
host: your-postgres-host
port: 5432
database: your-database
username: your-username
password: your-password
```
Set `database.active` to choose the database backend. Both backends expect the
same database name, schemas, and table structure.
### Local Overrides
For local development, create `config/settings.local.yaml` to override specific values without committing them.

View File

@@ -2,8 +2,20 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.schemas.box import BoxInfoResponse, BoxSaveRequest, BoxSaveResponse from app.schemas.box import (
from app.services.box_service import get_box_info, save_box_record BoxDeleteResponse,
BoxInfoResponse,
BoxSaveRequest,
BoxSaveResponse,
BoxUpdateRequest,
BoxUpdateResponse,
)
from app.services.box_service import (
delete_box_item,
get_box_info,
save_box_record,
update_box_item,
)
router = APIRouter(tags=["boxing"]) router = APIRouter(tags=["boxing"])
@@ -21,3 +33,15 @@ def box_info(
def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)): def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)):
"""保存装箱记录:将总排号记录到指定箱号中。""" """保存装箱记录:将总排号记录到指定箱号中。"""
return save_box_record(db, req) return save_box_record(db, req)
@router.patch("/box/{box_item_id}", response_model=BoxUpdateResponse)
def update_box(box_item_id: int, req: BoxUpdateRequest, db: Session = Depends(get_db)):
"""更新已分配装箱明细。"""
return update_box_item(db, box_item_id, req)
@router.delete("/box/{box_item_id}", response_model=BoxDeleteResponse)
def delete_box(box_item_id: int, db: Session = Depends(get_db)):
"""删除误录的装箱明细。"""
return delete_box_item(db, box_item_id)

View File

@@ -3,8 +3,12 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.location import LocationRequest, LocationResponse from app.schemas.location import (
from app.services.location_service import register_location LocationRequest,
LocationResponse,
PaichaOverviewResponse,
)
from app.services.location_service import get_paicha_overview, register_location
router = APIRouter() router = APIRouter()
@@ -18,7 +22,7 @@ router = APIRouter()
"model": ErrorResponse, "model": ErrorResponse,
}, },
409: { 409: {
"description": "该总排号已存在货位记录", "description": "重复上架或已下架",
"model": ErrorResponse, "model": ErrorResponse,
}, },
}, },
@@ -29,4 +33,23 @@ def create_location(req: LocationRequest, db: Session = Depends(get_db)):
zongpai_no=record.zongpai_no, zongpai_no=record.zongpai_no,
location_code=record.location_code, location_code=record.location_code,
created_at=record.created_at.isoformat(), created_at=record.created_at.isoformat(),
previous_location=getattr(record, "previous_location", None),
) )
@router.get(
"/location/paicha-overview",
response_model=PaichaOverviewResponse,
responses={
400: {
"description": "总排号格式不合法",
"model": ErrorResponse,
},
404: {
"description": "该总排号未找到对应排产号",
"model": ErrorResponse,
},
},
)
def paicha_overview(zongpai_no: str, db: Session = Depends(get_db)):
return get_paicha_overview(db, zongpai_no)

View File

@@ -1,32 +0,0 @@
from sqlalchemy.engine import URL
from pydantic_settings import BaseSettings, SettingsConfigDict
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) -> URL:
return URL.create(
"mssql+pyodbc",
username=self.SQL_SERVER_USERNAME,
password=self.SQL_SERVER_PASSWORD,
host=self.SQL_SERVER_HOST,
port=self.SQL_SERVER_PORT,
database=self.SQL_SERVER_DATABASE,
query={
"driver": self.SQL_SERVER_DRIVER.strip("{}"),
"TrustServerCertificate": self.SQL_SERVER_TRUST_SERVER_CERTIFICATE,
},
)
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()

View File

@@ -3,7 +3,7 @@ from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings from config.settings import settings
engine = create_engine( engine = create_engine(
settings.database_url, settings.database_url,

View File

@@ -1,11 +1,36 @@
import sys
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from config.settings import load_settings
# 加载配置文件,失败则退出
try:
settings = load_settings()
except FileNotFoundError as e:
print(f"配置文件错误: {e}")
sys.exit(1)
except Exception as e:
print(f"加载配置失败: {e}")
sys.exit(1)
from app.api.v1.location import router as location_router from app.api.v1.location import router as location_router
from app.api.v1.box import router as box_router from app.api.v1.box import router as box_router
from app.services.location_service import DuplicateLocationError from app.services.location_service import (
from app.services.box_service import ZongpaiNotFoundError, DuplicateBoxItemError, InvalidZongpaiError AlreadyOffShelfError,
DuplicateLocationError,
PaichaNotFoundError,
)
from app.services.box_service import (
BoxItemNotFoundError,
DuplicateBoxItemError,
InvalidBoxItemError,
InvalidQuantityError,
InvalidZongpaiError,
ZongpaiNotFoundError,
)
app = FastAPI(title="CargoTrace API", version="0.1.0") app = FastAPI(title="CargoTrace API", version="0.1.0")
@@ -26,6 +51,19 @@ async def duplicate_location_handler(request: Request, exc: DuplicateLocationErr
) )
@app.exception_handler(AlreadyOffShelfError)
async def already_off_shelf_handler(request: Request, exc: AlreadyOffShelfError):
return JSONResponse(
status_code=409,
content={
"error_code": "ALREADY_OFF_SHELF",
"message": "该总排号已下架至转运区域,不可重新上架",
"location_code": exc.location_code,
"registered_at": exc.registered_at,
},
)
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError): async def validation_error_handler(request: Request, exc: RequestValidationError):
for err in exc.errors(): for err in exc.errors():
@@ -92,6 +130,50 @@ async def duplicate_box_handler(request: Request, exc: DuplicateBoxItemError):
) )
@app.exception_handler(BoxItemNotFoundError)
async def box_item_not_found_handler(request: Request, exc: BoxItemNotFoundError):
return JSONResponse(
status_code=404,
content={
"error_code": "BOX_ITEM_NOT_FOUND",
"message": "指定装箱明细不存在",
},
)
@app.exception_handler(PaichaNotFoundError)
async def paicha_not_found_handler(request: Request, exc: PaichaNotFoundError):
return JSONResponse(
status_code=404,
content={
"error_code": "PAICHA_NOT_FOUND",
"message": "暂无排产信息",
},
)
@app.exception_handler(InvalidQuantityError)
async def invalid_quantity_handler(request: Request, exc: InvalidQuantityError):
return JSONResponse(
status_code=400,
content={
"error_code": "INVALID_QUANTITY",
"message": "装箱数量超出可装数量上限",
},
)
@app.exception_handler(InvalidBoxItemError)
async def invalid_box_item_handler(request: Request, exc: InvalidBoxItemError):
return JSONResponse(
status_code=400,
content={
"error_code": "INVALID_BOX_ITEM",
"message": "装箱明细记录不合法或不允许修改",
},
)
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Welcome to CargoTrace API"} return {"message": "Welcome to CargoTrace API"}

View File

@@ -20,7 +20,10 @@ class FinishedGoodsLocation(Base):
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False) zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
location_code: Mapped[str] = mapped_column(String(64), nullable=False) location_code: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )
@@ -36,7 +39,10 @@ class FinishedGoodsBox(Base):
paichan_no: Mapped[str] = mapped_column(String(64), nullable=False) paichan_no: Mapped[str] = mapped_column(String(64), nullable=False)
box_no: Mapped[int] = mapped_column(nullable=False) box_no: Mapped[int] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )
@@ -53,5 +59,8 @@ class FinishedGoodsBoxItem(Base):
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False) zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True) quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )

View File

@@ -1,13 +1,23 @@
import re import re
from pydantic import BaseModel, field_validator from pydantic import BaseModel, Field, field_validator
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$") ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
class BoxItemDetail(BaseModel): class BoxItemDetail(BaseModel):
"""箱号下某个总排号的明细""" """箱号下某个总排号的明细"""
box_item_id: int | None = None
zongpai_no: str zongpai_no: str
work_order_no: str | None = None
quantity: int
total_quantity: int | None = None
class CurrentZongpaiBox(BaseModel):
"""当前总排号已经分配的箱号明细"""
box_item_id: int
box_no: int
quantity: int quantity: int
@@ -21,7 +31,9 @@ class BoxInfoResponse(BaseModel):
"""GET /box/info 响应""" """GET /box/info 响应"""
zongpai_no: str zongpai_no: str
paichan_no: str paichan_no: str
work_order_no: str | None = None
quantity: int quantity: int
current_zongpai_boxes: list[CurrentZongpaiBox] = Field(default_factory=list)
existing_boxes: list[BoxDetail] existing_boxes: list[BoxDetail]
max_box_no: int max_box_no: int
suggested_box_no: int suggested_box_no: int
@@ -58,8 +70,45 @@ class BoxSaveRequest(BaseModel):
class BoxSaveResponse(BaseModel): class BoxSaveResponse(BaseModel):
"""POST /box 响应""" """POST /box 响应"""
box_item_id: int
paichan_no: str paichan_no: str
box_no: int box_no: int
zongpai_no: str zongpai_no: str
quantity: int quantity: int
created_at: str created_at: str
class BoxUpdateRequest(BaseModel):
"""PATCH /box/{box_item_id} 请求"""
box_no: int
quantity: int
@field_validator("box_no")
@classmethod
def validate_box_no(cls, v: int) -> int:
if v <= 0:
raise ValueError("INVALID_BOX_NO")
return v
@field_validator("quantity")
@classmethod
def validate_quantity(cls, v: int) -> int:
if v <= 0:
raise ValueError("INVALID_QUANTITY")
return v
class BoxUpdateResponse(BaseModel):
"""PATCH /box/{box_item_id} 响应"""
box_item_id: int
paichan_no: str
box_no: int
zongpai_no: str
quantity: int
updated_at: str
class BoxDeleteResponse(BaseModel):
"""DELETE /box/{box_item_id} 响应"""
box_item_id: int
deleted: bool

View File

@@ -32,6 +32,21 @@ class LocationResponse(BaseModel):
zongpai_no: str zongpai_no: str
location_code: str location_code: str
created_at: str created_at: str
previous_location: str | None = None
class PaichaOverviewItem(BaseModel):
zongpai_no: str
work_order_no: str | None = None
quantity: int
location_code: str | None = None
status: str
class PaichaOverviewResponse(BaseModel):
paicha_no: str
total_count: int
items: list[PaichaOverviewItem]
class DuplicateLocationDetail(BaseModel): class DuplicateLocationDetail(BaseModel):

View File

@@ -1,10 +1,10 @@
import re import re
from sqlalchemy import text from sqlalchemy import bindparam, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem from app.models.finished_goods import FinishedGoodsBox, FinishedGoodsBoxItem
from app.schemas.box import BoxSaveRequest from app.schemas.box import BoxSaveRequest, BoxUpdateRequest
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$") ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
@@ -23,13 +23,21 @@ class DuplicateBoxItemError(Exception):
self.box_no = box_no self.box_no = box_no
class BoxItemNotFoundError(Exception):
pass
class InvalidBoxItemError(Exception):
pass
class InvalidQuantityError(Exception):
pass
def query_erp_info(db: Session, zongpai_no: str) -> dict: def query_erp_info(db: Session, zongpai_no: str) -> dict:
"""从 ERP 视图查询总排号对应的排产号和数量。""" """从 ERP 视图查询总排号对应的排产号、工令号和数量。"""
sql = text( sql = text(_erp_info_sql(db))
"SELECT TOP 1 [总排号], [排产号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone() row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
if not row: if not row:
raise ZongpaiNotFoundError() raise ZongpaiNotFoundError()
@@ -37,6 +45,54 @@ def query_erp_info(db: Session, zongpai_no: str) -> dict:
"zongpai_no": row[0], "zongpai_no": row[0],
"paichan_no": row[1], "paichan_no": row[1],
"quantity": int(row[2]), "quantity": int(row[2]),
"work_order_no": row[3],
}
def _erp_info_sql(db: Session) -> str:
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
return (
'SELECT "总排号", "排产号", "数量", "工令号" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "总排号" = :zongpai_no '
"LIMIT 1"
)
return (
"SELECT TOP 1 [总排号], [排产号], [数量], [工令号] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
def query_erp_item_info_map(db: Session, zongpai_nos: list[str]) -> dict[str, dict]:
"""批量查询总排号对应的工令号和 ERP 总数量。"""
if not zongpai_nos:
return {}
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
sql = text(
'SELECT "总排号", "工令号", "数量" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "总排号" IN :zongpai_nos'
)
else:
sql = text(
"SELECT [总排号], [工令号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] IN :zongpai_nos"
)
rows = db.execute(
sql.bindparams(bindparam("zongpai_nos", expanding=True)),
{"zongpai_nos": sorted(set(zongpai_nos))},
).fetchall()
return {
row[0]: {
"work_order_no": row[1],
"total_quantity": int(row[2]) if row[2] is not None else None,
}
for row in rows
} }
@@ -56,18 +112,45 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
.all() .all()
) )
existing_boxes = [] box_items = {}
max_box_no = 0 item_zongpai_nos = []
for box in boxes: for box in boxes:
items = ( items = (
db.query(FinishedGoodsBoxItem) db.query(FinishedGoodsBoxItem)
.filter(FinishedGoodsBoxItem.box_id == box.id) .filter(FinishedGoodsBoxItem.box_id == box.id)
.all() .all()
) )
box_items[box.id] = items
item_zongpai_nos.extend(item.zongpai_no for item in items)
erp_item_info_map = query_erp_item_info_map(db, item_zongpai_nos)
existing_boxes = []
current_zongpai_boxes = []
max_box_no = 0
for box in boxes:
items = box_items[box.id]
for item in items:
if item.zongpai_no == zongpai_no:
current_zongpai_boxes.append({
"box_item_id": item.id,
"box_no": box.box_no,
"quantity": int(item.quantity or 0),
})
existing_boxes.append({ existing_boxes.append({
"box_no": box.box_no, "box_no": box.box_no,
"items": [ "items": [
{"zongpai_no": item.zongpai_no, "quantity": int(item.quantity or 0)} {
"box_item_id": item.id,
"zongpai_no": item.zongpai_no,
"work_order_no": erp_item_info_map.get(
item.zongpai_no, {}
).get("work_order_no"),
"quantity": int(item.quantity or 0),
"total_quantity": erp_item_info_map.get(
item.zongpai_no, {}
).get("total_quantity"),
}
for item in items for item in items
], ],
}) })
@@ -77,15 +160,54 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
return { return {
"zongpai_no": zongpai_no, "zongpai_no": zongpai_no,
"paichan_no": paichan_no, "paichan_no": paichan_no,
"work_order_no": erp["work_order_no"],
"quantity": quantity, "quantity": quantity,
"current_zongpai_boxes": current_zongpai_boxes,
"existing_boxes": existing_boxes, "existing_boxes": existing_boxes,
"max_box_no": max_box_no, "max_box_no": max_box_no,
"suggested_box_no": max_box_no + 1, "suggested_box_no": max_box_no + 1,
} }
def _packed_quantity_for_zongpai(
db: Session,
paichan_no: str,
zongpai_no: str,
exclude_box_item_id: int | None = None,
) -> int:
query = (
db.query(FinishedGoodsBoxItem)
.join(FinishedGoodsBox, FinishedGoodsBoxItem.box_id == FinishedGoodsBox.id)
.filter(
FinishedGoodsBox.paichan_no == paichan_no,
FinishedGoodsBoxItem.zongpai_no == zongpai_no,
)
)
if exclude_box_item_id is not None:
query = query.filter(FinishedGoodsBoxItem.id != exclude_box_item_id)
return sum(int(item.quantity or 0) for item in query.all())
def _ensure_quantity_within_erp_total(
db: Session,
paichan_no: str,
zongpai_no: str,
erp_quantity: int,
submitted_quantity: int,
exclude_box_item_id: int | None = None,
) -> None:
packed_quantity = _packed_quantity_for_zongpai(
db,
paichan_no,
zongpai_no,
exclude_box_item_id=exclude_box_item_id,
)
if packed_quantity + submitted_quantity > erp_quantity:
raise InvalidQuantityError()
def save_box_record(db: Session, req: BoxSaveRequest) -> dict: def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
"""保存装箱记录。箱已存在时仅追加明细(多码一箱场景)""" """保存装箱记录。同箱可凑箱,但同箱同总排不可重复"""
erp = query_erp_info(db, req.zongpai_no) erp = query_erp_info(db, req.zongpai_no)
paichan_no = erp["paichan_no"] paichan_no = erp["paichan_no"]
@@ -109,7 +231,16 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
) )
if existing_item: if existing_item:
raise DuplicateBoxItemError(paichan_no, req.box_no) raise DuplicateBoxItemError(paichan_no, req.box_no)
else:
_ensure_quantity_within_erp_total(
db,
paichan_no,
req.zongpai_no,
erp["quantity"],
req.quantity,
)
if box is None:
box = FinishedGoodsBox( box = FinishedGoodsBox(
paichan_no=paichan_no, paichan_no=paichan_no,
box_no=req.box_no, box_no=req.box_no,
@@ -127,9 +258,128 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
db.refresh(item) db.refresh(item)
return { return {
"box_item_id": item.id,
"paichan_no": paichan_no, "paichan_no": paichan_no,
"box_no": req.box_no, "box_no": req.box_no,
"zongpai_no": req.zongpai_no, "zongpai_no": req.zongpai_no,
"quantity": req.quantity, "quantity": req.quantity,
"created_at": item.created_at.isoformat(), "created_at": item.created_at.isoformat(),
} }
def update_box_item(db: Session, box_item_id: int, req: BoxUpdateRequest) -> dict:
"""更新已分配明细的箱号和数量。"""
item = (
db.query(FinishedGoodsBoxItem)
.filter(FinishedGoodsBoxItem.id == box_item_id)
.first()
)
if item is None:
raise BoxItemNotFoundError()
current_box = (
db.query(FinishedGoodsBox)
.filter(FinishedGoodsBox.id == item.box_id)
.first()
)
if current_box is None:
raise InvalidBoxItemError()
paichan_no = current_box.paichan_no
target_box = (
db.query(FinishedGoodsBox)
.filter(
FinishedGoodsBox.paichan_no == paichan_no,
FinishedGoodsBox.box_no == req.box_no,
)
.first()
)
if target_box is None:
target_box = FinishedGoodsBox(paichan_no=paichan_no, box_no=req.box_no)
db.add(target_box)
db.flush()
else:
duplicate_item = (
db.query(FinishedGoodsBoxItem)
.filter(
FinishedGoodsBoxItem.box_id == target_box.id,
FinishedGoodsBoxItem.zongpai_no == item.zongpai_no,
FinishedGoodsBoxItem.id != item.id,
)
.first()
)
if duplicate_item is not None:
raise DuplicateBoxItemError(paichan_no, req.box_no)
erp = query_erp_info(db, item.zongpai_no)
_ensure_quantity_within_erp_total(
db,
paichan_no,
item.zongpai_no,
erp["quantity"],
req.quantity,
exclude_box_item_id=box_item_id,
)
old_box_id = item.box_id
item.box_id = target_box.id
item.quantity = req.quantity
if old_box_id != target_box.id:
old_box_has_items = (
db.query(FinishedGoodsBoxItem.id)
.filter(
FinishedGoodsBoxItem.box_id == old_box_id,
FinishedGoodsBoxItem.id != item.id,
)
.first()
)
if old_box_has_items is None:
old_box = (
db.query(FinishedGoodsBox)
.filter(FinishedGoodsBox.id == old_box_id)
.first()
)
if old_box is not None:
db.delete(old_box)
db.commit()
db.refresh(item)
return {
"box_item_id": item.id,
"paichan_no": paichan_no,
"box_no": req.box_no,
"zongpai_no": item.zongpai_no,
"quantity": int(item.quantity or 0),
"updated_at": item.created_at.isoformat(),
}
def delete_box_item(db: Session, box_item_id: int) -> dict:
"""删除误录的装箱明细;若箱号下无其他明细,同步删除空箱号。"""
item = (
db.query(FinishedGoodsBoxItem)
.filter(FinishedGoodsBoxItem.id == box_item_id)
.first()
)
if item is None:
raise BoxItemNotFoundError()
box_id = item.box_id
db.delete(item)
db.flush()
remaining = (
db.query(FinishedGoodsBoxItem.id)
.filter(FinishedGoodsBoxItem.box_id == box_id)
.first()
)
if remaining is None:
box = db.query(FinishedGoodsBox).filter(FinishedGoodsBox.id == box_id).first()
if box is not None:
db.delete(box)
db.commit()
return {"box_item_id": box_item_id, "deleted": True}

View File

@@ -1,7 +1,14 @@
import re
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.finished_goods import FinishedGoodsLocation from app.models.finished_goods import FinishedGoodsLocation
from app.schemas.location import LocationRequest from app.schemas.location import LocationRequest
from app.services.box_service import InvalidZongpaiError
ZONGPAI_PATTERN = re.compile(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$")
class DuplicateLocationError(Exception): class DuplicateLocationError(Exception):
@@ -12,6 +19,135 @@ class DuplicateLocationError(Exception):
self.registered_at = registered_at self.registered_at = registered_at
class AlreadyOffShelfError(Exception):
"""总排号已下架至转运区域。"""
def __init__(self, location_code: str, registered_at: str):
self.location_code = location_code
self.registered_at = registered_at
class PaichaNotFoundError(Exception):
pass
def _is_transit_location(location_code: str) -> bool:
return location_code.startswith("TRANS-")
def _is_temp_storage_location(location_code: str) -> bool:
return location_code.startswith("B")
def _erp_info_sql(db: Session) -> str:
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
return (
'SELECT "总排号", "排产号", "数量", "工令号" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "总排号" = :zongpai_no '
"LIMIT 1"
)
return (
"SELECT TOP 1 [总排号], [排产号], [数量], [工令号] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
def _erp_paicha_items_sql(db: Session) -> str:
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
return (
'SELECT "总排号", "工令号", "数量" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "排产号" = :paicha_no'
)
return (
"SELECT [总排号], [工令号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [排产号] = :paicha_no"
)
def _query_paicha_no(db: Session, zongpai_no: str) -> str:
row = db.execute(text(_erp_info_sql(db)), {"zongpai_no": zongpai_no}).fetchone()
if not row:
raise PaichaNotFoundError()
return row[1]
def _query_paicha_items(db: Session, paicha_no: str) -> list[dict]:
rows = db.execute(
text(_erp_paicha_items_sql(db)), {"paicha_no": paicha_no}
).fetchall()
return [
{
"zongpai_no": row[0],
"work_order_no": row[1],
"quantity": int(row[2]) if row[2] is not None else 0,
}
for row in rows
]
def _location_status(location_code: str | None) -> str:
if location_code is None:
return "not_shelved"
if _is_transit_location(location_code):
return "transferred"
if _is_temp_storage_location(location_code):
return "temp_stored"
return "on_shelf"
def get_paicha_overview(db: Session, zongpai_no: str) -> dict:
zongpai_no = zongpai_no.strip().upper()
if not ZONGPAI_PATTERN.match(zongpai_no):
raise InvalidZongpaiError()
paicha_no = _query_paicha_no(db, zongpai_no)
erp_items = _query_paicha_items(db, paicha_no)
if not erp_items:
raise PaichaNotFoundError()
zongpai_nos = [item["zongpai_no"] for item in erp_items]
locations = (
db.query(FinishedGoodsLocation)
.filter(FinishedGoodsLocation.zongpai_no.in_(zongpai_nos))
.all()
)
location_map = {item.zongpai_no: item.location_code for item in locations}
status_order = {"on_shelf": 0, "temp_stored": 1, "transferred": 2, "not_shelved": 3}
overview_items = []
for item in erp_items:
location_code = location_map.get(item["zongpai_no"])
status = _location_status(location_code)
overview_items.append(
{
"zongpai_no": item["zongpai_no"],
"work_order_no": item["work_order_no"],
"quantity": item["quantity"],
"location_code": location_code,
"status": status,
}
)
overview_items.sort(
key=lambda item: (
status_order.get(item["status"], 99),
item["zongpai_no"],
)
)
return {
"paicha_no": paicha_no,
"total_count": len(overview_items),
"items": overview_items,
}
def register_location(db: Session, req: LocationRequest) -> FinishedGoodsLocation: def register_location(db: Session, req: LocationRequest) -> FinishedGoodsLocation:
existing = ( existing = (
db.query(FinishedGoodsLocation) db.query(FinishedGoodsLocation)
@@ -19,6 +155,27 @@ def register_location(db: Session, req: LocationRequest) -> FinishedGoodsLocatio
.first() .first()
) )
if existing: if existing:
# Terminal state: transit location → any operation raises error
if _is_transit_location(existing.location_code):
raise AlreadyOffShelfError(
location_code=existing.location_code,
registered_at=existing.created_at.isoformat(),
)
target_is_transit = _is_transit_location(req.location_code)
target_is_temp = _is_temp_storage_location(req.location_code)
current_is_temp = _is_temp_storage_location(existing.location_code)
# Allowed: target is transit (off-shelf) or normal→temp (shelf change)
if target_is_transit or (not current_is_temp and target_is_temp):
previous_location = existing.location_code
existing.location_code = req.location_code
existing.created_at = datetime.now()
db.commit()
db.refresh(existing)
existing.previous_location = previous_location
return existing
raise DuplicateLocationError( raise DuplicateLocationError(
location_code=existing.location_code, location_code=existing.location_code,
registered_at=existing.created_at.isoformat(), registered_at=existing.created_at.isoformat(),

3
config/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from config.settings import settings, Settings, load_settings
__all__ = ["settings", "Settings", "load_settings"]

94
config/settings.py Normal file
View File

@@ -0,0 +1,94 @@
from pathlib import Path
from typing import Literal
from sqlalchemy.engine import URL
from pydantic import BaseModel
from pydantic_yaml import parse_yaml_raw_as
class SqlServerConfig(BaseModel):
"""SQL Server 连接配置"""
host: str
port: int = 1433
database: str
username: str
password: str
driver: str = "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: str = "yes"
class PostgreSqlConfig(BaseModel):
"""PostgreSQL 连接配置"""
host: str
port: int = 5432
database: str
username: str
password: str
class DatabaseConfig(BaseModel):
"""数据库配置"""
active: Literal["sql_server", "postgresql"] = "sql_server"
sql_server: SqlServerConfig
postgresql: PostgreSqlConfig | None = None
class Settings(BaseModel):
"""应用配置"""
database: DatabaseConfig
@property
def database_url(self) -> URL:
"""构建数据库连接 URL"""
if self.database.active == "postgresql":
return self._postgresql_url()
return self._sql_server_url()
def _sql_server_url(self) -> URL:
conf = self.database.sql_server
return URL.create(
"mssql+pyodbc",
username=conf.username,
password=conf.password,
host=conf.host,
port=conf.port,
database=conf.database,
query={
"driver": conf.driver.strip("{}"),
"TrustServerCertificate": conf.trust_server_certificate,
},
)
def _postgresql_url(self) -> URL:
conf = self.database.postgresql
if conf is None:
raise ValueError("已选择 postgresql但未配置 database.postgresql")
return URL.create(
"postgresql+psycopg",
username=conf.username,
password=conf.password,
host=conf.host,
port=conf.port,
database=conf.database,
)
def load_settings(config_path: str = "config/settings.yaml") -> Settings:
"""加载 YAML 配置文件"""
# Resolve relative to the project root (where config/ directory exists)
path = Path(config_path)
if not path.is_absolute():
# __file__ is config/settings.py, so parent.parent gives us project root
project_root = Path(__file__).resolve().parent.parent
path = project_root / config_path
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {config_path}\n"
f"请确保文件存在于项目根目录或指定正确路径"
)
# Use UTF-8 encoding to avoid Windows GBK encoding issues
with open(path, "r", encoding="utf-8") as f:
return parse_yaml_raw_as(Settings, f)
# 全局配置单例
settings = load_settings()

View File

@@ -0,0 +1,17 @@
# 数据库配置
database:
active: sql_server # 可选: sql_server, postgresql
sql_server:
host: YOUR_DB_HOST
port: 1433
database: YOUR_DB_NAME
username: YOUR_USERNAME
password: YOUR_PASSWORD
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
postgresql:
host: YOUR_POSTGRES_HOST
port: 5432
database: YOUR_POSTGRES_DB_NAME
username: YOUR_POSTGRES_USERNAME
password: YOUR_POSTGRES_PASSWORD

View File

@@ -0,0 +1,223 @@
# YAML 配置系统设计文档
**日期:** 2026-05-12
**作者:** Claude
**状态:** 已批准
## 概述
将 FastAPI 服务的配置管理从 `.env` 文件迁移到 YAML 格式,提升配置可读性和团队维护性。
## 需求背景
- **驱动因素:** 更好的可读性,便于团队维护
- **环境支持:** 单文件配置,无需多环境切换
- **实现方案:** 使用 `pydantic-yaml` 保留类型验证
## 文件结构
```
services/fastapi/
├── config/
│ ├── __init__.py
│ ├── settings.yaml # 配置文件
│ └── settings.py # 配置类定义
├── app/
│ ├── core/
│ │ └── database.py # 使用配置
│ └── ...
├── tests/
│ ├── fixtures/
│ │ └── test_config.yaml
│ └── ...
└── requirements.txt
```
## 配置文件格式
### config/settings.yaml
```yaml
# 数据库配置
database:
sql_server:
host: 192.168.110.114
port: 1433
database: CompanyDB
username: peng
password: Cqbld123456.
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
```
## 配置类设计
### config/settings.py
```python
from pathlib import Path
from sqlalchemy.engine import URL
from pydantic import BaseModel
from pydantic_yaml import YamlModel
class SqlServerConfig(BaseModel):
"""SQL Server 连接配置"""
host: str
port: int = 1433
database: str
username: str
password: str
driver: str = "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: str = "yes"
class Settings(YamlModel):
"""应用配置"""
database: SqlServerConfig
@property
def database_url(self) -> URL:
"""构建数据库连接 URL"""
conf = self.database.sql_server
return URL.create(
"mssql+pyodbc",
username=conf.username,
password=conf.password,
host=conf.host,
port=conf.port,
database=conf.database,
query={
"driver": conf.driver.strip("{}"),
"TrustServerCertificate": conf.trust_server_certificate,
},
)
def load_settings(config_path: str = "config/settings.yaml") -> Settings:
"""加载 YAML 配置文件"""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {config_path}\n"
f"请确保文件存在于项目根目录或指定正确路径"
)
return Settings.parse_yaml_file(path)
# 全局配置单例
settings = load_settings()
```
### config/__init__.py
```python
from config.settings import settings, Settings, load_settings
__all__ = ["settings", "Settings", "load_settings"]
```
## 依赖变更
### requirements.txt 新增
```txt
pydantic-yaml>=0.12.0
pyyaml>=6.0
```
### 可选移除
```txt
python-dotenv # 如无其他用途
```
## 导入路径变更
| 旧导入 | 新导入 |
|--------|--------|
| `from app.core.config import settings` | `from config.settings import settings` |
## 错误处理
### 应用启动时验证
```python
# app/main.py
import sys
from config.settings import load_settings
try:
settings = load_settings()
except FileNotFoundError as e:
print(f"配置文件错误: {e}")
sys.exit(1)
except Exception as e:
print(f"加载配置失败: {e}")
sys.exit(1)
```
## 测试策略
### 单元测试
```python
# tests/test_config.py
import pytest
from config.settings import load_settings
def test_load_settings_success():
settings = load_settings("tests/fixtures/test_config.yaml")
assert settings.database.sql_server.port == 1433
def test_load_settings_file_not_found():
with pytest.raises(FileNotFoundError):
load_settings("nonexistent.yaml")
def test_database_url_property():
settings = load_settings("tests/fixtures/test_config.yaml")
url = settings.database_url
assert "mssql+pyodbc" in url
```
### 测试配置文件
```yaml
# tests/fixtures/test_config.yaml
database:
sql_server:
host: localhost
port: 1433
database: TestDB
username: test_user
password: test_pass
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
```
## 迁移步骤
1. **新增依赖** - 更新 requirements.txt
2. **创建模块** - 创建 config/ 目录和相关文件
3. **更新导入** - 替换所有 `from app.core.config import settings`
4. **更新测试** - 创建测试配置和夹具
5. **清理旧代码** - 删除旧配置文件
6. **验证** - 运行测试和启动应用
## 后续扩展
如需支持多环境,可通过以下方式扩展:
```yaml
# config/settings.base.yaml (基础配置)
database:
sql_server:
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
# config/settings.dev.yaml (开发环境覆盖)
database:
sql_server:
host: localhost
database: DevDB
```

View File

@@ -0,0 +1,511 @@
# YAML Configuration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Migrate FastAPI configuration from .env to YAML using pydantic-yaml
**Architecture:** Create new `config/` module with YAML-based settings using `pydantic-yaml`, preserving type validation and improving readability through nested configuration structure.
**Tech Stack:** pydantic-yaml, pyyaml, pytest
---
## Task 1: Add pydantic-yaml Dependency
**Files:**
- Modify: `requirements.txt`
**Step 1: Add pydantic-yaml to requirements.txt**
Add this line to `requirements.txt`:
```txt
pydantic-yaml>=0.12.0
```
**Step 2: Install the dependency**
Run: `.venv/Scripts/pip install pydantic-yaml`
Expected: Successfully installs pydantic-yaml and its dependencies
**Step 3: Verify installation**
Run: `.venv/Scripts/python -c "import pydantic_yaml; print(pydantic_yaml.__version__)"`
Expected: Prints version number without errors
**Step 4: Commit**
```bash
git add requirements.txt
git commit -m "deps: add pydantic-yaml for YAML configuration support"
```
---
## Task 2: Create config Directory Structure
**Files:**
- Create: `config/__init__.py`
- Create: `config/settings.py`
- Create: `config/settings.yaml`
**Step 1: Create config directory**
Run: `mkdir config`
**Step 2: Create config/__init__.py**
```python
from config.settings import settings, Settings, load_settings
__all__ = ["settings", "Settings", "load_settings"]
```
**Step 3: Create config/settings.py with configuration classes**
```python
from pathlib import Path
from sqlalchemy.engine import URL
from pydantic import BaseModel
from pydantic_yaml import YamlModel
class SqlServerConfig(BaseModel):
"""SQL Server 连接配置"""
host: str
port: int = 1433
database: str
username: str
password: str
driver: str = "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: str = "yes"
class Settings(YamlModel):
"""应用配置"""
database: SqlServerConfig
@property
def database_url(self) -> URL:
"""构建数据库连接 URL"""
conf = self.database.sql_server
return URL.create(
"mssql+pyodbc",
username=conf.username,
password=conf.password,
host=conf.host,
port=conf.port,
database=conf.database,
query={
"driver": conf.driver.strip("{}"),
"TrustServerCertificate": conf.trust_server_certificate,
},
)
def load_settings(config_path: str = "config/settings.yaml") -> Settings:
"""加载 YAML 配置文件"""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {config_path}\n"
f"请确保文件存在于项目根目录或指定正确路径"
)
return Settings.parse_yaml_file(path)
# 全局配置单例
settings = load_settings()
```
**Step 4: Create config/settings.yaml with current configuration**
```yaml
# 数据库配置
database:
sql_server:
host: 192.168.110.114
port: 1433
database: CompanyDB
username: peng
password: Cqbld123456.
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
```
**Step 5: Commit**
```bash
git add config/
git commit -m "feat: add YAML configuration module with pydantic-yaml"
```
---
## Task 3: Write Configuration Loading Tests
**Files:**
- Create: `tests/test_config.py`
- Create: `tests/fixtures/test_config.yaml`
**Step 1: Create tests/fixtures directory**
Run: `mkdir tests/fixtures`
**Step 2: Create tests/fixtures/test_config.yaml**
```yaml
# 测试配置
database:
sql_server:
host: localhost
port: 1433
database: TestDB
username: test_user
password: test_pass
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
```
**Step 3: Write the failing test for settings loading**
Create `tests/test_config.py`:
```python
import pytest
from config.settings import load_settings
def test_load_settings_success():
"""测试成功加载配置文件"""
settings = load_settings("tests/fixtures/test_config.yaml")
assert settings.database.sql_server.port == 1433
assert settings.database.sql_server.host == "localhost"
assert settings.database.sql_server.database == "TestDB"
def test_load_settings_file_not_found():
"""测试配置文件不存在时抛出异常"""
with pytest.raises(FileNotFoundError):
load_settings("nonexistent.yaml")
def test_database_url_property():
"""测试 database_url 属性生成"""
settings = load_settings("tests/fixtures/test_config.yaml")
url = settings.database_url
assert "mssql+pyodbc" in str(url)
assert "test_user" in str(url)
assert "TestDB" in str(url)
```
**Step 4: Run tests to verify they pass**
Run: `.venv/Scripts/pytest tests/test_config.py -v`
Expected: All tests PASS
**Step 5: Commit**
```bash
git add tests/test_config.py tests/fixtures/test_config.yaml
git commit -m "test: add configuration loading tests"
```
---
## Task 4: Update database.py to Use New Config
**Files:**
- Modify: `app/core/database.py`
**Step 1: Read current database.py to understand usage**
Check the current import and usage of settings
**Step 2: Update import statement**
Change:
```python
from app.core.config import settings
```
To:
```python
from config.settings import settings
```
**Step 3: Verify the rest of the code works unchanged**
The database_url property should work exactly as before
**Step 4: Run existing tests to ensure no breakage**
Run: `.venv/Scripts/pytest tests/ -v`
Expected: All existing tests still PASS
**Step 5: Commit**
```bash
git add app/core/database.py
git commit -m "refactor: update database.py to use new config module"
```
---
## Task 5: Update API Module Imports
**Files:**
- Modify: `app/api/v1/box.py`
- Modify: `app/api/v1/location.py`
- Modify: `app/services/box_service.py`
- Modify: `app/services/location_service.py`
**Step 1: Check all files that import from app.core.config**
Run: `grep -r "from app.core.config import" app/`
**Step 2: Update each file's import**
Change:
```python
from app.core.config import settings
```
To:
```python
from config.settings import settings
```
**Step 3: Run tests after each file change**
Run: `.venv/Scripts/pytest tests/ -v`
Expected: All tests PASS
**Step 4: Commit all import changes**
```bash
git add app/api/v1/box.py app/api/v1/location.py app/services/
git commit -m "refactor: update all imports to use new config module"
```
---
## Task 6: Update Test Fixtures
**Files:**
- Modify: `tests/conftest.py`
**Step 1: Read current conftest.py**
Check for any references to app.core.config
**Step 2: Update imports in conftest.py**
Change any:
```python
from app.core.config import settings
```
To:
```python
from config.settings import settings
```
**Step 3: Run tests**
Run: `.venv/Scripts/pytest tests/ -v`
Expected: All tests PASS
**Step 4: Commit**
```bash
git add tests/conftest.py
git commit -m "test: update conftest imports"
```
---
## Task 7: Update Main Application Entry Point
**Files:**
- Modify: `app/main.py`
**Step 1: Read current main.py**
Check for any config-related initialization
**Step 2: Add error handling for configuration loading**
Add at the top of main.py or update existing initialization:
```python
import sys
from config.settings import load_settings
try:
settings = load_settings()
except FileNotFoundError as e:
print(f"配置文件错误: {e}")
sys.exit(1)
except Exception as e:
print(f"加载配置失败: {e}")
sys.exit(1)
```
**Step 3: Test application startup**
Run: `.venv/Scripts/python -m app.main`
Expected: Application starts without errors
**Step 4: Commit**
```bash
git add app/main.py
git commit -m "feat: add config error handling on startup"
```
---
## Task 8: Remove Old Configuration Files
**Files:**
- Delete: `app/core/config.py`
- Delete: `.env`
**Step 1: Verify no remaining imports of old config**
Run: `grep -r "from app.core.config import" .`
Expected: No results (or only in .git directory)
**Step 2: Remove old config.py file**
Run: `rm app/core/config.py`
**Step 3: Remove .env file**
Run: `rm .env`
**Step 4: Add .env to .gitignore if not already present**
Ensure `.env` is in `.gitignore`
**Step 5: Run final test suite**
Run: `.venv/Scripts/pytest tests/ -v`
Expected: All tests PASS
**Step 6: Run application to verify**
Run: `.venv/Scripts/python -m app.main`
Expected: Application starts successfully
**Step 7: Commit cleanup**
```bash
git add app/core/config.py .env .gitignore
git commit -m "refactor: remove old .env and config.py files"
```
---
## Task 9: Update .gitignore for YAML Config
**Files:**
- Modify: `.gitignore` (if it exists, otherwise create)
**Step 1: Check if .gitignore exists**
Run: `ls -la .gitignore`
**Step 2: Add or verify config/settings.local.yaml is ignored**
Add to `.gitignore`:
```gitignore
# Local configuration overrides
config/settings.local.yaml
```
This allows developers to have local overrides without committing them
**Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: ignore local YAML config overrides"
```
---
## Task 10: Final Verification and Documentation
**Files:**
- Update: `README.md` (if exists)
**Step 1: Run complete test suite**
Run: `.venv/Scripts/pytest tests/ -v --cov=app --cov-report=term-missing`
Expected: All tests pass, coverage maintained
**Step 2: Start application and verify database connection**
Run: `.venv/Scripts/python -m app.main`
Expected: Application connects to database successfully
**Step 3: Update README.md with configuration instructions**
Add to README.md:
```markdown
## Configuration
Configuration is managed via YAML files in the `config/` directory.
### Configuration File
Edit `config/settings.yaml` to configure your environment:
```yaml
database:
sql_server:
host: your-host
port: 1433
database: your-database
username: your-username
password: your-password
```
### Local Overrides
For local development, create `config/settings.local.yaml` to override specific values without committing them.
```
**Step 4: Final commit**
```bash
git add README.md
git commit -m "docs: update README with YAML configuration instructions"
```
---
## Verification Checklist
After completing all tasks:
- [ ] All tests pass: `pytest tests/ -v`
- [ ] Application starts successfully
- [ ] Database connection works
- [ ] No references to `app.core.config` remain
- [ ] `.env` file removed
- [ ] YAML config file exists and is valid
- [ ] README updated with configuration instructions

View File

@@ -2,7 +2,9 @@ fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0
sqlalchemy>=2.0.0 sqlalchemy>=2.0.0
pyodbc>=5.2.0 pyodbc>=5.2.0
psycopg[binary]>=3.2.0
pydantic-settings>=2.0.0 pydantic-settings>=2.0.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
pytest>=8.0.0 pytest>=8.0.0
httpx>=0.28.0 httpx>=0.28.0
pydantic-yaml>=0.12.0

View File

@@ -4,7 +4,11 @@ from sqlalchemy.orm import Session
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.main import app from app.main import app
from app.models.finished_goods import FinishedGoodsLocation from app.models.finished_goods import (
FinishedGoodsBox,
FinishedGoodsBoxItem,
FinishedGoodsLocation,
)
@pytest.fixture @pytest.fixture
@@ -22,8 +26,32 @@ def db():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def cleanup_test_data(db: Session): def cleanup_test_data(db: Session):
yield yield
test_zongpai_nos = ["26B999", "26C999", "26BW0999", "26T999"] test_zongpai_nos = [
"26B999",
"26C999",
"26BW0999",
"26T999",
"26T998",
"26B998",
"26C998",
]
db.query(FinishedGoodsLocation).filter( db.query(FinishedGoodsLocation).filter(
FinishedGoodsLocation.zongpai_no.in_(test_zongpai_nos) FinishedGoodsLocation.zongpai_no.in_(test_zongpai_nos)
).delete(synchronize_session=False) ).delete(synchronize_session=False)
test_boxes = (
db.query(FinishedGoodsBox)
.filter(
FinishedGoodsBox.paichan_no == "W00009",
FinishedGoodsBox.box_no >= 900,
)
.all()
)
test_box_ids = [box.id for box in test_boxes]
if test_box_ids:
db.query(FinishedGoodsBoxItem).filter(
FinishedGoodsBoxItem.box_id.in_(test_box_ids)
).delete(synchronize_session=False)
db.query(FinishedGoodsBox).filter(
FinishedGoodsBox.id.in_(test_box_ids)
).delete(synchronize_session=False)
db.commit() db.commit()

17
tests/fixtures/test_config.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
# 测试配置
database:
active: sql_server
sql_server:
host: localhost
port: 1433
database: TestDB
username: test_user
password: test_pass
driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes
postgresql:
host: localhost
port: 5432
database: TestDB
username: test_user
password: test_pass

View File

@@ -13,8 +13,14 @@ def test_box_info_success(client: TestClient):
data = resp.json() data = resp.json()
assert data["zongpai_no"] == "26BW0011" assert data["zongpai_no"] == "26BW0011"
assert data["paichan_no"] == "W00009" assert data["paichan_no"] == "W00009"
assert "work_order_no" in data
assert data["work_order_no"] is None or isinstance(data["work_order_no"], str)
assert data["quantity"] == 80 assert data["quantity"] == 80
assert "current_zongpai_boxes" in data
assert "existing_boxes" in data assert "existing_boxes" in data
for box in data["existing_boxes"]:
for item in box["items"]:
assert "box_item_id" in item
assert "max_box_no" in data assert "max_box_no" in data
assert data["suggested_box_no"] == data["max_box_no"] + 1 assert data["suggested_box_no"] == data["max_box_no"] + 1
@@ -76,3 +82,182 @@ def test_box_save_zongpai_not_found(client: TestClient):
) )
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.json()["error_code"] == "ZONGPAI_NOT_FOUND" assert resp.json()["error_code"] == "ZONGPAI_NOT_FOUND"
def test_box_save_multi_code_appends_different_zongpais(client: TestClient):
"""多码凑箱:同一排产号同一箱号可追加不同总排号。"""
box_no = 901
first = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0011",
"box_no": box_no,
"quantity": 10,
},
)
assert first.status_code == 200
assert first.json()["box_item_id"] is not None
second = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0012",
"box_no": box_no,
"quantity": 8,
},
)
assert second.status_code == 200
assert second.json()["box_no"] == box_no
assert second.json()["zongpai_no"] == "26BW0012"
info = client.get("/CargoTrace/box/info", params={"zongpai_no": "26BW0011"})
assert info.status_code == 200
target_box = next(
box for box in info.json()["existing_boxes"] if box["box_no"] == box_no
)
assert {item["zongpai_no"] for item in target_box["items"]} == {
"26BW0011",
"26BW0012",
}
for item in target_box["items"]:
assert "work_order_no" in item
assert isinstance(item["total_quantity"], int)
def test_box_save_multi_code_duplicate_same_zongpai(client: TestClient):
"""多码凑箱:同箱同总排重复录入返回 DUPLICATE_BOX_NO。"""
box_no = 902
first = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0011",
"box_no": box_no,
"quantity": 10,
},
)
assert first.status_code == 200
duplicate = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0011",
"box_no": box_no,
"quantity": 5,
},
)
assert duplicate.status_code == 409
assert duplicate.json()["error_code"] == "DUPLICATE_BOX_NO"
def test_box_save_split_same_zongpai_up_to_total_quantity(client: TestClient):
"""单码装箱:同一总排号可分箱提交,累计等于 ERP 数量时成功。"""
first = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 905, "quantity": 30},
)
assert first.status_code == 200
second = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 906, "quantity": 50},
)
assert second.status_code == 200
info = client.get("/CargoTrace/box/info", params={"zongpai_no": "26BW0011"})
assert info.status_code == 200
current_boxes = [
item
for item in info.json()["current_zongpai_boxes"]
if item["box_no"] in (905, 906)
]
assert sum(item["quantity"] for item in current_boxes) == 80
def test_box_save_rejects_quantity_over_remaining(client: TestClient):
"""保存后累计数量超过 ERP 总数量时返回 INVALID_QUANTITY。"""
created = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 907, "quantity": 70},
)
assert created.status_code == 200
over = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 908, "quantity": 11},
)
assert over.status_code == 400
assert over.json()["error_code"] == "INVALID_QUANTITY"
def test_box_update_same_box_no_does_not_conflict(client: TestClient):
"""PATCH 原箱号未变时,应排除当前 box_item_id避免误判重复。"""
box_no = 903
created = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0011",
"box_no": box_no,
"quantity": 10,
},
)
assert created.status_code == 200
box_item_id = created.json()["box_item_id"]
updated = client.patch(
f"/CargoTrace/box/{box_item_id}",
json={"box_no": box_no, "quantity": 7},
)
assert updated.status_code == 200
assert updated.json()["quantity"] == 7
def test_box_update_rejects_quantity_over_remaining(client: TestClient):
"""PATCH 调整数量导致累计超过 ERP 总数量时返回 INVALID_QUANTITY。"""
first = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 909, "quantity": 70},
)
assert first.status_code == 200
second = client.post(
"/CargoTrace/box",
json={"zongpai_no": "26BW0011", "box_no": 910, "quantity": 5},
)
assert second.status_code == 200
box_item_id = second.json()["box_item_id"]
over = client.patch(
f"/CargoTrace/box/{box_item_id}",
json={"box_no": 910, "quantity": 11},
)
assert over.status_code == 400
assert over.json()["error_code"] == "INVALID_QUANTITY"
def test_box_delete_removes_empty_box(client: TestClient):
"""DELETE 删除最后一条明细后,同步清理空箱号。"""
box_no = 904
created = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0011",
"box_no": box_no,
"quantity": 10,
},
)
assert created.status_code == 200
box_item_id = created.json()["box_item_id"]
deleted = client.delete(f"/CargoTrace/box/{box_item_id}")
assert deleted.status_code == 200
assert deleted.json() == {"box_item_id": box_item_id, "deleted": True}
recreated = client.post(
"/CargoTrace/box",
json={
"zongpai_no": "26BW0012",
"box_no": box_no,
"quantity": 8,
},
)
assert recreated.status_code == 200

37
tests/test_config.py Normal file
View File

@@ -0,0 +1,37 @@
import pytest
from config.settings import load_settings
def test_load_settings_success():
"""测试成功加载配置文件"""
settings = load_settings("tests/fixtures/test_config.yaml")
assert settings.database.active == "sql_server"
assert settings.database.sql_server.port == 1433
assert settings.database.sql_server.host == "localhost"
assert settings.database.sql_server.database == "TestDB"
def test_load_settings_file_not_found():
"""测试配置文件不存在时抛出异常"""
with pytest.raises(FileNotFoundError):
load_settings("nonexistent.yaml")
def test_database_url_property():
"""测试 database_url 属性生成"""
settings = load_settings("tests/fixtures/test_config.yaml")
url = settings.database_url
assert "mssql+pyodbc" in str(url)
assert "test_user" in str(url)
assert "TestDB" in str(url)
def test_postgresql_database_url_property():
"""测试 PostgreSQL database_url 属性生成"""
settings = load_settings("tests/fixtures/test_config.yaml")
settings.database.active = "postgresql"
url = settings.database_url
assert url.drivername == "postgresql+psycopg"
assert url.host == "localhost"
assert url.port == 5432
assert url.database == "TestDB"

View File

@@ -1,5 +1,8 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.database import SessionLocal
from app.models.finished_goods import FinishedGoodsLocation
def test_register_location_success(client: TestClient): def test_register_location_success(client: TestClient):
"""正常上架 — 应返回 200""" """正常上架 — 应返回 200"""
@@ -13,8 +16,8 @@ def test_register_location_success(client: TestClient):
assert data["location_code"] == "A01-02-03" assert data["location_code"] == "A01-02-03"
def test_register_location_duplicate(client: TestClient): def test_register_location_duplicate_normal_to_normal(client: TestClient):
"""重复上架 — 应返回 409包含统一错误格式""" """普通货位重复上架到普通货位 — 应返回 DUPLICATE_LOCATION"""
client.post( client.post(
"/CargoTrace/location", "/CargoTrace/location",
json={"zongpai_no": "26C999", "location_code": "A01-02-03"}, json={"zongpai_no": "26C999", "location_code": "A01-02-03"},
@@ -30,6 +33,69 @@ def test_register_location_duplicate(client: TestClient):
assert "registered_at" in data assert "registered_at" in data
def test_register_location_normal_to_transit_updates_record(client: TestClient):
"""普通货位下架到转运货位 — 应更新记录并返回 previous_location"""
client.post(
"/CargoTrace/location",
json={"zongpai_no": "26T998", "location_code": "A01-02-03"},
)
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26T998", "location_code": "TRANS-01"},
)
assert resp.status_code == 200
data = resp.json()
assert data["zongpai_no"] == "26T998"
assert data["location_code"] == "TRANS-01"
assert data["previous_location"] == "A01-02-03"
db = SessionLocal()
try:
record = (
db.query(FinishedGoodsLocation)
.filter(FinishedGoodsLocation.zongpai_no == "26T998")
.first()
)
assert record is not None
assert record.location_code == "TRANS-01"
finally:
db.close()
def test_register_location_transit_to_normal_rejected(client: TestClient):
"""已下架后再上架到普通货位 — 应返回 ALREADY_OFF_SHELF"""
client.post(
"/CargoTrace/location",
json={"zongpai_no": "26B998", "location_code": "TRANS-01"},
)
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26B998", "location_code": "A01-02-03"},
)
assert resp.status_code == 409
data = resp.json()
assert data["error_code"] == "ALREADY_OFF_SHELF"
assert data["message"] == "该总排号已下架至转运区域,不可重新上架"
assert data["location_code"] == "TRANS-01"
assert "registered_at" in data
def test_register_location_transit_to_transit_rejected(client: TestClient):
"""已下架后再提交转运货位 — 应返回 ALREADY_OFF_SHELF"""
client.post(
"/CargoTrace/location",
json={"zongpai_no": "26C998", "location_code": "TRANS-01"},
)
resp = client.post(
"/CargoTrace/location",
json={"zongpai_no": "26C998", "location_code": "TRANS-02"},
)
assert resp.status_code == 409
data = resp.json()
assert data["error_code"] == "ALREADY_OFF_SHELF"
assert data["location_code"] == "TRANS-01"
def test_register_location_invalid_zongpai(client: TestClient): def test_register_location_invalid_zongpai(client: TestClient):
"""无效总排号 — 应返回 400""" """无效总排号 — 应返回 400"""
resp = client.post( resp = client.post(

View File

@@ -0,0 +1,106 @@
import pytest
from fastapi.testclient import TestClient
from app.api.v1 import location as location_api
from app.services.box_service import InvalidZongpaiError
from app.services.location_service import PaichaNotFoundError, get_paicha_overview
class _Bind:
class Dialect:
name = "postgresql"
dialect = Dialect()
class _Rows:
def __init__(self, rows):
self._rows = rows
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return self._rows
class _Location:
def __init__(self, zongpai_no, location_code):
self.zongpai_no = zongpai_no
self.location_code = location_code
class _Query:
def __init__(self, rows):
self._rows = rows
def filter(self, *_args, **_kwargs):
return self
def all(self):
return self._rows
class _Session:
bind = _Bind()
def execute(self, _sql, params):
if "zongpai_no" in params:
if params["zongpai_no"] == "26B404":
return _Rows([])
return _Rows([("26B1", "R00001", 80, "6-1(7)")])
return _Rows([
("26B2", "6-1(7)", 34),
("26B3", "6-2(3)", 60),
("26B1", "6-1(7)", 80),
("26B4", "6-2(3)", 45),
])
def query(self, _model):
return _Query([
_Location("26B1", "A01-02-03"),
_Location("26B3", "TRANS-01"),
])
def test_paicha_overview_maps_statuses_and_sorts():
data = get_paicha_overview(_Session(), "26B1")
assert data["paicha_no"] == "R00001"
assert data["total_count"] == 4
assert [item["zongpai_no"] for item in data["items"]] == [
"26B1",
"26B3",
"26B2",
"26B4",
]
assert data["items"][0]["status"] == "on_shelf"
assert data["items"][0]["location_code"] == "A01-02-03"
assert data["items"][1]["status"] == "transferred"
assert data["items"][1]["location_code"] == "TRANS-01"
assert data["items"][2]["status"] == "not_shelved"
assert data["items"][2]["location_code"] is None
def test_paicha_overview_invalid_zongpai():
with pytest.raises(InvalidZongpaiError):
get_paicha_overview(_Session(), "INVALID")
def test_paicha_overview_not_found():
with pytest.raises(PaichaNotFoundError):
get_paicha_overview(_Session(), "26B404")
def test_paicha_overview_api_not_found(client: TestClient, monkeypatch):
def raise_not_found(_db, _zongpai_no):
raise PaichaNotFoundError()
monkeypatch.setattr(location_api, "get_paicha_overview", raise_not_found)
resp = client.get(
"/CargoTrace/location/paicha-overview",
params={"zongpai_no": "26B404"},
)
assert resp.status_code == 404
assert resp.json()["error_code"] == "PAICHA_NOT_FOUND"