feat(qc): implement material library with CRUD, stock-in, usage tracking and task deduction
This commit is contained in:
1 parent
4137c54588
commit
e6072457fd
9 files changed
+1638
-22
No files matched your search
@@ -14,6 +14,8 @@ from app.crud import qc_task_result as result_svc
|
||||
from app.crud import qc_task as task_svc
|
||||
from app.crud import qc_third_party_company as tpc_svc
|
||||
from app.crud import qc_third_party_item as tpi_svc
|
||||
from app.crud import qc_material as mat_svc
|
||||
from app.crud import qc_material_usage as mu_svc
|
||||
from app.crud import qc_work_order as wo_svc
|
||||
from app.crud.collection_protocol import total_pages
|
||||
from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
|
||||
@@ -373,6 +375,7 @@ class TaskCompleteWithResultBody(BaseModel):
|
||||
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
|
||||
remark: Optional[str] = Field(None, alias="remark")
|
||||
third_party_attachments: Optional[str] = Field(None, alias="thirdPartyAttachments")
|
||||
material_usages: Optional[List[Dict[str, Any]]] = Field(None, alias="materialUsages")
|
||||
|
||||
|
||||
@router.get("/qc-lab-test-items")
|
||||
@@ -745,6 +748,18 @@ async def complete_task_with_result(
|
||||
db, task_id, attachments=body.third_party_attachments,
|
||||
tenant_no=tenant_no, user_id=user_id,
|
||||
)
|
||||
# 物料扣减(可选,仅当传了 materialUsages 时)
|
||||
if body.material_usages:
|
||||
task_obj_pre = task_svc.get_task(db, task_id, tenant_no=tenant_no)
|
||||
task_no = task_obj_pre.task_no if task_obj_pre else ""
|
||||
mu_svc.deduct_for_task(
|
||||
db,
|
||||
task_id,
|
||||
task_no=task_no,
|
||||
material_usages=body.material_usages,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
obj = task_svc.apply_task_result(
|
||||
db,
|
||||
task_id,
|
||||
@@ -788,3 +803,165 @@ async def update_qc_task_attachments(
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 质检物料库 ----------
|
||||
|
||||
|
||||
class QcMaterialBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
material_code: str = Field(..., alias="materialCode")
|
||||
material_name: str = Field(..., alias="materialName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
unit: Optional[str] = None
|
||||
warning_qty: Optional[float] = Field(0, alias="warningQty")
|
||||
status: str = "1"
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class QcMaterialUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
material_code: Optional[str] = Field(None, alias="materialCode")
|
||||
material_name: Optional[str] = Field(None, alias="materialName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
unit: Optional[str] = None
|
||||
warning_qty: Optional[float] = Field(None, alias="warningQty")
|
||||
status: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class StockInBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
qty: float = Field(..., gt=0)
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/qc-materials")
|
||||
async def list_qc_materials(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = mat_svc.list_materials(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-materials/options")
|
||||
async def qc_material_options(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(mat_svc.options_list(db, tenant_no=tenant_no))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-materials")
|
||||
async def create_qc_material(
|
||||
body: QcMaterialBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = mat_svc.create_material(db, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(obj, message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-materials/{material_id}")
|
||||
async def update_qc_material(
|
||||
material_id: str,
|
||||
body: QcMaterialUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = mat_svc.update_material(db, material_id, body.model_dump(by_alias=True, exclude_none=True), tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(obj, message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-materials/{material_id}")
|
||||
async def delete_qc_material(
|
||||
material_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-materials/{material_id}/stock-in")
|
||||
async def stock_in_qc_material(
|
||||
material_id: str,
|
||||
body: StockInBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterial:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = mat_svc.stock_in(db, material_id, qty=body.qty, remark=body.remark, tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(obj, message="入库成功")
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 物料变动记录 ----------
|
||||
|
||||
|
||||
@router.get("/qc-material-usages")
|
||||
async def list_qc_material_usages(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
materialId: Optional[str] = None,
|
||||
changeType: Optional[str] = None,
|
||||
startTime: Optional[str] = None,
|
||||
endTime: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcMaterialUsage:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = mu_svc.list_usages(
|
||||
db, tenant_no, page=page, page_size=pageSize,
|
||||
material_id=materialId, change_type=changeType,
|
||||
start_time=startTime, end_time=endTime,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,313 @@
|
||||
"""物料库 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizQcMaterial, BizQcMaterialUsage
|
||||
|
||||
|
||||
def _dec(v: Any) -> float:
|
||||
if v is None:
|
||||
return 0.0
|
||||
return float(v)
|
||||
|
||||
|
||||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcMaterial) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"materialCode": r.material_code,
|
||||
"materialName": r.material_name,
|
||||
"specModel": r.spec_model or "",
|
||||
"unit": r.unit or "",
|
||||
"stockQty": _dec(r.stock_qty),
|
||||
"warningQty": _dec(r.warning_qty),
|
||||
"status": r.status or "1",
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": _fmt_dt(r.create_time),
|
||||
"updateTime": _fmt_dt(r.update_time),
|
||||
}
|
||||
|
||||
|
||||
def code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizQcMaterial).filter(
|
||||
BizQcMaterial.material_code == c,
|
||||
BizQcMaterial.tenant_no == tenant_no,
|
||||
BizQcMaterial.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizQcMaterial.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def total_pages(db: Session, tenant_no: str, page_size: int = 10) -> int:
|
||||
cnt = (
|
||||
db.query(func.count(BizQcMaterial.id))
|
||||
.filter(
|
||||
BizQcMaterial.tenant_no == tenant_no,
|
||||
BizQcMaterial.del_flag == 0,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
ps = max(1, page_size)
|
||||
return (cnt + ps - 1) // ps
|
||||
|
||||
|
||||
def list_materials(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcMaterial).filter(BizQcMaterial.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcMaterial.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizQcMaterial.material_code.like(like),
|
||||
BizQcMaterial.material_name.like(like),
|
||||
)
|
||||
)
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcMaterial.status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcMaterial.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcMaterial.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_material(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcMaterial]:
|
||||
q = db.query(BizQcMaterial).filter(
|
||||
BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcMaterial.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_material(
|
||||
db: Session,
|
||||
*,
|
||||
material_code: str,
|
||||
material_name: str,
|
||||
spec_model: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
stock_qty: Optional[Decimal] = None,
|
||||
warning_qty: Optional[Decimal] = None,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcMaterial:
|
||||
code = (material_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("物料编码不能为空")
|
||||
if code_exists(db, code, tenant_no):
|
||||
raise ValueError("物料编码已存在")
|
||||
name = (material_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("物料名称不能为空")
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj = BizQcMaterial(
|
||||
id=generate_uuid(),
|
||||
material_code=code,
|
||||
material_name=name,
|
||||
spec_model=(spec_model or "").strip() or None,
|
||||
unit=(unit or "").strip() or None,
|
||||
stock_qty=stock_qty if stock_qty is not None else 0,
|
||||
warning_qty=warning_qty if warning_qty is not None else 0,
|
||||
status=st,
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_material(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
material_code: Optional[str] = None,
|
||||
material_name: Optional[str] = None,
|
||||
spec_model: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
warning_qty: Optional[Decimal] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcMaterial:
|
||||
obj = get_material(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("物料不存在")
|
||||
if material_code is not None:
|
||||
code = material_code.strip()
|
||||
if not code:
|
||||
raise ValueError("物料编码不能为空")
|
||||
if code != obj.material_code and code_exists(db, code, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("物料编码已存在")
|
||||
obj.material_code = code
|
||||
if material_name is not None:
|
||||
name = (material_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("物料名称不能为空")
|
||||
obj.material_name = name
|
||||
if spec_model is not None:
|
||||
obj.spec_model = (spec_model or "").strip() or None
|
||||
if unit is not None:
|
||||
obj.unit = (unit or "").strip() or None
|
||||
if warning_qty is not None:
|
||||
obj.warning_qty = warning_qty
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_material(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_material(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("物料不存在")
|
||||
if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
|
||||
raise ValueError("该物料仍有库存,无法删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def stock_in(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
qty: Decimal,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcMaterial:
|
||||
if qty is None or float(qty) <= 0:
|
||||
raise ValueError("入库数量必须大于0")
|
||||
# 行锁锁定物料行
|
||||
obj = (
|
||||
db.query(BizQcMaterial)
|
||||
.filter(BizQcMaterial.id == row_id, BizQcMaterial.del_flag == 0)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise ValueError("物料不存在")
|
||||
old_stock = obj.stock_qty or 0
|
||||
new_stock = old_stock + qty
|
||||
obj.stock_qty = new_stock
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
# 写入使用记录
|
||||
usage = BizQcMaterialUsage(
|
||||
id=generate_uuid(),
|
||||
material_id=obj.id,
|
||||
material_code=obj.material_code,
|
||||
material_name=obj.material_name,
|
||||
change_type="in",
|
||||
change_qty=qty,
|
||||
stock_after=new_stock,
|
||||
source_type="manual_in",
|
||||
tenant_no=obj.tenant_no,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=obj.create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(usage)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcMaterial)
|
||||
.filter(
|
||||
BizQcMaterial.tenant_no == tenant_no,
|
||||
BizQcMaterial.del_flag == 0,
|
||||
BizQcMaterial.status == "1",
|
||||
)
|
||||
.order_by(BizQcMaterial.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"label": r.material_name,
|
||||
"materialCode": r.material_code,
|
||||
"materialName": r.material_name,
|
||||
"specModel": r.spec_model or "",
|
||||
"unit": r.unit or "",
|
||||
"stockQty": _dec(r.stock_qty),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""物料变动记录 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcMaterialUsage) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"materialId": r.material_id,
|
||||
"materialCode": r.material_code or "",
|
||||
"materialName": r.material_name or "",
|
||||
"changeType": r.change_type,
|
||||
"changeQty": float(r.change_qty) if r.change_qty is not None else 0,
|
||||
"stockAfter": float(r.stock_after) if r.stock_after is not None else 0,
|
||||
"qcTaskId": r.qc_task_id or "",
|
||||
"qcTaskNo": r.qc_task_no or "",
|
||||
"sourceType": r.source_type,
|
||||
"remark": r.remark or "",
|
||||
"createBy": r.create_by or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_usages(
|
||||
db: Session,
|
||||
tenant_no: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
material_id: Optional[str] = None,
|
||||
change_type: Optional[str] = None,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcMaterialUsage).filter(
|
||||
BizQcMaterialUsage.tenant_no == tenant_no,
|
||||
BizQcMaterialUsage.del_flag == 0,
|
||||
)
|
||||
if material_id:
|
||||
q0 = q0.filter(BizQcMaterialUsage.material_id == material_id)
|
||||
if change_type:
|
||||
q0 = q0.filter(BizQcMaterialUsage.change_type == change_type)
|
||||
if start_time:
|
||||
q0 = q0.filter(BizQcMaterialUsage.create_time >= start_time)
|
||||
if end_time:
|
||||
q0 = q0.filter(BizQcMaterialUsage.create_time <= end_time + " 23:59:59")
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcMaterialUsage.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcMaterialUsage.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def deduct_for_task(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
task_no: str,
|
||||
material_usages: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
dept_id: str = "",
|
||||
) -> None:
|
||||
"""扣减物料库存并生成变动记录。调用方控制事务,本函数不 commit。"""
|
||||
for item in material_usages:
|
||||
mid = item.get("materialId") or ""
|
||||
qty = item.get("qty")
|
||||
if not mid:
|
||||
raise HTTPException(status_code=400, detail="物料ID不能为空")
|
||||
if qty is None or Decimal(str(qty)) <= 0:
|
||||
raise HTTPException(status_code=400, detail="物料使用数量必须大于0")
|
||||
|
||||
qty_dec = Decimal(str(qty))
|
||||
|
||||
material = (
|
||||
db.query(BizQcMaterial)
|
||||
.with_for_update()
|
||||
.filter(
|
||||
BizQcMaterial.id == mid,
|
||||
BizQcMaterial.tenant_no == tenant_no,
|
||||
BizQcMaterial.del_flag == 0,
|
||||
BizQcMaterial.status == "1",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail=f"物料不存在或已停用(ID: {mid})")
|
||||
|
||||
stock_qty = material.stock_qty if material.stock_qty is not None else Decimal("0")
|
||||
if stock_qty < qty_dec:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"物料「{material.material_name}」库存不足,当前库存 {stock_qty},需使用 {qty_dec}",
|
||||
)
|
||||
|
||||
new_stock = stock_qty - qty_dec
|
||||
material.stock_qty = new_stock
|
||||
material.update_by = user_id
|
||||
material.update_time = datetime.now()
|
||||
|
||||
usage = BizQcMaterialUsage(
|
||||
id=generate_uuid(),
|
||||
material_id=mid,
|
||||
material_code=material.material_code,
|
||||
material_name=material.material_name,
|
||||
change_type="use",
|
||||
change_qty=-qty_dec,
|
||||
stock_after=new_stock,
|
||||
qc_task_id=task_id,
|
||||
qc_task_no=task_no,
|
||||
source_type="task_complete",
|
||||
tenant_no=tenant_no,
|
||||
remark="",
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(usage)
|
||||
@@ -0,0 +1,39 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcMaterialUsage = {
|
||||
id: string
|
||||
materialId: string
|
||||
materialCode: string
|
||||
materialName: string
|
||||
changeType: string
|
||||
changeQty: number
|
||||
stockAfter: number
|
||||
qcTaskId: string
|
||||
qcTaskNo: string
|
||||
sourceType: string
|
||||
remark: string
|
||||
createBy: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
export function fetchQcMaterialUsageList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
materialId?: string
|
||||
changeType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}) {
|
||||
return request.get<PageList<QcMaterialUsage>>({
|
||||
url: '/web/admin/qc-material-usages',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcMaterial = {
|
||||
id: string
|
||||
materialCode: string
|
||||
materialName: string
|
||||
specModel: string
|
||||
unit: string
|
||||
stockQty: number
|
||||
warningQty: number
|
||||
status: string
|
||||
tenantNo: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
createBy: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export type QcMaterialOption = {
|
||||
id: string
|
||||
label: string
|
||||
materialCode: string
|
||||
materialName: string
|
||||
specModel: string
|
||||
unit: string
|
||||
stockQty: number
|
||||
}
|
||||
|
||||
export function fetchQcMaterialList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return request.get<PageList<QcMaterial>>({
|
||||
url: '/web/admin/qc-materials',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcMaterialOptions() {
|
||||
return request.get<QcMaterialOption[]>({
|
||||
url: '/web/admin/qc-materials/options'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateQcMaterial(data: Record<string, any>) {
|
||||
return request.post<QcMaterial>({
|
||||
url: '/web/admin/qc-materials',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateQcMaterial(id: string, data: Record<string, any>) {
|
||||
return request.put<QcMaterial>({
|
||||
url: `/web/admin/qc-materials/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteQcMaterial(id: string) {
|
||||
return request.delete<void>({
|
||||
url: `/web/admin/qc-materials/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchStockInQcMaterial(id: string, data: { qty: number; remark?: string }) {
|
||||
return request.post<QcMaterial>({
|
||||
url: `/web/admin/qc-materials/${id}/stock-in`,
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -191,6 +191,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
path: 'sample-code',
|
||||
component: '/basic-info/sample-code/index',
|
||||
meta: { title: '样本编码管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '40000000000000000000000000000007',
|
||||
name: 'BasicInfoInsuranceCompany',
|
||||
path: 'insurance-company',
|
||||
component: '/basic-info/insurance-company/index',
|
||||
meta: { title: '保险公司及项目价格', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -240,6 +247,20 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
path: 'refund-confirm',
|
||||
component: '/cell-storage/refund-confirm/index',
|
||||
meta: { title: '退费确认(财务)', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '50000000000000000000000000000006',
|
||||
name: 'CellStorageStorageCert',
|
||||
path: 'storage-cert',
|
||||
component: '/cell-storage/storage-cert/index',
|
||||
meta: { title: '储存证管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '50000000000000000000000000000008',
|
||||
name: 'CellStorageFullmoonTodo',
|
||||
path: 'fullmoon-todo',
|
||||
component: '/cell-storage/fullmoon-todo/index',
|
||||
meta: { title: '满月待办管理', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -344,6 +365,20 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:qcTask:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000009',
|
||||
name: 'QualityInspectionQcMaterial',
|
||||
path: 'qc-material',
|
||||
component: '/quality-inspection/qc-material/index',
|
||||
meta: { title: '质检物料库', keepAlive: true, menuCode: 'biz:qcMaterial:list' }
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000010',
|
||||
name: 'QualityInspectionQcMaterialUsage',
|
||||
path: 'qc-material-usage',
|
||||
component: '/quality-inspection/qc-material-usage/index',
|
||||
meta: { title: '物料变动记录', keepAlive: true, menuCode: 'biz:qcMaterialUsage:list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<!-- 物料变动记录 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>物料变动记录</h1>
|
||||
<p class="page-desc">查看检测物料的使用与入库变动流水,支持按物料、变动类型和时间范围筛选。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2>变动记录列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="物料筛选">
|
||||
<el-select
|
||||
v-model="searchForm.materialId"
|
||||
placeholder="全部物料"
|
||||
clearable
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="m in materialOptions"
|
||||
:key="m.id"
|
||||
:label="m.label"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="变动类型">
|
||||
<el-select v-model="searchForm.changeType" placeholder="全部" clearable>
|
||||
<el-option label="使用" value="use" />
|
||||
<el-option label="入库" value="in" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按物料、变动类型和时间范围查询变动记录。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-end">
|
||||
<el-tooltip content="列设置" placement="top">
|
||||
<el-button class="btn-ghost" circle @click="showColumnSettings = true">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新" placement="top">
|
||||
<el-button class="btn-ghost" circle @click="loadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="materialCode" label="物料编码" min-width="120" />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="140" />
|
||||
<el-table-column prop="changeType" label="变动类型" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.changeType === 'use' ? 'warning' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.changeType === 'use' ? '使用' : '入库' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="changeQty" label="变动数量" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.changeQty < 0 ? 'qty-negative' : 'qty-positive'">
|
||||
{{ row.changeQty < 0 ? row.changeQty : `+${row.changeQty}` }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stockAfter" label="变动后库存" min-width="120" align="right" />
|
||||
<el-table-column prop="qcTaskNo" label="关联任务号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.qcTaskNo || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sourceType" label="来源类型" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="getSourceTagType(row.sourceType)"
|
||||
size="small"
|
||||
>
|
||||
{{ getSourceLabel(row.sourceType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="createBy" label="操作人" min-width="100" />
|
||||
<el-table-column prop="createTime" label="变动时间" min-width="170" />
|
||||
</el-table>
|
||||
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 列设置抽屉 -->
|
||||
<el-drawer v-model="showColumnSettings" title="列设置" size="300px">
|
||||
<p>列设置功能预留</p>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Search, RefreshLeft, Refresh, Setting } from '@element-plus/icons-vue'
|
||||
import { fetchQcMaterialUsageList } from '@/api/qc-material-usage'
|
||||
import type { QcMaterialUsage } from '@/api/qc-material-usage'
|
||||
import { fetchQcMaterialOptions } from '@/api/qc-material'
|
||||
import type { QcMaterialOption } from '@/api/qc-material'
|
||||
|
||||
defineOptions({ name: 'QcMaterialUsage' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const searchForm = reactive<{
|
||||
materialId: string
|
||||
changeType: string
|
||||
}>({
|
||||
materialId: '',
|
||||
changeType: ''
|
||||
})
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const tableData = ref<QcMaterialUsage[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const materialOptions = ref<QcMaterialOption[]>([])
|
||||
const showColumnSettings = ref(false)
|
||||
|
||||
function getSourceTagType(sourceType: string) {
|
||||
if (sourceType === 'task_complete') return 'primary'
|
||||
if (sourceType === 'manual_in') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function getSourceLabel(sourceType: string) {
|
||||
if (sourceType === 'task_complete') return '任务完成'
|
||||
if (sourceType === 'manual_in') return '手动入库'
|
||||
return sourceType || '-'
|
||||
}
|
||||
|
||||
async function loadMaterialOptions() {
|
||||
try {
|
||||
const res = await fetchQcMaterialOptions()
|
||||
materialOptions.value = res || []
|
||||
} catch {
|
||||
materialOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
}
|
||||
if (searchForm.materialId) params.materialId = searchForm.materialId
|
||||
if (searchForm.changeType) params.changeType = searchForm.changeType
|
||||
if (dateRange.value && dateRange.value[0]) {
|
||||
params.startTime = dateRange.value[0]
|
||||
params.endTime = dateRange.value[1]
|
||||
}
|
||||
const res = await fetchQcMaterialUsageList(params)
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.materialId = ''
|
||||
searchForm.changeType = ''
|
||||
dateRange.value = null
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMaterialOptions()
|
||||
if (route.query.materialId) {
|
||||
searchForm.materialId = route.query.materialId as string
|
||||
}
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.qty-positive {
|
||||
color: var(--el-color-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qty-negative {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<!-- 质检物料库 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>质检物料库</h1>
|
||||
<p class="page-desc">维护质检物料的编码、名称、规格、库存预警等信息,支持入库操作。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2>物料列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="物料名称/编码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按物料名称、编码模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleStockInFromToolbar"
|
||||
>
|
||||
入库
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="materialCode" label="物料编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="materialName" label="物料名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column label="当前库存" min-width="100" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.stockQty ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预警库存" min-width="100" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.warningQty ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存状态" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.stockQty <= row.warningQty ? 'danger' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.stockQty <= row.warningQty ? '库存预警' : '正常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="80" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === '1' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="140" align="center" header-align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleStockIn(row)">入库</el-button>
|
||||
<el-button class="btn-op-item" @click="handleViewUsage(row)">详情</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑 Dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="material-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="物料编码" prop="materialCode">
|
||||
<el-input v-model="formData.materialCode" placeholder="请输入物料编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="物料名称" prop="materialName">
|
||||
<el-input v-model="formData.materialName" placeholder="请输入物料名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格型号">
|
||||
<el-input v-model="formData.specModel" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="formData.unit" placeholder="请输入单位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预警库存">
|
||||
<el-input-number
|
||||
v-model="formData.warningQty"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入预警库存"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="1">启用</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 入库 Dialog -->
|
||||
<el-dialog
|
||||
v-model="stockInVisible"
|
||||
title="物料入库"
|
||||
width="480px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="stockInFormRef"
|
||||
:model="stockInForm"
|
||||
:rules="stockInRules"
|
||||
label-position="top"
|
||||
class="material-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<el-form-item label="物料名称">
|
||||
<el-input :model-value="stockInForm.materialName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前库存">
|
||||
<el-input :model-value="stockInForm.currentStock" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="入库数量" prop="qty">
|
||||
<el-input-number
|
||||
v-model="stockInForm.qty"
|
||||
:min="0.0001"
|
||||
:precision="4"
|
||||
style="width: 100%"
|
||||
placeholder="请输入入库数量"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="stockInForm.remark" type="textarea" :rows="3" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="stockInVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleStockInSubmit" :loading="stockInLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchQcMaterialList,
|
||||
fetchCreateQcMaterial,
|
||||
fetchUpdateQcMaterial,
|
||||
fetchDeleteQcMaterial,
|
||||
fetchStockInQcMaterial,
|
||||
} from '@/api/qc-material'
|
||||
import type { QcMaterial } from '@/api/qc-material'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionQcMaterial' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const tableData = ref<QcMaterial[]>([])
|
||||
const selectedRows = ref<QcMaterial[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// 新增/编辑
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增物料')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
materialCode: '',
|
||||
materialName: '',
|
||||
specModel: '',
|
||||
unit: '',
|
||||
warningQty: 0,
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
materialCode: [{ required: true, message: '请输入物料编码', trigger: 'blur' }],
|
||||
materialName: [{ required: true, message: '请输入物料名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 入库
|
||||
const stockInVisible = ref(false)
|
||||
const stockInLoading = ref(false)
|
||||
const stockInFormRef = ref()
|
||||
const stockInTargetId = ref<string | null>(null)
|
||||
|
||||
const stockInForm = reactive({
|
||||
materialName: '',
|
||||
currentStock: 0,
|
||||
qty: undefined as number | undefined,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const stockInRules = {
|
||||
qty: [{ required: true, message: '请输入入库数量', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: QcMaterial[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchQcMaterialList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 新增/编辑
|
||||
function resetForm() {
|
||||
formData.materialCode = ''
|
||||
formData.materialName = ''
|
||||
formData.specModel = ''
|
||||
formData.unit = ''
|
||||
formData.warningQty = 0
|
||||
formData.status = '1'
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增物料'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: QcMaterial) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑物料'
|
||||
editingId.value = row.id
|
||||
formData.materialCode = row.materialCode || ''
|
||||
formData.materialName = row.materialName || ''
|
||||
formData.specModel = row.specModel || ''
|
||||
formData.unit = row.unit || ''
|
||||
formData.warningQty = row.warningQty ?? 0
|
||||
formData.status = row.status || '1'
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
materialCode: formData.materialCode.trim(),
|
||||
materialName: formData.materialName.trim(),
|
||||
specModel: formData.specModel?.trim() || undefined,
|
||||
unit: formData.unit?.trim() || undefined,
|
||||
warningQty: formData.warningQty,
|
||||
status: formData.status,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateQcMaterial(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateQcMaterial(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 入库
|
||||
function openStockInDialog(row: QcMaterial) {
|
||||
stockInTargetId.value = row.id
|
||||
stockInForm.materialName = row.materialName || ''
|
||||
stockInForm.currentStock = row.stockQty ?? 0
|
||||
stockInForm.qty = undefined
|
||||
stockInForm.remark = ''
|
||||
stockInVisible.value = true
|
||||
stockInFormRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleStockIn(row: QcMaterial) {
|
||||
openStockInDialog(row)
|
||||
}
|
||||
|
||||
function handleStockInFromToolbar() {
|
||||
if (selectedRows.value.length === 0) return
|
||||
openStockInDialog(selectedRows.value[0])
|
||||
}
|
||||
|
||||
async function handleStockInSubmit() {
|
||||
await stockInFormRef.value?.validate?.()
|
||||
if (!stockInTargetId.value) return
|
||||
stockInLoading.value = true
|
||||
try {
|
||||
await fetchStockInQcMaterial(stockInTargetId.value, {
|
||||
qty: stockInForm.qty!,
|
||||
remark: stockInForm.remark?.trim() || undefined
|
||||
})
|
||||
ElMessage.success('入库成功')
|
||||
stockInVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
stockInLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看变动记录
|
||||
function handleViewUsage(row: QcMaterial) {
|
||||
window.location.hash = `/sqxp/quality-inspection/qc-material-usage?materialId=${row.id}`
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function handleDelete(row: QcMaterial) {
|
||||
if ((row.stockQty ?? 0) > 0) {
|
||||
ElMessage.warning('当前库存大于0,无法删除')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该物料吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await fetchDeleteQcMaterial(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.material-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">任务列表</h2>
|
||||
<h2>任务列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
@@ -178,6 +178,43 @@
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section material-usage-section">
|
||||
<div class="section-title">物料使用(选填)</div>
|
||||
<div v-for="(mu, idx) in materialUsages" :key="idx" class="material-usage-row">
|
||||
<el-form-item label="物料" class="mu-field-material">
|
||||
<el-select
|
||||
v-model="mu.materialId"
|
||||
filterable
|
||||
placeholder="选择物料"
|
||||
style="width: 100%"
|
||||
@change="(v: string) => onMaterialSelect(mu, v)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in materialOptions"
|
||||
:key="opt.id"
|
||||
:label="opt.label"
|
||||
:value="opt.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用数量" class="mu-field-qty">
|
||||
<el-input-number
|
||||
v-model="mu.qty"
|
||||
:min="0.0001"
|
||||
:precision="4"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
placeholder="数量"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button class="btn-ghost mu-remove-btn" @click="materialUsages.splice(idx, 1)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="btn-ghost" size="small" @click="materialUsages.push({ materialId: '', materialName: '', qty: undefined })">
|
||||
<el-icon><Plus /></el-icon>添加物料
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-section result-conclusion-section">
|
||||
<div class="section-title">检测结论</div>
|
||||
<el-form-item label="结论" required>
|
||||
@@ -186,21 +223,18 @@
|
||||
<el-radio value="btg">不通过</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-section result-remark-section">
|
||||
<div class="section-title">备注</div>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="resultRemark" type="textarea" :rows="3" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-section result-attachment-section">
|
||||
<ProtocolFinanceConfirmFields
|
||||
v-model:finance-remark="resultRemark"
|
||||
v-model:attachments="resultAttachments"
|
||||
:show-remark="false"
|
||||
:attachment-label="resultAttachmentLabel"
|
||||
:upload-folder="resultUploadFolder"
|
||||
/>
|
||||
<div class="result-attachment-block">
|
||||
<ProtocolFinanceConfirmFields
|
||||
v-model:finance-remark="resultRemark"
|
||||
v-model:attachments="resultAttachments"
|
||||
:show-remark="false"
|
||||
:attachment-label="resultAttachmentLabel"
|
||||
:upload-folder="resultUploadFolder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -270,6 +304,7 @@
|
||||
type QcTaskDetail
|
||||
} from '@/api/qc-task'
|
||||
import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
|
||||
import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
|
||||
import {
|
||||
taskTypeLabel,
|
||||
taskStatusLabel,
|
||||
@@ -298,6 +333,8 @@
|
||||
const resultRemark = ref('')
|
||||
const resultConclusion = ref('')
|
||||
const resultAttachments = ref<FinanceAttachmentItem[]>([])
|
||||
const materialOptions = ref<QcMaterialOption[]>([])
|
||||
const materialUsages = ref<Array<{ materialId: string; materialName: string; qty: number | undefined }>>([])
|
||||
|
||||
function canFillResult(row: QcTask) {
|
||||
if (row.taskType === 'sysz') {
|
||||
@@ -321,6 +358,11 @@
|
||||
resultTaskType.value === 'dsfjc' ? 'qc-third-party' : 'qc-lab'
|
||||
)
|
||||
|
||||
function onMaterialSelect(mu: { materialId: string; materialName: string; qty: number | undefined }, id: string) {
|
||||
const opt = materialOptions.value.find((o) => o.id === id)
|
||||
mu.materialName = opt ? opt.materialName : ''
|
||||
}
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const viewDetail = ref<QcTaskDetail | null>(null)
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
@@ -580,6 +622,7 @@
|
||||
resultRemark.value = row.remark || ''
|
||||
resultConclusion.value = ''
|
||||
resultAttachments.value = apiFilesToFinanceItems(row.attachmentFiles)
|
||||
materialUsages.value = []
|
||||
resultLoading.value = true
|
||||
try {
|
||||
const detail = await fetchQcTaskDetail(row.id)
|
||||
@@ -594,6 +637,11 @@
|
||||
} finally {
|
||||
resultLoading.value = false
|
||||
}
|
||||
try {
|
||||
materialOptions.value = await fetchQcMaterialOptions()
|
||||
} catch {
|
||||
materialOptions.value = []
|
||||
}
|
||||
resultVisible.value = true
|
||||
}
|
||||
|
||||
@@ -612,11 +660,15 @@
|
||||
}
|
||||
resultLoading.value = true
|
||||
try {
|
||||
const validMaterialUsages = materialUsages.value.filter(
|
||||
(mu) => mu.materialId && mu.qty && mu.qty > 0
|
||||
)
|
||||
const data: {
|
||||
conclusion: string
|
||||
results: QcTaskResultItem[]
|
||||
remark?: string
|
||||
thirdPartyAttachments?: string
|
||||
materialUsages?: Array<{ materialId: string; qty: number }>
|
||||
} = {
|
||||
conclusion: resultConclusion.value,
|
||||
remark: resultRemark.value,
|
||||
@@ -628,6 +680,12 @@
|
||||
}))
|
||||
}
|
||||
data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
|
||||
if (validMaterialUsages.length > 0) {
|
||||
data.materialUsages = validMaterialUsages.map((mu) => ({
|
||||
materialId: mu.materialId,
|
||||
qty: mu.qty!,
|
||||
}))
|
||||
}
|
||||
await fetchCompleteTaskWithResult(resultTaskId.value, data)
|
||||
ElMessage.success(resultTaskStatus.value === 'ywc' ? '已保存' : '已完成')
|
||||
resultVisible.value = false
|
||||
@@ -658,19 +716,61 @@
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.material-usage-section {
|
||||
margin-top: 12px;
|
||||
|
||||
.material-usage-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.mu-field-material {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
.mu-field-qty {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.mu-remove-btn {
|
||||
margin-top: 28px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-conclusion-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.result-remark-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.result-attachment-section {
|
||||
margin-top: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.result-attachment-block {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
:deep(.protocol-finance-confirm-fields .el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.biz-dialog-form {
|
||||
|
||||
Reference in new issue
Block a user