feat: P0-6 Excel导入导出全套(客户/合同/库存/统计, 自动表头探测)

This commit is contained in:
HHY
2026-09-25 03:30:27 -07:00
parent 436d006b5a
commit 40c16d56a1
14 changed files with 367 additions and 9 deletions
+2
View File
@@ -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
View File
@@ -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 |
+8
View File
@@ -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
}
+4 -1
View File
@@ -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>
+21 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
+19 -1
View File
@@ -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>
+1
View File
@@ -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]
+28
View File
@@ -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):
"""编辑合同。""" """编辑合同。"""
+65 -1
View File
@@ -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"})
+49
View File
@@ -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"})
+76 -2
View File
@@ -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):
"""编辑材料。""" """编辑材料。"""
+65 -2
View File
@@ -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)
Generated
+11
View File
@@ -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"