361 lines
15 KiB
Python
361 lines
15 KiB
Python
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.deps import require_roles
|
|
from app.models import DeviceQRCode, MoldLockFeedback, Personnel, Product, ProductionReportItem, Role, WorkSessionDevice
|
|
from app.schemas import ImportResult, MoldNameOut, PageResponse, ProductCreate, ProductOut
|
|
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
|
from app.services.common import normalize_device_no, paginate
|
|
from app.services.excel_import import ProductImportValidationError, import_products
|
|
from app.services.excel_export import export_products
|
|
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
|
|
from app.services.continuous_die import is_continuous_die_product
|
|
from app.services.display_names import mold_process_display_name
|
|
from app.services.misc_work import ensure_misc_work_products, is_misc_stamping_method
|
|
from app.services.multi_person import is_multi_person_product
|
|
from app.services.process_names import MISC_PROCESS_NAME, normalize_process_name
|
|
from app.services.serializers import product_out
|
|
|
|
router = APIRouter(prefix="/api/products", tags=["products"])
|
|
|
|
|
|
def _cascade_product_key_change(db: Session, original_key: dict, next_key: dict) -> None:
|
|
if original_key == next_key:
|
|
return
|
|
old_point = original_key["attendance_point_name"]
|
|
old_product = original_key["product_name"]
|
|
old_process = original_key["process_name"]
|
|
next_point = next_key["attendance_point_name"]
|
|
next_product = next_key["product_name"]
|
|
next_process = next_key["process_name"]
|
|
|
|
old_qr = db.get(DeviceQRCode, {
|
|
"attendance_point_name": old_point,
|
|
"device_no": old_product,
|
|
"process_name": old_process,
|
|
})
|
|
if old_qr is not None:
|
|
qr_target = db.get(DeviceQRCode, {
|
|
"attendance_point_name": next_point,
|
|
"device_no": next_product,
|
|
"process_name": next_process,
|
|
})
|
|
if qr_target is not None:
|
|
raise HTTPException(status_code=409, detail="目标产品/工序二维码已存在,不能合并覆盖")
|
|
old_qr.attendance_point_name = next_point
|
|
old_qr.device_no = next_product
|
|
old_qr.process_name = next_process
|
|
|
|
for device in db.scalars(select(WorkSessionDevice).where(
|
|
WorkSessionDevice.attendance_point_name == old_point,
|
|
WorkSessionDevice.device_no == old_product,
|
|
WorkSessionDevice.process_name == old_process,
|
|
)).all():
|
|
device.attendance_point_name = next_point
|
|
device.device_no = next_product
|
|
device.process_name = next_process
|
|
|
|
for item in db.scalars(select(ProductionReportItem).where(
|
|
ProductionReportItem.attendance_point_name == old_point,
|
|
ProductionReportItem.project_no == original_key["project_no"],
|
|
ProductionReportItem.product_name == old_product,
|
|
ProductionReportItem.process_name == old_process,
|
|
)).all():
|
|
item.attendance_point_name = next_point
|
|
item.project_no = next_key["project_no"]
|
|
item.product_name = next_product
|
|
item.process_name = next_process
|
|
|
|
for feedback in db.scalars(select(MoldLockFeedback).where(
|
|
MoldLockFeedback.attendance_point_name == old_point,
|
|
MoldLockFeedback.mold_name == old_product,
|
|
MoldLockFeedback.process_name == old_process,
|
|
)).all():
|
|
feedback.attendance_point_name = next_point
|
|
feedback.mold_name = next_product
|
|
feedback.process_name = next_process
|
|
|
|
|
|
@router.get("/molds", response_model=list[MoldNameOut])
|
|
def list_mold_names(
|
|
keyword: str = "",
|
|
limit: int = Query(100, ge=1, le=500),
|
|
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> list[MoldNameOut]:
|
|
point_names = accessible_point_names(db, user)
|
|
ensure_misc_work_products(db, point_names)
|
|
db.commit()
|
|
query = (
|
|
select(Product)
|
|
.where(
|
|
Product.attendance_point_name.in_(point_names),
|
|
Product.product_name != "",
|
|
Product.process_name != "",
|
|
Product.device_no == "",
|
|
)
|
|
.distinct()
|
|
.order_by(Product.attendance_point_name, Product.product_name, Product.process_name)
|
|
)
|
|
if keyword:
|
|
like = f"%{keyword}%"
|
|
query = query.where(or_(Product.product_name.like(like), Product.process_name.like(like), Product.stamping_method.like(like)))
|
|
rows = db.scalars(query.limit(limit)).all()
|
|
return [
|
|
MoldNameOut(
|
|
attendance_point_name=product.attendance_point_name,
|
|
name=product.product_name,
|
|
process_name=product.process_name or "",
|
|
stamping_method=product.stamping_method,
|
|
operator_count=product.operator_count,
|
|
is_cleaning=is_cleaning_stamping_method(product.stamping_method),
|
|
is_misc=is_misc_stamping_method(product.stamping_method),
|
|
is_continuous_die=is_continuous_die_product(product),
|
|
is_multi_person=is_multi_person_product(product),
|
|
display_name=mold_process_display_name(product.product_name, product.process_name, product.stamping_method),
|
|
)
|
|
for product in rows
|
|
]
|
|
|
|
|
|
@router.get("", response_model=PageResponse)
|
|
def list_products(
|
|
attendance_point_name: str = "",
|
|
keyword: str = "",
|
|
device_no: str = "",
|
|
product_name: str = "",
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(10, ge=1, le=100),
|
|
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> PageResponse:
|
|
point_names = accessible_point_names(db, user)
|
|
ensure_misc_work_products(db, point_names)
|
|
db.commit()
|
|
query = (
|
|
select(Product)
|
|
.where(Product.device_no == "", Product.attendance_point_name.in_(point_names))
|
|
.order_by(
|
|
Product.updated_at.desc(),
|
|
Product.attendance_point_name.asc(),
|
|
Product.project_no.asc(),
|
|
Product.product_name.asc(),
|
|
Product.process_name.asc(),
|
|
)
|
|
)
|
|
if attendance_point_name:
|
|
try:
|
|
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
|
except (ValueError, PermissionError) as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
query = query.where(Product.attendance_point_name == point_name)
|
|
if product_name:
|
|
query = query.where(Product.product_name == product_name)
|
|
if keyword:
|
|
like = f"%{keyword}%"
|
|
query = query.where(
|
|
or_(
|
|
Product.project_no.like(like),
|
|
Product.product_name.like(like),
|
|
Product.attendance_point_name.like(like),
|
|
Product.material_code.like(like),
|
|
Product.material_name.like(like),
|
|
Product.supplier.like(like),
|
|
Product.process_name.like(like),
|
|
Product.stamping_method.like(like),
|
|
)
|
|
)
|
|
result = paginate(db, query, page, page_size)
|
|
return PageResponse(**{**result, "rows": [product_out(row) for row in result["rows"]]})
|
|
|
|
|
|
@router.post("", response_model=ProductOut)
|
|
def save_product(
|
|
payload: ProductCreate,
|
|
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> ProductOut:
|
|
try:
|
|
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except PermissionError as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
raw_process_name = str(payload.process_name or "").strip()
|
|
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
|
|
raise HTTPException(status_code=400, detail="清洗请填写在冲压方式中,工序只能填写数字,处理杂活除外")
|
|
try:
|
|
process_name = normalize_process_name(raw_process_name)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
is_cleaning = is_cleaning_stamping_method(payload.stamping_method)
|
|
is_misc = is_misc_stamping_method(payload.stamping_method)
|
|
if is_misc and process_name != MISC_PROCESS_NAME:
|
|
raise HTTPException(status_code=400, detail="处理杂活的工序必须填写杂活")
|
|
if not is_misc and process_name == MISC_PROCESS_NAME:
|
|
raise HTTPException(status_code=400, detail="只有处理杂活的工序可以填写杂活")
|
|
if not is_cleaning and not is_misc and payload.standard_beat <= 0:
|
|
raise HTTPException(status_code=400, detail="非清洗工序必须填写标准节拍")
|
|
|
|
next_key = {
|
|
"attendance_point_name": point_name,
|
|
"project_no": payload.project_no,
|
|
"product_name": payload.product_name,
|
|
"device_no": "",
|
|
"process_name": process_name,
|
|
}
|
|
original_key = {
|
|
"attendance_point_name": payload.original_attendance_point_name or point_name,
|
|
"project_no": payload.original_project_no or payload.project_no,
|
|
"product_name": payload.original_product_name or payload.product_name,
|
|
"device_no": normalize_device_no(payload.original_device_no if payload.original_device_no is not None else ""),
|
|
"process_name": payload.original_process_name if payload.original_process_name is not None else process_name,
|
|
}
|
|
|
|
product = db.get(Product, original_key)
|
|
if product is not None and original_key != next_key:
|
|
existed = db.get(Product, next_key)
|
|
if existed is not None:
|
|
raise HTTPException(status_code=409, detail="目标产品/工序已存在,不能合并覆盖")
|
|
else:
|
|
_cascade_product_key_change(db, original_key, next_key)
|
|
product.project_no = next_key["project_no"]
|
|
product.product_name = next_key["product_name"]
|
|
product.device_no = next_key["device_no"]
|
|
product.process_name = next_key["process_name"]
|
|
elif product is None:
|
|
product = db.get(Product, next_key)
|
|
if product is None:
|
|
product = Product(
|
|
attendance_point_name=next_key["attendance_point_name"],
|
|
project_no=next_key["project_no"],
|
|
product_name=next_key["product_name"],
|
|
device_no=next_key["device_no"],
|
|
process_name=next_key["process_name"],
|
|
)
|
|
db.add(product)
|
|
|
|
aux_values = (
|
|
payload.product_net_weight_kg,
|
|
payload.product_gross_weight_kg,
|
|
payload.scrap_loss_rate,
|
|
payload.waste_price_yuan_per_kg,
|
|
)
|
|
if all(value is None for value in aux_values):
|
|
sibling = next(
|
|
(
|
|
item for item in db.scalars(
|
|
select(Product).where(Product.product_name == next_key["product_name"], Product.device_no == "")
|
|
.where(Product.attendance_point_name == point_name)
|
|
).all()
|
|
if item.product_net_weight_kg is not None
|
|
and item.product_gross_weight_kg is not None
|
|
and item.scrap_loss_rate is not None
|
|
and item.waste_price_yuan_per_kg is not None
|
|
),
|
|
None,
|
|
)
|
|
if sibling is not None:
|
|
aux_values = (
|
|
sibling.product_net_weight_kg,
|
|
sibling.product_gross_weight_kg,
|
|
sibling.scrap_loss_rate,
|
|
sibling.waste_price_yuan_per_kg,
|
|
)
|
|
|
|
product.profile_no = payload.profile_no
|
|
product.attendance_point_name = next_key["attendance_point_name"]
|
|
product.material_code = payload.material_code
|
|
product.material_name = payload.material_name
|
|
product.supplier = payload.supplier
|
|
product.product_net_weight_kg = aux_values[0]
|
|
product.product_gross_weight_kg = aux_values[1]
|
|
product.scrap_loss_rate = aux_values[2]
|
|
product.waste_price_yuan_per_kg = aux_values[3]
|
|
product.device_no = next_key["device_no"]
|
|
product.process_name = next_key["process_name"]
|
|
product.stamping_method = payload.stamping_method
|
|
product.operator_count = payload.operator_count or 1
|
|
product.process_unit_price_yuan = payload.process_unit_price_yuan or 0
|
|
product.standard_beat = 0 if (is_cleaning or is_misc) else payload.standard_beat
|
|
product.standard_workload = 0
|
|
for sibling in db.scalars(select(Product).where(
|
|
Product.attendance_point_name == point_name,
|
|
Product.product_name == next_key["product_name"],
|
|
Product.device_no == "",
|
|
)).all():
|
|
sibling.product_net_weight_kg = product.product_net_weight_kg
|
|
sibling.product_gross_weight_kg = product.product_gross_weight_kg
|
|
sibling.scrap_loss_rate = product.scrap_loss_rate
|
|
sibling.waste_price_yuan_per_kg = product.waste_price_yuan_per_kg
|
|
db.commit()
|
|
db.refresh(product)
|
|
return product_out(product)
|
|
|
|
|
|
@router.delete("", status_code=204)
|
|
def delete_product(
|
|
attendance_point_name: str,
|
|
project_no: str,
|
|
product_name: str,
|
|
device_no: str = "",
|
|
process_name: str = "",
|
|
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> None:
|
|
try:
|
|
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
|
except (ValueError, PermissionError) as exc:
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
product = db.get(
|
|
Product,
|
|
{
|
|
"attendance_point_name": point_name,
|
|
"project_no": project_no,
|
|
"product_name": product_name,
|
|
"device_no": "",
|
|
"process_name": process_name,
|
|
},
|
|
)
|
|
if product is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="产品不存在")
|
|
qrcode = db.get(DeviceQRCode, {
|
|
"attendance_point_name": point_name,
|
|
"device_no": product.product_name,
|
|
"process_name": product.process_name or "",
|
|
})
|
|
if qrcode is not None:
|
|
db.delete(qrcode)
|
|
db.delete(product)
|
|
db.commit()
|
|
|
|
|
|
@router.post("/import", response_model=ImportResult)
|
|
async def import_product_excel(
|
|
file: UploadFile = File(...),
|
|
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> ImportResult:
|
|
content = await file.read()
|
|
try:
|
|
result = import_products(db, content, user=user)
|
|
except ProductImportValidationError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return ImportResult(**result.as_dict())
|
|
|
|
|
|
@router.get("/export")
|
|
def export_product_excel(
|
|
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
content = export_products(db, user=user)
|
|
return Response(
|
|
content=content,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": 'attachment; filename="products.xlsx"'},
|
|
)
|