diff --git a/backend/app/api/outbound_release_review.py b/backend/app/api/outbound_release_review.py index dc7413de..5adae3fc 100644 --- a/backend/app/api/outbound_release_review.py +++ b/backend/app/api/outbound_release_review.py @@ -47,6 +47,8 @@ async def list_pilot_release_reviews( bizType: Optional[str] = None, infusionTimeStart: Optional[str] = None, infusionTimeEnd: Optional[str] = None, + reviewPhase: Optional[str] = Query(None), + readyForReviewOnly: Optional[bool] = Query(None), db: Session = Depends(get_db), user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")), ): @@ -62,6 +64,8 @@ async def list_pilot_release_reviews( biz_type=bizType, infusion_time_start=infusionTimeStart, infusion_time_end=infusionTimeEnd, + review_phase=reviewPhase, + ready_for_review_only=readyForReviewOnly, page=page, page_size=pageSize, user_info=user, diff --git a/backend/app/crud/outbound_release_review.py b/backend/app/crud/outbound_release_review.py index 98df252d..33db171d 100644 --- a/backend/app/crud/outbound_release_review.py +++ b/backend/app/crud/outbound_release_review.py @@ -6,7 +6,7 @@ from collections import defaultdict from datetime import datetime from typing import Any, Dict, List, Optional -from sqlalchemy import and_, func, or_ +from sqlalchemy import and_, exists, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid @@ -68,6 +68,117 @@ def _total_pages(total: int, page_size: int) -> int: return int(math.ceil(total / float(page_size))) +def normalize_outbound_review_phase(review_phase: Optional[str]) -> Optional[str]: + p = (review_phase or "").strip().lower() + if p in ("pending", "reviewed"): + return p + return None + + +def normalize_outbound_ready_for_review_only(raw) -> Optional[bool]: + """Return True when ready-only filter should apply; None means no filter.""" + if raw is None: + return None + if isinstance(raw, bool): + return True if raw else None + s = str(raw).strip().lower() + if s in ("true", "1", "yes"): + return True + return None + + +def outbound_work_orders_all_void(statuses) -> bool: + """True when ≥1 non-empty status and every one equals yzf.""" + cleaned = [(s or "").strip() for s in (statuses or [])] + if not cleaned or not any(s for s in cleaned): + return False + return all(s.lower() == "yzf" for s in cleaned) + + +def _outbound_qc_unfinished_exists_for_schedule(tenant_no: str): + """Any unfinished outbound schedule QC (dpz/ypz) for this infusion schedule.""" + return ( + exists() + .where( + and_( + BizQcWorkOrder.tenant_no == tenant_no, + BizQcWorkOrder.del_flag == 0, + BizQcWorkOrder.qc_biz_type == "outbound", + BizQcWorkOrder.trigger_source == "schedule", + BizQcWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, + BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")), + ) + ) + .correlate(BizPilotInfusionSchedule) + ) + + +def _outbound_production_unfinished_exists_for_schedule(tenant_no: str): + return ( + exists() + .where( + and_( + BizProductionWorkOrder.tenant_no == tenant_no, + BizProductionWorkOrder.del_flag == 0, + BizProductionWorkOrder.work_order_type == "outbound", + BizProductionWorkOrder.infusion_schedule_id + == BizPilotInfusionSchedule.id, + BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")), + ) + ) + .correlate(BizPilotInfusionSchedule) + ) + + +def _outbound_qc_all_void_for_schedule(tenant_no: str): + base = [ + BizQcWorkOrder.tenant_no == tenant_no, + BizQcWorkOrder.del_flag == 0, + BizQcWorkOrder.qc_biz_type == "outbound", + BizQcWorkOrder.trigger_source == "schedule", + BizQcWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, + ] + any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule) + any_non_void = ( + exists() + .where( + and_( + *base, + or_( + BizQcWorkOrder.work_order_status.is_(None), + BizQcWorkOrder.work_order_status != "yzf", + ), + ) + ) + .correlate(BizPilotInfusionSchedule) + ) + return and_(any_wo, ~any_non_void) + + +def _outbound_production_all_void_for_schedule(tenant_no: str): + base = [ + BizProductionWorkOrder.tenant_no == tenant_no, + BizProductionWorkOrder.del_flag == 0, + BizProductionWorkOrder.work_order_type == "outbound", + BizProductionWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, + ] + any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule) + any_non_void = ( + exists() + .where( + and_( + *base, + or_( + BizProductionWorkOrder.work_order_status.is_(None), + BizProductionWorkOrder.work_order_status != "yzf", + ), + ) + ) + .correlate(BizPilotInfusionSchedule) + ) + return and_(any_wo, ~any_non_void) + + def _get_user_name(db: Session, user_id: str) -> str: u = db.query(SysUser).filter(SysUser.id == user_id).first() return (u.nick_name or "") if u else "" @@ -132,6 +243,8 @@ def list_reviews_paginated( biz_type: Optional[str] = None, infusion_time_start: Optional[str] = None, infusion_time_end: Optional[str] = None, + review_phase: Optional[str] = None, + ready_for_review_only=None, page: int = 1, page_size: int = 10, user_info=None, @@ -246,24 +359,45 @@ def list_reviews_paginated( if time_bounds: q0 = q0.filter(or_(and_(*time_bounds), BizPilotInfusionSchedule.infusion_time.is_(None))) - # ── review_conclusion 过滤 ── + # ── reviewPhase / review_conclusion 过滤 ── + phase = normalize_outbound_review_phase(review_phase) rc = (review_conclusion or "").strip() - if rc == "pending": - # 待审核:不存在审核记录 + if phase == "pending" or (phase is None and rc == "pending"): reviewed_schedule_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( BizOutboundReleaseReview.tenant_no == tenant_no, BizOutboundReleaseReview.del_flag == 0, + BizOutboundReleaseReview.review_conclusion.isnot(None), BizOutboundReleaseReview.review_conclusion != "", ) .subquery() ) - q0 = q0.filter( - ~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids) + q0 = q0.filter(~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) + elif phase == "reviewed": + reviewed_schedule_ids = ( + db.query(BizOutboundReleaseReview.infusion_schedule_id) + .filter( + BizOutboundReleaseReview.tenant_no == tenant_no, + BizOutboundReleaseReview.del_flag == 0, + BizOutboundReleaseReview.review_conclusion.isnot(None), + BizOutboundReleaseReview.review_conclusion != "", + ) + .subquery() ) - elif rc: - # 有具体结论 + q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) + if rc in ("tg", "btg"): + conclusion_ids = ( + db.query(BizOutboundReleaseReview.infusion_schedule_id) + .filter( + BizOutboundReleaseReview.tenant_no == tenant_no, + BizOutboundReleaseReview.del_flag == 0, + BizOutboundReleaseReview.review_conclusion == rc, + ) + .subquery() + ) + q0 = q0.filter(BizPilotInfusionSchedule.id.in_(conclusion_ids)) + elif rc in ("tg", "btg"): reviewed_schedule_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( @@ -273,10 +407,22 @@ def list_reviews_paginated( ) .subquery() ) + q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) + + if normalize_outbound_ready_for_review_only(ready_for_review_only) is True: q0 = q0.filter( - BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids) + ~_outbound_qc_unfinished_exists_for_schedule(tenant_no), + ~_outbound_production_unfinished_exists_for_schedule(tenant_no), ) + # 出库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻) + q0 = q0.filter( + ~or_( + _outbound_qc_all_void_for_schedule(tenant_no), + _outbound_production_all_void_for_schedule(tenant_no), + ) + ) + # ── 数据权限 ── if user_info: from app.core.data_scope import apply_data_scope diff --git a/backend/tests/test_outbound_release_review_filters.py b/backend/tests/test_outbound_release_review_filters.py new file mode 100644 index 00000000..78681578 --- /dev/null +++ b/backend/tests/test_outbound_release_review_filters.py @@ -0,0 +1,34 @@ +from app.crud.outbound_release_review import ( + normalize_outbound_ready_for_review_only, + normalize_outbound_review_phase, + outbound_work_orders_all_void, +) + + +def test_normalize_outbound_review_phase(): + assert normalize_outbound_review_phase("pending") == "pending" + assert normalize_outbound_review_phase("REVIEWED") == "reviewed" + assert normalize_outbound_review_phase(" reviewed ") == "reviewed" + assert normalize_outbound_review_phase(None) is None + assert normalize_outbound_review_phase("") is None + assert normalize_outbound_review_phase("all") is None + + +def test_normalize_outbound_ready_for_review_only(): + assert normalize_outbound_ready_for_review_only(True) is True + assert normalize_outbound_ready_for_review_only("true") is True + assert normalize_outbound_ready_for_review_only("1") is True + assert normalize_outbound_ready_for_review_only("yes") is True + assert normalize_outbound_ready_for_review_only(False) is None + assert normalize_outbound_ready_for_review_only("false") is None + assert normalize_outbound_ready_for_review_only(None) is None + + +def test_outbound_all_void_predicate(): + assert outbound_work_orders_all_void([]) is False + assert outbound_work_orders_all_void([""]) is False + assert outbound_work_orders_all_void(["yzf"]) is True + assert outbound_work_orders_all_void(["yzf", "yzf"]) is True + assert outbound_work_orders_all_void([" YZF ", "yzf"]) is True + assert outbound_work_orders_all_void(["yzf", "ywc"]) is False + assert outbound_work_orders_all_void(["yzf", ""]) is False