"""运输计划归档导出:按段次×产品展开(装货行与各送货段各一行)。""" from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Optional, Union from sqlalchemy.orm import Session from app.models.pl_models import PlPlan, PlPlanContract, PlPlanProduct, PlTransport from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead from app.services.pl.plan_list_export_segments import ( format_stop_segment_label, iter_segment_product_slots, ) from app.services.pl.plan_list_filters import customer_name_matches_keywords from app.services.pl_brand_archive import ( expand_export_rows_for_plan_brand, load_brand_qtys_by_plan_product_ids, load_brand_qtys_by_stop_product_ids, ) from app.services.pl_internal_customer import is_internal_customer_name from app.services.pl_plan_stop_service import load_plan_stops_grouped from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names StopKind = str # "load" | "delivery" def _unique_contract_nos(*sources: Optional[str]) -> str: """按出现顺序去重拼接合同编号。""" seen: list[str] = [] for raw in sources: for part in str(raw or "").replace(",", ",").split(","): no = part.strip() if no and no not in seen: seen.append(no) return "、".join(seen) def _contract_nos_from_stop_links(contracts: Optional[list]) -> list[str]: """段次合同关联 → 编号列表(去重保序)。""" seen: list[str] = [] for item in contracts or []: no = ( getattr(item, "contract_no", None) or (item.get("contractNo") if isinstance(item, dict) else None) or getattr(item, "contractNo", None) or "" ) no = str(no or "").strip() if not no: cid = ( getattr(item, "contract_id", None) or (item.get("contractId") if isinstance(item, dict) else None) or getattr(item, "contractId", None) or "" ) no = str(cid or "").strip() if no and no not in seen: seen.append(no) return seen def format_stop_contract_export_label( kind: StopKind, contracts: Optional[list], customer_name: Optional[str], ) -> str: """与前端列表一致:有编号显示编号;山峡/云栋缺合同 → 装货出库 / 送货入库。""" nos = _contract_nos_from_stop_links(contracts) if nos: return "、".join(nos) if is_internal_customer_name(customer_name): return "出库" if kind == "load" else "入库" return "" def _load_contract_nos_by_plan( db: Session, tenant_no: str, plan_ids: list[str] ) -> dict[str, list[str]]: if not plan_ids: return {} rows = ( db.query(PlPlanContract) .filter( PlPlanContract.tenant_no == tenant_no, PlPlanContract.del_flag == 0, PlPlanContract.plan_id.in_(plan_ids), ) .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc()) .all() ) out: dict[str, list[str]] = {pid: [] for pid in plan_ids} for row in rows: no = (row.contract_no or "").strip() if not no: continue bucket = out.setdefault(row.plan_id, []) if no not in bucket: bucket.append(no) return out PLAN_TYPE_LABELS = { "direct": "直销单", "inbound": "入库单", "outbound": "出库单", } ARCHIVE_STATUS_LABELS = { "cancelled": "已取消", "archived": "已归档", "unarchived": "未归档", "draft": "已暂存", } FOLLOW_STATUS_LABELS = { "drafting": "草稿中", "wait_notice": "等通知", "pending_arrange": "待安排", "arranged": "已安排", "completed": "已完成", "cancelled": "已取消", "draft": "待安排", "processing": "进行中", } FREIGHT_TYPE_LABELS = { "by_weight": "按重量", "by_trip": "按趟", } def resolve_archive_list_io_column(kind: StopKind, customer_name: Optional[str]) -> str: """装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。""" internal = is_internal_customer_name(customer_name) if kind == "load": return "outbound" if internal else "inbound" return "inbound" if internal else "outbound" def _format_date_value(value: Optional[Union[date, datetime, str]]) -> str: if value is None: return "" return str(value)[:10] def _format_plate_display(license_plate: Optional[str], trailer_no: Optional[str]) -> str: plate = (license_plate or "").strip() trailer = (trailer_no or "").strip() if not plate and not trailer: return "" if plate and trailer: return f"{plate}{trailer}" return plate or trailer def _get_stop_archive_actual_qty(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: return product.inbound_qty if kind == "load" else product.outbound_qty def _get_stop_archive_unit_price(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: return product.inbound_price if kind == "load" else product.outbound_price def _get_stop_archive_amount(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]: stored = product.inbound_amount if kind == "load" else product.outbound_amount if stored is not None: return stored qty = _get_stop_archive_actual_qty(product, kind) price = _get_stop_archive_unit_price(product, kind) if qty is not None and price is not None: return qty * price return None def _stop_time(kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead]) -> str: if kind == "load": return _format_date_value(getattr(stop, "load_time", None)) return _format_date_value(getattr(stop, "delivery_time", None)) def _load_legacy_products_by_plan( db: Session, tenant_no: str, plan_ids: list[str] ) -> dict[str, list[PlPlanProduct]]: if not plan_ids: return {} products = ( db.query(PlPlanProduct) .filter( PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, PlPlanProduct.plan_id.in_(plan_ids), ) .order_by(PlPlanProduct.order_num) .all() ) grouped: dict[str, list[PlPlanProduct]] = {} for product in products: grouped.setdefault(product.plan_id, []).append(product) return grouped def _plan_common_export_fields( plan: PlPlan, transport: Optional[PlTransport], *, contract_nos: Optional[list[str]] = None, ) -> dict: freight_type_raw = transport.freight_type if transport else "by_weight" freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "") freight_price = transport.freight_price if transport else None freight_amount = plan.freight_amount if freight_amount is None and transport is not None: freight_amount = transport.freight_amount plan_date = _format_date_value(plan.plan_date) link_nos = contract_nos or [] return { "planId": plan.id or "", "planNo": plan.plan_no or "", "contractNo": _unique_contract_nos(plan.contract_no, *link_nos), "planDate": plan_date, "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""), "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""), "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""), "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no), "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否", "freightType": freight_type_label, "freightPrice": freight_price, "freightAmount": freight_amount, "remark": plan.remark or "", } def _empty_pair_side_fields(*, side: str) -> dict: """side: load | delivery""" prefix = "load" if side == "load" else "delivery" return { f"{prefix}Customer": "", f"{prefix}Address": "", f"{prefix}Time": "", f"{prefix}PlanQty": None, f"{prefix}ActualQty": None, f"{prefix}InboundQty": None, f"{prefix}OutboundQty": None, f"{prefix}UnitPrice": None, f"{prefix}Amount": None, } def _empty_product_identity_fields() -> dict: return { "productName": "", "brandName": "", "aliasName": "", "model": "", "packageSpec": "", "unit": "", } def _resolve_export_product_display( plan_product: Optional[PlPlanProduct], stop_product: Optional[PlanStopProductRead], *, standard_by_id: dict[str, str], alias_options_by_id: dict[str, list[str]], ) -> tuple[str, str]: """返回 (展示用产品名称, 别名)。""" saved_name = "" product_id = "" if plan_product is not None: saved_name = plan_product.product_name or "" product_id = plan_product.product_id or "" elif stop_product is not None: saved_name = stop_product.product_name or "" product_id = stop_product.product_id or "" display_name, alias = resolve_product_display_names( saved_product_name=saved_name, product_id=product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) return (display_name or saved_name or "").strip(), (alias or "").strip() def _lookup_plan_product( stop_product: PlanStopProductRead, plan_products_by_id: dict[str, PlPlanProduct], plan_products_by_product_id: dict[str, PlPlanProduct], ) -> Optional[PlPlanProduct]: plan_product_id = str(getattr(stop_product, "plan_product_id", None) or "").strip() if plan_product_id and plan_product_id in plan_products_by_id: return plan_products_by_id[plan_product_id] product_id = str(stop_product.product_id or "").strip() if product_id and product_id in plan_products_by_product_id: return plan_products_by_product_id[product_id] return None def _product_identity_from_stop( product: PlanStopProductRead, *, plan_product: Optional[PlPlanProduct] = None, standard_by_id: Optional[dict[str, str]] = None, alias_options_by_id: Optional[dict[str, list[str]]] = None, ) -> dict: display_name, alias_name = _resolve_export_product_display( plan_product, product, standard_by_id=standard_by_id or {}, alias_options_by_id=alias_options_by_id or {}, ) return { "productName": display_name or (product.product_name or ""), "brandName": "", "aliasName": alias_name, "model": product.model or "", "packageSpec": product.package_spec or "", "unit": product.unit or "", } def _segment_slot_customer( kind: str, load: Optional[PlanLoadRead], delivery: Optional[PlanDeliveryRead], ) -> str: if kind == "load" and load is not None: return load.customer_name or "" if kind == "delivery" and delivery is not None: return delivery.customer_name or "" return "" def _build_archive_segment_row( row_no: int, plan_common: dict, kind: str, load: Optional[PlanLoadRead], delivery: Optional[PlanDeliveryRead], delivery_seq: Optional[int], product: Optional[PlanStopProductRead], *, plan_products_by_id: dict[str, PlPlanProduct], plan_products_by_product_id: dict[str, PlPlanProduct], standard_by_id: dict[str, str], alias_options_by_id: dict[str, list[str]], ) -> dict: """一段次×一个产品一行:只填本侧客户/地址/时间/合同与本侧量价。""" fallback_contract = str(plan_common.get("contractNo") or "") row = { **plan_common, "rowNo": row_no, "_stopKind": kind, "_deliverySeq": delivery_seq, "stopSegment": format_stop_segment_label(kind, delivery_seq), "loadCustomer": "", "loadAddress": "", "loadTime": "", "deliveryCustomer": "", "deliveryAddress": "", "deliveryTime": "", "contractNo": fallback_contract, **_empty_product_identity_fields(), "planQty": None, "actualQty": None, "unitPrice": None, "amount": None, } if kind == "load" and load is not None: customer_name = load.customer_name or "" row["loadCustomer"] = customer_name row["loadAddress"] = load.address or "" row["loadTime"] = _stop_time("load", load) row["contractNo"] = format_stop_contract_export_label( "load", load.contracts, customer_name ) or fallback_contract elif kind == "delivery" and delivery is not None: customer_name = delivery.customer_name or "" row["deliveryCustomer"] = customer_name row["deliveryAddress"] = delivery.address or "" row["deliveryTime"] = _stop_time("delivery", delivery) row["contractNo"] = format_stop_contract_export_label( "delivery", delivery.contracts, customer_name ) or fallback_contract if product is not None: plan_product = _lookup_plan_product( product, plan_products_by_id, plan_products_by_product_id ) row.update( _product_identity_from_stop( product, plan_product=plan_product, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) ) row["planQty"] = product.plan_qty row["actualQty"] = _get_stop_archive_actual_qty(product, kind) row["unitPrice"] = _get_stop_archive_unit_price(product, kind) row["amount"] = _get_stop_archive_amount(product, kind) return row def _append_legacy_plan_product_rows( rows: list[dict], row_no: int, plan: PlPlan, plan_common: dict, products: list[PlPlanProduct], *, standard_by_id: Optional[dict[str, str]] = None, alias_options_by_id: Optional[dict[str, list[str]]] = None, brand_by_plan_product: Optional[dict[str, list]] = None, ) -> int: """无段表数据时回退计划产品级导出(兼容旧数据);有品牌明细时按品牌拆行。""" product_rows = products or [None] fallback_no = str(plan_common.get("contractNo") or "") if fallback_no: legacy_contract_no = fallback_no else: legacy_contract_no = _unique_contract_nos( format_stop_contract_export_label("load", None, plan.supplier_name), format_stop_contract_export_label( "delivery", None, plan.customer_name or plan.destination_name ), ) brand_index = brand_by_plan_product or {} for product in product_rows: row = { **plan_common, "rowNo": 0, "_stopKind": "", "_deliverySeq": None, "stopSegment": "", "deliveryStopKind": "", **_empty_pair_side_fields(side="load"), **_empty_pair_side_fields(side="delivery"), **_empty_product_identity_fields(), "loadCustomer": plan.supplier_name or plan.customer_name or "", "loadAddress": "", "deliveryCustomer": plan.customer_name or "", "deliveryAddress": plan.destination_name or "", "deliveryTime": _format_date_value(plan.delivery_time), "contractNo": legacy_contract_no, # 兼容旧字段 "stopKind": "", "stopCustomer": plan.customer_name or plan.supplier_name or "", "stopAddress": plan.destination_name or "", "stopTime": _format_date_value(plan.delivery_time), "planQty": None, "actualQty": None, "inboundQty": None, "outboundQty": None, "unitPrice": None, "amount": None, } if product is not None: display_name, alias_name = _resolve_export_product_display( product, None, standard_by_id=standard_by_id or {}, alias_options_by_id=alias_options_by_id or {}, ) row.update( { "productName": display_name or (product.product_name or ""), "brandName": "", "aliasName": alias_name, "model": product.model or "", "packageSpec": product.package_spec or "", "unit": product.unit or "", "loadPlanQty": product.quantity, "loadActualQty": product.actual_qty, "loadInboundQty": product.inbound_qty, "loadOutboundQty": product.outbound_qty, "loadUnitPrice": product.inbound_price or product.outbound_price, "loadAmount": product.inbound_amount or product.outbound_amount, "planQty": product.quantity, "actualQty": product.actual_qty, "inboundQty": product.inbound_qty, "outboundQty": product.outbound_qty, "unitPrice": product.inbound_price or product.outbound_price, "amount": product.inbound_amount or product.outbound_amount, } ) for expanded in expand_export_rows_for_plan_brand( row, archive_status=getattr(plan, "archive_status", None), plan_product=product, stop_product_id=None, plan_product_id=str(getattr(product, "id", None) or "") if product is not None else None, by_stop_id={}, by_plan_product_id=brand_index, apply_unit_price=True, ): row_no += 1 expanded["rowNo"] = row_no rows.append(expanded) return row_no def build_plan_archive_export_rows( db: Session, tenant_no: str, plans: list[PlPlan], transports_by_id: dict[str, PlTransport], *, stop_kind: Optional[str] = None, stop_customer_keywords: Optional[list] = None, ) -> list[dict]: """构建归档导出数据行。 默认按段次×产品展开:先装货产品行,再各送货段产品行。 stop_kind 为 load / delivery 时只保留对应侧。 stop_customer_keywords 非空时按该行所属侧客户过滤。 无段表数据时回退计划产品级导出,stopSegment 为空。 """ if not plans: return [] include_load = stop_kind != "delivery" include_delivery = stop_kind != "load" keywords = [str(item).strip() for item in (stop_customer_keywords or []) if str(item).strip()] def _keep_customer(name: Optional[str]) -> bool: if not keywords: return True return customer_name_matches_keywords(name, keywords) plan_ids = [plan.id for plan in plans] stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids) legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids) contract_nos_by_plan = _load_contract_nos_by_plan(db, tenant_no, plan_ids) crm_product_ids: list[str] = [] for products in legacy_products_by_plan.values(): for product in products: if product.product_id: crm_product_ids.append(product.product_id) for load_read, delivery_reads in stops_by_plan.values(): for stop in ([load_read] if load_read is not None else []) + list(delivery_reads or []): for stop_product in stop.products or []: if stop_product.product_id: crm_product_ids.append(stop_product.product_id) standard_by_id, alias_options_by_id = load_crm_product_alias_maps( db, tenant_no=tenant_no, product_ids=crm_product_ids, ) plan_product_ids: list[str] = [] stop_product_ids: list[str] = [] for products in legacy_products_by_plan.values(): for product in products: if product.id: plan_product_ids.append(str(product.id)) for load_read, delivery_reads in stops_by_plan.values(): for stop in ([load_read] if load_read is not None else []) + list(delivery_reads or []): for stop_product in stop.products or []: sid = str(getattr(stop_product, "id", None) or "").strip() if sid: stop_product_ids.append(sid) brand_by_plan_product = load_brand_qtys_by_plan_product_ids( db, tenant_no=tenant_no, plan_product_ids=plan_product_ids ) brand_by_stop = load_brand_qtys_by_stop_product_ids( db, tenant_no=tenant_no, stop_product_ids=stop_product_ids ) rows: list[dict] = [] row_no = 0 for plan in plans: transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None plan_common = _plan_common_export_fields( plan, transport, contract_nos=contract_nos_by_plan.get(plan.id, []), ) load_read, delivery_reads = stops_by_plan.get(plan.id, (None, [])) plan_products = legacy_products_by_plan.get(plan.id, []) plan_products_by_id = {str(p.id): p for p in plan_products if p.id} plan_products_by_product_id: dict[str, PlPlanProduct] = {} for product in plan_products: pid = str(product.product_id or "").strip() if pid and pid not in plan_products_by_product_id: plan_products_by_product_id[pid] = product has_stops = load_read is not None or bool(delivery_reads) if not has_stops: if not include_load: continue legacy_customer = plan.customer_name or plan.supplier_name or "" if not _keep_customer(legacy_customer): continue row_no = _append_legacy_plan_product_rows( rows, row_no, plan, plan_common, plan_products, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, brand_by_plan_product=brand_by_plan_product, ) continue for kind, slot_load, slot_delivery, seq, stop_product in iter_segment_product_slots( load_read, delivery_reads ): if kind == "load" and not include_load: continue if kind == "delivery" and not include_delivery: continue if not _keep_customer(_segment_slot_customer(kind, slot_load, slot_delivery)): continue base_row = _build_archive_segment_row( 0, plan_common, kind, slot_load, slot_delivery, seq, stop_product, plan_products_by_id=plan_products_by_id, plan_products_by_product_id=plan_products_by_product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) plan_product = None if stop_product is not None: plan_product = _lookup_plan_product( stop_product, plan_products_by_id, plan_products_by_product_id ) for expanded in expand_export_rows_for_plan_brand( base_row, archive_status=getattr(plan, "archive_status", None), plan_product=plan_product, stop_product_id=str(getattr(stop_product, "id", None) or "") if stop_product is not None else None, plan_product_id=str(getattr(plan_product, "id", None) or "") if plan_product is not None else str(getattr(stop_product, "plan_product_id", None) or "") if stop_product is not None else None, by_stop_id=brand_by_stop, by_plan_product_id=brand_by_plan_product, apply_unit_price=True, ): row_no += 1 expanded["rowNo"] = row_no rows.append(expanded) return rows ARCHIVE_MERGED_SHEET_TITLE = "计划归档(合并)" # 与列表 PL_PLAN_SPAN_PROPS 对应:整张计划纵向合并(含装货侧)。 _PLAN_LEVEL_MERGE_FIELDS = frozenset( { "rowNo", "planNo", "planDate", "archiveStatus", "followStatus", "planType", "erpEntered", "licensePlate", "freightType", "freightPrice", "freightAmount", "remark", "loadCustomer", "loadAddress", "loadTime", } ) # 与列表 PL_DELIVERY_SPAN_PROPS 对应:同一送货段合并;仅一个送货段时连装货行一起合并。 _DELIVERY_LEVEL_MERGE_FIELDS = frozenset( { "deliveryCustomer", "deliveryAddress", "deliveryTime", } ) def _iter_equal_spans(rows: list[dict], key_fn) -> list[tuple[int, int]]: """返回需要合并的闭区间(数据行下标,不含表头)。单行不合并。""" spans: list[tuple[int, int]] = [] index = 0 total = len(rows) while index < total: key = key_fn(rows[index]) end = index + 1 while end < total and key_fn(rows[end]) == key: end += 1 if end - index > 1: spans.append((index, end - 1)) index = end return spans _PLAN_LIST_PLAN_MERGE_FIELDS = frozenset( { "planNo", "belongsCompany", "followStatus", "planDate", "deliveryTimes", "planType", "licensePlate", "personnelSummary", "followUserName", "archiveStatus", "erpEntered", "hazardCertReady", "loadCustomer", "loadAddressTime", "loadDibang", } ) _PLAN_LIST_DELIVERY_MERGE_FIELDS = frozenset( { "deliveryCustomer", "deliveryAddressTime", "deliveryDibang", } ) _PLAN_LIST_LOAD_COPY_FIELDS = ("loadCustomer", "loadAddressTime", "loadDibang") def _export_plan_group_key(row: dict) -> str: return str(row.get("planId") or row.get("id") or "") def prepare_archive_list_display_rows( rows: list[dict], *, contract_field: str = "contractNo", load_fields: tuple[str, ...] = ("loadCustomer", "loadAddress", "loadTime"), delivery_fields: Optional[frozenset] = None, number_plans: bool = True, join_plan_fields: tuple[str, ...] = (), ) -> list[dict]: """按列表合并规则整理单元格取值。 序号按计划计,一张计划一个号(归档导出)。 只有一个送货段时,送货侧字段写到该计划首行,以便和装货行合并后仍能看见。 多个送货段时,装货行的送货客户为「—」。 """ if delivery_fields is None: delivery_fields = _DELIVERY_LEVEL_MERGE_FIELDS prepared: list[dict] = [] plan_index = 0 cursor = 0 while cursor < len(rows): plan_id = _export_plan_group_key(rows[cursor]) end = cursor + 1 while end < len(rows) and _export_plan_group_key(rows[end]) == plan_id: end += 1 group = [dict(item) for item in rows[cursor:end]] plan_index += 1 delivery_seqs: list[int] = [] for item in group: if item.get("_stopKind") == "delivery" and item.get("_deliverySeq") is not None: seq = int(item["_deliverySeq"]) if seq not in delivery_seqs: delivery_seqs.append(seq) sole_seq = delivery_seqs[0] if len(delivery_seqs) == 1 else None for item in group: kind = item.get("_stopKind") or "" if kind == "load": merge_seq = sole_seq if sole_seq is not None else 0 elif kind == "delivery": merge_seq = int(item.get("_deliverySeq") or 1) else: merge_seq = 1 item["_mergeDeliverySeq"] = merge_seq item["_contractMergeKey"] = ( f"{kind}|{merge_seq}|{item.get(contract_field) or ''}" ) if number_plans: item["rowNo"] = plan_index if sole_seq is not None: delivery_row = next((item for item in group if item.get("_stopKind") == "delivery"), None) if delivery_row is not None: for field in delivery_fields: group[0][field] = delivery_row.get(field) or "" else: for item in group: if item.get("_mergeDeliverySeq") != 0: continue if not item.get("deliveryCustomer"): item["deliveryCustomer"] = "—" load_row = next((item for item in group if item.get("_stopKind") == "load"), None) if load_row is not None: for field in load_fields: if not group[0].get(field): group[0][field] = load_row.get(field) or "" for field in join_plan_fields: seen: list[str] = [] for item in group: text = str(item.get(field) or "").strip() if text and text not in seen: seen.append(text) if seen: group[0][field] = "\n".join(seen) prepared.extend(group) cursor = end return prepared def append_archive_list_merge_sheet( wb, columns, rows: list[dict], resolver, *, sheet_title: Optional[str] = None, plan_fields: Optional[frozenset] = None, delivery_fields: Optional[frozenset] = None, contract_field: str = "contractNo", load_fields: tuple[str, ...] = ("loadCustomer", "loadAddress", "loadTime"), number_plans: bool = True, join_plan_fields: tuple[str, ...] = (), ) -> None: """追加按列表合并的 sheet。默认标题为「计划归档(合并)」。""" from openpyxl.styles import Alignment if delivery_fields is None: delivery_fields = _DELIVERY_LEVEL_MERGE_FIELDS if plan_fields is None: plan_fields = _PLAN_LEVEL_MERGE_FIELDS display_rows = prepare_archive_list_display_rows( list(rows), contract_field=contract_field, load_fields=load_fields, delivery_fields=delivery_fields, number_plans=number_plans, join_plan_fields=join_plan_fields, ) sheet = wb.create_sheet((sheet_title or ARCHIVE_MERGED_SHEET_TITLE)[:31]) sheet.append([column.title for column in columns]) for row in display_rows: sheet.append(resolver.row_to_cells(row, columns)) field_column = {column.field: index + 1 for index, column in enumerate(columns)} vertical = Alignment(vertical="center", wrap_text=True) def merge_fields(fields, spans: list[tuple[int, int]]) -> None: for field in fields: column_index = field_column.get(field) if not column_index: continue for start, end in spans: sheet.merge_cells( start_row=start + 2, start_column=column_index, end_row=end + 2, end_column=column_index, ) sheet.cell(start + 2, column_index).alignment = vertical plan_key = _export_plan_group_key plan_spans = _iter_equal_spans(display_rows, plan_key) merged_plan_fields = set(plan_fields) merged_plan_fields.update(field for field in field_column if str(field).startswith("attach")) merge_fields(merged_plan_fields, plan_spans) merge_fields( delivery_fields, _iter_equal_spans( display_rows, lambda row: (_export_plan_group_key(row), row.get("_mergeDeliverySeq")), ), ) if contract_field in field_column: merge_fields( [contract_field], _iter_equal_spans( display_rows, lambda row: (_export_plan_group_key(row), row.get("_contractMergeKey")), ), ) def export_plan_list_xlsx_response( db, *, tenant_no: str, sheet_title: str, columns, rows: list[dict], filename_stem: str, ): """运输计划 / 危化品证列表 Excel:明细页 + 按列表合并的第二页。""" from app.utils.excel_export import ( DictLabelResolver, ExportValidationError, assert_export_row_count, workbook_to_streaming_response, ) from openpyxl import Workbook assert_export_row_count(len(rows)) if not columns: raise ExportValidationError("没有可导出的列") resolver = DictLabelResolver(db, columns, tenant_no=tenant_no) workbook = Workbook() detail = workbook.active detail.title = sheet_title[:31] detail.append([column.title for column in columns]) for row in rows: detail.append(resolver.row_to_cells(row, columns)) append_archive_list_merge_sheet( workbook, columns, rows, resolver, sheet_title=f"{sheet_title}(合并)", plan_fields=_PLAN_LIST_PLAN_MERGE_FIELDS, delivery_fields=_PLAN_LIST_DELIVERY_MERGE_FIELDS, contract_field="productContract", load_fields=_PLAN_LIST_LOAD_COPY_FIELDS, number_plans=False, join_plan_fields=("deliveryTimes",), ) return workbook_to_streaming_response(workbook, filename_stem=filename_stem) def export_plan_archive_xlsx_response( db, *, tenant_no: str, columns, rows: list[dict], filename_stem: str, ): """归档 Excel:第一页为段次明细,第二页为列表合并样式。""" from app.utils.excel_export import ( DictLabelResolver, ExportValidationError, assert_export_row_count, workbook_to_streaming_response, ) from openpyxl import Workbook assert_export_row_count(len(rows)) if not columns: raise ExportValidationError("没有可导出的列") resolver = DictLabelResolver(db, columns, tenant_no=tenant_no) workbook = Workbook() detail = workbook.active detail.title = "计划归档" detail.append([column.title for column in columns]) for row in rows: detail.append(resolver.row_to_cells(row, columns)) append_archive_list_merge_sheet(workbook, columns, rows, resolver) return workbook_to_streaming_response(workbook, filename_stem=filename_stem)