"""移动市场运费批次:按现场范围过滤明细并执行原子写入。""" from __future__ import annotations from datetime import datetime, timedelta from decimal import Decimal, InvalidOperation from typing import Any, Dict, List, Optional, Tuple from fastapi import HTTPException from sqlalchemy import and_, exists, func, literal, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, aliased from app.core.data_scope import apply_data_scope from app.core.utils import generate_uuid from app.models.models import ( BizCellSampleType, BizCollectionProtocol, BizCollectionRegionStaff, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolSample, BizSampleFreight, BizSampleFreightItem, SysDept, SysDictData, SysPost, SysPostRole, SysRole, SysRoleDataScopeDetail, SysUser, SysUserPost, SysUserRole, ) from app.models.research_donation import ( BizResearchDonation, BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample, ) from app.services.market_field_scope import ( MarketFieldContext, apply_cell_protocol_scope, apply_donation_scope, ) from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value from app.services.sf_express_settlement import ( assert_freight_mutable, freight_amount_label, resolve_header_for_save, ) from app.services.web_auth_roles import ( _map_role_name_to_web, resolve_web_role_for_user, ) CELL = 'cell_storage' DONATION = 'research_donation' SOURCES = (CELL, DONATION) def _current_tenant_user_info( db: Session, ctx: MarketFieldContext, user_info: dict ) -> dict: """用当前租户有效角色覆盖不可信的令牌 role 快照。""" if not isinstance(user_info, dict): raise PermissionError('用户身份与现场范围上下文不一致') user_id = str(user_info.get('user_id') or '').strip() tenant_no = str(user_info.get('tenant_no') or '').strip() if user_id != ctx.user_id or tenant_no != ctx.tenant_no: raise PermissionError('用户身份与现场范围上下文不一致') current = dict(user_info) current['role'] = resolve_web_role_for_user( db, ctx.user_id, tenant_no=ctx.tenant_no ) return current def _latest_cell_party_id(ctx: MarketFieldContext): latest = aliased(BizProtocolPartyCustomer) return ( select(latest.id) .where( latest.collection_protocol_id == BizCollectionProtocol.id, latest.tenant_no == ctx.tenant_no, latest.del_flag == 0, ) .order_by(latest.create_time.desc(), latest.id.desc()) .limit(1) .correlate(BizCollectionProtocol) .scalar_subquery() ) def _latest_donation_party_id(ctx: MarketFieldContext): latest = aliased(BizResearchDonationParty) return ( select(latest.id) .where( latest.research_donation_id == BizResearchDonation.id, latest.tenant_no == ctx.tenant_no, latest.del_flag == 0, ) .order_by(latest.create_time.desc(), latest.id.desc()) .limit(1) .correlate(BizResearchDonation) .scalar_subquery() ) def _amount(value: Any) -> Decimal: try: result = Decimal(str(value)) except (InvalidOperation, ValueError, TypeError) as exc: raise ValueError('运费格式无效') from exc if not result.is_finite() or result < 0: raise ValueError('运费格式无效') return result.quantize(Decimal('0.01')) def _fmt_dt(value: Optional[datetime]) -> str: return value.strftime('%Y-%m-%d %H:%M:%S') if value else '' def _normalize_items(items: List[Dict[str, Any]]) -> List[Tuple[str, str]]: if not items: raise ValueError('至少选择一个样本') result: List[Tuple[str, str]] = [] seen = set() for item in items: source = str(item.get('sampleSource') or item.get('sample_source') or '').strip() sample_id = str(item.get('sampleId') or item.get('sample_id') or '').strip() if source not in SOURCES or not sample_id: raise ValueError('关联样本须包含有效的 sampleSource 与 sampleId') key = (source, sample_id) if key in seen: raise ValueError('关联样本不可重复') seen.add(key) result.append(key) return result def _cell_item_query(db: Session, ctx: MarketFieldContext, user_info: dict): query = ( db.query( BizSampleFreightItem, BizProtocolSample, BizCollectionProtocol, BizProtocolPartyCustomer, BizCellSampleType, ) .join( BizProtocolSample, and_( BizProtocolSample.id == BizSampleFreightItem.sample_id, BizProtocolSample.tenant_no == ctx.tenant_no, BizProtocolSample.del_flag == 0, ), ) .join( BizCollectionProtocol, and_( BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, BizCollectionProtocol.tenant_no == ctx.tenant_no, BizCollectionProtocol.del_flag == 0, ), ) .outerjoin( BizProtocolPartyCustomer, and_( BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx), BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, BizProtocolPartyCustomer.del_flag == 0, ), ) .outerjoin( BizCellSampleType, and_( BizCellSampleType.id == BizProtocolSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0, ), ) .filter( BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, BizSampleFreightItem.sample_source == CELL, ) ) return apply_cell_protocol_scope(query, db, ctx, user_info=user_info) def _donation_item_query(db: Session, ctx: MarketFieldContext, user_info: dict): query = ( db.query( BizSampleFreightItem, BizResearchDonationSample, BizResearchDonation, BizResearchDonationParty, BizCellSampleType, ) .join( BizResearchDonationSample, and_( BizResearchDonationSample.id == BizSampleFreightItem.sample_id, BizResearchDonationSample.tenant_no == ctx.tenant_no, BizResearchDonationSample.del_flag == 0, ), ) .join( BizResearchDonation, and_( BizResearchDonation.id == BizResearchDonationSample.research_donation_id, BizResearchDonation.tenant_no == ctx.tenant_no, BizResearchDonation.del_flag == 0, ), ) .outerjoin( BizResearchDonationParty, and_( BizResearchDonationParty.id == _latest_donation_party_id(ctx), BizResearchDonationParty.tenant_no == ctx.tenant_no, BizResearchDonationParty.del_flag == 0, ), ) .outerjoin( BizCellSampleType, and_( BizCellSampleType.id == BizResearchDonationSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0, ), ) .filter( BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, BizSampleFreightItem.sample_source == DONATION, ) ) return apply_donation_scope(query, db, ctx, user_info=user_info) def _visible_item_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict): return _cell_item_query(db, ctx, user_info) if source == CELL else _donation_item_query(db, ctx, user_info) def _visible_rows(db: Session, freight_ids: List[str], ctx: MarketFieldContext, user_info: dict): rows = [] if not freight_ids: return rows for source in SOURCES: rows.extend( _visible_item_query(db, source, ctx, user_info) .filter(BizSampleFreightItem.freight_id.in_(freight_ids)) .order_by(BizSampleFreightItem.create_time.asc(), BizSampleFreightItem.id.asc()) .all() ) rows.sort(key=lambda row: (row[0].create_time or datetime.min, row[0].id)) # 业务库历史上可能存在多条有效客户方信息;明细身份始终以关联行 ID 为准。 unique = {} for row in rows: unique.setdefault(row[0].id, row) return list(unique.values()) def _item_dict(row) -> Dict[str, Any]: item, sample, parent, party, sample_type = row source = item.sample_source return { 'id': item.id, 'freightId': item.freight_id, 'sampleSource': source, 'sampleId': sample.id, 'sampleCode': sample.sample_code or '', 'protocolId': parent.id, 'protocolNo': (parent.protocol_no if source == CELL else parent.donation_no) or '', 'sampleTypeName': sample_type.type_name if sample_type else '', 'motherName': party.mother_name if party else '', 'newbornName': party.newborn_name if party else '', } def _label_map(db: Session, tenant_no: str, values: List[str]) -> Dict[str, str]: wanted = list({value for value in values if value}) if not wanted: return {} rows = db.query(SysDictData.dict_value, SysDictData.dict_label).filter( SysDictData.tenant_no == tenant_no, SysDictData.dict_type == DICT_TYPE, SysDictData.dict_value.in_(wanted), SysDictData.del_flag == 0, ).all() return {value: label or value for value, label in rows} def _creator_name_map( db: Session, tenant_no: str, user_ids: List[str] ) -> Dict[str, str]: wanted = list({str(user_id or '').strip() for user_id in user_ids if user_id}) if not wanted: return {} rows = ( db.query(SysUser.id, SysUser.nick_name) .filter( SysUser.id.in_(wanted), SysUser.tenant_no == tenant_no, ) .all() ) return {user_id: nick_name or '' for user_id, nick_name in rows} def _batch_dict( obj: BizSampleFreight, count: int, label: str, creator_name: str = '' ) -> Dict[str, Any]: amount = obj.freight_amount settle_time = getattr(obj, 'settle_time', None) return { 'id': obj.id, 'freightNo': obj.freight_no or '', 'transportMode': obj.transport_mode or '', 'transportModeLabel': label or obj.transport_mode or '', 'freightAmount': None if amount is None else str(Decimal(amount).quantize(Decimal('0.01'))), 'freightAmountLabel': freight_amount_label(obj.transport_mode, amount), 'waybillNo': getattr(obj, 'waybill_no', None) or '', 'settleStatus': getattr(obj, 'settle_status', None) or '', 'settleTime': _fmt_dt(settle_time) if settle_time else '', 'visibleItemCount': int(count), 'remark': obj.remark or '', 'createBy': obj.create_by or '', 'createByName': creator_name, 'createTime': _fmt_dt(obj.create_time), 'updateTime': _fmt_dt(obj.update_time), } def _freight_accessible( db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict ) -> Optional[BizSampleFreight]: """按 Web 运费单数据权限解析批次;不再要求现场可见明细。""" q = db.query(BizSampleFreight).filter( BizSampleFreight.id == freight_id, BizSampleFreight.tenant_no == ctx.tenant_no, BizSampleFreight.del_flag == 0, ) return apply_data_scope(q, BizSampleFreight, user_info, db, ctx.tenant_no).first() def list_freights( db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, transport_mode: Optional[str] = None, sample_source: Optional[str] = None, include_items: bool = False, create_time_start: Optional[str] = None, create_time_end: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: """列表与 Web 一致:租户 + 通用数据权限;明细仍按现场范围脱敏。""" user_info = _current_tenant_user_info(db, ctx, user_info) page, page_size = max(1, int(page)), max(1, min(100, int(page_size))) source = (sample_source or '').strip() if source and source not in SOURCES: raise ValueError('sampleSource 无效') q = db.query(BizSampleFreight).filter( BizSampleFreight.tenant_no == ctx.tenant_no, BizSampleFreight.del_flag == 0, ) q = apply_data_scope(q, BizSampleFreight, user_info, db, ctx.tenant_no) def date_value(raw: Optional[str], *, end: bool = False) -> Optional[datetime]: text = str(raw or '').strip() if not text: return None try: parsed = datetime.strptime(text, '%Y-%m-%d') except ValueError as exc: raise ValueError('日期格式无效,应为 YYYY-MM-DD') from exc return parsed + timedelta(days=1) if end else parsed created_from = date_value(create_time_start) created_until = date_value(create_time_end, end=True) if created_from: q = q.filter(BizSampleFreight.create_time >= created_from) if created_until: q = q.filter(BizSampleFreight.create_time < created_until) if source: item_exists = exists().where( and_( BizSampleFreightItem.freight_id == BizSampleFreight.id, BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, BizSampleFreightItem.sample_source == source, ) ) q = q.filter(item_exists) if transport_mode: q = q.filter(BizSampleFreight.transport_mode == transport_mode.strip()) text = (keyword or '').strip() if text: like = f'%{text}%' label_values = [ value for value, in db.query(SysDictData.dict_value) .filter( SysDictData.dict_type == DICT_TYPE, SysDictData.tenant_no == ctx.tenant_no, SysDictData.del_flag == 0, SysDictData.dict_label.like(like), ) .all() ] sample_code_exists = exists().where( and_( BizSampleFreightItem.freight_id == BizSampleFreight.id, BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, BizSampleFreightItem.sample_code.like(like), ) ) conds = [ BizSampleFreight.freight_no.like(like), BizSampleFreight.transport_mode.like(like), sample_code_exists, ] if label_values: conds.append(BizSampleFreight.transport_mode.in_(label_values)) q = q.filter(or_(*conds)) total = q.count() batches = q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()).offset((page - 1) * page_size).limit(page_size).all() ids = [row.id for row in batches] visible_rows = _visible_rows(db, ids, ctx, user_info) active_counts = { freight_id: int(count) for freight_id, count in db.query( BizSampleFreightItem.freight_id, func.count(BizSampleFreightItem.id), ).filter( BizSampleFreightItem.freight_id.in_(ids or ['']), BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, ).group_by(BizSampleFreightItem.freight_id).all() } by_batch: Dict[str, list] = {} for row in visible_rows: by_batch.setdefault(row[0].freight_id, []).append(row) labels = _label_map(db, ctx.tenant_no, [row.transport_mode for row in batches]) creator_names = _creator_name_map( db, ctx.tenant_no, [row.create_by for row in batches] ) result = [] for batch in batches: rows = by_batch.get(batch.id, []) data = _batch_dict( batch, len(rows), labels.get(batch.transport_mode or '', ''), creator_names.get(batch.create_by or '', ''), ) has_outside_scope_item = active_counts.get(batch.id, 0) > len(rows) data['allowedActions'] = ( ['link', 'unlink'] if has_outside_scope_item else ['edit', 'delete', 'link', 'unlink'] ) if include_items: data['items'] = [_item_dict(row) for row in rows] result.append(data) return result, total def get_freight_detail(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict) -> Dict[str, Any]: user_info = _current_tenant_user_info(db, ctx, user_info) obj = _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info) if not obj: raise HTTPException(status_code=404, detail='数据不存在或无权访问') rows = _visible_rows(db, [freight_id], ctx, user_info) labels = _label_map(db, ctx.tenant_no, [obj.transport_mode]) creator_names = _creator_name_map(db, ctx.tenant_no, [obj.create_by]) data = _batch_dict( obj, len(rows), labels.get(obj.transport_mode or '', ''), creator_names.get(obj.create_by or '', ''), ) data['items'] = [_item_dict(row) for row in rows] visible_item_ids = {row[0].id for row in rows} has_outside_scope_item = ( db.query(BizSampleFreightItem.id) .filter( BizSampleFreightItem.freight_id == freight_id, BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, *([BizSampleFreightItem.id.notin_(visible_item_ids)] if visible_item_ids else []), ) .first() is not None ) data['allowedActions'] = ( ['link', 'unlink'] if has_outside_scope_item or not rows else ['edit', 'delete', 'link', 'unlink'] ) return data def _option_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict, keyword: str): if source == CELL: q = db.query( literal(CELL).label('source'), BizProtocolSample.id.label('sample_id'), BizProtocolSample.sample_code.label('sample_code'), BizCollectionProtocol.id.label('protocol_id'), BizCollectionProtocol.protocol_no.label('protocol_no'), BizProtocolPartyCustomer.mother_name.label('customer_name'), BizProtocolPartyCustomer.newborn_name.label('newborn_name'), BizCellSampleType.type_name.label('sample_type_name'), BizProtocolSample.create_time.label('create_time'), ).join(BizCollectionProtocol, and_(BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, BizCollectionProtocol.tenant_no == ctx.tenant_no, BizCollectionProtocol.del_flag == 0)).outerjoin(BizProtocolPartyCustomer, and_(BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx), BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, BizProtocolPartyCustomer.del_flag == 0)).outerjoin(BizCellSampleType, and_(BizCellSampleType.id == BizProtocolSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0)).filter(BizProtocolSample.tenant_no == ctx.tenant_no, BizProtocolSample.del_flag == 0) q = apply_cell_protocol_scope(q, db, ctx, user_info=user_info) if keyword: like = f'%{keyword}%' q = q.filter(or_(BizProtocolSample.sample_code.like(like), BizCollectionProtocol.protocol_no.like(like), BizProtocolPartyCustomer.mother_name.like(like))) return q.distinct() q = db.query( literal(DONATION).label('source'), BizResearchDonationSample.id.label('sample_id'), BizResearchDonationSample.sample_code.label('sample_code'), BizResearchDonation.id.label('protocol_id'), BizResearchDonation.donation_no.label('protocol_no'), BizResearchDonationParty.mother_name.label('customer_name'), BizResearchDonationParty.newborn_name.label('newborn_name'), BizCellSampleType.type_name.label('sample_type_name'), BizResearchDonationSample.create_time.label('create_time'), ).join(BizResearchDonation, and_(BizResearchDonation.id == BizResearchDonationSample.research_donation_id, BizResearchDonation.tenant_no == ctx.tenant_no, BizResearchDonation.del_flag == 0)).outerjoin(BizResearchDonationParty, and_(BizResearchDonationParty.id == _latest_donation_party_id(ctx), BizResearchDonationParty.tenant_no == ctx.tenant_no, BizResearchDonationParty.del_flag == 0)).outerjoin(BizCellSampleType, and_(BizCellSampleType.id == BizResearchDonationSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0)).filter(BizResearchDonationSample.tenant_no == ctx.tenant_no, BizResearchDonationSample.del_flag == 0) q = apply_donation_scope(q, db, ctx, user_info=user_info) if keyword: like = f'%{keyword}%' q = q.filter(or_(BizResearchDonationSample.sample_code.like(like), BizResearchDonation.donation_no.like(like), BizResearchDonationParty.mother_name.like(like))) return q.distinct() def list_sample_options(db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, page_size: int = 20, keyword: Optional[str] = None, sample_source: Optional[str] = None): user_info = _current_tenant_user_info(db, ctx, user_info) page, page_size = max(1, int(page)), max(1, min(100, int(page_size))) source = (sample_source or '').strip() if source and source not in SOURCES: raise ValueError('sampleSource 无效') sources = (source,) if source else SOURCES queries = [_option_query(db, src, ctx, user_info, (keyword or '').strip()) for src in sources] combined = queries[0] if len(queries) == 1 else queries[0].union_all(*queries[1:]) sub = combined.subquery() total = db.query(func.count()).select_from(sub).scalar() or 0 rows = db.query(sub).order_by(sub.c.create_time.desc(), sub.c.source.desc(), sub.c.sample_id.desc()).offset((page - 1) * page_size).limit(page_size).all() keys = [(row.source, row.sample_id) for row in rows] links = {} if keys: clauses = [and_(BizSampleFreightItem.sample_source == src, BizSampleFreightItem.sample_id == sid) for src, sid in keys] for item, freight_no in db.query( BizSampleFreightItem, BizSampleFreight.freight_no ).join( BizSampleFreight, and_( BizSampleFreight.id == BizSampleFreightItem.freight_id, BizSampleFreight.tenant_no == ctx.tenant_no, BizSampleFreight.del_flag == 0, ), ).filter( BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, or_(*clauses), ).all(): links[(item.sample_source, item.sample_id)] = ( item.freight_id, freight_no or '' ) return [{ 'sampleSource': row.source, 'sampleId': row.sample_id, 'sampleCode': row.sample_code or '', 'protocolId': row.protocol_id, 'protocolNo': row.protocol_no or '', 'customerName': row.customer_name or '', 'newbornName': row.newborn_name or '', 'sampleTypeName': row.sample_type_name or '', 'createTime': _fmt_dt(row.create_time), 'freightId': (links.get((row.source, row.sample_id)) or ('', ''))[0], 'freightNo': (links.get((row.source, row.sample_id)) or ('', ''))[1], } for row in rows], int(total) def _sample_parent_lock_query( db: Session, source: str, sample_ids: List[str], ctx: MarketFieldContext, ): ids = sorted(set(sample_ids)) if source == CELL: query = ( db.query(BizProtocolSample, BizCollectionProtocol) .join( BizCollectionProtocol, and_( BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, BizCollectionProtocol.tenant_no == ctx.tenant_no, BizCollectionProtocol.del_flag == 0, ), ) .filter( BizProtocolSample.id.in_(ids), BizProtocolSample.tenant_no == ctx.tenant_no, BizProtocolSample.del_flag == 0, ) ) return query.order_by( BizProtocolSample.id.asc(), BizCollectionProtocol.id.asc() ).populate_existing().with_for_update() query = ( db.query(BizResearchDonationSample, BizResearchDonation) .join( BizResearchDonation, and_( BizResearchDonation.id == BizResearchDonationSample.research_donation_id, BizResearchDonation.tenant_no == ctx.tenant_no, BizResearchDonation.del_flag == 0, ), ) .filter( BizResearchDonationSample.id.in_(ids), BizResearchDonationSample.tenant_no == ctx.tenant_no, BizResearchDonationSample.del_flag == 0, ) ) return query.order_by( BizResearchDonationSample.id.asc(), BizResearchDonation.id.asc() ).populate_existing().with_for_update() def _collection_lock_query( db: Session, source: str, parent_ids: List[str], ctx: MarketFieldContext, ): ids = sorted(set(parent_ids)) if source == CELL: return ( db.query(BizProtocolCollectionInfo) .filter( BizProtocolCollectionInfo.collection_protocol_id.in_(ids), BizProtocolCollectionInfo.tenant_no == ctx.tenant_no, BizProtocolCollectionInfo.del_flag == 0, ) .order_by( BizProtocolCollectionInfo.collection_protocol_id.asc(), BizProtocolCollectionInfo.id.asc(), ) .populate_existing() .with_for_update() ) return ( db.query(BizResearchDonationCollection) .filter( BizResearchDonationCollection.research_donation_id.in_(ids), BizResearchDonationCollection.tenant_no == ctx.tenant_no, BizResearchDonationCollection.del_flag == 0, ) .order_by( BizResearchDonationCollection.research_donation_id.asc(), BizResearchDonationCollection.id.asc(), ) .populate_existing() .with_for_update() ) def _leader_lock_query(db: Session, ctx: MarketFieldContext): """锁定经理当前有效的地区负责人关系,供字段范围直接计算。""" return ( db.query(BizCollectionRegionStaff) .filter( BizCollectionRegionStaff.tenant_no == ctx.tenant_no, BizCollectionRegionStaff.del_flag == 0, BizCollectionRegionStaff.user_id == ctx.user_id, BizCollectionRegionStaff.is_leader == 1, ) .order_by( BizCollectionRegionStaff.region_id.asc(), BizCollectionRegionStaff.id.asc(), ) .populate_existing() .with_for_update() ) def _current_field_visible_parent_ids( source: str, collection_rows: List[Any], leader_rows: List[BizCollectionRegionStaff], ctx: MarketFieldContext, ) -> set[str]: """只使用已加锁的采集分配/负责人当前值计算现场字段范围。""" parent_attr = ( 'collection_protocol_id' if source == CELL else 'research_donation_id' ) leader_regions = {row.region_id for row in leader_rows} visible: set[str] = set() for row in collection_rows: if ctx.scope_kind == 'specialist': allowed = row.sales_user_id == ctx.user_id else: allowed = ( row.region_id in leader_regions or row.region_manager_user_id == ctx.user_id ) if allowed: visible.add(getattr(row, parent_attr)) return visible def _current_data_scope_parent_ids( db: Session, parents: List[Any], ctx: MarketFieldContext, user_info: dict, ) -> set[str]: """锁定数据权限依赖并按锁后当前值计算多角色并集范围。""" if not isinstance(user_info, dict) or str( user_info.get('user_id') or '' ).strip() != ctx.user_id: raise PermissionError('用户身份与现场范围上下文不一致') parent_ids = {parent.id for parent in parents} current_users = ( db.query(SysUser) .filter( SysUser.id == ctx.user_id, SysUser.tenant_no == ctx.tenant_no, SysUser.del_flag == 0, ) .order_by(SysUser.id.asc()) .populate_existing() .with_for_update() .all() ) if not current_users: return set() current_user = current_users[0] direct_links = ( db.query(SysUserRole) .filter( SysUserRole.user_id == ctx.user_id, SysUserRole.tenant_no == ctx.tenant_no, SysUserRole.del_flag == 0, ) .order_by(SysUserRole.role_id.asc(), SysUserRole.id.asc()) .populate_existing() .with_for_update() .all() ) user_posts = ( db.query(SysUserPost) .filter( SysUserPost.user_id == ctx.user_id, SysUserPost.tenant_no == ctx.tenant_no, SysUserPost.del_flag == 0, ) .order_by(SysUserPost.post_id.asc(), SysUserPost.id.asc()) .populate_existing() .with_for_update() .all() ) post_ids = sorted({row.post_id for row in user_posts}) posts = [] if post_ids: posts = ( db.query(SysPost) .filter( SysPost.id.in_(post_ids), SysPost.tenant_no == ctx.tenant_no, SysPost.del_flag == 0, SysPost.status == '1', ) .order_by(SysPost.id.asc()) .populate_existing() .with_for_update() .all() ) enabled_post_ids = sorted({row.id for row in posts}) post_role_links = [] if enabled_post_ids: post_role_links = ( db.query(SysPostRole) .filter( SysPostRole.post_id.in_(enabled_post_ids), SysPostRole.tenant_no == ctx.tenant_no, SysPostRole.del_flag == 0, ) .order_by( SysPostRole.post_id.asc(), SysPostRole.role_id.asc(), SysPostRole.id.asc(), ) .populate_existing() .with_for_update() .all() ) role_ids = sorted( {row.role_id for row in direct_links} | {row.role_id for row in post_role_links} ) roles = [] if role_ids: roles = ( db.query(SysRole) .filter( SysRole.id.in_(role_ids), SysRole.tenant_no == ctx.tenant_no, SysRole.del_flag == 0, SysRole.status == '1', ) .order_by(SysRole.id.asc()) .populate_existing() .with_for_update() .all() ) if not roles: return set() if any( _map_role_name_to_web(role.role_name) == 'super_admin' for role in roles ): return parent_ids if any((role.data_scope or '1') == '1' for role in roles): return parent_ids active_role_ids = sorted({role.id for role in roles}) details = ( db.query(SysRoleDataScopeDetail) .filter( SysRoleDataScopeDetail.role_id.in_(active_role_ids), SysRoleDataScopeDetail.tenant_no == ctx.tenant_no, SysRoleDataScopeDetail.del_flag == 0, ) .order_by( SysRoleDataScopeDetail.role_id.asc(), SysRoleDataScopeDetail.scope_type.asc(), SysRoleDataScopeDetail.scope_value.asc(), SysRoleDataScopeDetail.id.asc(), ) .populate_existing() .with_for_update() .all() ) details_by_role: Dict[str, List[SysRoleDataScopeDetail]] = {} for detail in details: details_by_role.setdefault(detail.role_id, []).append(detail) dept_ids: set[str] = set() user_ids: set[str] = set() current_dept_id = (current_user.dept_id or '').strip() needs_descendants = any( (role.data_scope or '1') == '3' for role in roles ) dept_rows = [] if needs_descendants and current_dept_id: dept_rows = ( db.query(SysDept) .filter( SysDept.tenant_no == ctx.tenant_no, SysDept.del_flag == 0, ) .order_by(SysDept.id.asc()) .populate_existing() .with_for_update() .all() ) descendants = {current_dept_id} if current_dept_id else set() if dept_rows and current_dept_id: pending = [current_dept_id] while pending: parent_id = pending.pop(0) for dept in dept_rows: if dept.parent_id == parent_id and dept.id not in descendants: descendants.add(dept.id) pending.append(dept.id) for role in roles: scope = role.data_scope or '1' if scope == '2' and current_dept_id: dept_ids.add(current_dept_id) elif scope == '3': dept_ids.update(descendants) elif scope == '4': user_ids.add(ctx.user_id) elif scope == '5': for detail in details_by_role.get(role.id, []): value = (detail.scope_value or '').strip() if not value: continue if detail.scope_type == 'dept': dept_ids.add(value) elif detail.scope_type == 'user': user_ids.add(value) return { parent.id for parent in parents if ( (parent.create_dept_id in dept_ids if dept_ids else False) or (parent.create_by in user_ids if user_ids else False) ) } def _active_item_lock_query( db: Session, ctx: MarketFieldContext, *, freight_id: Optional[str] = None, keys: Optional[List[Tuple[str, str]]] = None, ): conditions = [] if freight_id: conditions.append(BizSampleFreightItem.freight_id == freight_id) for source, sample_id in sorted(set(keys or [])): conditions.append( and_( BizSampleFreightItem.sample_source == source, BizSampleFreightItem.sample_id == sample_id, ) ) query = db.query(BizSampleFreightItem).filter( BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, ) if conditions: query = query.filter(or_(*conditions)) else: query = query.filter(False) return query.order_by( BizSampleFreightItem.sample_source.asc(), BizSampleFreightItem.sample_id.asc(), BizSampleFreightItem.id.asc(), ).populate_existing().with_for_update() def _lock_batch_header( db: Session, freight_id: str, ctx: MarketFieldContext ) -> BizSampleFreight: obj = ( db.query(BizSampleFreight) .filter( BizSampleFreight.id == freight_id, BizSampleFreight.tenant_no == ctx.tenant_no, BizSampleFreight.del_flag == 0, ) .populate_existing() .with_for_update() .first() ) if not obj: raise HTTPException(status_code=404, detail='数据不存在或无权访问') return obj def _discover_batch_keys( db: Session, freight_id: str, ctx: MarketFieldContext ) -> List[Tuple[str, str]]: """批次头已锁后发现参与者;最终关联锁会再次验证该快照。""" rows = ( db.query( BizSampleFreightItem.sample_source, BizSampleFreightItem.sample_id, ) .filter( BizSampleFreightItem.freight_id == freight_id, BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, ) .order_by( BizSampleFreightItem.sample_source.asc(), BizSampleFreightItem.sample_id.asc(), BizSampleFreightItem.id.asc(), ) .all() ) return [(source, sample_id) for source, sample_id in rows] def _lock_sample_graph( db: Session, keys: List[Tuple[str, str]], ctx: MarketFieldContext, user_info: dict, ) -> Dict[Tuple[str, str], Tuple[Any, Any]]: """按来源和 ID 锁样本/父单/采集分配,再用锁定当前读重算范围。""" normalized = sorted(set(keys)) locked_rows: Dict[str, List[Tuple[Any, Any]]] = {} locked_collections: Dict[str, List[Any]] = {} for source in SOURCES: ids = [sample_id for item_source, sample_id in normalized if item_source == source] if not ids: continue rows = _sample_parent_lock_query(db, source, ids, ctx).all() locked_rows[source] = rows parent_ids = [parent.id for _, parent in rows] locked_collections[source] = _collection_lock_query( db, source, parent_ids, ctx ).all() leader_rows = ( _leader_lock_query(db, ctx).all() if ctx.scope_kind == 'manager' else [] ) all_parents = [ parent for rows in locked_rows.values() for _, parent in rows ] data_scope_ids = _current_data_scope_parent_ids( db, all_parents, ctx, user_info ) visible: Dict[Tuple[str, str], Tuple[Any, Any]] = {} for source, rows in locked_rows.items(): field_scope_ids = _current_field_visible_parent_ids( source, locked_collections[source], leader_rows, ctx ) for sample, parent in rows: if parent.id in field_scope_ids and parent.id in data_scope_ids: visible[(source, sample.id)] = (sample, parent) return visible def _lock_active_items( db: Session, ctx: MarketFieldContext, *, freight_id: Optional[str] = None, keys: Optional[List[Tuple[str, str]]] = None, expected_batch_keys: Optional[List[Tuple[str, str]]] = None, ) -> List[BizSampleFreightItem]: rows = _active_item_lock_query( db, ctx, freight_id=freight_id, keys=keys ).all() if freight_id is not None and expected_batch_keys is not None: current = sorted( (row.sample_source, row.sample_id) for row in rows if row.freight_id == freight_id ) if current != sorted(expected_batch_keys): raise PermissionError('批次明细已发生变化,请重试') return rows def _next_freight_no(db: Session, tenant_no: str, now: datetime, offset: int = 0) -> str: prefix = f"YF{now.strftime('%Y%m%d')}" latest = db.query(BizSampleFreight.freight_no).filter(BizSampleFreight.tenant_no == tenant_no, BizSampleFreight.freight_no.like(f'{prefix}%')).order_by(BizSampleFreight.freight_no.desc()).with_for_update().first() seq = int(str(latest[0])[-3:]) + 1 if latest and str(latest[0])[-3:].isdigit() else 1 seq += offset if seq > 999: raise ValueError('当日运费批次号已达上限(999)') return f'{prefix}{seq:03d}' def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, transport_mode: str, freight_amount: Any, items: List[Dict[str, Any]], waybill_no: Any = None): keys = _normalize_items(items) try: samples = _lock_sample_graph(db, keys, ctx, user_info) if len(samples) != len(set(keys)): raise HTTPException(status_code=404, detail='样本不存在或无权操作') conflicts = _lock_active_items(db, ctx, keys=keys) if conflicts: raise ValueError('样本已关联其他运费批次') mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=transport_mode, user_id=ctx.user_id) plan = resolve_header_for_save( transport_mode=mode, existing_waybill=None, existing_amount=None, existing_settle_status=None, waybill_supplied=True, waybill_no=waybill_no, amount_supplied=True, freight_amount=freight_amount, ) now = datetime.now() obj = None for attempt in range(3): candidate = BizSampleFreight(id=generate_uuid(), freight_no=_next_freight_no(db, ctx.tenant_no, now, attempt), transport_mode=plan.transport_mode, freight_amount=plan.freight_amount, waybill_no=plan.waybill_no, settle_status=plan.settle_status, settle_time=None, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0) try: with db.begin_nested(): db.add(candidate) db.flush() obj = candidate break except IntegrityError: if attempt == 2: raise ValueError('运费批次号生成冲突,请重试') assert obj is not None for source, sample_id in keys: sample, parent = samples[(source, sample_id)] db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0)) db.flush() db.commit() return get_freight_detail(db, obj.id, ctx=ctx, user_info=user_info) except IntegrityError as exc: db.rollback() raise ValueError('样本已关联运费批次,不允许移动') from exc except Exception: db.rollback() raise def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, transport_mode: Optional[str] = None, freight_amount: Any = None, waybill_no: Any = None, waybill_supplied: bool = False, amount_supplied: bool = False): try: obj = _lock_batch_header(db, freight_id, ctx) expected_keys = _discover_batch_keys(db, freight_id, ctx) visible = _lock_sample_graph(db, expected_keys, ctx, user_info) active = _lock_active_items( db, ctx, freight_id=freight_id, expected_batch_keys=expected_keys, ) if not active or not visible: raise HTTPException(status_code=404, detail='数据不存在或无权访问') if any( (row.sample_source, row.sample_id) not in visible for row in active ): raise PermissionError('仅当批次全部有效样本均在当前范围内时可修改批次信息') mode = obj.transport_mode or '' if transport_mode is not None and str(transport_mode).strip() != '': mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=str(transport_mode), user_id=ctx.user_id) plan = resolve_header_for_save( transport_mode=mode, existing_waybill=obj.waybill_no, existing_amount=obj.freight_amount, existing_settle_status=obj.settle_status, waybill_supplied=waybill_supplied, waybill_no=waybill_no, amount_supplied=amount_supplied, freight_amount=freight_amount, ) obj.transport_mode = plan.transport_mode obj.waybill_no = plan.waybill_no obj.freight_amount = plan.freight_amount obj.settle_status = plan.settle_status if plan.clear_settle_time: obj.settle_time = None obj.update_by, obj.update_time = ctx.user_id, datetime.now() db.flush() db.commit() return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) except Exception: db.rollback() raise def assert_link_target_access( db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, ) -> None: """确认目标批次在 Web 同款数据权限内可关联(不要求已有现场可见明细)。""" user_info = _current_tenant_user_info(db, ctx, user_info) if not _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info): raise HTTPException(status_code=404, detail='数据不存在或无权访问') def link_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]): keys = _normalize_items(items) try: user_info = _current_tenant_user_info(db, ctx, user_info) if not _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info): raise HTTPException(status_code=404, detail='数据不存在或无权访问') obj = _lock_batch_header(db, freight_id, ctx) assert_freight_mutable(obj.settle_status, 'items') expected_keys = _discover_batch_keys(db, freight_id, ctx) samples = _lock_sample_graph( db, sorted(set(expected_keys + keys)), ctx, user_info ) locked_items = _lock_active_items( db, ctx, freight_id=freight_id, keys=keys, expected_batch_keys=expected_keys, ) if any(key not in samples for key in keys): raise HTTPException(status_code=404, detail='样本不存在或无权操作') requested = set(keys) conflicts = [ row for row in locked_items if (row.sample_source, row.sample_id) in requested ] if conflicts: raise ValueError('样本已关联运费批次,不允许移动') now = datetime.now() for source, sample_id in keys: sample, parent = samples[(source, sample_id)] db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0)) db.flush() db.commit() return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) except IntegrityError as exc: db.rollback() raise ValueError('样本已关联运费批次,不允许移动') from exc except Exception: db.rollback() raise def unlink_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]): keys = _normalize_items(items) try: obj = _lock_batch_header(db, freight_id, ctx) assert_freight_mutable(obj.settle_status, 'items') expected_keys = _discover_batch_keys(db, freight_id, ctx) visible = _lock_sample_graph(db, expected_keys, ctx, user_info) active = _lock_active_items( db, ctx, freight_id=freight_id, expected_batch_keys=expected_keys, ) if not any( (row.sample_source, row.sample_id) in visible for row in active ): raise HTTPException(status_code=404, detail='数据不存在或无权访问') requested = set(keys) matched = [ row for row in active if (row.sample_source, row.sample_id) in visible and (row.sample_source, row.sample_id) in requested ] if len(matched) != len(keys): raise ValueError('关联明细不存在或无权操作') now = datetime.now() for row in matched: row.del_flag, row.update_by, row.update_time = 1, ctx.user_id, now db.flush() matched_ids = {row.id for row in matched} remaining = [row for row in active if row.id not in matched_ids] remaining_visible = [ row for row in remaining if (row.sample_source, row.sample_id) in visible ] if not remaining_visible: if not remaining: obj.del_flag, obj.update_by, obj.update_time = 1, ctx.user_id, now db.commit() return {'id': freight_id, 'visibleItemCount': 0, 'disappeared': True} db.commit() return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) except Exception: db.rollback() raise def delete_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict): """级联软删运费单;仅当全部有效关联样本均在当前范围内时允许。""" try: obj = _lock_batch_header(db, freight_id, ctx) assert_freight_mutable(obj.settle_status, 'delete') expected_keys = _discover_batch_keys(db, freight_id, ctx) visible = _lock_sample_graph(db, expected_keys, ctx, user_info) active = _lock_active_items( db, ctx, freight_id=freight_id, expected_batch_keys=expected_keys, ) if not active or not visible: raise HTTPException(status_code=404, detail='数据不存在或无权访问') if any( (row.sample_source, row.sample_id) not in visible for row in active ): raise PermissionError('仅当批次全部有效样本均在当前范围内时可删除运费单') now = datetime.now() for row in active: row.del_flag, row.update_by, row.update_time = 1, ctx.user_id, now obj.del_flag, obj.update_by, obj.update_time = 1, ctx.user_id, now db.flush() db.commit() return {'id': freight_id, 'visibleItemCount': 0, 'disappeared': True} except Exception: db.rollback() raise