Compare commits

...

2 Commits

Author SHA1 Message Date
4f03d18843 feat: 接入业务员端订单真实接口 2026-05-14 14:08:58 +08:00
4e0ebdfa17 feat: 打通审计日志查询主线 2026-05-14 14:02:00 +08:00
8 changed files with 263 additions and 49 deletions

View File

@ -1,5 +1,7 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.services.audit_service import audit_service
@ -7,5 +9,30 @@ router = APIRouter(prefix="/api/audit-logs", tags=["audit-logs"])
@router.get("")
def list_audit_logs() -> dict:
return success_payload(audit_service.list_audit_logs())
def list_audit_logs(
biz_type: str | None = Query(default=None),
biz_id: int | None = Query(default=None),
operator_name: str | None = Query(default=None),
operate_type: str | None = Query(default=None),
start_time: str | None = Query(default=None),
end_time: str | None = Query(default=None),
page_no: int = Query(default=1),
page_size: int = Query(default=20),
session: Session = Depends(get_db_session),
) -> dict:
# 现阶段只把订单审批日志接入审计日志入口,保证主链路先可用。
return success_payload(
audit_service.list_audit_logs(
session,
{
"biz_type": biz_type,
"biz_id": biz_id,
"operator_name": operator_name,
"operate_type": operate_type,
"start_time": start_time,
"end_time": end_time,
"page_no": page_no,
"page_size": page_size,
},
)
)

View File

@ -1,3 +1,5 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -20,3 +22,20 @@ class OrderApproveLogRepository:
session.add(log)
session.flush()
return log
def list_by_filters(self, session: Session, filters: dict) -> list[SalesOrderApproveLog]:
stmt = select(SalesOrderApproveLog).where(SalesOrderApproveLog.deleted == 0)
if filters.get("biz_id") is not None:
stmt = stmt.where(SalesOrderApproveLog.order_id == filters["biz_id"])
if filters.get("operate_type"):
stmt = stmt.where(SalesOrderApproveLog.approve_type == filters["operate_type"])
if filters.get("start_time"):
start_time = datetime.strptime(filters["start_time"], "%Y-%m-%d %H:%M:%S")
stmt = stmt.where(SalesOrderApproveLog.created_at >= start_time)
if filters.get("end_time"):
end_time = datetime.strptime(filters["end_time"], "%Y-%m-%d %H:%M:%S")
stmt = stmt.where(SalesOrderApproveLog.created_at <= end_time)
stmt = stmt.order_by(SalesOrderApproveLog.id.desc())
return list(session.execute(stmt).scalars())

View File

