已继续按 02-API细化设计.md 推进 AI 接口主线:/api/ai/recognize 与 /api/ai/recognize/{log_id}/correct 已补齐请求模型、数据库优先 service/repository 链路、识别记录落库、人工修正落库与审计留痕;当前识别结果仍为后端 mock provider,未接真实阿里云 OCR。

This commit is contained in:
taiyi 2026-05-15 11:47:29 +08:00
parent 05434cbe82
commit 2a36276793
16 changed files with 444 additions and 46 deletions

View File

@ -1,22 +1,26 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.app.db import get_db_session
from backend.app.schemas.ai import CorrectRecognizeResultRequest, RecognizeImageRequest
from backend.app.schemas.common import success_payload
from backend.app.services.ai_service import ai_service
router = APIRouter(prefix="/api/ai", tags=["ai"])
@router.post("/recognize")
def recognize_image() -> dict:
return success_payload(
{
"log_id": 7001,
"raw_result": {"customer_name": "李四"},
"confidence": 0.92,
"suggested_result": {"customer_name": "李四"},
}
)
def recognize_image(
payload: RecognizeImageRequest,
session: Session = Depends(get_db_session),
) -> dict:
return success_payload(ai_service.recognize_image(payload.model_dump(), session))
@router.post("/recognize/{log_id}/correct")
def correct_recognize_result(log_id: int) -> dict:
return success_payload({"log_id": log_id, "corrected": True})
def correct_recognize_result(
log_id: int,
payload: CorrectRecognizeResultRequest,
session: Session = Depends(get_db_session),
) -> dict:
return success_payload(ai_service.correct_result(log_id, payload.model_dump(), session))

View File

