From 8f1f08b284d1b8556e987827455963cdc409d995 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 18:14:58 +0800 Subject: [PATCH] feat(pl): add Excel export for inventory ledger and inbound/outbound lists Allow filtered and selected row export on inventory and shared IO order pages. Co-authored-by: Cursor --- backend/app/api/pl/inventory.py | 74 ++++++++++++- backend/app/api/pl/io_order.py | 103 +++++++++++++++++- web-admin/src/api/pl/index.ts | 6 + .../src/views/pl/_shared/IoOrderListPage.vue | 37 +++++++ web-admin/src/views/pl/inventory/ledger.vue | 24 ++++ 5 files changed, 241 insertions(+), 3 deletions(-) diff --git a/backend/app/api/pl/inventory.py b/backend/app/api/pl/inventory.py index 930e3fd6b..465fab9eb 100644 --- a/backend/app/api/pl/inventory.py +++ b/backend/app/api/pl/inventory.py @@ -1,9 +1,10 @@ """运输计划管理 - 盘存统计 API""" -from datetime import date +from datetime import date, datetime from decimal import Decimal -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -11,6 +12,13 @@ from app.core.database import get_db from app.core.db_auth import DbUserContext, get_db_current_user from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope 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.core.utils import generate_uuid from app.models.pl_models import ( PlDocumentReminder, @@ -77,6 +85,68 @@ def get_inventory_ledger( # ============================================================================= + +LEDGER_EXPORT_COLUMNS = [ + ExportColumn(title="产品名称", field="productName"), + ExportColumn(title="品牌", field="brandName"), + ExportColumn(title="产品编号", field="productNo"), + ExportColumn(title="规格型号", field="model"), + ExportColumn(title="单位", field="unit"), + ExportColumn(title="库存数量", field="stockQty"), + ExportColumn(title="预警阈值", field="warnThreshold"), + ExportColumn(title="状态", field="status"), +] + + +class InventoryLedgerExportBody(BaseModel): + product_name: Optional[str] = Field(default=None, alias="productName") + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + model_config = {"populate_by_name": True} + + +@router.post("/ledger/export") +def export_inventory_ledger( + body: InventoryLedgerExportBody, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + try: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + query = db.query(PlInventory).filter( + PlInventory.tenant_no == tenant_no, + PlInventory.del_flag == 0, + ) + if body.product_name: + query = query.filter(PlInventory.product_name.contains(body.product_name)) + if body.ids: + query = query.filter(PlInventory.id.in_(body.ids)) + query = apply_pl_scope(query, PlInventory, user, db, tenant_no) + items = query.order_by(PlInventory.product_name).limit(EXPORT_MAX_ROWS + 1).all() + rows = [] + for x in items: + data = InventoryRead.model_validate(x).model_dump(by_alias=True) + low = data.get("lowStockWarning") + high = data.get("highStockWarning") + data["warnThreshold"] = f"{low or '-'} ~ {high or '-'}" + stock = data.get("stockQty") or 0 + try: + stock_f = float(stock) + low_f = float(low or 0) + except (TypeError, ValueError): + stock_f, low_f = 0.0, 0.0 + data["status"] = "库存不足" if stock_f < low_f else "正常" + rows.append(data) + cols = filter_export_columns(LEDGER_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.post("/warning-threshold") def set_warning_threshold( payload: InventoryWarningThreshold, diff --git a/backend/app/api/pl/io_order.py b/backend/app/api/pl/io_order.py index c6c87f7d2..50a36f276 100644 --- a/backend/app/api/pl/io_order.py +++ b/backend/app/api/pl/io_order.py @@ -3,8 +3,9 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal -from typing import Optional +from typing import List, Optional +from pydantic import BaseModel, Field from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -12,6 +13,13 @@ 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 ( PlInboundOrder, PlInboundOrderItem, @@ -76,6 +84,8 @@ def _list_orders( page_size: int, db: Session, user: DbUserContext, + ids: Optional[list] = None, + max_page_size: Optional[int] = None, ) -> IoOrderListResponse: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) header_m, item_m = _models(kind) @@ -103,7 +113,12 @@ def _list_orders( header_m.vehicle_no.contains(q), ) ) + if ids: + query = query.filter(header_m.id.in_(ids)) 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(header_m.create_time.desc()) .offset((page - 1) * page_size) @@ -248,6 +263,92 @@ def list_inbound( ) + +IO_ORDER_EXPORT_COLUMNS = [ + ExportColumn(title="单号", field="orderNo"), + ExportColumn(title="状态", field="status"), + ExportColumn(title="计划号", field="planNo"), + ExportColumn(title="往来单位", field="partnerCompany"), + ExportColumn(title="车牌", field="vehicleNo"), + ExportColumn(title="取货地址", field="pickupAddress"), + ExportColumn(title="送货地址", field="destinationAddress"), + ExportColumn(title="计划日期", field="planDate"), + ExportColumn(title="实际数量合计", field="actualQtyTotal"), + ExportColumn(title="最近提交", field="siteFeedbackTime"), +] + + +class IoOrderExportBody(BaseModel): + order_no: Optional[str] = Field(default=None, alias="orderNo") + plan_no: Optional[str] = Field(default=None, alias="planNo") + status: Optional[str] = None + partner_company: Optional[str] = Field(default=None, alias="partnerCompany") + vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo") + q: Optional[str] = None + plan_date_from: Optional[str] = Field(default=None, alias="planDateFrom") + plan_date_to: Optional[str] = Field(default=None, alias="planDateTo") + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + model_config = {"populate_by_name": True} + + +def _export_orders(kind: str, body: IoOrderExportBody, db: Session, user: DbUserContext, sheet: str): + try: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + resp = _list_orders( + kind=kind, + order_no=body.order_no, + plan_no=body.plan_no, + status=body.status, + partner_company=body.partner_company, + vehicle_no=body.vehicle_no, + q=body.q, + plan_date_from=body.plan_date_from, + plan_date_to=body.plan_date_to, + page=1, + page_size=EXPORT_MAX_ROWS, + db=db, + user=user, + ids=body.ids, + max_page_size=EXPORT_MAX_ROWS, + ) + rows = [] + for item in resp.list: + data = item.model_dump(by_alias=True) + for k, v in list(data.items()): + if hasattr(v, 'isoformat'): + data[k] = str(v)[:19] + if not data.get('destinationAddress'): + data['destinationAddress'] = data.get('destinationName') or '' + rows.append(data) + cols = filter_export_columns(IO_ORDER_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=sheet, columns=cols, rows=rows, + filename_stem=f"{sheet}_{date_str}", + ) + except ExportValidationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@inbound_router.post("/export") +def export_inbound( + body: IoOrderExportBody, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + return _export_orders("inbound", body, db, user, "入库单") + + +@outbound_router.post("/export") +def export_outbound( + body: IoOrderExportBody, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + return _export_orders("outbound", body, db, user, "出库单") + + @inbound_router.get("/{id}", response_model=IoOrderRead) def get_inbound( id: str, diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index 8b9d95f07..37f8decca 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -963,6 +963,8 @@ export interface PlIoOrderListResponse { export const inboundOrderApi = { list: (params?: PlIoOrderQuery) => request.get('/transport/inbound/', { params }), + export: (data: Record) => + request.post('/transport/inbound/export', data, { responseType: 'blob' }), get: (id: string) => request.get(`/transport/inbound/${id}`), getByPlan: (planId: string) => request.get(`/transport/inbound/by-plan/${planId}`), @@ -975,6 +977,8 @@ export const inboundOrderApi = { export const outboundOrderApi = { list: (params?: PlIoOrderQuery) => request.get('/transport/outbound/', { params }), + export: (data: Record) => + request.post('/transport/outbound/export', data, { responseType: 'blob' }), get: (id: string) => request.get(`/transport/outbound/${id}`), getByPlan: (planId: string) => request.get(`/transport/outbound/by-plan/${planId}`), @@ -1288,6 +1292,8 @@ export interface InventoryLedgerResponse { export const inventoryApi = { ledger: (params?: InventoryLedgerQuery) => request.get('/transport/inventory/ledger', { params }), + exportLedger: (data: Record) => + request.post('/transport/inventory/ledger/export', data, { responseType: 'blob' }), transactions: (params?: any) => request.get<{ total: number; list: InventoryTransaction[] }>('/transport/inventory/transactions', { params }), summary: (params: { groupBy: string; dateFrom: string; dateTo: string; productId?: string }) => diff --git a/web-admin/src/views/pl/_shared/IoOrderListPage.vue b/web-admin/src/views/pl/_shared/IoOrderListPage.vue index 9ff134c86..84285075a 100644 --- a/web-admin/src/views/pl/_shared/IoOrderListPage.vue +++ b/web-admin/src/views/pl/_shared/IoOrderListPage.vue @@ -54,6 +54,14 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + ([]) const selectedRows = ref([]) + + function getIoOrderExportFilterParams() { + const keyword = quickSearch.value.trim() + return { + orderNo: keyword ? undefined : searchForm.orderNo || undefined, + planNo: keyword ? undefined : searchForm.planNo || undefined, + partnerCompany: keyword ? undefined : searchForm.partnerCompany || undefined, + status: searchForm.status || undefined, + vehicleNo: searchForm.vehicleNo || undefined, + q: keyword || undefined, + planDateFrom: dateRange.value?.[0] || undefined, + planDateTo: dateRange.value?.[1] || undefined + } + } + + const ioOrderExportColumns = [ + 'orderNo', + 'status', + 'planNo', + 'partnerCompany', + 'vehicleNo', + 'pickupAddress', + 'destinationAddress', + 'planDate', + 'actualQtyTotal', + 'siteFeedbackTime' + ] + const dialogVisible = ref(false) const editForm = ref<(Omit & { items: IoOrderEditItem[] }) | null>(null) const editImagesCsv = ref('') diff --git a/web-admin/src/views/pl/inventory/ledger.vue b/web-admin/src/views/pl/inventory/ledger.vue index 7516b9c5f..5fe9ec240 100644 --- a/web-admin/src/views/pl/inventory/ledger.vue +++ b/web-admin/src/views/pl/inventory/ledger.vue @@ -36,6 +36,15 @@ @clear="handleQuickSearchClear" /> + + 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 { RefreshLeft, Search } from '@element-plus/icons-vue' import { inventoryApi, type InventoryRecord } from '@/api/pl' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' @@ -202,6 +212,20 @@ const { const loading = ref(false) const selectedRows = ref([]) + +function getLedgerExportFilterParams() { + const keyword = quickSearchKeyword.value.trim() + return { + productName: keyword || searchForm.productName || undefined + } +} + +const ledgerExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('productName')) cols.unshift('productName') + return cols +}) + const inventoryList = ref([]) const quickSearchKeyword = ref('')