fix: BOM修订功能Bug修复 - bom_number唯一键冲突

问题:
- revise接口生成新版本时bom_number未更新,导致唯一键冲突

修复内容:
1. 修改revise接口逻辑,生成新版本时同步更新bom_number
2. 格式:{原编号}-V{版本号}(如 BOM-001 -> BOM-001-V2)
3. 处理已修订BOM的再次修订(如 BOM-001-V2 -> BOM-001-V3)
4. 添加新编号唯一性检查

新增测试:
- test_revise_bom_creates_unique_bom_number:核心Bug验证
- test_revise_bom_multiple_times:多次修订验证
- test_revise_bom_preserves_items:明细保留验证
- test_revise_already_revised_bom:已修订BOM再修订验证

验收标准:
- ✓ BOM修订功能正常
- ✓ 新版本bom_number唯一
- ✓ 单元测试通过(12 passed)
This commit is contained in:
admin
2026-04-04 16:58:14 +08:00
committed by market
parent 39ab7a0f81
commit 76d9c761d7
2 changed files with 328 additions and 43 deletions

View File

@@ -386,21 +386,44 @@ async def revise_bom(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""创建BOM的新版本"""
"""
创建BOM的新版本
- 版本号格式major.revision (如 1.2)
- BOM编号格式{原编号}-V{版本号} (如 BOM-001-V2)
- 新版本状态为DRAFT
"""
stmt = select(BOMHeader).options(selectinload(BOMHeader.items)).where(BOMHeader.id == bom_id)
result = await db.execute(stmt)
original = result.scalar_one_or_none()
if not original:
raise HTTPException(status_code=404, detail=f"BOM ID {bom_id} 不存在")
# 创建新版本
# 计算新版本
new_revision = original.revision + 1
version_parts = original.version.split('.')
major = int(float(version_parts[0])) if version_parts else 1
new_version = f"{major}.{new_revision}"
# 生成新的BOM编号格式{原编号}-V{版本号}
# 如果原编号已包含版本后缀(如 -V2则移除后追加新版本
base_number = original.bom_number
if "-V" in base_number:
# 移除现有版本后缀
base_number = base_number.rsplit("-V", 1)[0]
new_bom_number = f"{base_number}-V{new_revision}"
# 确保新编号唯一
check_stmt = select(BOMHeader).where(BOMHeader.bom_number == new_bom_number)
check_result = await db.execute(check_stmt)
if check_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"BOM编号 '{new_bom_number}' 已存在,无法创建新版本"
)
new_bom = BOMHeader(
bom_number=original.bom_number,
bom_number=new_bom_number,
bom_name=original.bom_name,
description=original.description,
version=new_version,

View File

@@ -1,30 +1,15 @@
"""
BOM API Unit Tests - PLM System
BOM服务测试用例TD-001 Phase 1
测试修订功能Bug修复
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@pytest.fixture(scope="module")
def auth_token():
"""获取认证Token"""
response = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "Admin@123456"}
)
if response.status_code == 200:
return response.json()["access_token"]
return None
@pytest.fixture(scope="module")
def test_bom_id(auth_token):
@pytest.fixture(scope="session")
def test_bom_id(client: TestClient, auth_token: str):
"""创建测试BOM并返回ID"""
if not auth_token:
return None
@@ -57,7 +42,7 @@ def test_bom_id(auth_token):
class TestBOMHeaderAPI:
"""BOM头API测试类"""
def test_create_bom(self, auth_token):
def test_create_bom(self, client: TestClient, auth_token: str):
"""测试创建BOM"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -75,7 +60,7 @@ class TestBOMHeaderAPI:
)
assert response.status_code in [201, 400] # 400可能是已存在
def test_create_bom_duplicate_number(self, auth_token):
def test_create_bom_duplicate_number(self, client: TestClient, auth_token: str):
"""测试创建重复编号BOM"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -103,7 +88,7 @@ class TestBOMHeaderAPI:
)
assert response.status_code == 400
def test_list_boms(self, auth_token):
def test_list_boms(self, client: TestClient, auth_token: str):
"""测试获取BOM列表"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -117,7 +102,7 @@ class TestBOMHeaderAPI:
assert "total" in data
assert "data" in data
def test_list_boms_with_filter(self, auth_token):
def test_list_boms_with_filter(self, client: TestClient, auth_token: str):
"""测试带筛选条件的BOM列表"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -129,7 +114,7 @@ class TestBOMHeaderAPI:
data = response.json()
assert data["success"] is True
def test_get_bom_detail(self, auth_token, test_bom_id):
def test_get_bom_detail(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试获取BOM详情"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -141,7 +126,7 @@ class TestBOMHeaderAPI:
data = response.json()
assert "bom_number" in data
def test_get_bom_not_found(self, auth_token):
def test_get_bom_not_found(self, client: TestClient, auth_token: str):
"""测试获取不存在的BOM"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -151,7 +136,7 @@ class TestBOMHeaderAPI:
)
assert response.status_code == 404
def test_update_bom(self, auth_token, test_bom_id):
def test_update_bom(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试更新BOM"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -162,7 +147,7 @@ class TestBOMHeaderAPI:
)
assert response.status_code == 200
def test_delete_bom(self, auth_token):
def test_delete_bom(self, client: TestClient, auth_token: str):
"""测试删除BOM"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -189,7 +174,7 @@ class TestBOMHeaderAPI:
class TestBOMItemAPI:
"""BOM明细API测试类"""
def test_add_bom_item(self, auth_token, test_bom_id):
def test_add_bom_item(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试添加BOM明细"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -206,7 +191,7 @@ class TestBOMItemAPI:
)
assert response.status_code == 201
def test_list_bom_items(self, auth_token, test_bom_id):
def test_list_bom_items(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试获取BOM明细列表"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -216,7 +201,7 @@ class TestBOMItemAPI:
)
assert response.status_code == 200
def test_update_bom_item(self, auth_token, test_bom_id):
def test_update_bom_item(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试更新BOM明细"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -234,7 +219,7 @@ class TestBOMItemAPI:
)
assert response.status_code == 200
def test_bom_item_quantity_boundary(self, auth_token, test_bom_id):
def test_bom_item_quantity_boundary(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试BOM明细数量边界值"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -257,7 +242,7 @@ class TestBOMItemAPI:
class TestBOMStatusAPI:
"""BOM状态管理测试"""
def test_activate_bom(self, auth_token, test_bom_id):
def test_activate_bom(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试激活BOM"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -267,7 +252,7 @@ class TestBOMStatusAPI:
)
assert response.status_code == 200
def test_obsolete_bom(self, auth_token):
def test_obsolete_bom(self, client: TestClient, auth_token: str):
"""测试作废BOM"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -294,7 +279,7 @@ class TestBOMStatusAPI:
class TestBOMAIAPI:
"""BOM AI分析测试"""
def test_ai_analyze_bom(self, auth_token, test_bom_id):
def test_ai_analyze_bom(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试AI分析BOM"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -307,7 +292,7 @@ class TestBOMAIAPI:
assert "recommendation" in data
assert "cost_estimate" in data
def test_ai_analyze_cost(self, auth_token, test_bom_id):
def test_ai_analyze_cost(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试AI成本分析"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -317,7 +302,7 @@ class TestBOMAIAPI:
)
assert response.status_code in [200, 404]
def test_ai_analyze_risk(self, auth_token, test_bom_id):
def test_ai_analyze_risk(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试AI风险分析"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -331,7 +316,7 @@ class TestBOMAIAPI:
class TestBOMTreeView:
"""BOM树形展示测试"""
def test_get_bom_tree_view(self, auth_token, test_bom_id):
def test_get_bom_tree_view(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试获取BOM树形结构"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -343,7 +328,7 @@ class TestBOMTreeView:
data = response.json()
assert "items" in data
def test_get_bom_flat_view(self, auth_token, test_bom_id):
def test_get_bom_flat_view(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试获取BOM平铺结构"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -353,7 +338,7 @@ class TestBOMTreeView:
)
assert response.status_code == 200
def test_bom_tree_with_children(self, auth_token):
def test_bom_tree_with_children(self, client: TestClient, auth_token: str):
"""测试BOM树形结构 - 带子件"""
if not auth_token:
pytest.skip("需要认证Token")
@@ -390,9 +375,9 @@ class TestBOMTreeView:
class TestBOMVersionAPI:
"""BOM版本管理测试"""
"""BOM版本管理测试 - 验证修订Bug修复"""
def test_revise_bom(self, auth_token, test_bom_id):
def test_revise_bom(self, client: TestClient, auth_token: str, test_bom_id: int):
"""测试修订BOM创建新版本"""
if not auth_token or not test_bom_id:
pytest.skip("需要认证Token和测试BOM")
@@ -405,6 +390,283 @@ class TestBOMVersionAPI:
# 版本号应该增加
assert "version" in data
def test_revise_bom_creates_unique_bom_number(self, client: TestClient, auth_token: str):
"""
测试修订BOM时生成唯一的bom_number
验证修复revise接口生成新版本时bom_number应更新
- 原编号BOM-REVISE-001
- 新编号BOM-REVISE-001-V2
这是核心Bug修复验证测试
"""
if not auth_token:
pytest.skip("需要认证Token")
# 创建原始BOM
create_response = client.post(
"/api/v1/bom",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"bom_number": "BOM-REVISE-001",
"bom_name": "修订测试BOM",
"description": "测试修订功能",
"version": "1.0",
"revision": 1,
"product_id": 1,
"items": [
{
"item_number": "ITEM-REVISE-001",
"item_name": "测试物料",
"item_type": "material",
"quantity": 10,
"unit": "pcs"
}
]
}
)
original_bom_number = "BOM-REVISE-001"
if create_response.status_code == 201:
bom_id = create_response.json()["id"]
elif create_response.status_code == 400:
# BOM已存在获取其ID
list_response = client.get(
"/api/v1/bom?keyword=BOM-REVISE-001",
headers={"Authorization": f"Bearer {auth_token}"}
)
if list_response.status_code == 200 and list_response.json()["data"]:
bom_id = list_response.json()["data"][0]["id"]
else:
pytest.skip("无法获取测试BOM")
else:
pytest.skip(f"创建测试BOM失败: {create_response.status_code}")
# 执行修订
revise_response = client.post(
f"/api/v1/bom/{bom_id}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise_response.status_code == 200, f"修订失败: {revise_response.text}"
revised_data = revise_response.json()
# 验证新版本的bom_number应该是唯一的格式为 {原编号}-V{版本号}
assert "bom_number" in revised_data
new_bom_number = revised_data["bom_number"]
# 核心验证bom_number必须更新
assert new_bom_number != original_bom_number, \
f"❌ Bug未修复bom_number未更新原编号和新编号相同: {original_bom_number}"
# 验证格式正确
assert "-V" in new_bom_number, \
f"bom_number格式不正确应包含版本后缀: {new_bom_number}"
assert new_bom_number == "BOM-REVISE-001-V2", \
f"bom_number格式不正确期望 BOM-REVISE-001-V2实际: {new_bom_number}"
# 验证版本号正确
assert revised_data["revision"] == 2, "版本号应为2"
assert revised_data["version"] == "1.2", "版本号格式应为 1.2"
# 验证状态为DRAFT
assert revised_data["status"] == "draft", "新版本状态应为draft"
# 验证新编号唯一(不会与原编号冲突)
check_response = client.get(
f"/api/v1/bom?keyword={new_bom_number}",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert check_response.status_code == 200
def test_revise_bom_multiple_times(self, client: TestClient, auth_token: str):
"""
测试多次修订BOM确保每次都生成唯一的bom_number
- 第一次修订BOM-MULTI-001 -> BOM-MULTI-001-V2
- 第二次修订BOM-MULTI-001-V2 -> BOM-MULTI-001-V3
"""
if not auth_token:
pytest.skip("需要认证Token")
# 创建原始BOM
create_response = client.post(
"/api/v1/bom",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"bom_number": "BOM-MULTI-001",
"bom_name": "多次修订测试BOM",
"version": "1.0",
"revision": 1,
"product_id": 1
}
)
if create_response.status_code == 201:
original_bom_id = create_response.json()["id"]
elif create_response.status_code == 400:
# BOM已存在获取其ID
list_response = client.get(
"/api/v1/bom?keyword=BOM-MULTI-001",
headers={"Authorization": f"Bearer {auth_token}"}
)
if list_response.status_code == 200 and list_response.json()["data"]:
original_bom_id = list_response.json()["data"][0]["id"]
else:
pytest.skip("无法获取测试BOM")
else:
pytest.skip("创建测试BOM失败")
# 第一次修订
revise1_response = client.post(
f"/api/v1/bom/{original_bom_id}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise1_response.status_code == 200, f"第一次修订失败: {revise1_response.text}"
revised1 = revise1_response.json()
assert revised1["bom_number"] == "BOM-MULTI-001-V2", \
f"第一次修订bom_number错误: {revised1['bom_number']}"
# 第二次修订(基于第一次修订的版本)
revise2_response = client.post(
f"/api/v1/bom/{revised1['id']}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise2_response.status_code == 200, f"第二次修订失败: {revise2_response.text}"
revised2 = revise2_response.json()
assert revised2["bom_number"] == "BOM-MULTI-001-V3", \
f"第二次修订bom_number错误: {revised2['bom_number']}"
# 验证所有版本的bom_number都是唯一的
bom_numbers = [revised1["bom_number"], revised2["bom_number"]]
assert len(bom_numbers) == len(set(bom_numbers)), "bom_number应该唯一"
def test_revise_bom_preserves_items(self, client: TestClient, auth_token: str):
"""测试修订BOM时保留原有明细项"""
if not auth_token:
pytest.skip("需要认证Token")
# 创建带明细的BOM
create_response = client.post(
"/api/v1/bom",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"bom_number": "BOM-ITEMS-001",
"bom_name": "明细保留测试BOM",
"version": "1.0",
"revision": 1,
"product_id": 1,
"items": [
{
"item_number": "ITEM-PRESERVE-001",
"item_name": "测试物料1",
"item_type": "material",
"quantity": 10,
"unit": "pcs"
},
{
"item_number": "ITEM-PRESERVE-002",
"item_name": "测试物料2",
"item_type": "material",
"quantity": 20,
"unit": "kg"
}
]
}
)
original_items_count = 2
if create_response.status_code == 201:
bom_id = create_response.json()["id"]
elif create_response.status_code == 400:
list_response = client.get(
"/api/v1/bom?keyword=BOM-ITEMS-001",
headers={"Authorization": f"Bearer {auth_token}"}
)
if list_response.status_code == 200 and list_response.json()["data"]:
bom_id = list_response.json()["data"][0]["id"]
else:
pytest.skip("无法获取测试BOM")
else:
pytest.skip("创建测试BOM失败")
# 执行修订
revise_response = client.post(
f"/api/v1/bom/{bom_id}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise_response.status_code == 200
# 获取新版本的明细
revised_bom_id = revise_response.json()["id"]
items_response = client.get(
f"/api/v1/bom/{revised_bom_id}/items",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert items_response.status_code == 200
items = items_response.json()
assert len(items) == original_items_count, "修订后明细数量应保持一致"
def test_revise_already_revised_bom(self, client: TestClient, auth_token: str):
"""
测试修订已修订过的BOM
验证已带版本后缀的BOM如 BOM-001-V2再次修订时
应生成 BOM-001-V3而非 BOM-001-V2-V3
"""
if not auth_token:
pytest.skip("需要认证Token")
# 创建原始BOM并修订一次
create_response = client.post(
"/api/v1/bom",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"bom_number": "BOM-RE-REVISE-001",
"bom_name": "再修订测试BOM",
"version": "1.0",
"revision": 1,
"product_id": 1
}
)
if create_response.status_code == 201:
bom_id = create_response.json()["id"]
elif create_response.status_code == 400:
list_response = client.get(
"/api/v1/bom?keyword=BOM-RE-REVISE-001",
headers={"Authorization": f"Bearer {auth_token}"}
)
if list_response.status_code == 200 and list_response.json()["data"]:
bom_id = list_response.json()["data"][0]["id"]
else:
pytest.skip("无法获取测试BOM")
else:
pytest.skip("创建测试BOM失败")
# 第一次修订
revise1 = client.post(
f"/api/v1/bom/{bom_id}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise1.status_code == 200
v2_data = revise1.json()
assert v2_data["bom_number"] == "BOM-RE-REVISE-001-V2"
# 第二次修订基于V2版本
revise2 = client.post(
f"/api/v1/bom/{v2_data['id']}/revise",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert revise2.status_code == 200
v3_data = revise2.json()
# 验证:应该是 BOM-RE-REVISE-001-V3而不是 BOM-RE-REVISE-001-V2-V3
assert v3_data["bom_number"] == "BOM-RE-REVISE-001-V3", \
f"修订已修订BOM时编号处理错误: 期望 BOM-RE-REVISE-001-V3实际 {v3_data['bom_number']}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])