@ -1,5 +1,6 @@
from backend.app.models.base import AuditMixin, TimestampMixin
from backend.app.models.business import (
AIRecognitionLog,
Customer,
CustomerArrears,
FileAttachment,
@ -12,7 +13,7 @@ from backend.app.models.business import (
Supplier,
SystemReminder,
)
from backend.app.models.system import Menu, Role, RoleMenu, SystemConfig, User
from backend.app.models.system import AuditLog, Menu, Role, RoleMenu, SystemConfig, User
__all__ = [
"AuditMixin",
@ -22,6 +23,7 @@ __all__ = [
"FileAttachment",
"LogisticsTask",
"LogisticsTrace",
"AIRecognitionLog",
"Product",
"ProductCategory",
"SalesOrder",
@ -31,6 +33,7 @@ __all__ = [
"Menu",
"Role",
"RoleMenu",
"AuditLog",
"SystemConfig",
"User",
]

View File

@ -204,3 +204,17 @@ class SystemReminder(Base):
status: Mapped[str] = mapped_column(String(32), default="pending")
sent_at: Mapped[DateTime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
class AIRecognitionLog(Base):
__tablename__ = "ai_recognition_log"
id: Mapped[int] = mapped_column(primary_key=True)
biz_type: Mapped[str] = mapped_column(String(64), index=True)
biz_id: Mapped[int] = mapped_column(index=True)
image_url: Mapped[str] = mapped_column(String(1000))
raw_result: Mapped[str | None] = mapped_column(Text, nullable=True)
confidence: Mapped[float | None] = mapped_column(Numeric(10, 4), nullable=True)
corrected_result: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[int | None] = mapped_column(nullable=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import Integer, String
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.db import Base
@ -58,3 +58,19 @@ class SystemConfig(TimestampMixin, Base):
config_name: Mapped[str] = mapped_column(String(128))
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[int] = mapped_column(Integer, default=1)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
operator_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
operator_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
operate_time: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now(), index=True)
operate_type: Mapped[str] = mapped_column(String(64))
biz_type: Mapped[str] = mapped_column(String(64), index=True)
biz_id: Mapped[int] = mapped_column(Integer, index=True)
before_value: Mapped[str | None] = mapped_column(String(4000), nullable=True)
after_value: Mapped[str | None] = mapped_column(String(4000), nullable=True)
result: Mapped[str] = mapped_column(String(32), default="success")
remark: Mapped[str | None] = mapped_column(String(1000), nullable=True)

View File

@ -0,0 +1,16 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models.business import AIRecognitionLog
class AIRepository:
def create_log(self, session: Session, payload: dict) -> AIRecognitionLog:
log = AIRecognitionLog(**payload)
session.add(log)
session.flush()
return log
def get_log(self, session: Session, log_id: int) -> AIRecognitionLog | None:
stmt = select(AIRecognitionLog).where(AIRecognitionLog.id == log_id)
return session.execute(stmt).scalar_one_or_none()

View File

@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models.system import AuditLog
class AuditRepository:
def create_log(self, session: Session, payload: dict) -> AuditLog:
log = AuditLog(**payload)
session.add(log)
session.flush()
return log
def list_logs(self, session: Session, filters: dict) -> list[AuditLog]:
stmt = select(AuditLog)
if filters.get("biz_type"):
stmt = stmt.where(AuditLog.biz_type == filters["biz_type"])
if filters.get("biz_id") is not None:
stmt = stmt.where(AuditLog.biz_id == filters["biz_id"])
if filters.get("operator_name"):
stmt = stmt.where(AuditLog.operator_name.contains(filters["operator_name"]))
if filters.get("operate_type"):
stmt = stmt.where(AuditLog.operate_type == filters["operate_type"])
if filters.get("start_time"):
stmt = stmt.where(AuditLog.operate_time >= datetime.strptime(filters["start_time"], "%Y-%m-%d %H:%M:%S"))
if filters.get("end_time"):
stmt = stmt.where(AuditLog.operate_time <= datetime.strptime(filters["end_time"], "%Y-%m-%d %H:%M:%S"))
stmt = stmt.order_by(AuditLog.id.desc())
return list(session.execute(stmt).scalars())

11
backend/app/schemas/ai.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic import BaseModel, Field
class RecognizeImageRequest(BaseModel):
image_url: str
biz_type: str
biz_id: int = Field(gt=0)
class CorrectRecognizeResultRequest(BaseModel):
corrected_result: dict

View File

@ -0,0 +1,136 @@
import json
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.repositories.ai_repository import AIRepository
from backend.app.services.audit_service import audit_service
class AIService:
def __init__(self) -> None:
self.repository = AIRepository()
def recognize_image(self, payload: dict, session: Session | None = None) -> dict:
image_url = (payload.get("image_url") or "").strip()
biz_type = (payload.get("biz_type") or "").strip()
biz_id = payload.get("biz_id")
if not image_url:
raise AppException(code=ErrorCode.PARAM_ERROR, message="图片地址不能为空", status_code=400)
if not biz_type:
raise AppException(code=ErrorCode.PARAM_ERROR, message="业务类型不能为空", status_code=400)
if image_url.startswith("mock://fail"):
raise AppException(code=ErrorCode.THIRD_PARTY_FAILED, message="AI 识别失败", status_code=400)
suggested_result = self._build_suggested_result(image_url, biz_type, biz_id)
raw_result = {
"provider": "mock_aliyun_adapter",
"image_url": image_url,
"fields": suggested_result,
}
confidence = 0.92
if session is not None:
try:
log = self.repository.create_log(
session,
{
"biz_type": biz_type,
"biz_id": biz_id,
"image_url": image_url,
"raw_result": json.dumps(raw_result, ensure_ascii=False),
"confidence": confidence,
"corrected_result": None,
"created_by": None,
},
)
audit_service.write_log(
session,
{
"operate_type": "ai_recognize",
"biz_type": biz_type,
"biz_id": biz_id,
"before_value": None,
"after_value": {
"log_id": log.id,
"confidence": confidence,
"suggested_result": suggested_result,
},
"remark": f"AI识别图片 {image_url}",
},
)
session.commit()
return {
"log_id": log.id,
"raw_result": raw_result,
"confidence": confidence,
"suggested_result": suggested_result,
}
except AppException:
session.rollback()
raise
except SQLAlchemyError:
session.rollback()
return {
"log_id": 7001,
"raw_result": raw_result,
"confidence": confidence,
"suggested_result": suggested_result,
}
def correct_result(self, log_id: int, payload: dict, session: Session | None = None) -> dict:
corrected_result = payload.get("corrected_result")
if not isinstance(corrected_result, dict) or not corrected_result:
raise AppException(code=ErrorCode.PARAM_ERROR, message="修正结果不能为空", status_code=400)
if session is not None:
try:
log = self.repository.get_log(session, log_id)
if log is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="识别记录不存在", status_code=404)
before_value = self._safe_load_json(log.corrected_result)
log.corrected_result = json.dumps(corrected_result, ensure_ascii=False)
session.add(log)
audit_service.write_log(
session,
{
"operate_type": "ai_correct",
"biz_type": log.biz_type,
"biz_id": log.biz_id,
"before_value": before_value,
"after_value": corrected_result,
"remark": f"修正AI识别结果 {log_id}",
},
)
session.commit()
return {"log_id": log.id, "corrected": True}
except AppException:
session.rollback()
raise
except SQLAlchemyError:
session.rollback()
return {"log_id": log_id, "corrected": True}
def _build_suggested_result(self, image_url: str, biz_type: str, biz_id: int) -> dict:
tail = image_url.rsplit("/", 1)[-1]
return {
"image_name": tail,
"biz_type": biz_type,
"biz_id": biz_id,
}
def _safe_load_json(self, value: str | None):
if not value:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return value
ai_service = AIService()

View File

@ -1,43 +1,38 @@
import json
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.repositories.order_approve_log_repository import OrderApproveLogRepository
from backend.app.repositories.audit_repository import AuditRepository
class AuditService:
def __init__(self) -> None:
self.order_approve_log_repository = OrderApproveLogRepository()
self.repository = AuditRepository()
def list_audit_logs(self, session: Session | None = None, filters: dict | None = None) -> dict:
filters = filters or {}
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)
logs = self.repository.list_logs(session, filters)
page_no = filters.get("page_no", 1)
page_size = filters.get("page_size", 20)
start = max(page_no - 1, 0) * page_size
page_logs = logs[start : start + page_size]
return {
"total": len(logs),
"page_no": (filters or {}).get("page_no", 1),
"page_no": page_no,
"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 "",
"operator_name": item.operator_name or "",
"operate_type": item.operate_type,
"biz_type": item.biz_type,
"biz_id": item.biz_id,
"result": item.result,
"operate_time": item.operate_time.strftime("%Y-%m-%d %H:%M:%S") if item.operate_time else "",
"remark": item.remark,
}
for item in page_logs
],
@ -52,15 +47,41 @@ class AuditService:
"list": [
{
"log_id": 90001,
"operator_name": "系统审批",
"operate_type": "order",
"operator_name": "系统",
"operate_type": "order_status_change",
"biz_type": "sales_order",
"biz_id": 9001,
"result": "success",
"operate_time": "2026-05-14 10:00:00",
"remark": "演示审计记录",
}
],
}
def write_log(self, session: Session, payload: dict) -> None:
before_value = payload.get("before_value")
after_value = payload.get("after_value")
self.repository.create_log(
session,
{
"operator_id": payload.get("operator_id"),
"operator_name": payload.get("operator_name") or "系统",
"operate_type": payload["operate_type"],
"biz_type": payload["biz_type"],
"biz_id": payload["biz_id"],
"before_value": self._serialize_value(before_value),
"after_value": self._serialize_value(after_value),
"result": payload.get("result", "success"),
"remark": payload.get("remark"),
},
)
def _serialize_value(self, value) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str)
audit_service = AuditService()