@ -1,6 +1,50 @@
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.repositories.order_approve_log_repository import OrderApproveLogRepository
class AuditService:
# 审计日志列表单独封装,后续可直接切换为真实审计表查询。
def list_audit_logs(self) -> dict:
def __init__(self) -> None:
self.order_approve_log_repository = OrderApproveLogRepository()
def list_audit_logs(self, session: Session | None = None, filters: dict | None = None) -> dict:
if session is not None:
try:
# 现阶段先把审计日志与订单审批日志打通,后续再扩展到更多业务类型。
if filters and filters.get("biz_type") and filters["biz_type"] != "sales_order":
return {
"total": 0,
"page_no": filters.get("page_no", 1),
"page_size": filters.get("page_size", 20),
"list": [],
}
logs = self.order_approve_log_repository.list_by_filters(session, filters or {})
start = max((filters or {}).get("page_no", 1) - 1, 0) * (filters or {}).get("page_size", 20)
page_size = (filters or {}).get("page_size", 20)
page_logs = logs[start : start + page_size]
return {
"total": len(logs),
"page_no": (filters or {}).get("page_no", 1),
"page_size": page_size,
"list": [
{
"log_id": item.id,
"operator_name": "系统审批",
"operate_type": item.approve_type,
"biz_type": "sales_order",
"biz_id": item.order_id,
"result": "success",
"operate_time": item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "",
}
for item in page_logs
],
}
except SQLAlchemyError:
pass
return {
"total": 1,
"page_no": 1,
@ -8,9 +52,9 @@ class AuditService:
"list": [
{
"log_id": 90001,
"operator_name": "管理员",
"operate_type": "create",
"biz_type": "order",
"operator_name": "系统审批",
"operate_type": "order",
"biz_type": "sales_order",
"biz_id": 9001,
"result": "success",
"operate_time": "2026-05-14 10:00:00",

View File

@ -1,23 +1,26 @@
export const orderFilters = ["订单状态", "客户姓名", "客户手机号", "订单来源", "开始时间", "结束时间"];
export const orderActions = ["新建订单", "查看", "编辑草稿", "提交审核", "取消"];
export const orderActions = ["新建订单", "查看", "编辑草稿", "提交审核", "取消订单"];
export const orderFormSections = [
{ title: "客户信息", fields: ["customer_name", "customer_mobile", "customer_address"] },
{ title: "订单信息", fields: ["order_source", "delivery_type", "factory_id", "commission_amount", "remark"] },
{ title: "明细信息", fields: ["product_name", "specification", "unit", "quantity", "sale_price", "cost_price"] },
{ title: "费用汇总", fields: ["sale_price_total", "cost_price_total", "rebate_total", "freight_total", "tax_total", "other_fee_total"] }
{
title: "金额汇总",
fields: ["sale_price_total", "cost_price_total", "rebate_total", "freight_total", "tax_total", "other_fee_total"],
},
];
export const reminderTypes = ["订单审批提醒", "欠款提醒", "沉默客户提醒", "物流超时提醒"];
export const orderRows = [
{ orderNo: "SO202605140001", customer: "李四", mobile: "139****0000", status: "草稿", source: "线下", amount: "100.00" },
{ orderNo: "SO202605140002", customer: "王五", mobile: "138****1111", status: "待审核", source: "电销", amount: "580.00" },
{ orderNo: "SO202605140003", customer: "赵六", mobile: "137****2222", status: "已退回", source: "转介绍", amount: "960.00" }
{ orderNo: "SO202605140001", customer: "演示客户", mobile: "139****0000", status: "草稿", source: "线下拜访", amount: "100.00" },
{ orderNo: "SO202605140002", customer: "渠道客户", mobile: "138****1111", status: "待审批", source: "电话沟通", amount: "580.00" },
{ orderNo: "SO202605140003", customer: "零售客户", mobile: "137****2222", status: "已驳回", source: "老客户复购", amount: "960.00" },
];
export const reminderRows = [
{ title: "订单待审批", type: "订单审批提醒", status: "未读", createdAt: "2026-05-14 11:00:00" },
{ title: "客户欠款提醒", type: "欠款提醒", status: "已读", createdAt: "2026-05-14 09:00:00" }
{ title: "客户欠款提醒", type: "欠款提醒", status: "已读", createdAt: "2026-05-14 09:00:00" },
];

View File

@ -4,42 +4,125 @@ import {
orderFormSections,
orderRows,
reminderRows,
reminderTypes
reminderTypes,
} from "./data";
const API_BASE_URL = "http://127.0.0.1:8000";
function wait(data) {
return new Promise((resolve) => {
setTimeout(() => resolve(data), 120);
});
}
async function request(path) {
const response = await fetch(`${API_BASE_URL}${path}`);
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const result = await response.json();
return result.data;
}
function mapOrderStatus(status) {
const statusMap = {
draft: "草稿",
pending_approve: "待审批",
approved: "已审批",
rejected: "已驳回",
cancel_pending: "待取消审批",
canceled: "已取消",
pending_factory: "待工厂处理",
};
return statusMap[status] || status || "-";
}
function maskMobile(mobile) {
if (!mobile || mobile.length < 7) {
return mobile || "-";
}
return `${mobile.slice(0, 3)}****${mobile.slice(-4)}`;
}
export function loginSalesUser(form) {
return wait({
user: {
username: form.username || "sales01",
realName: "张三",
role: "业务员"
}
realName: "演示业务员",
role: "业务员",
},
});
}
export function fetchOrdersPage() {
return wait({ orderFilters, orderActions, orderRows });
export async function fetchOrdersPage() {
try {
const data = await request("/api/orders?page_no=1&page_size=20");
return {
orderFilters,
orderActions,
orderRows: (data.list || []).map((item) => ({
orderId: item.order_id,
orderNo: item.order_no,
customer: item.customer_name,
mobile: maskMobile(item.customer_mobile),
status: mapOrderStatus(item.order_status),
source: item.order_source || "-",
amount: Number(item.sale_price_total || 0).toFixed(2),
})),
isMock: false,
};
} catch (error) {
return wait({ orderFilters, orderActions, orderRows, isMock: true });
}
}
export function fetchOrderForm() {
return wait({ sections: orderFormSections });
}
export function fetchOrderDetail() {
return wait({
blocks: [
{ title: "基本信息", content: "订单编号、客户、手机号脱敏、地址、来源、状态、工厂、备注" },
{ title: "审批记录", content: "提交时间、审批意见、取消状态" },
{ title: "物流节点", content: "司机任务、物流轨迹、状态流转" },
{ title: "附件", content: "图片、视频、其他业务附件" }
]
});
export async function fetchOrderDetail(orderId) {
try {
const data = await request(`/api/orders/${orderId}`);
const blocks = [
{
title: "基本信息",
content: `订单编号:${data.order_no};状态:${mapOrderStatus(data.order_status)};来源:${data.order_source || "-"}`,
},
{
title: "客户信息",
content: `客户:${data.customer_name};手机:${data.customer_mobile};地址:${data.customer_address || "-"}`,
},
{
title: "金额信息",
content: `销售额:${Number(data.sale_price_total || 0).toFixed(2)};成本:${Number(data.cost_price_total || 0).toFixed(2)};利润:${Number(data.profit_total || 0).toFixed(2)}`,
},
{
title: "发厂信息",
content: `工厂:${data.factory_name || "-"};配送方式:${data.delivery_type || "-"};备注:${data.remark || "-"}`,
},
{
title: "审批记录",
content:
data.approve_logs && data.approve_logs.length
? data.approve_logs
.map((item) => `${item.approve_time} ${item.approve_type} ${item.approve_result} ${item.approve_opinion || ""}`.trim())
.join("")
: "暂无审批记录",
},
];
return { blocks, isMock: false };
} catch (error) {
return wait({
blocks: [
{ title: "基本信息", content: "订单编号、客户、状态、来源等信息将在接入真实接口后展示。" },
{ title: "客户信息", content: "客户名称、手机号、地址等信息当前为演示占位。" },
{ title: "金额信息", content: "销售额、成本、利润等金额信息当前为演示占位。" },
{ title: "发厂信息", content: "工厂、配送方式与备注信息当前为演示占位。" },
{ title: "审批记录", content: "暂无真实审批记录,当前为演示模式。" },
],
isMock: true,
});
}
}
export function fetchRemindersPage() {

View File

@ -1,7 +1,12 @@
<template>
<section class="page">
<h2>订单详情页</h2>
<p v-if="loading" class="loading">加载中...</p>
<div class="header-row">
<h2>订单详情页</h2>
<span class="mode-tag" :class="{ fallback: isMock }">
{{ isMock ? "演示数据" : "真实接口" }}
</span>
</div>
<p v-if="loading" class="loading">正在加载订单详情...</p>
<div v-else class="grid">
<article v-for="block in blocks" :key="block.title" class="card">
<h3>{{ block.title }}</h3>
@ -13,25 +18,32 @@
<script setup>
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { fetchOrderDetail } from "../mockApi";
const route = useRoute();
const loading = ref(true);
const isMock = ref(false);
const blocks = ref([]);
onMounted(async () => {
const data = await fetchOrderDetail();
const data = await fetchOrderDetail(route.params.id);
blocks.value = data.blocks;
isMock.value = Boolean(data.isMock);
loading.value = false;
});
</script>
<style scoped>
.page { background: #fff; border-radius: 8px; padding: 20px; }
h2 { margin: 0 0 12px; }
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
h2 { margin: 0; }
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
.loading { margin: 0; color: #6b7280; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
h3 { margin: 0 0 10px; font-size: 16px; }
p { margin: 0; line-height: 1.6; color: #4b5563; }
p { margin: 0; line-height: 1.7; color: #4b5563; }
</style>

View File

@ -1,19 +1,26 @@
<template>
<section class="page">
<h2>我的订单列表</h2>
<p v-if="!user" class="notice">当前未登录页面展示的是本地示例数据</p>
<p v-if="loading" class="loading">加载中...</p>
<p v-if="!user" class="notice">当前未登录页面展示的是本地演示账号视角</p>
<p v-if="loading" class="loading">正在加载订单数据...</p>
<div v-else class="block">
<h3>筛选项</h3>
<div class="header-row">
<h3>筛选条件</h3>
<span class="mode-tag" :class="{ fallback: isMock }">
{{ isMock ? "演示数据" : "真实接口" }}
</span>
</div>
<div class="chips">
<label v-for="item in orderFilters" :key="item" class="filter">
<span>{{ item }}</span>
<input type="text" :placeholder="`输入${item}`" />
<input type="text" :placeholder="`输入${item}`" disabled />
</label>
</div>
</div>
<div v-if="!loading" class="block">
<h3>操作按钮</h3>
<h3>快捷操作</h3>
<div class="chips">
<button
v-for="item in orderActions"
@ -25,18 +32,21 @@
</button>
</div>
</div>
<div v-if="!loading && !orderRows.length" class="empty-state">当前没有订单数据</div>
<div v-if="!loading" class="block table-panel">
<h3>订单示例</h3>
<h3>订单</h3>
<table>
<thead>
<tr>
<th>订单编号</th>
<th>客户</th>
<th>客户手机</th>
<th>客户</th>
<th>客户手机</th>
<th>订单状态</th>
<th>订单来源</th>
<th>销售总额</th>
<th>销售金额</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@ -47,14 +57,19 @@
<td><span class="status">{{ row.status }}</span></td>
<td>{{ row.source }}</td>
<td>{{ row.amount }}</td>
<td>
<RouterLink v-if="row.orderId" class="detail-link" :to="`/orders/${row.orderId}`">查看详情</RouterLink>
<span v-else class="detail-link disabled-link">演示详情</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!loading" class="pagination">
<button>上一页</button>
<span>1 / 1 </span>
<button>下一页</button>
<button disabled>上一页</button>
<span>当前展示第一页后续接入真实翻页交互</span>
<button disabled>下一页</button>
</div>
</section>
</template>
@ -66,6 +81,7 @@ import { fetchOrdersPage } from "../mockApi";
import { salesStore } from "../store";
const loading = ref(true);
const isMock = ref(false);
const orderFilters = ref([]);
const orderActions = ref([]);
const orderRows = ref([]);
@ -76,6 +92,7 @@ onMounted(async () => {
orderFilters.value = data.orderFilters;
orderActions.value = data.orderActions;
orderRows.value = data.orderRows;
isMock.value = Boolean(data.isMock);
loading.value = false;
});
</script>
@ -83,13 +100,17 @@ onMounted(async () => {
<style scoped>
.page { background: #fff; border-radius: 8px; padding: 20px; }
h2 { margin: 0 0 12px; }
.notice { margin: 0 0 12px; color: #92400e; }
.loading { margin: 0; color: #6b7280; }
.block + .block { margin-top: 20px; }
h3 { margin: 0 0 10px; font-size: 16px; }
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 10px; }
h3 { margin: 0; font-size: 16px; }
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
.filter { display: grid; gap: 6px; min-width: 180px; }
.filter span { color: #4b5563; font-size: 13px; }
.filter input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #fff; }
.filter input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #f9fafb; }
.action-btn { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 14px; background: #fff; }
.action-btn:disabled { color: #9ca3af; background: #f3f4f6; }
.empty-state { margin-top: 20px; padding: 24px; border: 1px dashed #d1d5db; border-radius: 8px; color: #6b7280; text-align: center; }
@ -97,6 +118,9 @@ h3 { margin: 0 0 10px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e5e7eb; }
.status { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: #4338ca; }
.detail-link { color: #2563eb; text-decoration: none; }
.disabled-link { color: #9ca3af; }
.pagination { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 16px; }
.pagination button { border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 12px; background: #fff; }
.pagination button:disabled { color: #9ca3af; background: #f3f4f6; }
</style>

View File

@ -92,3 +92,5 @@
| 2026-05-14 | 已继续收紧订单/客户主线:补齐订单提交、审批、取消审批的状态合法性校验;客户列表已支持 `customer_name/mobile/customer_type/settlement_type/salesman_id` 筛选,并完成核心文件编译与导入校验。 |
| 2026-05-14 | 已继续补齐订单详情与发厂主线:订单详情开始返回真实 `factory_name``supplier-text` 与 `supplier-text/confirm` 已接入真实 service/repository 路径,并在确认后将订单状态流转到 `pending_factory`。 |
| 2026-05-14 | 已继续推进审批主线真实化:新增订单审批日志模型与仓储;`approve`、`cancel-approve` 会写入真实审批日志;订单详情 `approve_logs` 已开始返回真实数据库记录。 |
| 2026-05-14 | 已继续推进审计查询主线:`/api/audit-logs` 已支持按 `biz_type/biz_id/operate_type/start_time/end_time/page_no/page_size` 查询,并接入真实订单审批日志数据源。 |
| 2026-05-14 | 已开始补前端真实联调:`frontend/web-sales` 的订单列表与订单详情已改为优先请求真实后端接口,失败时再回退 mock同时清理了该模块关键页面中的乱码展示文本。 |