Bladeren bron

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 <cursoragent@cursor.com>
zlwu 2 maanden geleden
bovenliggende
commit
fe15a130cb

+ 62 - 0
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,

+ 76 - 0
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,

+ 8 - 0
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()
 

+ 4 - 0
web-admin/src/api/pl/index.ts

@@ -755,6 +755,8 @@ export const planApi = {
     request.post<string>(`/transport/plan/${id}/subpack-slip-no`),
   listContractGaps: (params?: PlanContractGapQuery) =>
     request.get<PlListResponse<PlanContractGapItem>>('/transport/plan/contract-gaps', { params }),
+  exportContractGaps: (data: Record<string, any>) =>
+    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<DocumentReminder[]>('/transport/inventory/document-reminders', { params: { status } }),
+  exportDocumentReminders: (data: Record<string, any>) =>
+    request.post('/transport/inventory/document-reminders/export', data, { responseType: 'blob' }),
   documentReminderStats: () =>
     request.get<DocumentReminderStats>('/transport/inventory/document-reminders/stats'),
   updateDocumentReminder: (id: string, data: { status: string; remark?: string }) =>

+ 23 - 0
web-admin/src/views/pl/inventory/document-reminder.vue

@@ -43,6 +43,15 @@
             <el-icon><CircleCheck /></el-icon>处理
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="inventoryApi.exportDocumentReminders"
+            :get-filter-params="getReminderExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="reminderExportColumns"
+            filename="证件到期提醒.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -159,6 +168,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 { 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<DocumentReminder[]>([])
 const quickSearchKeyword = ref('')
 const selectedRows = ref<DocumentReminder[]>([])
 
+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: ''
 })

+ 32 - 0
web-admin/src/views/pl/plan-contract/index.vue

@@ -70,6 +70,14 @@
             @search="handleQuickSearch"
             @clear="handleQuickSearchClear"
           />
+          <ListExportDropdown
+            :export-fn="planApi.exportContractGaps"
+            :get-filter-params="getContractGapExportFilterParams"
+            :has-selection="false"
+            :selected-ids="[]"
+            :visible-columns="contractGapExportColumns"
+            filename="计划合同完善.xlsx"
+          />
           <el-button class="btn-ghost" :loading="loading" @click="fetchData">
             <el-icon><Refresh /></el-icon>刷新
           </el-button>
@@ -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,