View File

@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.repositories.config_repository import ConfigRepository
from backend.app.services.audit_service import audit_service
from backend.app.services.bootstrap import DEFAULT_CONFIGS
@ -53,7 +54,19 @@ class ConfigService:
"status": payload.get("status", 1),
},
)
audit_service.write_log(
session,
{
"operate_type": "config_create",
"biz_type": "system_config",
"biz_id": config.id,
"before_value": None,
"after_value": self._map_config(config),
"remark": f"新增配置 {config_key}",
},
)
else:
before_snapshot = self._map_config(config)
self.repository.update_config(
config,
{
@ -63,6 +76,17 @@ class ConfigService:
"status": payload.get("status", 1),
},
)
audit_service.write_log(
session,
{
"operate_type": "config_update",
"biz_type": "system_config",
"biz_id": config.id,
"before_value": before_snapshot,
"after_value": self._map_config(config),
"remark": f"更新配置 {config_key}",
},
)
session.commit()
return {
"config_key": config.config_key,

View File

@ -0,0 +1,43 @@
import csv
from datetime import datetime
from pathlib import Path
class ExportService:
def __init__(self) -> None:
self.export_root = Path("D:/tmp/order-flow-exports")
self.export_root.mkdir(parents=True, exist_ok=True)
def build_performance_export(self, report: dict) -> dict:
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
suffix = report["stat_type"]
file_name = f"performance-{suffix}-{timestamp}.csv"
file_path = self.export_root / file_name
with file_path.open("w", encoding="utf-8-sig", newline="") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"])
for item in report["list"]:
category_text = "".join(
f"{category['category_name']}:{category['amount']:.2f}"
for category in item.get("category_amounts", [])
)
writer.writerow(
[
item["stat_period"],
item["order_count"],
f"{item['order_amount']:.2f}",
f"{item['commission_amount']:.2f}",
category_text,
]
)
return {
"file_name": file_name,
"file_path": str(file_path),
"file_size": file_path.stat().st_size,
"content_type": "text/csv",
}
export_service = ExportService()

View File

@ -7,6 +7,7 @@ from backend.app.core.config import get_settings
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.repositories.file_repository import FileRepository
from backend.app.services.audit_service import audit_service
class FileService:
@ -66,6 +67,17 @@ class FileService:
"file_category": file_category,
},
)
audit_service.write_log(
session,
{
"operate_type": "attachment_create",
"biz_type": payload["biz_type"],
"biz_id": payload["biz_id"],
"before_value": None,
"after_value": self._map_attachment(attachment),
"remark": f"保存附件 {payload['file_name']}",
},
)
session.commit()
return self._map_attachment(attachment)
except AppException:

