feat: P0-6 Excel导入导出全套(客户/合同/库存/统计, 自动表头探测)
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
- [2026-09-25 14:50] FEAT: v2.25 P0-6 Excel 导入导出全套。①excel_service 扩展: read_rows 自动探测表头行(兼容首行标题合并格, 修复导入全跳过)/pick 中文列名取值/simple_table_workbook/statistics_workbook 双Sheet; ②客户: excel-template 模板/excel-in 批量导入(缺姓名行级报错)/excel-out 导出; ③合同 excel-out(含已收); ④库存 excel-out/excel-in(同名更新数量累加); ⑤reports/excel-statistics(订单汇总+收款统计); ⑥依赖补 python-multipart(UploadFile 必需, 缺失曾致后端启动失败); ⑦前端 apiUpload+四个页面导入导出按钮; 实测: 模板回传导入成功1条/库存导出→再导入更新1条; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/reports.py, frontend/src/api/index.ts, frontend/src/views/CustomersView.vue, frontend/src/views/WarehouseView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/ReportView.vue, doc/API.md)
|
||||||
|
|
||||||
- [2026-09-25 13:50] FEAT: v2.24 P0-5 收款财务闭环。①ContractReceipt 模型(SK+日期+流水/方式五类/作废状态)+finance.py(账户 CRUD/登记收款/作废/流水分页/年度统计); ②GET /receipts/statistics(收款总额/合同应收/未收/12 月分布, 对齐 statistics_tb); ③合同列表注入 received/unreceived(当页一次 GROUP BY 聚合); ④新页面 /financial 财务管理(统计卡+月度柱图+流水+登记+账户管理)+路由菜单; 合同页加已收/未收列+收款按钮对话框; 修复 v-for 索引 m+1 类型; vue-tsc 0 错+构建通过; API 实测登记 3000→统计/列表联动正确 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/finance.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/router.py, frontend/src/views/FinancialView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, doc/API.md)
|
- [2026-09-25 13:50] FEAT: v2.24 P0-5 收款财务闭环。①ContractReceipt 模型(SK+日期+流水/方式五类/作废状态)+finance.py(账户 CRUD/登记收款/作废/流水分页/年度统计); ②GET /receipts/statistics(收款总额/合同应收/未收/12 月分布, 对齐 statistics_tb); ③合同列表注入 received/unreceived(当页一次 GROUP BY 聚合); ④新页面 /financial 财务管理(统计卡+月度柱图+流水+登记+账户管理)+路由菜单; 合同页加已收/未收列+收款按钮对话框; 修复 v-for 索引 m+1 类型; vue-tsc 0 错+构建通过; API 实测登记 3000→统计/列表联动正确 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/finance.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/router.py, frontend/src/views/FinancialView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, doc/API.md)
|
||||||
|
|
||||||
- [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md)
|
- [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md)
|
||||||
|
|||||||
+14
@@ -431,3 +431,17 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
|||||||
| **收款统计** | GET | `/api/receipts/statistics` | `year?`(默认当年) | `{total_received, contract_total, unreceived, monthly[12]}`(对齐 statistics/_tb) |
|
| **收款统计** | GET | `/api/receipts/statistics` | `year?`(默认当年) | `{total_received, contract_total, unreceived, monthly[12]}`(对齐 statistics/_tb) |
|
||||||
|
|
||||||
合同列表(`GET /api/contracts`)响应行新增:`received`(有效收款合计)、`unreceived`(合同额-已收)。
|
合同列表(`GET /api/contracts`)响应行新增:`received`(有效收款合计)、`unreceived`(合同额-已收)。
|
||||||
|
|
||||||
|
### 12.20 Excel 导入导出(v2.20 新增,P0-数据迁移)
|
||||||
|
|
||||||
|
依赖 `openpyxl` + `python-multipart`。导入文件自动探测表头行(兼容带标题行的导出格式);导出为带样式 xlsx。
|
||||||
|
|
||||||
|
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 客户导入模板 | GET | `/api/customers/excel-template` | — | 列:姓名*/电话/地址/业务员/备注 |
|
||||||
|
| **客户批量导入** | POST | `/api/customers/excel-in` | multipart `file` | 对齐 customers_excel_in;缺姓名行跳过并逐行报错 |
|
||||||
|
| 客户导出 | GET | `/api/customers/excel-out` | — | 全量 |
|
||||||
|
| 合同导出 | GET | `/api/contracts/excel-out` | — | 对齐 contracts_excel,含已收金额 |
|
||||||
|
| **库存批量导入** | POST | `/api/warehouse/excel-in` | multipart `file` | 对齐 materials_config_excel:同名材料更新(数量累加),否则新增 |
|
||||||
|
| 库存导出 | GET | `/api/warehouse/excel-out` | — | 对齐 warehouse_excel_out |
|
||||||
|
| 统计报表导出 | GET | `/api/reports/excel-statistics` | — | 对齐 excel-statistics:订单汇总+收款统计双 Sheet |
|
||||||
|
|||||||
@@ -51,3 +51,11 @@ export async function apiDownload(url: string, filename: string): Promise<void>
|
|||||||
link.click()
|
link.click()
|
||||||
URL.revokeObjectURL(link.href)
|
URL.revokeObjectURL(link.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上传文件(multipart,自动带 token),返回 envelope data */
|
||||||
|
export async function apiUpload<T = any>(url: string, file: File): Promise<T> {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
const res = await http.post(url, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
return res.data.data as T
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="load">查询</el-button>
|
<el-button type="primary" @click="load">查询</el-button>
|
||||||
<div style="flex:1" />
|
<div style="flex:1" />
|
||||||
|
<el-button @click="exportExcel">导出 Excel</el-button>
|
||||||
<el-button type="primary" plain @click="openCreate">新建合同</el-button>
|
<el-button type="primary" plain @click="openCreate">新建合同</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="rows" v-loading="loading" stripe>
|
<el-table :data="rows" v-loading="loading" stripe>
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api, apiDownload } from '../api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
@@ -169,6 +170,8 @@ async function saveReceipt() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportExcel() { apiDownload('/api/contracts/excel-out', 'contracts.xlsx') }
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
|
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
|
||||||
<el-button type="primary" @click="load">查询</el-button>
|
<el-button type="primary" @click="load">查询</el-button>
|
||||||
<div style="flex:1" />
|
<div style="flex:1" />
|
||||||
|
<el-upload :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls">
|
||||||
|
<el-button type="warning" plain :loading="importing">导入 Excel</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button @click="downloadTemplate">下载模板</el-button>
|
||||||
|
<el-button @click="exportExcel">导出 Excel</el-button>
|
||||||
<el-button type="primary" plain @click="openEdit()">新增客户</el-button>
|
<el-button type="primary" plain @click="openEdit()">新增客户</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="rows" v-loading="loading" stripe>
|
<el-table :data="rows" v-loading="loading" stripe>
|
||||||
@@ -43,7 +48,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api, apiDownload, apiUpload } from '../api'
|
||||||
|
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -89,6 +94,21 @@ async function remove(row: any) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Excel 导入导出 ----
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
|
async function doImport(opt: any) {
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
await apiUpload('/api/customers/excel-in', opt.file as File)
|
||||||
|
ElMessage.success('导入完成')
|
||||||
|
load()
|
||||||
|
} finally { importing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTemplate() { apiDownload('/api/customers/excel-template', 'customers_template.xlsx') }
|
||||||
|
function exportExcel() { apiDownload('/api/customers/excel-out', 'customers.xlsx') }
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<el-radio-button :value="14">近14天</el-radio-button>
|
<el-radio-button :value="14">近14天</el-radio-button>
|
||||||
<el-radio-button :value="30">近30天</el-radio-button>
|
<el-radio-button :value="30">近30天</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<el-button size="small" plain @click="exportExcel">导出统计 Excel</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { api } from '../api'
|
import { api, apiDownload } from '../api'
|
||||||
|
|
||||||
const days = ref(14)
|
const days = ref(14)
|
||||||
const byDay = ref<any[]>([])
|
const byDay = ref<any[]>([])
|
||||||
@@ -75,6 +76,8 @@ async function load() {
|
|||||||
statusDist.value = d.status_dist
|
statusDist.value = d.status_dist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportExcel() { apiDownload('/api/reports/excel-statistics', 'statistics.xlsx') }
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" @click="load">查询</el-button>
|
<el-button type="primary" @click="load">查询</el-button>
|
||||||
<div style="flex:1" />
|
<div style="flex:1" />
|
||||||
|
<el-upload :show-file-list="false" :http-request="doImport" accept=".xlsx,.xls">
|
||||||
|
<el-button type="warning" plain :loading="importing">导入 Excel</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button @click="exportExcel">导出 Excel</el-button>
|
||||||
<el-button type="primary" plain @click="openEdit()">新增材料</el-button>
|
<el-button type="primary" plain @click="openEdit()">新增材料</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="rows" v-loading="loading" stripe>
|
<el-table :data="rows" v-loading="loading" stripe>
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api, apiDownload, apiUpload } from '../api'
|
||||||
|
|
||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -111,6 +115,20 @@ async function move(row: any, direction: number) {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Excel 导入导出 ----
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
|
async function doImport(opt: any) {
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
await apiUpload('/api/warehouse/excel-in', opt.file as File)
|
||||||
|
ElMessage.success('导入完成')
|
||||||
|
load()
|
||||||
|
} finally { importing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportExcel() { apiDownload('/api/warehouse/excel-out', 'warehouse.xlsx') }
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.3",
|
"pydantic-settings>=2.3",
|
||||||
"pyjwt>=2.8",
|
"pyjwt>=2.8",
|
||||||
"openpyxl>=3.1",
|
"openpyxl>=3.1",
|
||||||
|
"python-multipart>=0.0.32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ async def list_contracts(user: CurrentUser, db: DbSession, page: int = 1,
|
|||||||
return ok(data={"contracts": paginated(outs, page, page_size, total)})
|
return ok(data={"contracts": paginated(outs, page, page_size, total)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel-out")
|
||||||
|
async def excel_out(user: CurrentUser, db: DbSession):
|
||||||
|
"""合同全量导出 Excel(对齐目标站 contracts_excel)。"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from huamenchuang.services.excel_service import simple_table_workbook
|
||||||
|
from huamenchuang.models.business import ContractReceipt
|
||||||
|
from sqlalchemy import func
|
||||||
|
rows_db = list((await db.execute(
|
||||||
|
select(Contract).where(Contract.company_id == user.company_id)
|
||||||
|
.order_by(Contract.id))).scalars())
|
||||||
|
received_map: dict[int, float] = {}
|
||||||
|
if rows_db:
|
||||||
|
rrows = (await db.execute(
|
||||||
|
select(ContractReceipt.contract_id, func.sum(ContractReceipt.amount))
|
||||||
|
.where(ContractReceipt.contract_id.in_([c.id for c in rows_db]),
|
||||||
|
ContractReceipt.status == 1)
|
||||||
|
.group_by(ContractReceipt.contract_id))).all()
|
||||||
|
received_map = {cid: float(v or 0) for cid, v in rrows}
|
||||||
|
data = [[c.contract_no, c.customer_name, c.phone, c.address, float(c.money),
|
||||||
|
received_map.get(c.id, 0), status_label(c.status), str(c.created_at)[:10]]
|
||||||
|
for c in rows_db]
|
||||||
|
content = simple_table_workbook("合同列表",
|
||||||
|
["合同号", "客户", "电话", "地址", "金额", "已收", "状态", "创建日期"],
|
||||||
|
data, widths=[18, 14, 16, 28, 12, 12, 10, 12])
|
||||||
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=contracts.xlsx"})
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{contract_id}")
|
@router.put("/{contract_id}")
|
||||||
async def update_contract(contract_id: int, body: ContractBody, user: CurrentUser, db: DbSession):
|
async def update_contract(contract_id: int, body: ContractBody, user: CurrentUser, db: DbSession):
|
||||||
"""编辑合同。"""
|
"""编辑合同。"""
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""客户路由。"""
|
"""客户路由。"""
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||||
from huamenchuang.core.response import fail, ok, paginated
|
from huamenchuang.core.response import fail, ok, paginated
|
||||||
|
from huamenchuang.models.contract import Customer
|
||||||
from huamenchuang.services.customer_service import CustomerService
|
from huamenchuang.services.customer_service import CustomerService
|
||||||
|
|
||||||
router = APIRouter(prefix="/customers", tags=["客户"])
|
router = APIRouter(prefix="/customers", tags=["客户"])
|
||||||
@@ -66,3 +68,65 @@ async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession):
|
|||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
return fail(str(e))
|
return fail(str(e))
|
||||||
return ok(message="删除成功")
|
return ok(message="删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Excel 导入导出 ----------------
|
||||||
|
|
||||||
|
@router.get("/excel-template")
|
||||||
|
async def excel_template(user: CurrentUser):
|
||||||
|
"""客户导入模板下载。"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from huamenchuang.services.excel_service import simple_table_workbook
|
||||||
|
content = simple_table_workbook("客户导入模板", ["姓名*", "电话", "地址", "业务员", "备注"],
|
||||||
|
[["张三", "13800001111", "XX路10号", "李四", ""], []],
|
||||||
|
widths=[16, 18, 30, 14, 20])
|
||||||
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=customers_template.xlsx"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/excel-in")
|
||||||
|
async def excel_in(file: UploadFile, user: CurrentUser, db: DbSession):
|
||||||
|
"""客户批量导入(对齐目标站 customers_excel_in;列:姓名*/电话/地址/业务员/备注)。"""
|
||||||
|
if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")):
|
||||||
|
return fail("请上传 xlsx 文件")
|
||||||
|
from huamenchuang.services.excel_service import pick, read_rows
|
||||||
|
try:
|
||||||
|
rows = read_rows(await file.read())
|
||||||
|
except Exception:
|
||||||
|
return fail("文件解析失败,请使用导入模板")
|
||||||
|
if not rows:
|
||||||
|
return fail("文件中没有数据行")
|
||||||
|
ok_n, skip, errors = 0, 0, []
|
||||||
|
for i, row in enumerate(rows, 2):
|
||||||
|
name = pick(row, "姓名", "姓名*", "名称", "name")
|
||||||
|
if not name:
|
||||||
|
skip += 1
|
||||||
|
errors.append(f"第{i}行缺姓名")
|
||||||
|
continue
|
||||||
|
db.add(Customer(company_id=user.company_id, name=str(name).strip(),
|
||||||
|
phone=str(pick(row, "电话", "手机", "phone", default="") or ""),
|
||||||
|
address=str(pick(row, "地址", "address", default="") or ""),
|
||||||
|
salesmen=str(pick(row, "业务员", "salesmen", default="") or ""),
|
||||||
|
note=str(pick(row, "备注", "note", default="") or "")))
|
||||||
|
ok_n += 1
|
||||||
|
await db.flush()
|
||||||
|
msg = f"导入完成:成功 {ok_n} 条,跳过 {skip} 条"
|
||||||
|
if errors:
|
||||||
|
msg += ";" + ";".join(errors[:5])
|
||||||
|
return ok(data={"success": ok_n, "skipped": skip}, message=msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel-out")
|
||||||
|
async def excel_out(user: CurrentUser, db: DbSession):
|
||||||
|
"""客户全量导出(对齐目标站 warehouse_excel_out 类语义)。"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from huamenchuang.services.excel_service import simple_table_workbook
|
||||||
|
rows_db = list((await db.execute(
|
||||||
|
select(Customer).where(Customer.company_id == user.company_id)
|
||||||
|
.order_by(Customer.id))).scalars())
|
||||||
|
data = [[c.name, c.phone, c.address, c.salesmen or "", float(c.balance), str(c.created_at)[:10]]
|
||||||
|
for c in rows_db]
|
||||||
|
content = simple_table_workbook("客户列表", ["姓名", "电话", "地址", "业务员", "余额", "建档日期"],
|
||||||
|
data, widths=[16, 18, 30, 14, 12, 12])
|
||||||
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=customers.xlsx"})
|
||||||
|
|||||||
@@ -51,3 +51,52 @@ async def summary(user: CurrentUser, db: DbSession, days: int = 14):
|
|||||||
status_dist = [{"status": s, "count": c} for s, c in status_rows]
|
status_dist = [{"status": s, "count": c} for s, c in status_rows]
|
||||||
|
|
||||||
return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days})
|
return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel-statistics")
|
||||||
|
async def excel_statistics(user: CurrentUser, db: DbSession):
|
||||||
|
"""统计报表导出 Excel(对齐目标站 excel-statistics:订单汇总+收款统计)。"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from huamenchuang.models.business import ContractReceipt
|
||||||
|
from huamenchuang.services.excel_service import statistics_workbook
|
||||||
|
|
||||||
|
cid = user.company_id
|
||||||
|
import datetime as _dt
|
||||||
|
year = _dt.date.today().year
|
||||||
|
|
||||||
|
async def count(model, extra=None):
|
||||||
|
conds = [model.company_id == cid]
|
||||||
|
if extra is not None:
|
||||||
|
conds.append(extra)
|
||||||
|
return (await db.execute(select(func.count()).select_from(model).where(*conds))).scalar_one()
|
||||||
|
|
||||||
|
order_money = (await db.execute(select(func.coalesce(func.sum(Order.money), 0))
|
||||||
|
.where(Order.company_id == cid))).scalar() or 0
|
||||||
|
summary = {
|
||||||
|
"订单总数": await count(Order),
|
||||||
|
"意向单": await count(Order, Order.status == 0),
|
||||||
|
"已完工": await count(Order, Order.status == 3),
|
||||||
|
"报价单": await count(Quotation),
|
||||||
|
"客户数": await count(Customer),
|
||||||
|
"合同数": await count(Contract),
|
||||||
|
"量尺单": await count(MeasureOrder),
|
||||||
|
"售后单(未完结)": await count(AfterSale, AfterSale.status != 2),
|
||||||
|
"订单总金额": round(float(order_money), 2),
|
||||||
|
}
|
||||||
|
ycond = (ContractReceipt.company_id == cid, ContractReceipt.status == 1,
|
||||||
|
func.strftime("%Y", ContractReceipt.receipt_date) == str(year))
|
||||||
|
received = (await db.execute(select(func.coalesce(func.sum(ContractReceipt.amount), 0))
|
||||||
|
.where(*ycond))).scalar() or 0
|
||||||
|
months_rows = (await db.execute(
|
||||||
|
select(func.strftime("%m", ContractReceipt.receipt_date), func.sum(ContractReceipt.amount))
|
||||||
|
.where(*ycond).group_by(func.strftime("%m", ContractReceipt.receipt_date)))).all()
|
||||||
|
months = {int(m): float(v or 0) for m, v in months_rows}
|
||||||
|
contract_total = (await db.execute(select(func.coalesce(func.sum(Contract.money), 0))
|
||||||
|
.where(Contract.company_id == cid, Contract.status < 3))).scalar() or 0
|
||||||
|
receipts = {"year": year, "total_received": round(float(received), 2),
|
||||||
|
"contract_total": round(float(contract_total), 2),
|
||||||
|
"unreceived": round(float(contract_total) - float(received), 2),
|
||||||
|
"monthly": [months.get(m, 0) for m in range(1, 13)]}
|
||||||
|
content = statistics_workbook(summary, receipts)
|
||||||
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=statistics.xlsx"})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""仓库库存路由:材料 CRUD + 出入库。"""
|
"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。"""
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, select
|
||||||
|
|
||||||
@@ -69,6 +69,80 @@ async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
|
|||||||
return ok(data={"id": item.id}, message="创建成功")
|
return ok(data={"id": item.id}, message="创建成功")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Excel 导入导出 ----------------
|
||||||
|
|
||||||
|
@router.get("/excel-out")
|
||||||
|
async def excel_out(user: CurrentUser, db: DbSession):
|
||||||
|
"""库存全量导出(对齐目标站 warehouse_excel_out)。"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from huamenchuang.services.excel_service import simple_table_workbook
|
||||||
|
items = list((await db.execute(
|
||||||
|
select(WarehouseItem).where(WarehouseItem.company_id == user.company_id)
|
||||||
|
.order_by(WarehouseItem.id))).scalars())
|
||||||
|
mat_label = {"profile": "型材", "glass": "玻璃", "hardware": "五金", "addon": "配件"}
|
||||||
|
data = [[i.bom_code, i.name, mat_label.get(i.material, i.material), i.spec, i.color,
|
||||||
|
float(i.price), float(i.cost), float(i.count), i.unit, i.length, float(i.warning)]
|
||||||
|
for i in items]
|
||||||
|
content = simple_table_workbook("库存列表",
|
||||||
|
["编码", "名称", "类别", "规格", "颜色", "售价", "成本",
|
||||||
|
"库存数量", "单位", "单根长度mm", "预警线"],
|
||||||
|
data, widths=[12, 20, 8, 14, 10, 10, 10, 10, 8, 12, 10])
|
||||||
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=warehouse.xlsx"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/excel-in")
|
||||||
|
async def excel_in(file: UploadFile, user: CurrentUser, db: DbSession):
|
||||||
|
"""材料批量导入(对齐目标站 materials_config_excel:同名材料按名称匹配更新数量,否则新增)。"""
|
||||||
|
if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")):
|
||||||
|
return fail("请上传 xlsx 文件")
|
||||||
|
from huamenchuang.services.excel_service import pick, read_rows
|
||||||
|
try:
|
||||||
|
rows = read_rows(await file.read())
|
||||||
|
except Exception:
|
||||||
|
return fail("文件解析失败")
|
||||||
|
if not rows:
|
||||||
|
return fail("文件中没有数据行")
|
||||||
|
mat_map = {"型材": "profile", "玻璃": "glass", "五金": "hardware", "配件": "addon",
|
||||||
|
"profile": "profile", "glass": "glass", "hardware": "hardware", "addon": "addon"}
|
||||||
|
existing = {i.name: i for i in (await db.execute(
|
||||||
|
select(WarehouseItem).where(WarehouseItem.company_id == user.company_id))).scalars()}
|
||||||
|
created, updated, skipped = 0, 0, 0
|
||||||
|
for i, row in enumerate(rows, 2):
|
||||||
|
name = pick(row, "名称", "材料名称", "name")
|
||||||
|
if not name:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
name = str(name).strip()
|
||||||
|
mat = mat_map.get(str(pick(row, "类别", "材料", "material", default="型材") or "型材"), "profile")
|
||||||
|
values = dict(
|
||||||
|
bom_code=str(pick(row, "编码", "bom_code", default="") or ""),
|
||||||
|
material=mat, spec=str(pick(row, "规格", "spec", default="") or ""),
|
||||||
|
color=str(pick(row, "颜色", "color", default="") or ""),
|
||||||
|
price=float(pick(row, "售价", "price", default=0) or 0),
|
||||||
|
cost=float(pick(row, "成本", "cost", default=0) or 0),
|
||||||
|
count=float(pick(row, "库存数量", "数量", "count", default=0) or 0),
|
||||||
|
unit=str(pick(row, "单位", "unit", default="根") or "根"),
|
||||||
|
length=int(float(pick(row, "单根长度mm", "长度", "length", default=0) or 0)),
|
||||||
|
warning=float(pick(row, "预警线", "warning", default=0) or 0))
|
||||||
|
if name in existing: # 同名更新(数量累加)
|
||||||
|
item = existing[name]
|
||||||
|
for k, v in values.items():
|
||||||
|
if k == "count":
|
||||||
|
continue
|
||||||
|
setattr(item, k, v)
|
||||||
|
item.count = float(item.count) + values["count"]
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
item = WarehouseItem(company_id=user.company_id, name=name, **values)
|
||||||
|
db.add(item)
|
||||||
|
existing[name] = item
|
||||||
|
created += 1
|
||||||
|
await db.flush()
|
||||||
|
return ok(data={"created": created, "updated": updated, "skipped": skipped},
|
||||||
|
message=f"导入完成:新增 {created} 条,更新 {updated} 条,跳过 {skipped} 条")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{item_id}")
|
@router.put("/{item_id}")
|
||||||
async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession):
|
async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession):
|
||||||
"""编辑材料。"""
|
"""编辑材料。"""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Excel 生成服务(openpyxl):BOM 单、订单导出、业务数据导入导出共用工具。"""
|
"""Excel 生成/解析服务(openpyxl):BOM 单、订单导出、业务数据导入导出共用工具。"""
|
||||||
import io
|
import io
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
@@ -96,3 +96,66 @@ def order_workbook(order: dict, bom: dict) -> bytes:
|
|||||||
row += 1
|
row += 1
|
||||||
ws.cell(row=row, column=1, value="制单:____________ 审核:____________ 日期:____________")
|
ws.cell(row=row, column=1, value="制单:____________ 审核:____________ 日期:____________")
|
||||||
return save_bytes(wb)
|
return save_bytes(wb)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 导入解析 ----------------
|
||||||
|
|
||||||
|
def read_rows(data: bytes, sheet_index: int = 0) -> list[dict]:
|
||||||
|
"""解析上传的 xlsx → 行字典列表(自动探测表头行,兼容首行大标题的导出格式)。
|
||||||
|
|
||||||
|
探测规则:前 3 行中非空单元格最多的一行视为表头;空行跳过。
|
||||||
|
"""
|
||||||
|
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||||
|
ws = wb.worksheets[sheet_index]
|
||||||
|
all_rows = [list(r) for r in ws.iter_rows(values_only=True)]
|
||||||
|
if not all_rows:
|
||||||
|
return []
|
||||||
|
# 探测表头行:取前 3 行中非空单元格数最多的行
|
||||||
|
scan = all_rows[:3]
|
||||||
|
header_idx = max(range(len(scan)), key=lambda i: sum(1 for v in scan[i] if v is not None and str(v).strip()))
|
||||||
|
headers = [str(v).strip() if v is not None else "" for v in all_rows[header_idx]]
|
||||||
|
out: list[dict] = []
|
||||||
|
for values in all_rows[header_idx + 1:]:
|
||||||
|
row = {headers[i]: values[i] for i in range(min(len(headers), len(values)))}
|
||||||
|
if any(v is not None and str(v).strip() for v in row.values()):
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pick(row: dict, *names: str, default=None):
|
||||||
|
"""按候选列名(含中文表头)取值,全部缺失返回 default。"""
|
||||||
|
for n in names:
|
||||||
|
if n in row and row[n] not in (None, ""):
|
||||||
|
return row[n]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 业务导出工作簿 ----------------
|
||||||
|
|
||||||
|
def simple_table_workbook(title: str, headers: list[str], rows: list[list],
|
||||||
|
widths: list[int] | None = None) -> bytes:
|
||||||
|
"""单表导出(客户/合同/库存等通用)。"""
|
||||||
|
wb = new_workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = title[:28] or "导出"
|
||||||
|
row = write_title(ws, 1, title, len(headers))
|
||||||
|
write_table(ws, row, headers, rows, widths)
|
||||||
|
return save_bytes(wb)
|
||||||
|
|
||||||
|
|
||||||
|
def statistics_workbook(orders_summary: dict, receipts: dict) -> bytes:
|
||||||
|
"""统计报表导出:订单汇总 + 收款统计双 Sheet。"""
|
||||||
|
wb = new_workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "订单汇总"
|
||||||
|
row = write_title(ws, 1, "订单/业务统计", 4)
|
||||||
|
rows = [[k, v, "", ""] for k, v in orders_summary.items()]
|
||||||
|
row = write_table(ws, row, ["指标", "数值", "", ""], rows, widths=[24, 18, 8, 8])
|
||||||
|
ws2 = wb.create_sheet("收款统计")
|
||||||
|
row = write_title(ws2, 1, f"收款统计 {receipts['year']} 年", 14)
|
||||||
|
row = write_table(ws2, row,
|
||||||
|
["年度收款", "合同应收", "未收"] + [f"{m}月" for m in range(1, 13)],
|
||||||
|
[[receipts["total_received"], receipts["contract_total"], receipts["unreceived"],
|
||||||
|
*receipts["monthly"]]],
|
||||||
|
widths=[12] * 15)
|
||||||
|
return save_bytes(wb)
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -169,6 +170,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic", specifier = ">=2.7" },
|
{ name = "pydantic", specifier = ">=2.7" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.3" },
|
{ name = "pydantic-settings", specifier = ">=2.3" },
|
||||||
{ name = "pyjwt", specifier = ">=2.8" },
|
{ name = "pyjwt", specifier = ">=2.8" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.32" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
]
|
]
|
||||||
@@ -282,6 +284,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user