Compare commits
18 Commits
worktree-y
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec0fd3189 | ||
|
|
797a125d81 | ||
|
|
d9e6d38466 | ||
|
|
5a7e88c3c9 | ||
|
|
bd926050c2 | ||
|
|
afc540005c | ||
|
|
905988bed8 | ||
|
|
53abead657 | ||
|
|
7fe1fe7ef5 | ||
|
|
25982b12bf | ||
|
|
ee2b2f8f25 | ||
|
|
4b8c502a91 | ||
|
|
b1e369e9f3 | ||
|
|
7305ad9ae6 | ||
|
|
fb759ebe33 | ||
|
|
b9e13105e1 | ||
|
|
70cb6759ea | ||
|
|
f94a18fee7 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -6,4 +6,10 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
.claude/
|
||||
.agents/
|
||||
# Config with secrets
|
||||
config/settings.yaml
|
||||
config/settings.local.yaml
|
||||
|
||||
.runtime
|
||||
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal 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`
|
||||
32
README.md
32
README.md
@@ -11,3 +11,35 @@ source .venv/bin/activate # Linux/Mac
|
||||
pip install -r requirements.txt
|
||||
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.
|
||||
|
||||
@@ -2,8 +2,20 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.box import BoxInfoResponse, BoxSaveRequest, BoxSaveResponse
|
||||
from app.services.box_service import get_box_info, save_box_record
|
||||
from app.schemas.box import (
|
||||
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"])
|
||||
|
||||
@@ -21,3 +33,15 @@ def box_info(
|
||||
def create_box(req: BoxSaveRequest, db: Session = Depends(get_db)):
|
||||
"""保存装箱记录:将总排号记录到指定箱号中。"""
|
||||
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)
|
||||
|
||||
@@ -3,8 +3,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.schemas.location import LocationRequest, LocationResponse
|
||||
from app.services.location_service import register_location
|
||||
from app.schemas.location import (
|
||||
LocationRequest,
|
||||
LocationResponse,
|
||||
PaichaOverviewResponse,
|
||||
)
|
||||
from app.services.location_service import get_paicha_overview, register_location
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -18,7 +22,7 @@ router = APIRouter()
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
409: {
|
||||
"description": "该总排号已存在货位记录",
|
||||
"description": "重复上架或已下架",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
},
|
||||
@@ -29,4 +33,23 @@ def create_location(req: LocationRequest, db: Session = Depends(get_db)):
|
||||
zongpai_no=record.zongpai_no,
|
||||
location_code=record.location_code,
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
86
app/main.py
86
app/main.py
@@ -1,11 +1,36 @@
|
||||
import sys
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
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.box import router as box_router
|
||||
from app.services.location_service import DuplicateLocationError
|
||||
from app.services.box_service import ZongpaiNotFoundError, DuplicateBoxItemError, InvalidZongpaiError
|
||||
from app.services.location_service import (
|
||||
AlreadyOffShelfError,
|
||||
DuplicateLocationError,
|
||||
PaichaNotFoundError,
|
||||
)
|
||||
from app.services.box_service import (
|
||||
BoxItemNotFoundError,
|
||||
DuplicateBoxItemError,
|
||||
InvalidBoxItemError,
|
||||
InvalidQuantityError,
|
||||
InvalidZongpaiError,
|
||||
ZongpaiNotFoundError,
|
||||
)
|
||||
|
||||
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)
|
||||
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||
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("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to CargoTrace API"}
|
||||
|
||||
@@ -20,7 +20,10 @@ class FinishedGoodsLocation(Base):
|
||||
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()
|
||||
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)
|
||||
box_no: Mapped[int] = mapped_column(nullable=False)
|
||||
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)
|
||||
quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True)
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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})$")
|
||||
|
||||
|
||||
class BoxItemDetail(BaseModel):
|
||||
"""箱号下某个总排号的明细"""
|
||||
box_item_id: int | None = None
|
||||
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
|
||||
|
||||
|
||||
@@ -21,7 +31,9 @@ class BoxInfoResponse(BaseModel):
|
||||
"""GET /box/info 响应"""
|
||||
zongpai_no: str
|
||||
paichan_no: str
|
||||
work_order_no: str | None = None
|
||||
quantity: int
|
||||
current_zongpai_boxes: list[CurrentZongpaiBox] = Field(default_factory=list)
|
||||
existing_boxes: list[BoxDetail]
|
||||
max_box_no: int
|
||||
suggested_box_no: int
|
||||
@@ -58,8 +70,45 @@ class BoxSaveRequest(BaseModel):
|
||||
|
||||
class BoxSaveResponse(BaseModel):
|
||||
"""POST /box 响应"""
|
||||
box_item_id: int
|
||||
paichan_no: str
|
||||
box_no: int
|
||||
zongpai_no: str
|
||||
quantity: int
|
||||
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
|
||||
|
||||
@@ -32,6 +32,21 @@ class LocationResponse(BaseModel):
|
||||
zongpai_no: str
|
||||
location_code: 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):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import re
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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})$")
|
||||
|
||||
@@ -23,13 +23,21 @@ class DuplicateBoxItemError(Exception):
|
||||
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:
|
||||
"""从 ERP 视图查询总排号对应的排产号和数量。"""
|
||||
sql = text(
|
||||
"SELECT TOP 1 [总排号], [排产号], [数量] "
|
||||
"FROM [ERPAuto].[vw_productionContractData] "
|
||||
"WHERE [总排号] = :zongpai_no"
|
||||
)
|
||||
"""从 ERP 视图查询总排号对应的排产号、工令号和数量。"""
|
||||
sql = text(_erp_info_sql(db))
|
||||
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
|
||||
if not row:
|
||||
raise ZongpaiNotFoundError()
|
||||
@@ -37,6 +45,54 @@ def query_erp_info(db: Session, zongpai_no: str) -> dict:
|
||||
"zongpai_no": row[0],
|
||||
"paichan_no": row[1],
|
||||
"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()
|
||||
)
|
||||
|
||||
existing_boxes = []
|
||||
max_box_no = 0
|
||||
box_items = {}
|
||||
item_zongpai_nos = []
|
||||
for box in boxes:
|
||||
items = (
|
||||
db.query(FinishedGoodsBoxItem)
|
||||
.filter(FinishedGoodsBoxItem.box_id == box.id)
|
||||
.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({
|
||||
"box_no": box.box_no,
|
||||
"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
|
||||
],
|
||||
})
|
||||
@@ -77,15 +160,54 @@ def get_box_info(db: Session, zongpai_no: str) -> dict:
|
||||
return {
|
||||
"zongpai_no": zongpai_no,
|
||||
"paichan_no": paichan_no,
|
||||
"work_order_no": erp["work_order_no"],
|
||||
"quantity": quantity,
|
||||
"current_zongpai_boxes": current_zongpai_boxes,
|
||||
"existing_boxes": existing_boxes,
|
||||
"max_box_no": max_box_no,
|
||||
"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:
|
||||
"""保存装箱记录。箱已存在时仅追加明细(多码一箱场景)。"""
|
||||
"""保存装箱记录。同箱可凑箱,但同箱同总排不可重复。"""
|
||||
erp = query_erp_info(db, req.zongpai_no)
|
||||
paichan_no = erp["paichan_no"]
|
||||
|
||||
@@ -109,7 +231,16 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
|
||||
)
|
||||
if existing_item:
|
||||
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(
|
||||
paichan_no=paichan_no,
|
||||
box_no=req.box_no,
|
||||
@@ -127,9 +258,128 @@ def save_box_record(db: Session, req: BoxSaveRequest) -> dict:
|
||||
db.refresh(item)
|
||||
|
||||
return {
|
||||
"box_item_id": item.id,
|
||||
"paichan_no": paichan_no,
|
||||
"box_no": req.box_no,
|
||||
"zongpai_no": req.zongpai_no,
|
||||
"quantity": req.quantity,
|
||||
"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}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.finished_goods import FinishedGoodsLocation
|
||||
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):
|
||||
@@ -12,6 +19,135 @@ class DuplicateLocationError(Exception):
|
||||
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:
|
||||
existing = (
|
||||
db.query(FinishedGoodsLocation)
|
||||
@@ -19,6 +155,27 @@ def register_location(db: Session, req: LocationRequest) -> FinishedGoodsLocatio
|
||||
.first()
|
||||
)
|
||||
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(
|
||||
location_code=existing.location_code,
|
||||
registered_at=existing.created_at.isoformat(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal
|
||||
from sqlalchemy.engine import URL
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from pydantic_yaml import parse_yaml_raw_as
|
||||
|
||||
|
||||
@@ -16,9 +16,20 @@ class SqlServerConfig(BaseModel):
|
||||
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):
|
||||
@@ -28,6 +39,11 @@ class Settings(BaseModel):
|
||||
@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",
|
||||
@@ -42,6 +58,19 @@ class Settings(BaseModel):
|
||||
},
|
||||
)
|
||||
|
||||
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 配置文件"""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 数据库配置
|
||||
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
|
||||
17
config/settings.yaml.example
Normal file
17
config/settings.yaml.example
Normal 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
|
||||
@@ -2,6 +2,7 @@ fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy>=2.0.0
|
||||
pyodbc>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
pytest>=8.0.0
|
||||
|
||||
@@ -4,7 +4,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.main import app
|
||||
from app.models.finished_goods import FinishedGoodsLocation
|
||||
from app.models.finished_goods import (
|
||||
FinishedGoodsBox,
|
||||
FinishedGoodsBoxItem,
|
||||
FinishedGoodsLocation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -22,8 +26,32 @@ def db():
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_test_data(db: Session):
|
||||
yield
|
||||
test_zongpai_nos = ["26B999", "26C999", "26BW0999", "26T999"]
|
||||
test_zongpai_nos = [
|
||||
"26B999",
|
||||
"26C999",
|
||||
"26BW0999",
|
||||
"26T999",
|
||||
"26T998",
|
||||
"26B998",
|
||||
"26C998",
|
||||
]
|
||||
db.query(FinishedGoodsLocation).filter(
|
||||
FinishedGoodsLocation.zongpai_no.in_(test_zongpai_nos)
|
||||
).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()
|
||||
|
||||
7
tests/fixtures/test_config.yaml
vendored
7
tests/fixtures/test_config.yaml
vendored
@@ -1,5 +1,6 @@
|
||||
# 测试配置
|
||||
database:
|
||||
active: sql_server
|
||||
sql_server:
|
||||
host: localhost
|
||||
port: 1433
|
||||
@@ -8,3 +9,9 @@ database:
|
||||
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
|
||||
|
||||
@@ -13,8 +13,14 @@ def test_box_info_success(client: TestClient):
|
||||
data = resp.json()
|
||||
assert data["zongpai_no"] == "26BW0011"
|
||||
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 "current_zongpai_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 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.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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -23,3 +24,14 @@ def test_database_url_property():
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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):
|
||||
"""正常上架 — 应返回 200"""
|
||||
@@ -13,8 +16,8 @@ def test_register_location_success(client: TestClient):
|
||||
assert data["location_code"] == "A01-02-03"
|
||||
|
||||
|
||||
def test_register_location_duplicate(client: TestClient):
|
||||
"""重复上架 — 应返回 409,包含统一错误格式"""
|
||||
def test_register_location_duplicate_normal_to_normal(client: TestClient):
|
||||
"""普通货位重复上架到普通货位 — 应返回 DUPLICATE_LOCATION"""
|
||||
client.post(
|
||||
"/CargoTrace/location",
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""无效总排号 — 应返回 400"""
|
||||
resp = client.post(
|
||||
|
||||
106
tests/test_location_overview.py
Normal file
106
tests/test_location_overview.py
Normal 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"
|
||||
Reference in New Issue
Block a user