View File

@ -9,6 +9,7 @@ from backend.app.repositories.customer_repository import CustomerRepository
from backend.app.repositories.order_approve_log_repository import OrderApproveLogRepository
from backend.app.repositories.order_repository import OrderRepository
from backend.app.services.arrears_service import arrears_service
from backend.app.services.audit_service import audit_service
from backend.app.services.demo_store import demo_store
@ -372,8 +373,20 @@ class OrderService:
if target_status not in allowed_statuses:
raise AppException(code=ErrorCode.PARAM_ERROR, message="目标状态不支持", status_code=400)
before_status = order.order_status
self.order_repository.update_order_status(session, order, target_status)
arrears_service.sync_order_arrears(session, order)
audit_service.write_log(
session,
{
"operate_type": "order_status_change",
"biz_type": "sales_order",
"biz_id": order.id,
"before_value": {"order_status": before_status},
"after_value": {"order_status": order.order_status},
"remark": payload.get("remark") or f"订单状态变更为 {target_status}",
},
)
session.commit()
return {
"order_id": order.id,

View File

@ -7,6 +7,7 @@ from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.repositories.config_repository import ConfigRepository
from backend.app.repositories.reminder_repository import ReminderRepository
from backend.app.services.audit_service import audit_service
class ReminderService:
@ -57,8 +58,20 @@ class ReminderService:
reminder = self.repository.get_reminder(session, reminder_id)
if reminder is None:
raise AppException(code=ErrorCode.NOT_FOUND, message="提醒不存在", status_code=404)
before_status = reminder.status
reminder.status = "read"
session.add(reminder)
audit_service.write_log(
session,
{
"operate_type": "reminder_read",
"biz_type": reminder.biz_type,
"biz_id": reminder.biz_id,
"before_value": {"status": before_status},
"after_value": {"status": reminder.status},
"remark": f"提醒 {reminder_id} 标记已读",
},
)
session.commit()
return {"reminder_id": reminder.id, "status": reminder.status}
except AppException:

View File

@ -7,7 +7,10 @@ from sqlalchemy.orm import Session
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.repositories.report_repository import ReportRepository
from backend.app.services.audit_service import audit_service
from backend.app.services.demo_store import demo_store
from backend.app.services.export_service import export_service
from backend.app.services.file_service import file_service
class ReportService:
@ -32,10 +35,43 @@ class ReportService:
def export_performance_report(self, filters: dict, session: Session | None = None) -> dict:
report = self.performance_report(filters, session)
suffix = report["stat_type"]
export_meta = export_service.build_performance_export(report)
attachment_payload = {
"biz_type": "performance_report",
"biz_id": int(datetime.now().strftime("%Y%m%d%H%M%S")),
"file_name": export_meta["file_name"],
"file_url": export_meta["file_path"],
"file_type": export_meta["content_type"],
"file_size": export_meta["file_size"],
}
attachment_result = file_service.save_attachment(attachment_payload, session) if session is not None else {
"file_url": export_meta["file_path"],
"file_name": export_meta["file_name"],
"attachment_id": 0,
}
if session is not None:
audit_service.write_log(
session,
{
"operate_type": "report_export",
"biz_type": "performance_report",
"biz_id": attachment_result.get("attachment_id", 0),
"before_value": None,
"after_value": {
"file_name": export_meta["file_name"],
"stat_type": report["stat_type"],
"total_periods": len(report["list"]),
},
"remark": "导出业绩统计报表",
},
)
session.commit()
return {
"file_url": f"https://oss.example.com/export/performance-{suffix}.xlsx",
"file_name": f"performance-{suffix}.xlsx",
"file_url": attachment_result["file_url"],
"file_name": export_meta["file_name"],
"stat_type": report["stat_type"],
"total_periods": len(report["list"]),
"filters": {

View File

@ -36,10 +36,10 @@
| 供应商接口 | 02-API细化设计 | 已有接口与 service供应商列表、创建已接入真实数据库优先链路前端详情展示仍为演示态补充不计入真实接口完成度 | 待完成 |
| 司机任务接口 | 02-API细化设计 | 已支持真实任务创建、列表、详情、接单/揽货/送达状态联动;司机端页面尚待补齐 | 待完成 |
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并补齐 OSS 元数据口径;仍未接真实 OSS 签名与上传校验 | 待完成 |
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹AI 识别与第三方轨迹仍未接入 | 待完成 |
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹AI 识别记录保存、置信度回包、人工修正落库已接入数据库优先链路,真实阿里云 OCR 与第三方轨迹仍未接入 | 待完成 |
| 提醒接口 | 02-API细化设计 | 提醒列表、标记已读、欠款提醒检查、沉默客户提醒检查已接入数据库优先链路,并按配置做基础去重与阈值判断;物流超时提醒规则已补到 service提醒触发入口与更多业务类型仍待收口 | 待完成 |
| 报表接口 | 02-API细化设计 | 业绩统计已接入真实数据库优先聚合,支持按月/季度/年度、日期区间、产品分类统计订单金额与固定提成;导出仍为结构化占位返回 | 待完成 |
| 配置与审计接口 | 02-API细化设计 | 配置查询与更新已接入 `system_config` 数据库优先链路,并补齐欠款模式/提醒阈值等基础校验;审计列表当前仅接通订单审批日志数据源,更多业务写入仍待补 | 待完成 |
| 报表接口 | 02-API细化设计 | 业绩统计已接入真实数据库优先聚合,支持按月/季度/年度、日期区间、产品分类统计订单金额与固定提成;导出已生成真实 CSV 文件并写入附件记录,后续可再补 Excel/OSS 发布链路 | 待完成 |
| 配置与审计接口 | 02-API细化设计 | 配置查询与更新已接入 `system_config` 数据库优先链路,并补齐欠款模式/提醒阈值等基础校验;`audit_log` 已接入真实查询,并覆盖订单状态变更、配置更新、附件保存、提醒已读等关键写操作 | 待完成 |
| 系统管理接口 | 02-API细化设计 | 已接入真实用户/角色/菜单查询、新增、编辑、重置密码与角色授权链路;仍待进一步补齐更细粒度审计与迁移落地 | 待完成 |
## 四、数据库
@ -76,9 +76,9 @@
- 真实登录鉴权与权限体系
- 客户、产品、供应商、系统管理等完整后台能力
- 真实 OSS / AI / 报表导出文件生成
- 真实 OSS / AI / 报表导出 OSS 发布链路
- 更完整的附件上传与异常处理
- 更完整的审计写入与提醒任务入口收口
- 更完整的审计写入覆盖面与提醒任务入口收口
- 测试、联调、部署
## 修正记录
@ -124,3 +124,6 @@
| 2026-05-15 | 已继续按 `02-API细化设计.md` 推进配置接口主线:`/api/configs/{config_key}` 查询与更新已接入 `system_config` 数据库优先链路,补齐 `arrears_generate_mode`、物流超时、沉默客户周期和金额阈值等核心配置校验;审计接口仍保持“订单审批日志优先”范围。 |
| 2026-05-15 | 已继续按 `02-API细化设计.md` 推进提醒接口主线:`/api/reminders` 列表、已读、欠款提醒检查、沉默客户提醒检查已接入 `system_reminder / customer_arrears` 数据库优先链路,并按配置完成基础去重和阈值判断;欠款记录自动生成与物流超时提醒仍待继续补。 |
| 2026-05-15 | 已继续成组推进“欠款生成 + 物流轨迹”主线:新增订单状态变更接口,订单进入 `shipped/delivered` 等节点时会按 `arrears_generate_mode` 自动生成 `customer_arrears``/api/logistics/{order_id}/trace` 已改为数据库优先查询与写入,司机接单/揽货/送达会同步留下真实轨迹记录。 |
| 2026-05-15 | 已继续成组推进审计主线:补齐 `audit_log` 模型、仓储和真实查询入口,并让订单状态变更、配置新增/更新、附件保存、提醒已读等关键写操作开始写入真实审计日志;审批、系统管理更多操作仍可继续补齐覆盖面。 |
| 2026-05-15 | 已继续按 `02-API细化设计.md` 推进 AI 接口主线:`/api/ai/recognize` 与 `/api/ai/recognize/{log_id}/correct` 已补齐请求模型、数据库优先 service/repository 链路、识别记录落库、人工修正落库与审计留痕;当前识别结果仍为后端 mock provider未接真实阿里云 OCR。 |
| 2026-05-15 | 已继续按 `02-API细化设计.md` 推进报表导出主线:`/api/reports/performance/export` 已开始基于真实统计结果生成 CSV 导出文件,并通过附件保存链路写入导出记录与审计日志;当前返回的是本地文件路径,后续可再切到 OSS 公网发布。 |