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 <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 18:14:58 +08:00
1 parent 89e8d6e65c
commit 8f1f08b284
5 files changed
+241 -3

No files matched your search

+72 -2
View File
@@ -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,
+102 -1
View File
@@ -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,
+6
View File
@@ -963,6 +963,8 @@ export interface PlIoOrderListResponse {
export const inboundOrderApi = {
list: (params?: PlIoOrderQuery) =>
request.get<PlIoOrderListResponse>('/transport/inbound/', { params }),
export: (data: Record<string, any>) =>
request.post('/transport/inbound/export', data, { responseType: 'blob' }),
get: (id: string) => request.get<PlIoOrder>(`/transport/inbound/${id}`),
getByPlan: (planId: string) =>
request.get<PlIoOrder | null>(`/transport/inbound/by-plan/${planId}`),
@@ -975,6 +977,8 @@ export const inboundOrderApi = {
export const outboundOrderApi = {
list: (params?: PlIoOrderQuery) =>
request.get<PlIoOrderListResponse>('/transport/outbound/', { params }),
export: (data: Record<string, any>) =>
request.post('/transport/outbound/export', data, { responseType: 'blob' }),
get: (id: string) => request.get<PlIoOrder>(`/transport/outbound/${id}`),
getByPlan: (planId: string) =>
request.get<PlIoOrder | null>(`/transport/outbound/by-plan/${planId}`),
@@ -1288,6 +1292,8 @@ export interface InventoryLedgerResponse {
export const inventoryApi = {
ledger: (params?: InventoryLedgerQuery) =>
request.get<InventoryLedgerResponse>('/transport/inventory/ledger', { params }),
exportLedger: (data: Record<string, any>) =>
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 }) =>
@@ -54,6 +54,14 @@
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<ListExportDropdown
:export-fn="api.export"
:get-filter-params="getIoOrderExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="ioOrderExportColumns"
:filename="kind === 'inbound' ? '入库单.xlsx' : '出库单.xlsx'"
/>
<el-button
class="btn-main"
type="primary"
@@ -426,6 +434,7 @@
type BrandQtyRow
} from '@/components/business/brand-qty-chips-field/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 ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
@@ -584,6 +593,34 @@
const saving = ref(false)
const rows = ref<PlIoOrder[]>([])
const selectedRows = ref<PlIoOrder[]>([])
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<PlIoOrder, 'items'> & { items: IoOrderEditItem[] }) | null>(null)
const editImagesCsv = ref('')
@@ -36,6 +36,15 @@
@clear="handleQuickSearchClear"
/>
<ListExportDropdown
:export-fn="inventoryApi.exportLedger"
:get-filter-params="getLedgerExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => r.id)"
:visible-columns="ledgerExportColumns"
filename="库存台账.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
@@ -177,6 +186,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 { 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<any[]>([])
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<InventoryRecord[]>([])
const quickSearchKeyword = ref('')