feat(pl): add Excel export for freight unsettled plans and settlement tabs

Support filtered and selected export across freight settlement board panels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 18:17:10 +08:00
1 parent 8f1f08b284
commit b1f734dbb0
9 files changed
+303 -2

No files matched your search

+135 -2
View File
@@ -1,13 +1,21 @@
"""运输计划管理 - 运费结算 API"""
from datetime import date
from typing import Optional
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.db_auth import DbUserContext, get_db_current_user
from app.core.tenant_context import get_tenant_no
from app.utils.excel_export import (
EXPORT_MAX_ROWS,
ExportColumn,
ExportValidationError,
export_rows_to_xlsx_response,
filter_export_columns,
)
from app.models.pl_models import PlFreightSettlement
from app.schemas.pl.freight import (
FreightSettlementApprovalActionBody,
@@ -101,6 +109,131 @@ def list_settlements(
return FreightSettlementListResponse(total=total, list=items)
UNSETTLED_EXPORT_COLUMNS = [
ExportColumn(title="计划编号", field="planNo"),
ExportColumn(title="计划日期", field="planDate"),
ExportColumn(title="单据类型", field="planType"),
ExportColumn(title="客户/供应商", field="partnerName"),
ExportColumn(title="车牌号", field="licensePlate"),
ExportColumn(title="承运商", field="transportCompany"),
ExportColumn(title="运费(元)", field="freightAmount"),
ExportColumn(title="归档时间", field="archiveTime"),
]
PLAN_TYPE_LABELS = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"}
SETTLEMENT_STATUS_LABELS = {
"pending": "草稿",
"rejected": "已驳回",
"approving": "审批中",
"approved": "待发放",
"confirmed": "已发放",
"cancelled": "已作废",
}
class UnsettledExportBody(BaseModel):
q: Optional[str] = None
carrier_id: Optional[str] = Field(default=None, alias="carrierId")
plan_type: Optional[str] = Field(default=None, alias="planType")
license_plate: Optional[str] = Field(default=None, alias="licensePlate")
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
model_config = {"populate_by_name": True}
@router.post("/unsettled-plans/export")
def export_unsettled_plans(
body: UnsettledExportBody,
db: Session = Depends(get_db),
user: DbUserContext = Depends(get_db_current_user),
):
try:
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
_, items = freight_svc.list_unsettled_plans(
db, tenant_no=tenant_no, user=user,
q=body.q, carrier_id=body.carrier_id, plan_type=body.plan_type,
license_plate=body.license_plate, ids=body.ids,
page=1, page_size=EXPORT_MAX_ROWS, max_page_size=EXPORT_MAX_ROWS,
)
rows = []
for item in items:
data = dict(item)
data["partnerName"] = data.get("customerName") or data.get("supplierName") or ""
data["planType"] = PLAN_TYPE_LABELS.get(str(data.get("planType") or ""), data.get("planType"))
for k, v in list(data.items()):
if hasattr(v, "isoformat"):
data[k] = str(v)[:19]
rows.append(data)
cols = filter_export_columns(UNSETTLED_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db, tenant_no=tenant_no, sheet_title="待结算计划", columns=cols, rows=rows,
filename_stem=f"待结算计划_{date_str}",
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
SETTLEMENT_EXPORT_COLUMNS = [
ExportColumn(title="结算单号", field="settlementNo"),
ExportColumn(title="运费合计(元)", field="totalFreight"),
ExportColumn(title="关联计划数", field="planCount"),
ExportColumn(title="车牌摘要", field="licensePlateSummary"),
ExportColumn(title="结算状态", field="settlementStatus"),
ExportColumn(title="发放金额(元)", field="disburseAmount"),
ExportColumn(title="发放时间", field="disburseTime"),
ExportColumn(title="当前审批人", field="currentApproverName"),
ExportColumn(title="提交人", field="submitterName"),
ExportColumn(title="创建时间", field="createTime"),
ExportColumn(title="审批完成时间", field="approvalFinishedTime"),
]
class SettlementExportBody(BaseModel):
q: Optional[str] = None
carrier_id: Optional[str] = Field(default=None, alias="carrierId")
settlement_status: Optional[str] = Field(default=None, alias="settlementStatus")
tab: Optional[str] = None
ids: Optional[List[str]] = None
columns: Optional[List[str]] = None
model_config = {"populate_by_name": True}
@router.post("/export")
def export_settlements(
body: SettlementExportBody,
db: Session = Depends(get_db),
user: DbUserContext = Depends(get_db_current_user),
):
try:
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
_, items = freight_svc.list_settlements_enriched(
db, tenant_no=tenant_no, user_id=user.id, user=user,
q=body.q, carrier_id=body.carrier_id,
settlement_status=body.settlement_status, tab=body.tab, ids=body.ids,
page=1, page_size=EXPORT_MAX_ROWS, max_page_size=EXPORT_MAX_ROWS,
)
rows = []
for item in items:
data = dict(item)
stv = data.get("settlementStatus")
if stv is not None:
data["settlementStatus"] = SETTLEMENT_STATUS_LABELS.get(str(stv), stv)
for k, v in list(data.items()):
if hasattr(v, "isoformat"):
data[k] = str(v)[:19]
rows.append(data)
cols = filter_export_columns(SETTLEMENT_EXPORT_COLUMNS, body.columns)
date_str = datetime.now().strftime("%Y%m%d")
return export_rows_to_xlsx_response(
db, tenant_no=tenant_no, sheet_title="运费结算", columns=cols, rows=rows,
filename_stem=f"运费结算_{date_str}",
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get("/by-transport/{transport_id}", response_model=Optional[FreightSettlementByTransportRead])
def get_settlement_by_transport(
transport_id: str,
@@ -153,8 +153,10 @@ def list_unsettled_plans(
carrier_id: Optional[str] = None,
plan_type: Optional[str] = None,
license_plate: Optional[str] = None,
ids: Optional[List[str]] = None,
page: int = 1,
page_size: int = 20,
max_page_size: Optional[int] = None,
) -> Tuple[int, List[Dict[str, Any]]]:
occupied = _occupied_transport_ids_subquery(db, tenant_no).subquery()
@@ -182,11 +184,16 @@ def list_unsettled_plans(
query = query.filter(PlPlan.plan_type == plan_type)
if license_plate:
query = query.filter(PlPlan.license_plate.contains(license_plate))
if ids:
query = query.filter(PlPlan.id.in_(ids))
if user:
query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
total = query.count()
page = max(1, page)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
query.order_by(PlPlan.update_time.desc())
.offset((page - 1) * page_size)
@@ -1107,8 +1114,10 @@ def list_settlements_enriched(
carrier_id: Optional[str] = None,
settlement_status: Optional[str] = None,
tab: Optional[str] = None,
ids: Optional[List[str]] = None,
page: int = 1,
page_size: int = 20,
max_page_size: Optional[int] = None,
) -> Tuple[int, List[Dict[str, Any]]]:
_sync_disbursed_settlements(db, tenant_no)
query = db.query(PlFreightSettlement).filter(
@@ -1141,11 +1150,16 @@ def list_settlements_enriched(
.distinct()
)
query = query.filter(PlFreightSettlement.id.in_(carrier_settlement_ids))
if ids:
query = query.filter(PlFreightSettlement.id.in_(ids))
if user and tab != "approval_pending":
query = apply_pl_scope(query, PlFreightSettlement, user, db, tenant_no)
total = query.count()
page = max(1, page)
cap = int(max_page_size) if max_page_size else 500
page_size = min(max(1, page_size), cap)
rows = (
query.order_by(PlFreightSettlement.create_time.desc())
.offset((page - 1) * page_size)
+4
View File
@@ -1179,10 +1179,14 @@ export interface FreightSettlementUpdateBody {
export const freightApi = {
listUnsettledPlans: (params?: UnsettledPlanQuery) =>
request.get<UnsettledPlanListResponse>('/transport/freight/unsettled-plans', { params }),
exportUnsettledPlans: (data: Record<string, any>) =>
request.post('/transport/freight/unsettled-plans/export', data, { responseType: 'blob' }),
getByTransport: (transportId: string) =>
request.get<FreightSettlementBrief | null>(`/transport/freight/by-transport/${transportId}`),
list: (params?: FreightSettlementQuery) =>
request.get<FreightSettlementListResponse>('/transport/freight/', { params }),
export: (data: Record<string, any>) =>
request.post('/transport/freight/export', data, { responseType: 'blob' }),
get: (id: string) => request.get<FreightSettlement>(`/transport/freight/${id}`),
create: (data: { planIds?: string[]; transportIds?: string[] }) =>
request.post<FreightSettlement>('/transport/freight/', data),
@@ -39,6 +39,15 @@
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<ListExportDropdown
:export-fn="freightApi.export"
:get-filter-params="getSettlementExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="settlementExportColumns"
filename="已审批结算单.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -169,6 +178,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -232,6 +242,22 @@ function getDoneStatCellValue(row: FreightSettlement, prop: string): unknown {
const loading = ref(false)
const selectedRows = ref<FreightSettlement[]>([])
function getSettlementExportFilterParams() {
return {
tab: 'approval_done',
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
carrierId: searchForm.carrierId || undefined,
settlementStatus: searchForm.settlementStatus || undefined
}
}
const settlementExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('settlementNo')) cols.unshift('settlementNo')
return cols
})
const settlementList = ref<FreightSettlement[]>([])
const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
const quickSearchKeyword = ref('')
@@ -57,6 +57,15 @@
>
<el-icon><CircleClose /></el-icon>驳回
</el-button>
<ListExportDropdown
:export-fn="freightApi.export"
:get-filter-params="getSettlementExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="settlementExportColumns"
filename="待审批结算单.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -201,6 +210,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -262,6 +272,20 @@ function getPendingStatCellValue(row: FreightSettlement, prop: string): unknown
const loading = ref(false)
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
function getSettlementExportFilterParams() {
return {
tab: 'approval_pending',
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
}
}
const settlementExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('settlementNo')) cols.unshift('settlementNo')
return cols
})
const viewingRow = ref<FreightSettlement | null>(null)
const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
const approvalDialogRef = ref<InstanceType<typeof PlFreightApprovalDialog>>()
@@ -41,6 +41,15 @@
>
<el-icon><Money /></el-icon>发放登记
</el-button>
<ListExportDropdown
:export-fn="freightApi.export"
:get-filter-params="getSettlementExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="settlementExportColumns"
filename="待发放结算单.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -165,6 +174,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -226,6 +236,20 @@ function getDisburseStatCellValue(row: FreightSettlement, prop: string): unknown
const loading = ref(false)
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
function getSettlementExportFilterParams() {
return {
tab: 'disburse',
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
}
}
const settlementExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('settlementNo')) cols.unshift('settlementNo')
return cols
})
const viewingRow = ref<FreightSettlement | null>(null)
const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
const disburseDialogRef = ref<InstanceType<typeof PlFreightDisburseDialog>>()
@@ -33,6 +33,15 @@
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<ListExportDropdown
:export-fn="freightApi.export"
:get-filter-params="getSettlementExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="settlementExportColumns"
filename="已结算.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -151,6 +160,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -212,6 +222,20 @@ function getSettledStatCellValue(row: FreightSettlement, prop: string): unknown
const loading = ref(false)
const selectedRows = ref<FreightSettlement[]>([])
function getSettlementExportFilterParams() {
return {
tab: 'settled',
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
}
}
const settlementExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('settlementNo')) cols.unshift('settlementNo')
return cols
})
const settlementList = ref<FreightSettlement[]>([])
const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
const quickSearchKeyword = ref('')
@@ -112,6 +112,15 @@
>
<el-icon><RefreshLeft /></el-icon>撤回
</el-button>
<ListExportDropdown
:export-fn="freightApi.export"
:get-filter-params="getSettlementExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="settlementExportColumns"
filename="结算单.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -320,6 +329,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -413,6 +423,22 @@ const {
})
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
function getSettlementExportFilterParams() {
return {
tab: 'bills',
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
carrierId: searchForm.carrierId || undefined,
settlementStatus: searchForm.settlementStatus || undefined
}
}
const settlementExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('settlementNo')) cols.unshift('settlementNo')
return cols
})
const viewingRow = ref<FreightSettlement | null>(null)
const viewDialogRef = ref<InstanceType<typeof PlFreightSettlementViewDialog>>()
const formDialogRef = ref<InstanceType<typeof PlFreightSettlementFormDialog>>()
@@ -71,6 +71,15 @@
>
<el-icon><DocumentAdd /></el-icon>生成结算单
</el-button>
<ListExportDropdown
:export-fn="freightApi.exportUnsettledPlans"
:get-filter-params="getUnsettledExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="unsettledExportColumns"
filename="待结算计划.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -214,6 +223,7 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
@@ -297,6 +307,22 @@ const vehicleOptions = ref<Vehicle[]>([])
const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
const planList = ref<UnsettledPlanItem[]>([])
const selectedRows = ref<UnsettledPlanItem[]>([])
function getUnsettledExportFilterParams() {
return {
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
carrierId: searchForm.carrierId || undefined,
planType: searchForm.planType || undefined,
licensePlate: searchForm.licensePlate || undefined
}
}
const unsettledExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('planNo')) cols.unshift('planNo')
return cols
})
const planViewVisible = ref(false)
const viewPlanId = ref('')
const quickSearchKeyword = ref('')