| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962 |
- """运输计划归档导出:按段次×产品展开(装货行与各送货段各一行)。"""
- 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)
|