From fe15a130cbd85f589d1d8c3167b0cf051b86be26 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 18:19:07 +0800 Subject: [PATCH] feat(pl): add Excel export for plan contract gaps and document reminders Complete remaining logistics list export pages under the PL module wave. Co-authored-by: Cursor --- backend/app/api/pl/inventory.py | 62 +++++++++++++++ backend/app/api/pl/plan.py | 76 +++++++++++++++++++ backend/app/services/pl_plan_contract_gap.py | 8 ++ web-admin/src/api/pl/index.ts | 4 + .../views/pl/inventory/document-reminder.vue | 23 ++++++ .../src/views/pl/plan-contract/index.vue | 32 ++++++++ 6 files changed, 205 insertions(+) diff --git a/backend/app/api/pl/inventory.py b/backend/app/api/pl/inventory.py index 465fab9eb..fe1a2ef87 100644 --- a/backend/app/api/pl/inventory.py +++ b/backend/app/api/pl/inventory.py @@ -408,6 +408,68 @@ def list_document_reminders( return query.order_by(PlDocumentReminder.expire_date.asc()).all() + +DOCUMENT_REMINDER_EXPORT_COLUMNS = [ + ExportColumn(title="主体名称", field="ownerLabel"), + ExportColumn(title="主体类型", field="ownerType"), + ExportColumn(title="证件类型", field="documentType"), + ExportColumn(title="证件号", field="documentNo"), + ExportColumn(title="到期日期", field="expireDate"), + ExportColumn(title="剩余天数", field="daysRemaining"), + ExportColumn(title="状态", field="status"), + ExportColumn(title="处理备注", field="remark"), +] + + +class DocumentReminderExportBody(BaseModel): + status: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + model_config = {"populate_by_name": True} + + +@router.post("/document-reminders/export") +def export_document_reminders( + body: DocumentReminderExportBody, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + try: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + scan_cert_expiry(db, tenant_no) + db.commit() + query = db.query(PlDocumentReminder).filter( + PlDocumentReminder.tenant_no == tenant_no, + PlDocumentReminder.del_flag == 0, + ) + if body.status: + query = query.filter(PlDocumentReminder.status == body.status) + if body.ids: + query = query.filter(PlDocumentReminder.id.in_(body.ids)) + query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no) + items = query.order_by(PlDocumentReminder.expire_date.asc()).limit(EXPORT_MAX_ROWS + 1).all() + status_labels = {"pending": "待处理", "resolved": "已处理"} + owner_labels = {"vehicle": "车辆", "personnel": "人员", "driver": "司机"} + rows = [] + for x in items: + data = DocumentReminderRead.model_validate(x).model_dump(by_alias=True) + data["remark"] = getattr(x, "remark", None) or "" + data["status"] = status_labels.get(str(data.get("status") or ""), data.get("status")) + data["ownerType"] = owner_labels.get(str(data.get("ownerType") or ""), data.get("ownerType")) + for k, v in list(data.items()): + if hasattr(v, "isoformat"): + data[k] = str(v)[:19] + rows.append(data) + cols = filter_export_columns(DOCUMENT_REMINDER_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.put("/document-reminders/{id}", response_model=DocumentReminderRead) def update_document_reminder( id: str, diff --git a/backend/app/api/pl/plan.py b/backend/app/api/pl/plan.py index c446c05f4..f9d8b8201 100644 --- a/backend/app/api/pl/plan.py +++ b/backend/app/api/pl/plan.py @@ -679,6 +679,82 @@ def list_contract_gaps( return PlanContractGapListResponse(total=total, list=items) + +CONTRACT_GAP_EXPORT_COLUMNS = [ + ExportColumn(title="计划编号", field="planNo"), + ExportColumn(title="计划日期", field="planDate"), + ExportColumn(title="单据类型", field="planType"), + ExportColumn(title="跟进状态", field="followStatus"), + ExportColumn(title="供方", field="supplierName"), + ExportColumn(title="需方", field="customerName"), + ExportColumn(title="缺口类型", field="gapType"), +] + + +class ContractGapExportBody(BaseModel): + plan_no: Optional[str] = Field(default=None, alias="planNo") + gap_type: Optional[str] = Field(default=None, alias="gapType") + customer_keyword: Optional[str] = Field(default=None, alias="customerKeyword") + plan_date_from: Optional[date] = Field(default=None, alias="planDateFrom") + plan_date_to: Optional[date] = Field(default=None, alias="planDateTo") + q: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + model_config = {"populate_by_name": True} + + +@router.post("/contract-gaps/export") +def export_contract_gaps( + body: ContractGapExportBody, + 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 = list_plan_contract_gaps( + db, + tenant_no=tenant_no, + user=user, + page=1, + page_size=EXPORT_MAX_ROWS, + plan_no=body.plan_no, + gap_type=body.gap_type, + customer_keyword=body.customer_keyword, + plan_date_from=body.plan_date_from, + plan_date_to=body.plan_date_to, + q=body.q, + ids=body.ids, + max_page_size=EXPORT_MAX_ROWS, + ) + plan_type_labels = {"direct": "直销单", "inbound": "入库单", "outbound": "出库单"} + gap_labels = {"supplier": "仅缺供方合同", "demander": "仅缺需方合同", "both": "双侧缺口"} + rows = [] + for item in items: + data = item.model_dump(by_alias=True) + data["planType"] = plan_type_labels.get(str(data.get("planType") or ""), data.get("planType")) + sides = set(data.get("gapSides") or []) + if "supplier" in sides and "demander" in sides: + data["gapType"] = "双侧缺口" + elif "supplier" in sides: + data["gapType"] = "仅缺供方合同" + elif "demander" in sides: + data["gapType"] = "仅缺需方合同" + else: + data["gapType"] = "" + for k, v in list(data.items()): + if hasattr(v, "isoformat"): + data[k] = str(v)[:19] + rows.append(data) + cols = filter_export_columns(CONTRACT_GAP_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.put("/{id}/contracts/{side}", response_model=PlanContractGapRead) def put_plan_side_contracts( id: str, diff --git a/backend/app/services/pl_plan_contract_gap.py b/backend/app/services/pl_plan_contract_gap.py index e1aa97de5..58f51bda5 100644 --- a/backend/app/services/pl_plan_contract_gap.py +++ b/backend/app/services/pl_plan_contract_gap.py @@ -151,6 +151,8 @@ def list_plan_contract_gaps( plan_date_from: Optional[date] = None, plan_date_to: Optional[date] = None, q: Optional[str] = None, + ids: Optional[list] = None, + max_page_size: Optional[int] = None, ) -> tuple[int, list[PlanContractGapRead]]: query = db.query(PlPlan).filter( PlPlan.tenant_no == tenant_no, @@ -202,8 +204,14 @@ def list_plan_contract_gaps( ) ) + if ids: + query = query.filter(PlPlan.id.in_(ids)) + query = query.order_by(PlPlan.plan_date.desc(), PlPlan.create_time.desc()) 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) offset = (page - 1) * page_size plans = query.offset(offset).limit(page_size).all() diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index f891b247e..8598035ce 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -755,6 +755,8 @@ export const planApi = { request.post(`/transport/plan/${id}/subpack-slip-no`), listContractGaps: (params?: PlanContractGapQuery) => request.get>('/transport/plan/contract-gaps', { params }), + exportContractGaps: (data: Record) => + request.post('/transport/plan/contract-gaps/export', data, { responseType: 'blob' }), updateSideContracts: ( id: string, side: 'supplier' | 'demander', @@ -1315,6 +1317,8 @@ export const inventoryApi = { }) => request.post('/transport/inventory/warning-threshold', data), documentReminders: (status?: string) => request.get('/transport/inventory/document-reminders', { params: { status } }), + exportDocumentReminders: (data: Record) => + request.post('/transport/inventory/document-reminders/export', data, { responseType: 'blob' }), documentReminderStats: () => request.get('/transport/inventory/document-reminders/stats'), updateDocumentReminder: (id: string, data: { status: string; remark?: string }) => diff --git a/web-admin/src/views/pl/inventory/document-reminder.vue b/web-admin/src/views/pl/inventory/document-reminder.vue index 329453449..3169b1e22 100644 --- a/web-admin/src/views/pl/inventory/document-reminder.vue +++ b/web-admin/src/views/pl/inventory/document-reminder.vue @@ -43,6 +43,15 @@ 处理 + + 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 { CircleCheck, RefreshLeft, Search } from '@element-plus/icons-vue' import { inventoryApi, type DocumentReminder } from '@/api/pl' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' @@ -189,6 +199,19 @@ const reminderSourceList = ref([]) const quickSearchKeyword = ref('') const selectedRows = ref([]) +function getReminderExportFilterParams() { + return { + status: searchForm.status || undefined + } +} + +const reminderExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('ownerLabel')) cols.unshift('ownerLabel') + return cols +}) + + const searchForm = reactive({ status: '' }) diff --git a/web-admin/src/views/pl/plan-contract/index.vue b/web-admin/src/views/pl/plan-contract/index.vue index 0da927d8a..b893997e3 100644 --- a/web-admin/src/views/pl/plan-contract/index.vue +++ b/web-admin/src/views/pl/plan-contract/index.vue @@ -70,6 +70,14 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + 刷新 @@ -347,6 +355,7 @@ import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-mu import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue' import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue' import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue' import { useListDateShortcut } from '@/composables/useListDateShortcut' import { @@ -392,6 +401,29 @@ const pagination = reactive({ total: 0 }) +function getContractGapExportFilterParams() { + const keyword = quickSearch.value.trim() + return { + q: keyword || undefined, + planNo: keyword ? undefined : searchForm.planNo || undefined, + gapType: searchForm.gapType || undefined, + customerKeyword: keyword ? undefined : searchForm.customerKeyword || undefined, + planDateFrom: dateRange.value?.[0] || undefined, + planDateTo: dateRange.value?.[1] || undefined + } +} + +const contractGapExportColumns = [ + 'planNo', + 'planDate', + 'planType', + 'followStatus', + 'supplierName', + 'customerName', + 'gapType' +] + + function cloneLinks(links?: PlanContractLink[]): PlanContractLink[] { return (links || []).map(item => ({ contractId: item.contractId,