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:
1 parent
89e8d6e65c
commit
8f1f08b284
5 files changed
+241
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
Reference in new issue
Block a user