Просмотр исходного кода

feat: add MCP monthly settlement preview

Sheep 2 дней назад
Родитель
Сommit
aa5712809d

+ 192 - 0
docs/AI_PHASE1_FEASIBILITY.md

@@ -0,0 +1,192 @@
+# 市民请集合 AI 第一期需求可行性分析
+
+日期:2026-06-30
+
+## 结论
+
+第一期需求可以做,但要拆成三类能力落地:
+
+1. **可直接支持**:订单、商品、会员、物流、积分、充值、月结企业清单等查询类能力。业务系统已有接口和数据库表,Agent 可以通过 MCP 做只读查询并输出表格。
+2. **可半自动支持**:月结报告、充值导入、商品名称对应、报价单生成。需要 Agent 先生成预览、差异清单、Excel 文件,再由管理员确认。
+3. **暂不应直接自动执行**:上架、下架、批量改价、充值正式导入、结算确认等写操作。需要业务系统提供带审批/幂等/审计的 MCP Action 入口,Agent 不能直接写库。
+
+## 已确认的业务系统依据
+
+### 订单与物流
+
+业务入口:
+
+- `smqjh-oms/oms-boot/src/main/java/com/smqjh/mall/oms/controller/xsb/OrderController.java`
+- 后台列表:`GET /api/v1/order/backendOrderList`
+- 订单详情:`GET /api/v1/order/backendOrderInfo`
+- 物流节点:`GET /api/v1/order/deliveryNode`
+- 骑手信息:`GET /api/v1/order/riderInfo`
+- 物流轨迹:`GET /api/v1/order/queryTrace`
+- 导出:`POST /api/v1/order/export?exportType=ORDER|PRODUCT`
+
+关键表:
+
+- `smqjh_oms.oms_order`
+- `smqjh_oms.oms_order_item`
+
+关键字段:
+
+- 订单号:`order_number`
+- 企业渠道:`channel_id`, `channel_name`
+- 支付:`is_payed`, `pay_time`
+- 金额:`order_money`, `total`, `actual_total`, `freight_amount`, `offset_points`
+- 物流:`hb_logistic_status`, `dvy_type`, `dvy_flow_id`, `dvy_no`, `dvy_name`, `dvy_time`, `arrive_time`, `complete_time`
+- 收货人:`consignee_name`, `consignee_mobile`, `consignee_address`
+- 过滤:`COALESCE(delete_status,0)=0`
+
+可支持:
+
+- 查询某订单物流状态。
+- 按手机号、企业、时间统计订单数和销售额。
+- 导出订单表、商品订单表。
+- 月结中计算商品总额、现金支付、积分抵扣、运费等。
+
+### 商品、SKU 与渠道价
+
+业务入口:
+
+- `smqjh-pms/pms-boot/src/main/java/com/smqjh/pms/controller/admin/ShopSkuController.java`
+- `GET /api/v1/shopSku/export`
+- `smqjh-pms/pms-boot/src/main/java/com/smqjh/pms/controller/admin/ChannelProdController.java`
+
+关键表:
+
+- `smqjh_pms.tz_prod`
+- `smqjh_pms.tz_sku`
+- `smqjh_pms.tz_channel_prod`
+- `smqjh_pms.tz_shop_sku`
+
+关键字段:
+
+- 商品:`prod_id`, `prod_name`, `brand_name`, `brief`, `price`, `ori_price`, `status`, `is_delete`
+- SKU:`sku_id`, `sku_name`, `sku_code`, `price`, `ori_price`, `status`, `is_delete`
+- 渠道价:`channel_id`, `sku_id`, `shop_id`, `purchase_price`, `delivery_price`, `channel_prod_price`, `is_delete`
+- 门店 SKU:`shop_sku_price`, `shop_sku_stocks`, `sale_status`, `is_delete`
+
+可支持:
+
+- 查询商品描述、系统价、SKU、品牌和状态。
+- 报价单中补齐商品编码、SKU 编码、渠道价。
+- 月结商品明细补齐商品信息。
+
+需要谨慎:
+
+- 上架、下架、批量改价是写操作,必须走审批型 Action,不能直接数据库写入。
+
+### 会员、渠道与企业
+
+业务入口:
+
+- 企业会员导出:`/api/v1/members/enterprise/export`
+- 用户提供的后台入口:`https://shop.backend.zswlgz.com/plt/admin/enterprise/export?channelIdList%5B0%5D=0`
+
+关键表:
+
+- `smqjh_system.sm_member`
+- `smqjh_system.sm_channel`
+
+已确认月结企业:
+
+| channelId | channelNo | 企业 |
+| --- | --- | --- |
+| 54 | 1 | 招商银行贵阳分行 |
+| 55 | 2 | 中数未来 |
+| 58 | 5 | 铜仁移动 |
+
+可支持:
+
+- 根据手机号查会员归属企业。
+- 按企业渠道生成月结筛选条件。
+- 顶级 admin 不需要 tenantCode,非 admin 后续仍要保留租户上下文。
+
+### 积分充值与积分流水
+
+业务入口:
+
+- `smqjh-system/system-boot/src/main/java/com/smqjh/system/controller/PointsRechargeController.java`
+- `GET /api/v1/pointsRecharge/page`
+- `GET /api/v1/pointsRecharge/statisticsList`
+- `POST /api/v1/pointsRecharge/import`
+- `GET /api/v1/pointsRecharge/exportTemplate`
+- `GET /api/v1/pointsRecharge/export`
+- `smqjh-system/system-boot/src/main/java/com/smqjh/system/controller/PointsRecordController.java`
+- `GET /app-api/v1/pointsRecord/backendPointsRecordList`
+- `GET /app-api/v1/pointsRecord/backendPointsRecordListExport`
+
+关键表:
+
+- `smqjh_system.sm_points_recharge`
+- `smqjh_system.sm_points_record`
+
+关键字段:
+
+- 充值:`user_id`, `user_name`, `user_phone`, `channel_id`, `channel_name`, `recharge_number`, `points`, `available_points`, `consume_points`, `expiry_points`, `expiry_date`
+- 流水:`order_number`, `points_id`, `variable_points`, `points_type`, `currently_available_points`, `creation_date`, `is_adjust`
+
+可支持:
+
+- 充值信息表整理、查重、排错。
+- 生成导入预览和上账凭据。
+- 月结核对积分抵扣和消费流水。
+
+需要谨慎:
+
+- `POST /pointsRecharge/import` 是写操作,必须先预览、校验、人工确认。
+
+## 第一期功能支持矩阵
+
+| 需求 | 可行性 | 推荐实现方式 | 风险/缺口 |
+| --- | --- | --- | --- |
+| 平台生成结算报告 | 可做 | MCP 读取订单、商品明细、积分、运费导出,Python 生成 Excel 汇总与蓝色核对区 | 运费账单需确认是否稳定包含订单号或配送单号 |
+| 补齐编码 | 可做 | 从 `tz_prod/tz_sku/tz_channel_prod` 补齐商品、SKU、渠道价 | 供应商发票名称和商品名不一致时需要名称映射 |
+| 价格浮动人工确认 | 可做 | 生成差异表,标记“需人工确认” | 不能自动改业务系统 |
+| 发送客户核对/打印盖章/开票/待付款状态 | 可做流程管理 | Agent 生成状态记录和提醒,不直接财务确认 | 需要后续状态表或任务记录表 |
+| 充值信息调整排错 | 可做 | 上传企业表格后,Agent 校验手机号、企业、金额、重复记录,输出预览 | 正式导入前必须人工确认 |
+| 导入业务系统 | 部分可做 | 调用后端导入接口,前置审批 | 需要带审计、幂等、回滚/失败报告 |
+| 企业用户统一发货 | 部分可做 | 导出订单分仓,生成供应商/仓库/配送建议表 | 入库、配送、闪送/快递写操作需业务系统 Action |
+| 商品名称对应 | 部分可做 | OCR + 商品库模糊匹配 + 人工确认后保存映射 | 需要新增映射表或业务接口 |
+| 报价单生成 | 部分可做 | 读取商品、渠道价、外部报价线索,生成报价 Excel | 外部市场价来源需要稳定接口或采集源 |
+
+## 对话响应优化方案
+
+本轮已在 Web Agent 中加入流式进度事件:
+
+- 后端新增 `/api/chat/stream`,按 NDJSON 返回阶段事件。
+- LangGraph 节点在加载工具、分析需求、执行工具、整理结果时即时发出 trace。
+- 前端先显示“正在分析需求/正在执行工具/正在整理结果”等文本,再用最终答案替换进度气泡。
+- 如果流式接口不可用,前端自动降级到原 `/api/chat`。
+
+推荐后续继续增强:
+
+- 每个 MCP 工具返回 `progressLabel` 和 `evidence`。
+- 长任务输出 `taskId`,前端可轮询状态。
+- 生成 Excel/Word 后返回文件路径、文件大小、生成时间。
+- 对写操作统一展示“预览 -> 人工确认 -> 执行 -> 审计记录”的状态链。
+
+## MCP 工具规划建议
+
+优先新增或完善这些 MCP 工具:
+
+| 工具 | 作用 |
+| --- | --- |
+| `smqjh.settlement.monthly.preview` | 已实现:生成月结只读预览,包含订单、商品、积分、充值、运费、差异 |
+| `smqjh.settlement.monthly.exportExcel` | 输出结算报告 Excel |
+| `smqjh.recharge.import.preview` | 上传充值表后校验并生成导入预览 |
+| `smqjh.recharge.import.commit` | 人工确认后调用业务系统导入接口 |
+| `smqjh.product.match.preview` | 发票/OCR/供应商名称与商品库匹配 |
+| `smqjh.product.mapping.confirm` | 人工确认后保存名称映射 |
+| `smqjh.fulfillment.enterprise.plan` | 企业统一发货订单分仓、入库、配送建议 |
+| `smqjh.task.status.record` | 记录“待核对/待盖章/待开票/待付款/已完成”等状态 |
+
+## 当前结论
+
+第一期不是“能不能做”的问题,而是要按成熟 Agent 的方式做边界:
+
+- 查询和报表:Agent 可以自动完成。
+- 结算、充值、上架、下架:Agent 先生成可审计预览,管理员确认后由受控 Action 执行。
+- 所有结果必须带依据:业务接口、数据库表、SQL 口径、导出文件来源。

+ 466 - 0
docs/build_ai_phase1_summary_doc.py

@@ -0,0 +1,466 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from docx import Document
+from docx.enum.section import WD_SECTION_START
+from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Inches, Pt, RGBColor
+
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / "docs" / "市民请集合AI第一期方案汇总.docx"
+
+BLUE = "1E5B8C"
+DARK_BLUE = "123B5D"
+INK = "0B2545"
+MUTED = "667085"
+LIGHT_FILL = "EAF1F7"
+CALLOUT_FILL = "EEF5FA"
+CAUTION_FILL = "FFF8E8"
+BORDER = "D0D5DD"
+WHITE = "FFFFFF"
+
+
+def qn_attr(name: str) -> str:
+    return qn(name)
+
+
+def set_cell_shading(cell, fill: str) -> None:
+    tc_pr = cell._tc.get_or_add_tcPr()
+    shd = tc_pr.find(qn_attr("w:shd"))
+    if shd is None:
+        shd = OxmlElement("w:shd")
+        tc_pr.append(shd)
+    shd.set(qn_attr("w:fill"), fill)
+
+
+def set_cell_margins(cell, top=100, bottom=100, start=140, end=140) -> None:
+    tc_pr = cell._tc.get_or_add_tcPr()
+    tc_mar = tc_pr.first_child_found_in("w:tcMar")
+    if tc_mar is None:
+        tc_mar = OxmlElement("w:tcMar")
+        tc_pr.append(tc_mar)
+    for edge, value in (("top", top), ("bottom", bottom), ("start", start), ("end", end)):
+        node = tc_mar.find(qn_attr(f"w:{edge}"))
+        if node is None:
+            node = OxmlElement(f"w:{edge}")
+            tc_mar.append(node)
+        node.set(qn_attr("w:w"), str(value))
+        node.set(qn_attr("w:type"), "dxa")
+
+
+def set_table_borders(table, color=BORDER) -> None:
+    tbl_pr = table._tbl.tblPr
+    borders = tbl_pr.first_child_found_in("w:tblBorders")
+    if borders is None:
+        borders = OxmlElement("w:tblBorders")
+        tbl_pr.append(borders)
+    for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
+        node = borders.find(qn_attr(f"w:{edge}"))
+        if node is None:
+            node = OxmlElement(f"w:{edge}")
+            borders.append(node)
+        node.set(qn_attr("w:val"), "single")
+        node.set(qn_attr("w:sz"), "6")
+        node.set(qn_attr("w:space"), "0")
+        node.set(qn_attr("w:color"), color)
+
+
+def set_table_width(table, widths_dxa: list[int]) -> None:
+    tbl_pr = table._tbl.tblPr
+    tbl_w = tbl_pr.find(qn_attr("w:tblW"))
+    if tbl_w is None:
+        tbl_w = OxmlElement("w:tblW")
+        tbl_pr.append(tbl_w)
+    tbl_w.set(qn_attr("w:w"), str(sum(widths_dxa)))
+    tbl_w.set(qn_attr("w:type"), "dxa")
+
+    tbl_ind = tbl_pr.find(qn_attr("w:tblInd"))
+    if tbl_ind is None:
+        tbl_ind = OxmlElement("w:tblInd")
+        tbl_pr.append(tbl_ind)
+    tbl_ind.set(qn_attr("w:w"), "120")
+    tbl_ind.set(qn_attr("w:type"), "dxa")
+
+    grid = table._tbl.tblGrid
+    if grid is None:
+        grid = OxmlElement("w:tblGrid")
+        table._tbl.insert(0, grid)
+    for child in list(grid):
+        grid.remove(child)
+    for width in widths_dxa:
+        col = OxmlElement("w:gridCol")
+        col.set(qn_attr("w:w"), str(width))
+        grid.append(col)
+
+    for row in table.rows:
+        for cell, width in zip(row.cells, widths_dxa):
+            tc_pr = cell._tc.get_or_add_tcPr()
+            tc_w = tc_pr.find(qn_attr("w:tcW"))
+            if tc_w is None:
+                tc_w = OxmlElement("w:tcW")
+                tc_pr.append(tc_w)
+            tc_w.set(qn_attr("w:w"), str(width))
+            tc_w.set(qn_attr("w:type"), "dxa")
+            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
+            set_cell_margins(cell)
+
+
+def set_run_font(run, font_name="Calibri", east_asia="Microsoft YaHei") -> None:
+    run.font.name = font_name
+    run._element.rPr.rFonts.set(qn_attr("w:ascii"), font_name)
+    run._element.rPr.rFonts.set(qn_attr("w:hAnsi"), font_name)
+    run._element.rPr.rFonts.set(qn_attr("w:eastAsia"), east_asia)
+
+
+def set_paragraph_border_bottom(paragraph, color=BORDER, size="8") -> None:
+    p_pr = paragraph._p.get_or_add_pPr()
+    p_bdr = p_pr.find(qn_attr("w:pBdr"))
+    if p_bdr is None:
+        p_bdr = OxmlElement("w:pBdr")
+        p_pr.append(p_bdr)
+    bottom = p_bdr.find(qn_attr("w:bottom"))
+    if bottom is None:
+        bottom = OxmlElement("w:bottom")
+        p_bdr.append(bottom)
+    bottom.set(qn_attr("w:val"), "single")
+    bottom.set(qn_attr("w:sz"), size)
+    bottom.set(qn_attr("w:space"), "6")
+    bottom.set(qn_attr("w:color"), color)
+
+
+def add_para(
+    doc,
+    text: str,
+    style: str | None = None,
+    bold_prefix: str | None = None,
+    color: str | None = None,
+    italic: bool = False,
+):
+    p = doc.add_paragraph(style=style)
+    if bold_prefix and text.startswith(bold_prefix):
+        r = p.add_run(bold_prefix)
+        r.bold = True
+        if color:
+            r.font.color.rgb = RGBColor.from_string(color)
+        set_run_font(r)
+        tail = p.add_run(text[len(bold_prefix):])
+        tail.italic = italic
+        if color:
+            tail.font.color.rgb = RGBColor.from_string(color)
+        set_run_font(tail)
+    else:
+        r = p.add_run(text)
+        r.italic = italic
+        if color:
+            r.font.color.rgb = RGBColor.from_string(color)
+        set_run_font(r)
+    return p
+
+
+def add_bullet(doc, text: str):
+    p = doc.add_paragraph(style="List Bullet")
+    r = p.add_run(text)
+    set_run_font(r)
+    return p
+
+
+def add_number(doc, text: str):
+    p = doc.add_paragraph(style="List Number")
+    r = p.add_run(text)
+    set_run_font(r)
+    return p
+
+
+def add_heading(doc, text: str, level: int):
+    p = doc.add_heading(text, level=level)
+    for run in p.runs:
+        set_run_font(run)
+    return p
+
+
+def add_callout(doc, title: str, body: str, fill: str = CALLOUT_FILL) -> None:
+    table = doc.add_table(rows=1, cols=1)
+    table.alignment = WD_TABLE_ALIGNMENT.LEFT
+    table.style = "Table Grid"
+    set_table_width(table, [9360])
+    set_table_borders(table)
+    cell = table.cell(0, 0)
+    set_cell_shading(cell, fill)
+    p = cell.paragraphs[0]
+    p.paragraph_format.space_after = Pt(4)
+    r = p.add_run(title)
+    r.bold = True
+    r.font.color.rgb = RGBColor.from_string(DARK_BLUE)
+    set_run_font(r)
+    p2 = cell.add_paragraph()
+    p2.paragraph_format.space_after = Pt(0)
+    r2 = p2.add_run(body)
+    set_run_font(r2)
+    doc.add_paragraph()
+
+
+def add_table(doc, headers: list[str], rows: list[list[str]], widths_dxa: list[int]):
+    table = doc.add_table(rows=1, cols=len(headers))
+    table.alignment = WD_TABLE_ALIGNMENT.LEFT
+    table.style = "Table Grid"
+    set_table_width(table, widths_dxa)
+    set_table_borders(table)
+
+    for i, text in enumerate(headers):
+        cell = table.rows[0].cells[i]
+        set_cell_shading(cell, DARK_BLUE)
+        p = cell.paragraphs[0]
+        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+        p.paragraph_format.space_after = Pt(0)
+        r = p.add_run(text)
+        r.bold = True
+        r.font.color.rgb = RGBColor.from_string(WHITE)
+        set_run_font(r)
+
+    for row in rows:
+        cells = table.add_row().cells
+        for i, text in enumerate(row):
+            p = cells[i].paragraphs[0]
+            p.alignment = WD_ALIGN_PARAGRAPH.LEFT
+            p.paragraph_format.space_after = Pt(0)
+            r = p.add_run(text)
+            set_run_font(r)
+    set_table_width(table, widths_dxa)
+    doc.add_paragraph()
+    return table
+
+
+def add_metadata_rows(doc, rows: list[tuple[str, str]]) -> None:
+    for label, value in rows:
+        p = doc.add_paragraph()
+        p.paragraph_format.space_before = Pt(0)
+        p.paragraph_format.space_after = Pt(2)
+        p.paragraph_format.line_spacing = 1.10
+        r1 = p.add_run(label)
+        r1.bold = True
+        set_run_font(r1)
+        r2 = p.add_run(value)
+        set_run_font(r2)
+
+
+def configure_styles(doc: Document) -> None:
+    section = doc.sections[0]
+    section.page_width = Inches(8.5)
+    section.page_height = Inches(11)
+    for margin in ("top_margin", "bottom_margin", "left_margin", "right_margin"):
+        setattr(section, margin, Inches(1))
+    section.header_distance = Inches(0.492)
+    section.footer_distance = Inches(0.492)
+
+    styles = doc.styles
+    normal = styles["Normal"]
+    normal.font.name = "Calibri"
+    normal._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei")
+    normal.font.size = Pt(11)
+    normal.paragraph_format.space_before = Pt(0)
+    normal.paragraph_format.space_after = Pt(6)
+    normal.paragraph_format.line_spacing = 1.10
+
+    title = styles["Title"]
+    title.font.name = "Calibri"
+    title._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei")
+    title.font.size = Pt(24)
+    title.font.bold = True
+    title.font.color.rgb = RGBColor.from_string(INK)
+    title.paragraph_format.space_before = Pt(0)
+    title.paragraph_format.space_after = Pt(8)
+
+    subtitle = styles["Subtitle"]
+    subtitle.font.name = "Calibri"
+    subtitle._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei")
+    subtitle.font.size = Pt(11)
+    subtitle.font.color.rgb = RGBColor.from_string(MUTED)
+    subtitle.paragraph_format.space_after = Pt(14)
+
+    for name, size, color, before, after in [
+        ("Heading 1", 16, BLUE, 16, 8),
+        ("Heading 2", 13, BLUE, 12, 6),
+        ("Heading 3", 12, DARK_BLUE, 8, 4),
+    ]:
+        style = styles[name]
+        style.font.name = "Calibri"
+        style._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei")
+        style.font.size = Pt(size)
+        style.font.bold = True
+        style.font.color.rgb = RGBColor.from_string(color)
+        style.paragraph_format.space_before = Pt(before)
+        style.paragraph_format.space_after = Pt(after)
+
+    for name in ("List Bullet", "List Number"):
+        style = styles[name]
+        style.font.name = "Calibri"
+        style._element.rPr.rFonts.set(qn_attr("w:eastAsia"), "Microsoft YaHei")
+        style.font.size = Pt(11)
+        style.paragraph_format.space_after = Pt(8)
+        style.paragraph_format.line_spacing = 1.167
+
+    footer = section.footer.paragraphs[0]
+    footer.alignment = WD_ALIGN_PARAGRAPH.RIGHT
+    r = footer.add_run("市民请集合 AI 第一期方案汇总")
+    r.font.size = Pt(9)
+    r.font.color.rgb = RGBColor.from_string(MUTED)
+    set_run_font(r)
+
+
+def build_doc() -> None:
+    doc = Document()
+    configure_styles(doc)
+
+    doc.add_paragraph()
+    p = doc.add_paragraph(style="Title")
+    r = p.add_run("市民请集合 AI 第一期方案汇总")
+    set_run_font(r)
+    p = doc.add_paragraph(style="Subtitle")
+    r = p.add_run("基于当前 smqjh-agent 系统、第一期业务需求及 4 个 ChatGPT Pro 账号预算整理")
+    set_run_font(r)
+    add_metadata_rows(
+        doc,
+        [
+            ("版本日期:", "2026-06-30"),
+            ("一期预算口径:", "4 个 ChatGPT Pro 账号,USD 200/账号/月,合计 USD 800/月;采购与发票口径同步确认"),
+            ("资料依据:", "《市民请集合AI第一期需求.docx》、当前 smqjh-agent 代码与设计复盘文档、OpenAI 官方订阅说明(2026-06-30 查询)"),
+        ],
+    )
+    rule = doc.add_paragraph()
+    set_paragraph_border_bottom(rule)
+
+    add_callout(
+        doc,
+        "结论先行",
+        "第一期建议定位为“业务运营 AI 助手试点”:先用当前已具备的 Agent/MCP/只读数据库能力,把查询、报价、对账、充值文件整理等高频人工工作做成可追踪、可导出的辅助流程;ChatGPT 账号用于人员日常方案、表格、文档和复杂问题处理,系统侧继续使用受控 MCP 工具承接内部数据能力。整体不建议一开始承诺全自动写入业务系统,所有涉及金额、上下架、充值、开票和结算确认的动作都保留人工复核。"
+    )
+
+    add_heading(doc, "1. 解决什么问题", 1)
+    add_para(
+        doc,
+        "第一期主要解决运营、商务和财务在报价、商品、充值、对账、查询中的重复性整理和跨系统核对问题。当前痛点不是单点功能缺失,而是信息分散在报价单、供应商资料、后台系统、订单/积分/商品表和人工沟通中,导致每次都要重新查、重新改表、重新核口径。",
+    )
+    add_table(
+        doc,
+        ["问题类型", "当前表现", "AI 一期要解决的点"],
+        [
+            ["报价单", "新企业签约时需要基于星闪豹商品、京东旗舰店原价和企业反馈反复调整报价。", "生成固定格式报价单,辅助补齐商品、规格、价格依据,支持对方新增商品后的快速报价和汇总下载。"],
+            ["商品上下架", "最终报价单确定后,需要在海博和市民请集合系统做批量或零散上下架。", "解析报价单,生成待上架/下架清单、字段校验和执行建议;高风险写入动作人工确认后再执行。"],
+            ["商品名称对应", "平台商品名、供应商开票名称和采购单名称不一致,建采购单耗时。", "建立名称/规格/编码匹配表,上传供应商开票图片或清单后给出候选匹配,人工确认后沉淀映射。"],
+            ["积分充值", "企业给的积分导入名单格式不统一,导入后还要输出成功证明,错误反馈需要排查。", "自动清洗为系统导入格式,按规则检查风险,生成导入文件和成功反馈文件。"],
+            ["对账结算", "积分过期后需要多方核对、生成对账单、开票明细、盖章和付款跟踪。", "打包对账单和核对结果,计算开票金额/税额关系,标记异常差异,形成可给企业确认的文件包。"],
+            ["数据查询", "运营需要按企业、时间、状态查询积分充值/消耗/可用、正常订单和售后订单。", "管理员用自然语言查询,系统自动查只读数据库并输出表格、摘要和依据。"],
+        ],
+        [1500, 3900, 3960],
+    )
+
+    add_para(
+        doc,
+        "从系统建设角度看,第一期还要解决两个底层问题:一是把业务查询从“人找字段、人写 SQL、人导表”改成“Agent 调受控工具”;二是把 AI 的输出从长文本变成表格、文件、待确认项和可审计日志。",
+    )
+
+    add_heading(doc, "2. 能够解决到什么程度", 1)
+    add_para(
+        doc,
+        "当前 smqjh-agent 已有可用底座:Python + LangGraph Web Agent、Java MCP Server、DeepSeek 工具规划、业务 schema、只读数据库查询、商品/订单/月结工具、表格渲染和 CSV 下载。基于这个基础,一期能做到“辅助闭环 + 人工确认”,不宜直接承诺无人值守的全自动业务变更。",
+    )
+    add_table(
+        doc,
+        ["场景", "一期可交付程度", "预计自动化/减负", "需要人工确认的边界"],
+        [
+            ["数据查询", "自然语言查询商品、订单、会员、积分、月结相关数据,并输出表格与依据。", "高,常规查询约 80%-90% 可自动完成。", "字段口径不明确、数据异常、权限不足时确认。"],
+            ["报价单", "生成统一报价单模板,辅助商品匹配、价格引用、对方新增品报价和汇总下载。", "中高,格式整理和候选匹配约 60%-70% 可自动化。", "最终报价、利润率、外部平台最新价必须人工确认。"],
+            ["对账结算", "按企业和月份生成导出计划、核对规则、差异清单,进一步可生成对账文件包。", "中高,模板确认后约 70%-80% 工作量可减少。", "差异、税率、开票金额、盖章和付款状态确认。"],
+            ["积分充值", "清洗企业名单,校验字段、金额、手机号和重复项,生成导入格式和反馈文件。", "中,约 50%-70% 的整理排错可自动化。", "真实导入、批量发放、金额异常必须确认。"],
+            ["商品上下架", "从报价单生成上下架清单和字段校验,后续对接接口后进入确认执行。", "中,前处理约 50%-60% 可自动化。", "自动写入海博/市民请集合系统前必须审批。"],
+            ["名称/发票匹配", "建立供应商名称、平台商品、SKU、编码映射候选,支持图片/OCR 后续接入。", "中,样本积累前约 50%-60%;字典完善后可提升。", "同规格多候选、开票名称不规范、编码不唯一。"],
+        ],
+        [1300, 3450, 1850, 2760],
+    )
+
+    add_callout(
+        doc,
+        "一期能力边界",
+        "AI 可以先把“查、算、整、比、导、提示风险”做起来;凡是会改变业务系统状态的动作,例如上架、下架、充值导入、发券、结算确认、开票提交,都必须保留人工确认和日志记录。",
+        fill=CAUTION_FILL,
+    )
+
+    add_heading(doc, "3. 大致时间周期", 1)
+    add_para(
+        doc,
+        "建议一期按 4-6 周推进:4 周形成可演示和小范围试用版本,6 周完成重点场景试运行。账号采购申请和发票确认不等待研发完成,采用同步推进。",
+    )
+    add_table(
+        doc,
+        ["阶段", "周期", "主要工作", "交付结果"],
+        [
+            ["启动与确认", "第 1 周", "确认报价单模板、对账模板、充值导入模板、上下架字段、企业样本和账号采购申请。", "一期范围确认表、样表清单、账号申请单。"],
+            ["查询与报价 MVP", "第 2 周", "强化智能查询、商品/订单/积分表口径,完成报价单模板生成和新增品候选匹配。", "自然语言查询演示、报价单生成样例。"],
+            ["对账与充值流程", "第 3-4 周", "打通月结企业、订单/积分/运费导出规则、充值文件清洗和风险提示。", "对账文件包样例、充值导入前检查表。"],
+            ["上下架与名称匹配试点", "第 5 周", "根据报价单生成上下架清单,沉淀商品名、开票名、SKU 编码映射。", "上下架待确认清单、名称匹配表。"],
+            ["试运行与培训", "第 6 周", "用真实业务样本试跑,修正提示词、口径、模板和人工确认流程。", "试运行报告、使用说明、下一期清单。"],
+        ],
+        [1450, 1200, 4300, 2410],
+    )
+
+    add_para(
+        doc,
+        "如果只做“账号采购 + 文档/表格辅助”可以更快,约 1 周即可启用;如果要把报价、对账、充值、上下架真正沉淀到当前系统里,建议按 4-6 周评估。",
+        bold_prefix="补充说明:",
+    )
+
+    add_heading(doc, "4. 费用的具体数量", 1)
+    add_para(
+        doc,
+        "本次费用按 ChatGPT Pro 账号采购口径测算:4 个账号,每个账号 USD 200/月,合计 USD 800/月。该口径对应一个月试点费用,不与其他订阅档位混合测算。",
+    )
+    add_table(
+        doc,
+        ["费用项", "数量/单价", "金额", "说明"],
+        [
+            ["ChatGPT Pro 账号", "4 个账号 x USD 200/月", "USD 800/月", "用于业务人员日常方案、表格、文档、复杂问题分析和高强度 AI 使用。"],
+            ["采购申请金额", "按 1 个月试点", "USD 800", "先按一个月费用提交申请,后续是否续费根据试运行效果决定。"],
+            ["OpenAI API 或其他第三方接口", "未纳入本次 USD 800/月", "另行测算", "ChatGPT Pro 订阅不等同于 API 用量;当前系统侧主要通过 MCP/DeepSeek 跑业务工具。"],
+            ["内部系统开发/实施", "使用现有 smqjh-agent 底座推进", "如内部研发,不另列外采费用", "若后续外包 OCR、价格接口或专门爬取服务,需要单独报价。"],
+        ],
+        [2200, 2450, 1850, 2860],
+    )
+
+    add_callout(
+        doc,
+        "采购与发票说明",
+        "ChatGPT 自助订阅通常可在账户账单里下载 invoice/receipt,但这不等同于国内增值税专票或普票。当前建议先按 USD 800/月提交采购申请,同步让财务确认境外订阅 invoice、付款截图、信用卡流水等材料是否可作为报销/入账依据;如果必须取得国内发票,需要另查代理采购或企业合同路径,预算和周期都会变化。",
+        fill=CAUTION_FILL,
+    )
+
+    add_heading(doc, "5. 推荐推进方式", 1)
+    add_number(doc, "先提交 USD 800/月 / 4 个 ChatGPT Pro 账号的试点申请。")
+    add_number(doc, "账号采购和系统建设同步走,避免等采购流程导致研发试点停滞。")
+    add_number(doc, "第一条端到端样例建议选择“报价单 + 对账查询”组合:展示价值直接,也最贴合当前需求文档。")
+    add_number(doc, "所有涉及金额、上下架、充值、开票的结果都输出“待确认清单”,由业务人员确认后再执行。")
+    add_number(doc, "试运行结束后,用真实样本统计节省时间、错误率、人工确认比例,再决定第二期是否做更深的自动写入和审批流。")
+
+    add_heading(doc, "6. 可直接用于内部汇报的版本", 1)
+    add_callout(
+        doc,
+        "汇报口径",
+        "第一期主要解决报价、商品、充值、对账和查询这几类重复性工作。当前系统已经有 Agent、MCP、只读数据库和表格输出底座,所以不需要从零开始。账号侧先采购 4 个 ChatGPT Pro 账号做业务和文档辅助,预算按 USD 800/月申请;系统侧同步把查询、报价单、对账和充值整理做成可复用流程。预计 4 周能出可演示版本,6 周进入小范围试运行。ChatGPT 自助订阅暂时无法提供国内增值税发票,我们先提交申请,同时让财务确认境外 invoice/付款凭证是否可接受;如果必须走国内发票,再调整采购路径和预算。"
+    )
+
+    add_heading(doc, "参考信息", 1)
+    add_bullet(doc, "OpenAI Help Center:ChatGPT Pro 档位包含 USD 100/月和 USD 200/月两类,Pro USD 200 对应最高用量档。https://help.openai.com/en/articles/9793128-about-chatgpt-pro-tiers")
+    add_bullet(doc, "OpenAI Help Center:自助 ChatGPT 客户可在账单入口查看或下载过往 invoices;如果组织要求 invoice billing,需要联系 Sales 讨论 Enterprise/Education 等合同方案。https://help.openai.com/en/articles/12356340-how-can-i-find-my-past-chatgpt-invoices")
+    add_bullet(doc, "当前项目资料:smqjh-agent-runtime、smqjh-mcp-server、smqjh-admin-agent 以及《市民请集合AI第一期需求.docx》。")
+
+    OUT.parent.mkdir(parents=True, exist_ok=True)
+    doc.save(OUT)
+    print(OUT)
+
+
+if __name__ == "__main__":
+    build_doc()

BIN
docs/市民请集合AI第一期方案汇总.docx


+ 159 - 88
smqjh-agent-runtime/app/agent_graph.py

@@ -1,9 +1,11 @@
 from __future__ import annotations
 
 import json
+import re
 import time
+from contextvars import ContextVar
 from datetime import datetime, timezone
-from typing import Any, Literal
+from typing import Any, Callable, Literal
 
 from langgraph.graph import END, START, StateGraph
 
@@ -22,6 +24,9 @@ from .models import (
 
 
 Route = Literal["execute_tools", "summarize", "chat"]
+TraceSink = Callable[[AgentTraceEvent], None]
+
+_TRACE_SINK: ContextVar[TraceSink | None] = ContextVar("smqjh_agent_trace_sink", default=None)
 
 
 class SmqjhAgentGraph:
@@ -36,29 +41,39 @@ class SmqjhAgentGraph:
     def tools(self) -> list[McpToolDescriptor]:
         return self._load_runnable_tools()
 
-    def run(self, message: str, history: list[ChatMessage] | None = None) -> AgentRunResponse:
-        state: AgentState = {
-            "message": message,
-            "history": history or [],
-            "visitedSignatures": [],
-            "steps": 0,
-            "trace": [_trace("system", "收到用户请求", message)],
-        }
-        result = self.graph.invoke(state)
-        final = result.get("final")
-        if final:
-            return final
-        observations = result.get("observations", [])
-        trace_events = result.get("trace", [])
-        return {
-            "content": "这次没有生成有效回答,请稍后重试。",
-            "model": "none",
-            "usedMcp": False,
-            "steps": int(result.get("steps", 0)),
-            "toolCalls": observations,
-            "tables": _extract_tables(observations),
-            "trace": trace_events,
-        }
+    def run(
+        self,
+        message: str,
+        history: list[ChatMessage] | None = None,
+        emit: TraceSink | None = None,
+    ) -> AgentRunResponse:
+        token = _TRACE_SINK.set(emit)
+        try:
+            state: AgentState = {
+                "message": message,
+                "history": history or [],
+                "visitedSignatures": [],
+                "steps": 0,
+                "trace": [_trace("system", "已收到请求", message)],
+            }
+            result = self.graph.invoke(state)
+            final = result.get("final")
+            if final:
+                return final
+
+            observations = result.get("observations", [])
+            trace_events = result.get("trace", [])
+            return {
+                "content": "这次没有生成有效回答,请稍后重试。",
+                "model": "none",
+                "usedMcp": False,
+                "steps": int(result.get("steps", 0)),
+                "toolCalls": observations,
+                "tables": _extract_tables(observations),
+                "trace": trace_events,
+            }
+        finally:
+            _TRACE_SINK.reset(token)
 
     def _build_graph(self):
         workflow = StateGraph(AgentState)
@@ -84,25 +99,32 @@ class SmqjhAgentGraph:
         return workflow.compile()
 
     def _load_tools_node(self, state: AgentState) -> AgentState:
+        start = _trace("system", "正在连接 MCP", "加载可用业务工具和数据库只读查询能力")
         try:
             tools = self._load_runnable_tools()
+            done = _trace("system", "MCP 工具加载完成", f"可用工具 {len(tools)} 个")
             return {
                 "tools": tools,
-                "trace": _append_trace(state, _trace("system", "MCP 工具加载完成", f"可用工具 {len(tools)} 个")),
+                "trace": _append_trace(state, start, done),
             }
         except Exception as error:
+            failed = _trace("error", "MCP 工具加载失败", _error_message(error))
             return {
                 "tools": [],
-                "trace": _append_trace(state, _trace("error", "MCP 工具加载失败", _error_message(error))),
+                "trace": _append_trace(state, start, failed),
             }
 
     def _plan_node(self, state: AgentState) -> AgentState:
         if int(state.get("steps", 0)) >= self.config.max_steps:
             return {
                 "plannedToolCalls": [],
-                "trace": _append_trace(state, _trace("plan", "达到最大工具步数", f"maxSteps={self.config.max_steps}")),
+                "trace": _append_trace(
+                    state,
+                    _trace("plan", "达到最大工具步数", f"maxSteps={self.config.max_steps}"),
+                ),
             }
 
+        start = _trace("plan", "正在分析需求", "DeepSeek 正在判断是否需要查库或调用业务工具")
         try:
             result = self.mcp.call_tool(
                 "smqjh.ai.tool.plan",
@@ -121,28 +143,28 @@ class SmqjhAgentGraph:
                 for call in (_normalize_tool_call(item, state.get("message", "")) for item in raw_call_items)
                 if call and _tool_signature(call["name"], call["arguments"]) not in visited
             ][:3]
+            done = _trace(
+                "plan",
+                "DeepSeek 已规划工具调用" if planned else "DeepSeek 判断无需继续调用工具",
+                _reason_text(record),
+                {"toolCalls": planned},
+            )
             return {
                 "plannedToolCalls": planned,
-                "trace": _append_trace(
-                    state,
-                    _trace(
-                        "plan",
-                        "DeepSeek 已规划工具调用" if planned else "DeepSeek 判断无需继续调用工具",
-                        _reason_text(record),
-                        {"toolCalls": planned},
-                    ),
-                ),
+                "trace": _append_trace(state, start, done),
             }
         except Exception as error:
+            failed = _trace("error", "DeepSeek 工具规划失败", _error_message(error))
             return {
                 "plannedToolCalls": [],
-                "trace": _append_trace(state, _trace("error", "DeepSeek 工具规划失败", _error_message(error))),
+                "trace": _append_trace(state, start, failed),
             }
 
     def _execute_tools_node(self, state: AgentState) -> AgentState:
         by_name = {tool.get("name", ""): tool for tool in state.get("tools", [])}
         observations: list[ToolObservation] = []
         visited = set(state.get("visitedSignatures", []))
+        trace_events = list(state.get("trace", []))
 
         for call in state.get("plannedToolCalls", []):
             requested_name = call["name"]
@@ -152,54 +174,57 @@ class SmqjhAgentGraph:
             visited.add(signature)
 
             if not resolved_name:
-                observations.append(
-                    {
-                        "name": requested_name,
-                        "source": "mcp",
-                        "arguments": arguments,
-                        "ok": False,
-                        "error": "DeepSeek 选择了未注册 MCP 工具",
-                    }
-                )
+                observation: ToolObservation = {
+                    "name": requested_name,
+                    "source": "mcp",
+                    "arguments": arguments,
+                    "ok": False,
+                    "error": "DeepSeek 选择了未注册 MCP 工具",
+                }
+                observations.append(observation)
+                trace_events.append(_trace("error", f"工具未注册:{requested_name}", observation["error"]))
                 continue
 
+            trace_events.append(_trace("tool", f"正在执行:{resolved_name}", _tool_title(resolved_name)))
             started = time.perf_counter()
             try:
                 result = self.mcp.call_tool(resolved_name, arguments)
-                observations.append(
-                    {
-                        "name": resolved_name,
-                        "source": "mcp",
-                        "arguments": arguments,
-                        "ok": True,
-                        "result": result.get("structuredContent", result),
-                        "durationMs": int((time.perf_counter() - started) * 1000),
-                    }
+                observation = {
+                    "name": resolved_name,
+                    "source": "mcp",
+                    "arguments": arguments,
+                    "ok": True,
+                    "result": result.get("structuredContent", result),
+                    "durationMs": int((time.perf_counter() - started) * 1000),
+                }
+                observations.append(observation)
+                trace_events.append(
+                    _trace(
+                        "tool",
+                        f"工具执行完成:{resolved_name}",
+                        f"{observation.get('durationMs', 0)}ms",
+                        {"arguments": arguments, "result": _summarize_result(observation.get("result"))},
+                    )
                 )
             except Exception as error:
-                observations.append(
-                    {
-                        "name": resolved_name,
-                        "source": "mcp",
-                        "arguments": arguments,
-                        "ok": False,
-                        "error": _error_message(error),
-                        "durationMs": int((time.perf_counter() - started) * 1000),
-                    }
+                observation = {
+                    "name": resolved_name,
+                    "source": "mcp",
+                    "arguments": arguments,
+                    "ok": False,
+                    "error": _error_message(error),
+                    "durationMs": int((time.perf_counter() - started) * 1000),
+                }
+                observations.append(observation)
+                trace_events.append(
+                    _trace(
+                        "error",
+                        f"工具执行失败:{resolved_name}",
+                        observation.get("error", ""),
+                        {"arguments": arguments},
+                    )
                 )
 
-        trace_events = state.get("trace", [])
-        for item in observations:
-            trace_events = [
-                *trace_events,
-                _trace(
-                    "tool" if item.get("ok") else "error",
-                    f"工具执行完成:{item['name']}" if item.get("ok") else f"工具执行失败:{item['name']}",
-                    f"{item.get('durationMs', 0)}ms" if item.get("ok") else item.get("error", ""),
-                    {"arguments": item.get("arguments"), "result": _summarize_result(item.get("result"))},
-                ),
-            ]
-
         return {
             "observations": [*state.get("observations", []), *observations],
             "plannedToolCalls": [],
@@ -211,6 +236,7 @@ class SmqjhAgentGraph:
     def _summarize_node(self, state: AgentState) -> AgentState:
         observations = state.get("observations", [])
         tables = _extract_tables(observations)
+        start = _trace("summary", "正在整理结果", "将工具结果整理成结论、依据和表格")
         try:
             result = self.mcp.call_tool(
                 "smqjh.ai.tool.summarize",
@@ -222,7 +248,8 @@ class SmqjhAgentGraph:
             record = _as_dict(result.get("structuredContent"))
             content = str(record.get("content") or "").strip() or _fallback_summary(observations, tables)
             model = str(record.get("model") or "mcp-deepseek")
-            trace_events = _append_trace(state, _trace("summary", "结果总结完成", f"{len(tables)} 个表格"))
+            done = _trace("summary", "结果总结完成", f"{len(tables)} 个表格")
+            trace_events = _append_trace(state, start, done)
             return {
                 "final": {
                     "content": content,
@@ -236,7 +263,8 @@ class SmqjhAgentGraph:
                 "trace": trace_events,
             }
         except Exception as error:
-            trace_events = _append_trace(state, _trace("error", "结果总结失败,已使用工具结果兜底", _error_message(error)))
+            failed = _trace("error", "结果总结失败,已使用工具结果兜底", _error_message(error))
+            trace_events = _append_trace(state, start, failed)
             return {
                 "final": {
                     "content": _fallback_summary(observations, tables),
@@ -251,12 +279,14 @@ class SmqjhAgentGraph:
             }
 
     def _chat_node(self, state: AgentState) -> AgentState:
+        start = _trace("chat", "正在生成回复", "没有继续调用业务工具,进入普通对话回答")
         try:
             result = self.mcp.call_tool("smqjh.ai.chat", {"request": self._assistant_request(state)})
             record = _as_dict(result.get("structuredContent"))
             content = str(record.get("content") or "").strip() or "DeepSeek 没有返回有效内容。"
             model = str(record.get("model") or "mcp-deepseek")
-            trace_events = _append_trace(state, _trace("chat", "普通对话完成", model))
+            done = _trace("chat", "普通对话完成", model)
+            trace_events = _append_trace(state, start, done)
             return {
                 "final": {
                     "content": content,
@@ -270,7 +300,8 @@ class SmqjhAgentGraph:
                 "trace": trace_events,
             }
         except Exception as error:
-            trace_events = _append_trace(state, _trace("error", "普通对话失败", _error_message(error)))
+            failed = _trace("error", "普通对话失败", _error_message(error))
+            trace_events = _append_trace(state, start, failed)
             return {
                 "final": {
                     "content": f"暂时无法完成回答:{_error_message(error)}",
@@ -322,16 +353,24 @@ def _repair_tool_arguments(name: str, arguments: dict[str, Any], message: str) -
     if name == "smqjh.product.lookup.summary":
         current = str(arguments.get("productKeyword") or "").strip()
         return {**arguments, "productKeyword": current or _extract_product_keyword(message)}
+    if name in {
+        "smqjh.settlement.monthly.plan",
+        "smqjh.settlement.monthly.preview",
+        "settlement.monthly.plan",
+        "settlement.monthly.preview",
+    }:
+        enterprise = str(arguments.get("enterprise") or "").strip() or _extract_settlement_enterprise(message)
+        month = str(arguments.get("month") or "").strip() or _extract_month(message)
+        return {**arguments, "enterprise": enterprise, "month": month}
     return arguments
 
 
 def _extract_product_keyword(message: str) -> str:
     text = message
-    for token in [
+    stop_words = [
         "帮我",
         "麻烦",
         "查询一下",
-        "查一下",
         "查询",
         "查看",
         "当前",
@@ -353,9 +392,28 @@ def _extract_product_keyword(message: str) -> str:
         "是多少",
         "是什么",
         "呢",
-    ]:
+    ]
+    for token in stop_words:
         text = text.replace(token, " ")
-    return " ".join(text.replace(",", " ").replace("。", " ").replace("?", " ").replace("?", " ").split())
+    text = re.sub(r"[,。!?、;:,.!?;:]", " ", text)
+    return " ".join(text.split())
+
+
+def _extract_settlement_enterprise(message: str) -> str:
+    for enterprise in ("铜仁移动", "招商银行贵阳分行", "中数未来"):
+        if enterprise in message:
+            return enterprise
+    return ""
+
+
+def _extract_month(message: str) -> str:
+    explicit = re.search(r"(20\d{2})[-/年](0?[1-9]|1[0-2])", message)
+    if explicit:
+        return f"{explicit.group(1)}-{int(explicit.group(2)):02d}"
+    month_only = re.search(r"(?<!\d)(0?[1-9]|1[0-2])\s*月份?", message)
+    if month_only:
+        return f"{datetime.now().year}-{int(month_only.group(1)):02d}"
+    return ""
 
 
 def _resolve_tool_name(name: str, by_name: dict[str, McpToolDescriptor]) -> str | None:
@@ -366,6 +424,8 @@ def _resolve_tool_name(name: str, by_name: dict[str, McpToolDescriptor]) -> str
         "smqjh.order.count.query" if name == "order.count.query" else "",
         "smqjh.database.smart.query" if name == "database.smart.query" else "",
         "smqjh.database.readonly.query" if name == "database.readonly.query" else "",
+        "smqjh.settlement.monthly.preview" if name == "settlement.monthly.preview" else "",
+        "smqjh.settlement.monthly.plan" if name == "settlement.monthly.plan" else "",
         "smqjh.cloud.health" if name == "cloud.health" else "",
     ]
     return next((candidate for candidate in candidates if candidate and candidate in by_name), None)
@@ -402,7 +462,8 @@ def _extract_tables_from_value(title: str, value: Any) -> list[AgentResultTable]
         if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
             return
         row_records = [_normalize_row(row) for row in rows[:200]]
-        provided_columns = record.get("columns")
+        column_key = "comparisonColumns" if rows_key == "comparisonRows" else "columns"
+        provided_columns = record.get(column_key)
         if isinstance(provided_columns, list) and provided_columns:
             columns = [str(column) for column in provided_columns]
         else:
@@ -472,11 +533,17 @@ def _trace(phase: str, title: str, detail: str = "", data: Any = None) -> AgentT
         event["detail"] = detail
     if data is not None:
         event["data"] = data
+    sink = _TRACE_SINK.get()
+    if sink:
+        try:
+            sink(event)
+        except Exception:
+            pass
     return event
 
 
-def _append_trace(state: AgentState, event: AgentTraceEvent) -> list[AgentTraceEvent]:
-    return [*state.get("trace", []), event]
+def _append_trace(state: AgentState, *events: AgentTraceEvent) -> list[AgentTraceEvent]:
+    return [*state.get("trace", []), *events]
 
 
 def _reason_text(record: dict[str, Any]) -> str:
@@ -496,7 +563,10 @@ def _summarize_result(value: Any) -> Any:
 def _build_table_title(base_title: str, record: dict[str, Any], rows_key: str) -> str:
     title = str(record.get("title") or base_title).strip()
     count = f"({record['rowCount']} 行)" if "rowCount" in record else ""
-    return f"{title}:价格对比" if rows_key == "comparisonRows" else f"{title}{count}"
+    if rows_key == "comparisonRows":
+        suffix = str(record.get("comparisonTitle") or "明细表").strip()
+        return f"{title}:{suffix}"
+    return f"{title}{count}"
 
 
 def _tool_title(name: str) -> str:
@@ -512,6 +582,7 @@ def _tool_title(name: str) -> str:
         "smqjh.product.lookup.summary": "商品资料查询结果",
         "smqjh.settlement.enterprise.list": "月结企业清单",
         "smqjh.settlement.monthly.plan": "企业月结计划",
+        "smqjh.settlement.monthly.preview": "企业月结预览",
     }
     return titles.get(name, name)
 

+ 38 - 0
smqjh-agent-runtime/app/server.py

@@ -47,6 +47,9 @@ class AgentRequestHandler(BaseHTTPRequestHandler):
     def do_POST(self) -> None:
         try:
             path = urlparse(self.path).path
+            if path == "/api/chat/stream":
+                self._handle_chat_stream()
+                return
             if path != "/api/chat":
                 self._send_json(404, {"ok": False, "message": "Not found"})
                 return
@@ -63,6 +66,41 @@ class AgentRequestHandler(BaseHTTPRequestHandler):
         except Exception as error:
             self._send_json(500, {"ok": False, "message": str(error) or error.__class__.__name__})
 
+    def _handle_chat_stream(self) -> None:
+        payload = self._read_json_body()
+        message = str(payload.get("message") or "").strip() if isinstance(payload, dict) else ""
+        if not message:
+            self._send_json(400, {"ok": False, "message": "message is required"})
+            return
+        history = payload.get("history") if isinstance(payload, dict) else []
+        if not isinstance(history, list):
+            history = []
+
+        self.send_response(200)
+        self._cors_headers()
+        self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
+        self.send_header("Cache-Control", "no-cache")
+        self.end_headers()
+
+        def send_event(kind: str, event_payload: Any) -> None:
+            body = json.dumps(
+                {"type": kind, "payload": event_payload},
+                ensure_ascii=False,
+                default=str,
+            ).encode("utf-8")
+            self.wfile.write(body + b"\n")
+            self.wfile.flush()
+
+        try:
+            send_event("status", {"title": "开始处理", "detail": "正在进入 LangGraph 编排"})
+            result = AGENT.run(message, history, emit=lambda event: send_event("trace", event))
+            send_event("result", result)
+            send_event("done", {})
+        except (BrokenPipeError, ConnectionResetError):
+            return
+        except Exception as error:
+            send_event("error", {"message": str(error) or error.__class__.__name__})
+
     def log_message(self, format: str, *args: Any) -> None:
         if os.getenv("WEB_AGENT_ACCESS_LOG", "").lower() in {"1", "true", "yes"}:
             super().log_message(format, *args)

+ 124 - 27
smqjh-agent-runtime/public/app.js

@@ -1,6 +1,7 @@
 const state = {
   history: [],
-  lastTables: []
+  lastTables: [],
+  liveTrace: []
 };
 
 const els = {
@@ -23,7 +24,7 @@ boot();
 
 function boot() {
   addAssistantMessage(
-    "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、月结等问题,我会通过 MCP 工具查询后再回答。"
+    "你好,我是市民请集合智能助手 Web 测试版。你可以直接问业务系统里的订单、商品、会员、物流、月结等问题;我会先判断是否需要调用 MCP、数据库只读查询或 DeepSeek,再把结果整理成结论、依据和表格。"
   );
   bindEvents();
   refreshStatus();
@@ -87,31 +88,94 @@ async function sendMessage() {
   addUserMessage(message);
   els.input.value = "";
   els.inputCount.textContent = "0 字";
+  state.liveTrace = [];
   setBusy(true);
-  const thinkingId = addThinkingMessage();
+  const progressId = addProgressMessage("正在分析这个需求", "我会先判断是否需要查业务系统、数据库或外部工具。");
 
   try {
-    const response = await postJson("/api/chat", {
-      message,
-      history: state.history.slice(-12)
-    });
-    removeMessage(thinkingId);
-    if (!response.ok) {
-      throw new Error(response.message || "请求失败");
+    let result;
+    try {
+      result = await streamChat(message, state.history.slice(-12), progressId);
+    } catch (streamError) {
+      updateProgressMessage(progressId, "流式连接不可用", "正在切换为普通请求完成本次回答。");
+      const response = await postJson("/api/chat", {
+        message,
+        history: state.history.slice(-12)
+      });
+      if (!response.ok) {
+        throw new Error(response.message || "请求失败");
+      }
+      result = response.result;
     }
-    const result = response.result;
+
+    removeMessage(progressId);
     addAssistantMessage(result.content, result.tables || []);
-    renderTrace(result.trace || [], result.toolCalls || []);
+    renderTrace(result.trace || state.liveTrace, result.toolCalls || []);
     state.history.push({ role: "user", content: message });
     state.history.push({ role: "assistant", content: result.content });
   } catch (error) {
-    removeMessage(thinkingId);
+    removeMessage(progressId);
     addAssistantMessage(`暂时无法完成:${error.message || error}`);
   } finally {
     setBusy(false);
   }
 }
 
+async function streamChat(message, history, progressId) {
+  const response = await fetch("/api/chat/stream", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json"
+    },
+    body: JSON.stringify({ message, history })
+  });
+  if (!response.ok) {
+    const payload = await response.json().catch(() => ({}));
+    throw new Error(payload.message || `HTTP ${response.status}`);
+  }
+  if (!response.body) {
+    throw new Error("当前浏览器不支持流式响应");
+  }
+
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder("utf-8");
+  let buffer = "";
+  let resultPayload = null;
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) {
+      break;
+    }
+    buffer += decoder.decode(value, { stream: true });
+    const lines = buffer.split(/\r?\n/);
+    buffer = lines.pop() || "";
+    for (const line of lines) {
+      if (!line.trim()) {
+        continue;
+      }
+      const event = JSON.parse(line);
+      if (event.type === "status") {
+        updateProgressMessage(progressId, event.payload?.title || "正在处理", event.payload?.detail || "");
+      } else if (event.type === "trace") {
+        const trace = event.payload || {};
+        state.liveTrace.push(trace);
+        updateProgressMessage(progressId, trace.title || "正在处理", trace.detail || "");
+        renderTrace(state.liveTrace, []);
+      } else if (event.type === "result") {
+        resultPayload = event.payload;
+      } else if (event.type === "error") {
+        throw new Error(event.payload?.message || "流式请求失败");
+      }
+    }
+  }
+
+  if (!resultPayload) {
+    throw new Error("没有收到 Agent 结果");
+  }
+  return resultPayload;
+}
+
 function setBusy(busy) {
   els.sendBtn.disabled = busy;
   els.sendBtn.querySelector(".send-text").textContent = busy ? "处理中" : "发送";
@@ -125,23 +189,60 @@ function addAssistantMessage(content, tables = []) {
   appendMessage({ role: "assistant", content, tables });
 }
 
-function addThinkingMessage() {
+function addProgressMessage(title, detail) {
   const id = `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
-  appendMessage({
-    id,
-    role: "assistant",
-    content: '<span class="typing">正在处理 <i></i><i></i><i></i></span>',
-    trustedHtml: true
-  });
+  const row = document.createElement("div");
+  row.className = "message-row assistant";
+  row.dataset.messageId = id;
+
+  const avatar = document.createElement("div");
+  avatar.className = "avatar";
+  const img = document.createElement("img");
+  img.src = "/assets/logo.png";
+  img.alt = "";
+  avatar.appendChild(img);
+
+  const bubble = document.createElement("div");
+  bubble.className = "bubble progress-bubble";
+  const titleNode = document.createElement("strong");
+  titleNode.className = "progress-title";
+  titleNode.textContent = title;
+  const detailNode = document.createElement("span");
+  detailNode.className = "progress-detail";
+  detailNode.textContent = detail;
+  const dots = document.createElement("span");
+  dots.className = "typing";
+  dots.innerHTML = "<i></i><i></i><i></i>";
+  bubble.append(titleNode, detailNode, dots);
+
+  row.append(avatar, bubble);
+  els.messages.appendChild(row);
+  els.messages.scrollTop = els.messages.scrollHeight;
   return id;
 }
 
+function updateProgressMessage(id, title, detail) {
+  const node = document.querySelector(`[data-message-id="${id}"]`);
+  if (!node) {
+    return;
+  }
+  const titleNode = node.querySelector(".progress-title");
+  const detailNode = node.querySelector(".progress-detail");
+  if (titleNode && title) {
+    titleNode.textContent = title;
+  }
+  if (detailNode) {
+    detailNode.textContent = detail || "";
+  }
+  els.messages.scrollTop = els.messages.scrollHeight;
+}
+
 function removeMessage(id) {
   const node = document.querySelector(`[data-message-id="${id}"]`);
   node?.remove();
 }
 
-function appendMessage({ id = "", role, content, tables = [], trustedHtml = false }) {
+function appendMessage({ id = "", role, content, tables = [] }) {
   const row = document.createElement("div");
   row.className = `message-row ${role}`;
   if (id) {
@@ -161,11 +262,7 @@ function appendMessage({ id = "", role, content, tables = [], trustedHtml = fals
 
   const bubble = document.createElement("div");
   bubble.className = "bubble";
-  if (trustedHtml) {
-    bubble.innerHTML = content;
-  } else {
-    bubble.textContent = content;
-  }
+  bubble.textContent = content;
 
   if (tables.length) {
     state.lastTables = tables;
@@ -254,7 +351,7 @@ function renderTools(tools) {
     const title = document.createElement("strong");
     title.textContent = tool.title || tool.name;
     const desc = document.createElement("span");
-    desc.textContent = `${tool.name}${tool.description ? ` · ${tool.description}` : ""}`;
+    desc.textContent = `${tool.name}${tool.description ? ` - ${tool.description}` : ""}`;
     item.append(title, desc);
     els.toolList.appendChild(item);
   }

+ 1 - 1
smqjh-agent-runtime/public/index.html

@@ -33,7 +33,7 @@
     <main class="shell">
       <section class="hero">
         <div class="hero-copy">
-          <span class="eyebrow">静默业务智能体</span>
+          <span class="eyebrow">安静的业务智能体</span>
           <h1>市民请集合智能助手</h1>
           <p>以 Python + LangGraph 编排后台业务 Agent。通过 MCP 调用 DeepSeek、只读数据库和 smqjh 业务工具,把管理员的问题整理成结论、依据与表格。</p>
           <div class="hero-actions">

+ 21 - 0
smqjh-agent-runtime/public/styles.css

@@ -518,6 +518,27 @@ button:focus-visible {
   grid-row: 1;
 }
 
+.progress-bubble {
+  display: grid;
+  gap: 6px;
+  width: min(100%, 560px);
+  background: rgba(245, 243, 236, 0.78);
+}
+
+.progress-title {
+  display: block;
+  color: var(--moss-deep);
+  font-size: 15px;
+  font-weight: 700;
+}
+
+.progress-detail {
+  display: block;
+  color: var(--muted);
+  font-size: 13px;
+  line-height: 1.55;
+}
+
 .typing {
   display: inline-flex;
   align-items: center;

+ 125 - 47
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/BusinessSchemaRegistry.java

@@ -20,20 +20,23 @@ final class BusinessSchemaRegistry {
             new String[] {
                 "order_id: 订单ID",
                 "order_number: 订单号",
+                "third_order_id/out_trade_no/transaction_id: 第三方订单或交易流水",
                 "member_id: 会员ID",
                 "channel_id/channel_name: 企业渠道",
                 "hb_order_status: 业务订单状态",
-                "hb_logistic_status: 物流/配送状态",
-                "is_payed: 是否已付款,1 表示已付",
-                "actual_total/total/order_money: 订单金额,查询销售额优先 COALESCE(actual_total,total,order_money,0)",
+                "hb_logistic_status: 物流配送状态",
+                "is_payed: 是否已付款,1 表示已付",
+                "order_money/total/actual_total: 订单金额,销售额优先 COALESCE(actual_total,total,order_money,0)",
                 "freight_amount: 运费金额",
+                "offset_points: 积分抵扣",
+                "coupon_money/member_discount_amount: 优惠或会员折扣",
                 "dvy_type/dvy_id/dvy_flow_id/dvy_no/dvy_name: 配送方式、配送单号、快递/物流信息",
                 "consignee_name/consignee_mobile/consignee_address: 收货人信息",
                 "pay_time/dvy_time/arrive_time/complete_time/create_time: 支付、发货、到达、完成、创建时间",
-                "out_trade_no/transaction_id/third_order_id: 外部交易/第三方订单线索",
                 "delete_status: 逻辑删除状态"
             },
-            "SELECT order_number,hb_order_status,hb_logistic_status,dvy_type,dvy_flow_id,dvy_no,dvy_name,dvy_time,arrive_time,complete_time FROM smqjh_oms.oms_order WHERE COALESCE(delete_status,0)=0 AND order_number LIKE '%订单号%' LIMIT 20"
+            "SELECT order_number,hb_order_status,hb_logistic_status,dvy_type,dvy_flow_id,dvy_no,dvy_name,dvy_time,arrive_time,complete_time FROM smqjh_oms.oms_order WHERE COALESCE(delete_status,0)=0 AND order_number LIKE '%订单号%' LIMIT 20",
+            "OrderController: /api/v1/order/backendOrderList, /backendOrderInfo, /deliveryNode, /riderInfo, /queryTrace, POST /export"
         ));
         add(new TableInfo(
             "smqjh_oms.oms_order_item",
@@ -41,13 +44,15 @@ final class BusinessSchemaRegistry {
             "订单内商品行、商品金额、数量、SKU、商品名称。月结、商品汇总、订单明细通常需要关联订单主表。",
             new String[] {
                 "order_id/order_number: 关联订单",
-                "prod_id/sku_id: 商品与SKU",
-                "prod_name/sku_name: 商品名称和规格",
+                "prod_id/sku_id: 商品与 SKU",
+                "prod_name/sku_name/spec: 商品名称、SKU 名称、规格",
                 "price: 商品单价",
                 "prod_count/prod_num: 商品数量",
-                "product_total_amount/actual_total: 商品行小计,字段以实际库结构为准"
+                "product_total_amount: 商品行小计",
+                "item_discount_amount/member_discount_share_amount/use_score/gain_score: 商品行折扣、会员折扣、积分抵扣、赠送积分"
             },
-            "SELECT oi.order_number,oi.prod_name,oi.sku_name,oi.price,oi.prod_count FROM smqjh_oms.oms_order_item oi JOIN smqjh_oms.oms_order o ON o.order_id=oi.order_id WHERE COALESCE(o.delete_status,0)=0 LIMIT 50"
+            "SELECT oi.order_number,oi.prod_name,oi.sku_name,oi.spec,oi.price,oi.prod_count,oi.product_total_amount FROM smqjh_oms.oms_order_item oi JOIN smqjh_oms.oms_order o ON o.order_id=oi.order_id WHERE COALESCE(o.delete_status,0)=0 LIMIT 50",
+            "OmsOrderMapper.exportProductList"
         ));
         add(new TableInfo(
             "smqjh_system.sm_member",
@@ -60,34 +65,64 @@ final class BusinessSchemaRegistry {
                 "channel_id/channel_no: 企业渠道归属",
                 "create_time/update_time: 创建和更新时间"
             },
-            "SELECT id,mobile,channel_id,create_time FROM smqjh_system.sm_member WHERE mobile LIKE '%手机号%' LIMIT 20"
-        ));
-        add(new TableInfo(
-            "smqjh_system.ums_member_account",
-            "会员积分/账户流水表",
-            "积分充值、消费、抵扣、企业月结积分核对相关流水。只读查询时注意正负值含义。",
-            new String[] {
-                "member_id: 会员ID",
-                "mobile: 手机号",
-                "channel_id: 企业渠道",
-                "amount/score/point: 积分或金额字段,按实际库结构确认",
-                "third_order_no/order_number: 关联订单",
-                "create_time: 流水时间",
-                "deleted: 逻辑删除"
-            },
-            "SELECT * FROM smqjh_system.ums_member_account WHERE COALESCE(deleted,0)=0 LIMIT 50"
+            "SELECT id,mobile,channel_id,create_time FROM smqjh_system.sm_member WHERE mobile LIKE '%手机号%' LIMIT 20",
+            "企业会员查询接口: /api/v1/members/enterprise/getByMobile, /api/v1/members/enterprise/export"
         ));
         add(new TableInfo(
             "smqjh_system.sm_channel",
             "渠道/企业表",
-            "企业用户渠道。月结企业目前包括招商银行贵阳分行、中数未来、铜仁移动。",
+            "企业用户渠道。当前已确认月结企业:招商银行贵阳分行、中数未来、铜仁移动。",
             new String[] {
                 "id: channelId",
-                "channel_no: 渠道编",
+                "channel_no: 渠道编码",
                 "channel_name: 企业名称",
-                "status: 状态"
+                "type/status: 渠道类型和状态"
+            },
+            "SELECT id,channel_no,channel_name,type,status FROM smqjh_system.sm_channel WHERE status=1 LIMIT 50",
+            "月结企业:招商银行贵阳分行 channelId=54/channelNo=1;中数未来 channelId=55/channelNo=2;铜仁移动 channelId=58/channelNo=5"
+        ));
+        add(new TableInfo(
+            "smqjh_system.sm_points_recharge",
+            "积分充值记录表",
+            "企业员工充值、可用积分、消费积分、过期积分、充值单号。充值结算和员工积分核对需要使用。",
+            new String[] {
+                "id: 主键",
+                "tenant_id: 租户ID",
+                "user_id/user_name/user_phone: 用户ID、姓名、手机号",
+                "channel_id/channel_name: 企业渠道",
+                "recharge_number: 充值单号",
+                "type: 充值类型,1银行、2现金、3支付宝、4微信、5其他",
+                "recharge_status: 积分状态",
+                "points: 充值积分",
+                "available_points: 可用积分",
+                "consume_points: 消耗积分",
+                "expiry_points: 过期积分",
+                "expiry_date: 过期日期",
+                "create_time/update_time: 创建和更新时间"
+            },
+            "SELECT channel_id,channel_name,SUM(points) AS pointsTotal,SUM(available_points) AS availablePoints,SUM(consume_points) AS consumePoints,SUM(expiry_points) AS expiryPoints FROM smqjh_system.sm_points_recharge WHERE create_time>='2026-05-01' AND create_time<'2026-06-01' GROUP BY channel_id,channel_name",
+            "PointsRechargeController: /api/v1/pointsRecharge/page, /statisticsList, /import, /exportTemplate, /export"
+        ));
+        add(new TableInfo(
+            "smqjh_system.sm_points_record",
+            "积分流水明细表",
+            "积分充值、下单、退款、过期、冲正流水。月结时负数通常表示充值转换为积分再抵扣的金额。",
+            new String[] {
+                "id: 主键",
+                "tenant_id: 租户ID",
+                "user_id/channel_id: 用户和企业渠道",
+                "order_number: 订单编号",
+                "points_id: 关联充值积分ID",
+                "variable_points: 变动积分",
+                "expiry_points: 过期积分",
+                "points_type: 记录类型,1充值、2下单、3退款、4过期、5退款时过期积分",
+                "points_audit: 使用状态",
+                "currently_available_points: 当前可用积分",
+                "creation_date/update_time: 创建和更新时间",
+                "is_adjust: 是否冲正数据"
             },
-            "SELECT id,channel_no,channel_name,status FROM smqjh_system.sm_channel WHERE status=1 LIMIT 50"
+            "SELECT order_number,channel_id,points_type,SUM(variable_points) AS variablePoints FROM smqjh_system.sm_points_record WHERE creation_date>='2026-05-01' AND creation_date<'2026-06-01' GROUP BY order_number,channel_id,points_type LIMIT 100",
+            "PointsRecordController: /app-api/v1/pointsRecord/backendPointsRecordList, /backendPointsRecordListExport"
         ));
         add(new TableInfo(
             "smqjh_pms.tz_prod",
@@ -98,16 +133,17 @@ final class BusinessSchemaRegistry {
                 "prod_name: 商品名",
                 "brand_name: 品牌",
                 "brief: 商品描述",
-                "price/ori_price: 价格字段,当前工具按分转元展示,最终以业务口径为准",
+                "price/ori_price: 价格字段,最终以业务系统实际单位为准",
                 "status: 商品状态",
                 "is_delete: 逻辑删除"
             },
-            "SELECT p.prod_id,p.prod_name,p.brand_name,p.brief,p.price,p.ori_price,p.status FROM smqjh_pms.tz_prod p WHERE COALESCE(p.is_delete,0)=0 AND p.prod_name LIKE '%商品关键词%' LIMIT 20"
+            "SELECT p.prod_id,p.prod_name,p.brand_name,p.brief,p.price,p.ori_price,p.status FROM smqjh_pms.tz_prod p WHERE COALESCE(p.is_delete,0)=0 AND p.prod_name LIKE '%商品关键词%' LIMIT 20",
+            "商品查询、描述查询、报价补齐"
         ));
         add(new TableInfo(
             "smqjh_pms.tz_sku",
             "商城 SKU 表",
-            "商品规格、SKU 编码、SKU 价格。商品精确匹配时通常与 tz_prod 关联。",
+            "商品规格、SKU 编码、SKU 价格。商品精确匹配和报价补齐时通常与 tz_prod 关联。",
             new String[] {
                 "sku_id: SKU ID",
                 "prod_id: 商品ID",
@@ -117,7 +153,44 @@ final class BusinessSchemaRegistry {
                 "status: SKU 状态",
                 "is_delete: 逻辑删除"
             },
-            "SELECT p.prod_id,p.prod_name,s.sku_id,s.sku_name,s.sku_code,s.price FROM smqjh_pms.tz_prod p LEFT JOIN smqjh_pms.tz_sku s ON s.prod_id=p.prod_id AND COALESCE(s.is_delete,0)=0 WHERE COALESCE(p.is_delete,0)=0 AND (p.prod_name LIKE '%关键词%' OR s.sku_name LIKE '%关键词%') LIMIT 20"
+            "SELECT p.prod_id,p.prod_name,s.sku_id,s.sku_name,s.sku_code,s.price FROM smqjh_pms.tz_prod p LEFT JOIN smqjh_pms.tz_sku s ON s.prod_id=p.prod_id AND COALESCE(s.is_delete,0)=0 WHERE COALESCE(p.is_delete,0)=0 AND (p.prod_name LIKE '%关键词%' OR s.sku_name LIKE '%关键词%') LIMIT 20",
+            "商品/SKU 精确匹配"
+        ));
+        add(new TableInfo(
+            "smqjh_pms.tz_channel_prod",
+            "渠道商品价格表",
+            "企业渠道商品配置、采购价、出货价、渠道售价。报价单、上架、月结价格核对都需要用,但写操作必须走审批。",
+            new String[] {
+                "id: 主键",
+                "tenant_id: 租户ID",
+                "channel_id/channel_name: 企业渠道",
+                "sku_id: SKU ID",
+                "shop_id: 店铺ID",
+                "purchase_price: 采购价",
+                "delivery_price: 出货价",
+                "channel_prod_price: 渠道商品售价",
+                "is_delete: 逻辑删除",
+                "update_time/rec_time: 修改和记录时间"
+            },
+            "SELECT cp.channel_id,cp.channel_name,cp.sku_id,cp.shop_id,cp.purchase_price,cp.delivery_price,cp.channel_prod_price FROM smqjh_pms.tz_channel_prod cp WHERE COALESCE(cp.is_delete,0)=0 LIMIT 50",
+            "ChannelProdController: 查询、导入、批量改价、上架/下架相关写操作,Agent 只能预览和标记需人工确认"
+        ));
+        add(new TableInfo(
+            "smqjh_pms.tz_shop_sku",
+            "门店 SKU 表",
+            "门店商品价格、库存、可售状态。商品上架/下架和库存核对需要使用。",
+            new String[] {
+                "id: 主键",
+                "shop_id: 门店ID",
+                "sku_id/spu_id: SKU 与商品ID",
+                "shop_sku_price: 门店商品价格",
+                "shop_sku_stocks: 门店库存",
+                "sale_status: 可售状态,1可售、0不可售",
+                "is_delete: 逻辑删除",
+                "update_time/rec_time: 更新时间"
+            },
+            "SELECT shop_id,sku_id,spu_id,shop_sku_price,shop_sku_stocks,sale_status FROM smqjh_pms.tz_shop_sku WHERE COALESCE(is_delete,0)=0 LIMIT 50",
+            "ShopSkuMapper.exportList / ShopSkuService.exportChannelProds"
         ));
     }
 
@@ -129,7 +202,7 @@ final class BusinessSchemaRegistry {
             if (!emitted.add(table.name)) {
                 continue;
             }
-            String haystack = (table.name + " " + table.title + " " + table.description + " " + String.join(" ", table.fields)).toLowerCase(Locale.ROOT);
+            String haystack = (table.name + " " + table.title + " " + table.description + " " + table.interfaceHint + " " + String.join(" ", table.fields)).toLowerCase(Locale.ROOT);
             if (normalized.isBlank() || haystack.contains(normalized) || matchesBusinessKeyword(normalized, haystack)) {
                 rows.add(toSummary(table));
             }
@@ -138,7 +211,7 @@ final class BusinessSchemaRegistry {
         result.put("ok", true);
         result.put("query", query == null ? "" : query);
         result.put("rowCount", rows.size());
-        result.set("columns", Jsons.MAPPER.valueToTree(new String[] {"table", "title", "description", "exampleSql"}));
+        result.set("columns", Jsons.MAPPER.valueToTree(new String[] {"table", "title", "description", "interfaceHint", "exampleSql"}));
         result.set("rows", rows);
         return result;
     }
@@ -154,6 +227,7 @@ final class BusinessSchemaRegistry {
         result.put("table", table.name);
         result.put("title", table.title);
         result.put("description", table.description);
+        result.put("interfaceHint", table.interfaceHint);
         result.set("fields", Jsons.MAPPER.valueToTree(table.fields));
         result.put("exampleSql", table.exampleSql);
         return result;
@@ -167,31 +241,31 @@ final class BusinessSchemaRegistry {
         ArrayNode rules = Jsons.MAPPER.createArrayNode();
         rules.add("只要用户询问业务系统事实,Agent 应优先自动调用 MCP 工具查询,不让用户自己执行 SQL 或去后台翻。");
         rules.add("数据库查询默认只读 SELECT,默认过滤逻辑删除,明细查询必须 LIMIT。");
-        rules.add("订单销售额口径优先使用 COALESCE(actual_total,total,order_money,0),并区分已付 is_payed=1。");
+        rules.add("订单销售额优先使用 COALESCE(actual_total,total,order_money,0),并区分已付 is_payed=1。");
         rules.add("商品查询默认排除逻辑删除商品/SKU,优先匹配商品名、SKU 名、SKU 编码和品牌。");
-        rules.add("结算当前包含三条流程:平台生成结算报告、企业充值导入、企业用户统一发货/消费履约。");
         rules.add("月结企业包括:招商银行贵阳分行(channelId=54/channelNo=1)、中数未来(channelId=55/channelNo=2)、铜仁移动(channelId=58/channelNo=5)。");
         rules.add("月结范围为企业用户定时月结、所有已付款订单;商品总额、运费、积分抵扣、现金支付有差异时只标记“需人工确认”,不自动修改业务系统。");
         rules.add("结算报告后续流程:发送客户核对 -> 打印盖章 -> 开电子发票 -> 等待客户付款。");
         rules.add("充值流程:从企业获取客户充值信息表 -> 调整排错 -> 导入业务系统 -> 开具成功上账凭据和上账记录。");
-        rules.add("消费流程:C 端用户即时配送;企业用户统一发货,非及时性发货需导出订单分仓、联系供应商、入库,再配送闪送或物流快递。");
+        rules.add("消费流程:C 端用户即时配送;企业用户统一发货,非及时性发货需导出订单分仓、联系供应商、入库,再配送闪送或物流快递。");
         result.set("rules", rules);
         return result;
     }
 
     String promptContext() {
         StringBuilder builder = new StringBuilder();
-        builder.append("业务表与口径\n");
+        builder.append("业务表与口径:\n");
         Set<String> emitted = new LinkedHashSet<>();
         for (TableInfo table : tables.values()) {
             if (!emitted.add(table.name)) {
                 continue;
             }
-            builder.append("- ").append(table.name).append(":").append(table.title).append("。").append(table.description).append("\n");
-            builder.append("  常用字段:").append(String.join(";", table.fields)).append("\n");
-            builder.append("  SQL 示例:").append(table.exampleSql).append("\n");
+            builder.append("- ").append(table.name).append(": ").append(table.title).append("。").append(table.description).append("\n");
+            builder.append("  常用字段: ").append(String.join("; ", table.fields)).append("\n");
+            builder.append("  业务接口/来源: ").append(table.interfaceHint).append("\n");
+            builder.append("  SQL 示例: ").append(table.exampleSql).append("\n");
         }
-        builder.append("业务规则:订单默认排除逻辑删除;销售额优先 COALESCE(actual_total,total,order_money,0);月结只标记差异不修改系统。\n");
+        builder.append("业务规则: 订单默认排除逻辑删除;销售额优先 COALESCE(actual_total,total,order_money,0);商品默认排除 is_delete=1;月结差异只标记需人工确认,不自动写库。\n");
         return builder.toString();
     }
 
@@ -206,6 +280,7 @@ final class BusinessSchemaRegistry {
         row.put("table", table.name);
         row.put("title", table.title);
         row.put("description", table.description);
+        row.put("interfaceHint", table.interfaceHint);
         row.put("exampleSql", table.exampleSql);
         return row;
     }
@@ -218,18 +293,21 @@ final class BusinessSchemaRegistry {
         if ((query.contains("物流") || query.contains("配送") || query.contains("订单")) && haystack.contains("订单")) {
             return true;
         }
-        if ((query.contains("商品") || query.contains("sku") || query.contains("价格")) && haystack.contains("商品")) {
+        if ((query.contains("商品") || query.contains("sku") || query.contains("价格") || query.contains("描述")) && haystack.contains("商品")) {
+            return true;
+        }
+        if ((query.contains("会员") || query.contains("手机号") || query.contains("用户")) && haystack.contains("会员")) {
             return true;
         }
-        if ((query.contains("会员") || query.contains("手机号")) && haystack.contains("会员")) {
+        if ((query.contains("结算") || query.contains("月结") || query.contains("渠道") || query.contains("企业")) && haystack.contains("渠道")) {
             return true;
         }
-        if ((query.contains("结算") || query.contains("月结") || query.contains("渠道")) && haystack.contains("渠道")) {
+        if ((query.contains("积分") || query.contains("充值") || query.contains("抵扣")) && haystack.contains("积分")) {
             return true;
         }
         return false;
     }
 
-    private record TableInfo(String name, String title, String description, String[] fields, String exampleSql) {
+    private record TableInfo(String name, String title, String description, String[] fields, String exampleSql, String interfaceHint) {
     }
 }

+ 37 - 36
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/DeepSeekMcpClient.java

@@ -30,30 +30,30 @@ final class DeepSeekMcpClient {
         ArrayNode messages = Jsons.MAPPER.createArrayNode();
         messages.add(message("system", String.join("\n",
             "你是“市民请集合智能助手”的 MCP 侧工具调度器,只输出 JSON,不输出 Markdown。",
-            "目标:根据管理员自然语言问题,自主判断是否需要调用工具;只要能通过工具查询业务系统事实,就不要让用户自己执行 SQL、自己去后台查、或自己补充非必要字段。",
-            "范围:只处理 smqjh 项目业务、后台运营、商品、订单、会员、积分、券、月结、物流、系统配置、日志,以及日常简单问答。超出范围且没有工具时走 chat。",
-            "工具优先级:业务系统事实查询优先选 MCP 工具;通用事实查询优先选 smqjh.database.smart.query;外部价格对比选 product.price.compare;简单寒暄、常识解释、天气闲聊等无业务数据问题选 chat。",
+            "目标:根据管理员自然语言问题,自主判断是否需要调用工具。只要能通过工具查询业务系统事实,就不要让用户自己执行 SQL、自己去后台查、或自己补充非必要字段。",
+            "范围:只处理 smqjh 项目业务、后台运营、商品、订单、会员、积分、券、月结、物流、系统配置、日志,以及日常简单问答。",
+            "强规则:凡是询问业务系统里的事实或数据,例如商品描述/定价/状态、订单数量/金额/物流、会员信息、充值积分、月结数据、渠道企业,都必须优先调用 MCP 工具。",
+            "通用事实查询优先选择 smqjh.database.smart.query,让工具内部生成并执行安全 SELECT。除非有更专用工具能直接满足。",
+            "商品资料、商品描述、系统内定价、商品状态、品牌、规格,优先选择 smqjh.product.lookup.summary;如果需要更复杂字段,再选择 smqjh.database.smart.query。",
+            "订单数量、销售额、实付金额、按手机号统计,优先选择 smqjh.order.count.query;如果需要明细、物流、订单状态或字段更复杂,选择 smqjh.database.smart.query。",
+            "订单物流、配送状态、快递单号、运单号、订单明细、会员资料、积分流水、充值记录、渠道价,选择 smqjh.database.smart.query。",
+            "询问月结企业清单、channelId/channelNo,选择 smqjh.settlement.enterprise.list;询问某企业某月份月结、结算、对账、生成报表、预览数据,优先选择 smqjh.settlement.monthly.preview;只有询问流程、接口、规则、怎么做时才选择 smqjh.settlement.monthly.plan。",
+            "只有用户明确提到价格对比、市面价、电商平台、慢慢买、苏宁、淘宝、京东、拼多多、同平台价格等外部比价,才考虑外部价格工具;当前如果没有外部价格工具,就先查系统价并说明外部来源需要后续采集。",
+            "不要把普通问答误判成商品比价。例如天气、闲聊、解释概念,没有业务数据需求时返回 chat。",
             "如果已有 observations,要基于工具结果判断是否还需要继续调用工具;不要重复调用同名同参数工具。结果已经足够回答时返回 chat,toolCalls 为空。",
-            "不要把普通问答错判成商品比价。只有用户明确提到价格对比、市面价、电商、慢慢买、苏宁、淘宝、京东、拼多多、同平台价格等,才选 product.price.compare。",
-            "商品资料、商品描述、系统内定价、商品状态、品牌、规格,选 smqjh.product.lookup.summary,并把 productKeyword 提炼为真实商品名和规格。",
-            "订单数量、销售额、实付金额、营业额、按手机号统计,优先选 smqjh.order.count.query;如果需要明细字段或该工具无法表达,再选 smqjh.database.smart.query。",
-            "订单物流状态、配送状态、快递单号、运单号、订单明细、会员资料、任意后台表内字段查询,选 smqjh.database.smart.query,让工具内部生成并执行安全 SELECT。",
-            "询问有哪些月结企业、企业清单、channelId/channelNo 范围,选 smqjh.settlement.enterprise.list;询问某企业某月份结算计划,选 smqjh.settlement.monthly.plan。",
-            "SQL 安全要求:只能单条 SELECT;禁止写入、DDL、多语句、注释;明细查询 LIMIT 100 以内;默认排除逻辑删除;只访问 smqjh 白名单业务表。",
-            "常用表:smqjh_oms.oms_order、smqjh_oms.oms_order_item、smqjh_system.sm_member、smqjh_system.ums_member_account、smqjh_system.sm_channel、smqjh_pms.tz_prod、smqjh_pms.tz_sku。",
-            "常用订单字段:order_id、order_number、member_id、channel_id、channel_name、hb_order_status、hb_logistic_status、order_money、total、actual_total、freight_amount、dvy_type、dvy_id、dvy_flow_id、dvy_no、dvy_name、pay_time、dvy_time、create_time、consignee_mobile、is_payed、delete_status。",
-            "常用商品 SQL 示例:SELECT p.prod_id,p.prod_name,p.brand_name,p.brief,COALESCE(s.sku_name,'') AS sku_name,COALESCE(s.price,p.price,p.ori_price) AS price FROM smqjh_pms.tz_prod p LEFT JOIN smqjh_pms.tz_sku s ON s.prod_id=p.prod_id AND COALESCE(s.is_delete,0)=0 WHERE COALESCE(p.is_delete,0)=0 AND (p.prod_name LIKE '%关键词%' OR s.sku_name LIKE '%关键词%') LIMIT 20",
-            "常用物流 SQL 示例:SELECT order_number,hb_order_status,hb_logistic_status,dvy_type,dvy_flow_id,dvy_no,dvy_name,dvy_time,arrive_time,complete_time FROM smqjh_oms.oms_order WHERE COALESCE(delete_status,0)=0 AND order_number LIKE '%订单号%' LIMIT 20",
-            "返回 JSON:{\"mode\":\"tool\"或\"chat\",\"toolCalls\":[{\"name\":\"工具名\",\"arguments\":{}}],\"replyWhenNoTool\":\"chat 模式时的简短答复\",\"reason\":\"简短判断依据\"}。",
+            "SQL 安全要求由工具执行:只允许单条 SELECT,禁止写入、DDL、多语句和注释;默认排除逻辑删除;明细查询 LIMIT 100 内。",
+            "返回 JSON 格式:{\"mode\":\"tool\"或\"chat\",\"toolCalls\":[{\"name\":\"工具名\",\"arguments\":{}}],\"replyWhenNoTool\":\"chat 模式简短回答\",\"reason\":\"简短判断依据\"}。",
             "最多返回 3 个工具调用。arguments 必须符合工具 schema。不要编造不存在的工具名。"
         )));
-        messages.add(message("system", Jsons.stringify(Jsons.object()
-            .put("environmentName", request.path("environmentName").asText(config.environmentName))
-            .put("baseUrl", request.path("baseUrl").asText(config.baseUrl))
-            .put("authenticated", request.path("authenticated").asBoolean(false))
-            .put("username", request.path("username").asText(""))
-            .set("tools", tools)
-        )));
+
+        ObjectNode context = Jsons.object();
+        context.put("environmentName", request.path("environmentName").asText(config.environmentName));
+        context.put("baseUrl", request.path("baseUrl").asText(config.baseUrl));
+        context.put("authenticated", request.path("authenticated").asBoolean(false));
+        context.put("username", request.path("username").asText(""));
+        context.set("tools", tools);
+        messages.add(message("system", Jsons.stringify(context)));
+
         ObjectNode userPayload = Jsons.object();
         userPayload.put("message", request.path("message").asText(""));
         userPayload.set("history", request.path("history").isArray() ? request.path("history") : Jsons.MAPPER.createArrayNode());
@@ -78,14 +78,15 @@ final class DeepSeekMcpClient {
         ArrayNode messages = Jsons.MAPPER.createArrayNode();
         messages.add(message("system", String.join("\n",
             "你是“市民请集合智能助手”的数据库查询规划器,只输出 JSON,不输出 Markdown。",
-            "你的任务:把管理员自然语言问题转换成一条可执行的 MySQL SELECT。系统会自动执行,所以不要只给建议。",
+            "任务:把管理员自然语言问题转换成一条可执行的 MySQL SELECT。系统会自动执行,所以不要只给建议。",
             "只能生成单条 SELECT;禁止 INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE/GRANT/REVOKE/CALL/SET;禁止多语句和 SQL 注释。",
             "只能访问给定 schema 中的 smqjh 业务白名单表;不要访问 information_schema、mysql、performance_schema、sys。",
-            "默认排除逻辑删除数据;明细查询必须 LIMIT 100 以内;聚合查询可以不 LIMIT。",
-            "如果用户问订单数量/销售额,金额优先 COALESCE(actual_total,total,order_money,0),并明确是否过滤 is_payed=1。",
-            "如果用户问商品描述/状态/价格,优先查 smqjh_pms.tz_prod 和 smqjh_pms.tz_sku,过滤 COALESCE(is_delete,0)=0。",
-            "如果用户问订单物流/配送/快递,优先查 smqjh_oms.oms_order 的 hb_logistic_status、dvy_type、dvy_flow_id、dvy_no、dvy_name、dvy_time、arrive_time、complete_time。",
-            "如果字段不确定,选择最可能字段并在 warnings 标注“字段需按实际库结构复核”;不要让用户自己执行 SQL。",
+            "默认排除逻辑删除数据;明细查询必须 LIMIT 100 内;聚合查询可以不 LIMIT。",
+            "如果用户问订单数量、销售额或金额,金额优先 COALESCE(actual_total,total,order_money,0),并在 summary 中说明是否过滤 is_payed=1。",
+            "如果用户问商品描述、状态、价格,优先查 smqjh_pms.tz_prod 和 smqjh_pms.tz_sku,并过滤 COALESCE(is_delete,0)=0。",
+            "如果用户问订单物流、配送或快递,优先查 smqjh_oms.oms_order 的 hb_logistic_status、dvy_type、dvy_flow_id、dvy_no、dvy_name、dvy_time、arrive_time、complete_time。",
+            "如果用户问月结、充值、积分抵扣,结合 smqjh_oms.oms_order、smqjh_oms.oms_order_item、smqjh_system.sm_points_recharge、smqjh_system.sm_points_record、smqjh_system.sm_channel。",
+            "如果字段不确定,选择 schema 中最明确的字段并在 warnings 标注“字段需按实际库结构复核”;不要让用户自己执行 SQL。",
             "返回 JSON:{\"title\":\"表格标题\",\"summary\":\"查询口径说明\",\"sql\":\"SELECT ...\",\"warnings\":[\"...\"]}。"
         )));
         messages.add(message("system", schemaContext));
@@ -114,10 +115,10 @@ final class DeepSeekMcpClient {
         messages.add(message("system", String.join("\n",
             "你是“市民请集合智能助手”,需要根据工具执行结果给管理员一个连贯、可信、可审计的中文答复。",
             "不要让用户自己再执行 SQL;工具已经执行过了。回答要直接给结论、口径和依据。",
-            "如果工具结果包含 columns/rows 或 comparisonRows,说明“表格如下”即可,不要重复输出很宽的 Markdown 表格;前端会单独渲染表格。",
-            "必须说明依据来源,例如执行的 MCP 工具名、SQL executedSql、公开请求地址、只读查询口径。",
-            "如果结果为空,要说明已查的条件和可能原因,并给出下一步可补充的最少字段。",
-            "外部价格线索要提醒以平台实时页面为准;涉及业务写入、修改、发券、扣减、结算确认时只给方案和需人工确认,不声称已修改系统。",
+            "如果工具结果包含 columns/rows 或 comparisonRows,说明“表格如下”即可,不要重复输出很宽的 Markdown 表格;前端会单独渲染表格并提供下载。",
+            "必须说明依据来源,例如 MCP 工具名、executedSql、公开请求地址、只读查询口径。",
+            "如果结果为空,要说明已查的条件和可能原因,并给出下一步最少需要补充的字段。",
+            "涉及业务写入、修改、发券、扣减、结算确认、上架下架只给预览方案和需人工确认的点,不声称已修改系统。",
             "保持简洁,优先输出 2-5 个短段落。"
         )));
         ObjectNode userPayload = Jsons.object();
@@ -135,15 +136,15 @@ final class DeepSeekMcpClient {
         messages.add(message("system", String.join("\n",
             "你是“市民请集合智能助手”,运行在市民请集合后台管理员桌面端中,不是通用聊天机器人。",
             "工作范围严格限定为 smqjh 项目相关事项和日常简单问答。",
-            "业务系统事实应优先通过工具查询;普通聊天才直接回答。",
+            "业务系统事实应该优先通过工具查询;如果你进入普通对话,说明本轮没有可用工具或问题不需要业务数据。",
             "超出范围的问题要礼貌拒绝,并引导用户改问项目、后台操作或简单日常问题。",
             "回答要简洁、直接、中文优先;不要输出密钥、Token、密码、验证码或其他敏感信息。"
         )));
         messages.add(message("system", String.join("\n",
-            "当前环境" + request.path("environmentName").asText(config.environmentName),
-            "网关地址" + request.path("baseUrl").asText(config.baseUrl),
-            "当前时间" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
-            "后台登录状态" + (request.path("authenticated").asBoolean(false) ? "已登录" : "未登录")
+            "当前环境: " + request.path("environmentName").asText(config.environmentName),
+            "网关地址: " + request.path("baseUrl").asText(config.baseUrl),
+            "当前时间: " + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
+            "后台登录状态: " + (request.path("authenticated").asBoolean(false) ? "已登录" : "未登录")
         )));
         JsonNode history = request.path("history");
         if (history.isArray()) {

+ 32 - 39
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/MonthlySettlementPlanner.java

@@ -31,15 +31,15 @@ final class MonthlySettlementPlanner {
         root.put("month", month);
         root.put("startTime", start + " 00:00:00");
         root.put("endTime", end + " 00:00:00");
-        root.put("settlementScope", "企业用户定时月结;所有已付款订单;逻辑删除订单不参与");
+        root.put("settlementScope", "企业用户定时月结;所有已付款订单;逻辑删除订单不参与");
         ArrayNode exportRows = exports(enterprise, start, end);
-        root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"name", "method", "endpoint", "params", "note"}));
+        root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"name", "method", "adminEndpoint", "params", "backendEvidence", "note"}));
         root.set("rows", exportRows);
         root.set("exports", exportRows);
         root.set("rules", rules());
         root.set("invoiceStates", invoiceStates());
-        root.put("adminTenantRule", "顶级 admin 登录/调用不需要 tenantCode;非 admin 仍需要 tenantCode 或租户上下文");
-        root.put("mcpUsage", "agent 后续可以先调用本工具生成计划,再调用导出接口/只读 SQL/Excel 处理工具生成结算报告");
+        root.put("adminTenantRule", "顶级 admin 登录/调用不需要 tenantCode;非 admin 仍需要 tenantCode 或租户上下文");
+        root.put("mcpUsage", "后续可由 Agent 先调用本工具生成计划,再调用导出接口、只读 SQL 和 Excel 处理工具生成结算报告。");
         return root;
     }
 
@@ -59,7 +59,7 @@ final class MonthlySettlementPlanner {
         root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"channelId", "channelNo", "enterpriseName", "settlementType", "scope"}));
         root.set("rows", rows);
         root.put("rowCount", rows.size());
-        root.put("note", "目前来自管理员确认的 sm_channel 月结企业配置;顶级 admin 不需要 tenantCode。");
+        root.put("note", "来自管理员确认的 sm_channel 月结企业配置;顶级 admin 不需要 tenantCode。");
         return root;
     }
 
@@ -68,72 +68,65 @@ final class MonthlySettlementPlanner {
         exports.add(exportSpec(
             "员工列表积分情况",
             "GET",
-            "/smqjh-system/api/v1/members/enterprise/export",
-            "channelNoList[0]=" + enterprise.channelNo() + "&startTime=" + start + "%2000:00:00&endTime=" + end + "%2000:00:00",
-            "用于员工、手机号、积分余额/消费核对;积分建议按 user_id + channel_id 汇总"
+            "/plt/admin/enterprise/export",
+            "channelIdList[0]=" + enterprise.channelId(),
+            "业务代码证据:smqjh-system /api/v1/members/enterprise/export;积分充值表 sm_points_recharge;会员表 sm_member。",
+            "用于员工、手机号、充值积分、消费积分、可用积分核对;汇总时按 user_id + channel_id。"
         ));
         exports.add(exportSpec(
             "订单表",
-            "POST",
-            "/smqjh-oms/api/v1/order/export?exportType=ORDER",
-            Jsons.stringify(orderBody(enterprise, start, end)),
-            "后台导出后需二次筛选 is_payed=1,金额差异标记需人工确认"
+            "GET/POST",
+            "/plt/platform/order/export",
+            "channelIdList[0]=" + enterprise.channelId() + "&orderStatus=all&startTime=" + start + "%2000:00:00&endTime=" + end + "%2000:00:00",
+            "业务代码证据:OrderController POST /api/v1/order/export?exportType=ORDER;OmsOrderMapper.exportOrderList;oms_order。",
+            "导出后需要按 is_payed=1 和 COALESCE(delete_status,0)=0 口径核对,金额差异标记需人工确认。"
         ));
         exports.add(exportSpec(
             "商品订单表",
-            "POST",
-            "/smqjh-oms/api/v1/order/export?exportType=PRODUCT",
-            Jsons.stringify(orderBody(enterprise, start, end)),
-            "从商品表补齐海博编码、商品编码、SKU 编码;汇总商品总额"
+            "GET/POST",
+            "/plt/platform/order/export",
+            "channelIdList[0]=" + enterprise.channelId() + "&orderStatus=all&exportType=PRODUCT&startTime=" + start + "%2000:00:00&endTime=" + end + "%2000:00:00",
+            "业务代码证据:OrderController POST /api/v1/order/export?exportType=PRODUCT;OmsOrderMapper.exportProductList;oms_order_item + tz_sku。",
+            "从商品表补齐海博编码、商品编码、SKU 编码;汇总商品总额、数量和规格。"
         ));
         exports.add(exportSpec(
             "运费账单表",
             "GET",
             "/plt/platform/sku/freightStatisticsExcel",
             "channelIds[0]=" + enterprise.channelId(),
-            "优先按订单号关联订单表;汇总总运费"
+            "业务代码证据:运费导出入口由后台提供;优先按订单号和配送单号与 oms_order 关联。",
+            "汇总总运费;订单号无法关联或金额不一致时标记需人工确认。"
         ));
         exports.add(exportSpec(
             "对账汇总表模板",
             "GET",
             "/plt/platform/sku/skuStatisticsExcel",
             "channelIds[0]=" + enterprise.channelId(),
-            "业务系统导出模板,agent 补汇总公式和蓝色核对区"
+            "业务代码证据:后台对账汇总导出模板;Agent 负责补充汇总公式和蓝色核对区。",
+            "业务系统导出的模板本身可用,但缺少自动汇总计算和小蓝表核对区。"
         ));
         return exports;
     }
 
-    private ObjectNode exportSpec(String name, String method, String endpoint, String params, String note) {
+    private ObjectNode exportSpec(String name, String method, String endpoint, String params, String evidence, String note) {
         ObjectNode item = Jsons.object();
         item.put("name", name);
         item.put("method", method);
-        item.put("endpoint", endpoint);
+        item.put("adminEndpoint", endpoint);
         item.put("params", params);
+        item.put("backendEvidence", evidence);
         item.put("note", note);
         return item;
     }
 
-    private ObjectNode orderBody(Enterprise enterprise, LocalDate start, LocalDate end) {
-        ObjectNode body = Jsons.object();
-        ArrayNode channelIds = Jsons.MAPPER.createArrayNode();
-        channelIds.add(enterprise.channelId());
-        body.set("channelIdList", channelIds);
-        body.put("orderStatus", "all");
-        body.put("startTime", start + " 00:00:00");
-        body.put("endTime", end + " 00:00:00");
-        body.put("paidOnlyRule", "is_payed = 1");
-        body.put("notDeletedRule", "COALESCE(delete_status, 0) = 0");
-        return body;
-    }
-
     private ArrayNode rules() {
         ArrayNode rules = Jsons.MAPPER.createArrayNode();
-        rules.add("商品总额 = 已付款商品订单明细小计汇总");
-        rules.add("总运费 = 运费账单按订单号匹配后汇总");
-        rules.add("负数积分抵扣表示充值转换为积分后的抵扣金额");
-        rules.add("商品金额、运费、积分抵扣、现金支付任意差异不为 0,标记为需人工确认");
-        rules.add("只标记差异,不自动修改业务系统数据");
-        rules.add("结算报告生成后发送客户核对,再打印盖章、开电子发票、等待客户付款");
+        rules.add("商品总额 = 已付款商品订单明细小计汇总。");
+        rules.add("总运费 = 运费账单按订单号或配送单号匹配后汇总。");
+        rules.add("负数积分抵扣表示充值转换为积分后的抵扣金额。");
+        rules.add("商品金额、运费、积分抵扣、现金支付任意差异不为 0,标记为“需人工确认”。");
+        rules.add("只标记差异,不自动修改业务系统数据。");
+        rules.add("结算报告生成后发送客户核对,再打印盖章、开电子发票、等待客户付款。");
         return rules;
     }
 

+ 310 - 4
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/ReadOnlyDatabaseClient.java

@@ -3,6 +3,8 @@ package com.smqjh.agent.mcp;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.PreparedStatement;
@@ -11,6 +13,7 @@ import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
@@ -26,8 +29,12 @@ final class ReadOnlyDatabaseClient {
         "smqjh_system.sm_member",
         "smqjh_system.ums_member_account",
         "smqjh_system.sm_channel",
+        "smqjh_system.sm_points_recharge",
+        "smqjh_system.sm_points_record",
         "smqjh_pms.tz_prod",
         "smqjh_pms.tz_sku",
+        "smqjh_pms.tz_channel_prod",
+        "smqjh_pms.tz_shop_sku",
         "smqjh_pms.pms_djk_goods",
         "smqjh_pms.pms_spu",
         "smqjh_pms.pms_sku",
@@ -38,8 +45,12 @@ final class ReadOnlyDatabaseClient {
         "sm_member",
         "ums_member_account",
         "sm_channel",
+        "sm_points_recharge",
+        "sm_points_record",
         "tz_prod",
         "tz_sku",
+        "tz_channel_prod",
+        "tz_shop_sku",
         "pms_djk_goods",
         "pms_spu",
         "pms_sku",
@@ -124,7 +135,7 @@ final class ReadOnlyDatabaseClient {
                         if (payload.path("rowCount").asInt() > 0) {
                             payload.put("keyword", keyword);
                             payload.put("matchedKeyword", candidate);
-                            payload.put("priceUnit", "tz_prod/tz_sku price 字段按分转元展示;以业务系统实际字段单位为准");
+                            payload.put("priceUnit", "tz_prod/tz_sku price 字段当前按分转元展示;最终以业务系统实际字段单位为准");
                             enrichProductRows(payload);
                             return payload;
                         }
@@ -202,6 +213,297 @@ final class ReadOnlyDatabaseClient {
         }
     }
 
+    ObjectNode monthlySettlementPreview(String enterpriseInput, String monthInput) throws Exception {
+        ensureDatabaseEnabled();
+        Enterprise enterprise = findSettlementEnterprise(enterpriseInput);
+        if (enterprise == null) {
+            throw new IllegalArgumentException("未找到月结企业: " + enterpriseInput + "。当前支持:招商银行贵阳分行、中数未来、铜仁移动。");
+        }
+        LocalDate start = parseMonth(monthInput);
+        LocalDate end = start.plusMonths(1);
+        String startTime = start + " 00:00:00";
+        String endTime = end + " 00:00:00";
+
+        try (Connection connection = connect()) {
+            prepareReadOnlySession(connection);
+            ObjectNode orderSummary = queryMonthlyOrderSummary(connection, enterprise, startTime, endTime);
+            ObjectNode pointsSummary = queryMonthlyPointsSummary(connection, enterprise, startTime, endTime);
+            ObjectNode rechargeSummary = queryMonthlyRechargeSummary(connection, enterprise, startTime, endTime);
+            ObjectNode productSummary = queryMonthlyProductSummary(connection, enterprise, startTime, endTime);
+            return buildMonthlySettlementPayload(enterprise, monthInput, startTime, endTime, orderSummary, pointsSummary, rechargeSummary, productSummary);
+        }
+    }
+
+    private ObjectNode queryMonthlyOrderSummary(Connection connection, Enterprise enterprise, String startTime, String endTime) throws SQLException {
+        String sql = """
+            SELECT
+              COUNT(DISTINCT o.order_id) AS paidOrderCount,
+              COALESCE(SUM(COALESCE(o.total, 0)), 0) AS productAmount,
+              COALESCE(SUM(COALESCE(o.freight_amount, 0)), 0) AS freightAmount,
+              COALESCE(SUM(COALESCE(o.offset_points, 0)), 0) AS offsetPoints,
+              COALESCE(SUM(COALESCE(o.actual_total, 0)), 0) AS cashAmount,
+              COALESCE(SUM(COALESCE(o.total, 0) + COALESCE(o.freight_amount, 0)), 0) AS payableAmount,
+              MIN(COALESCE(o.pay_time, o.create_time)) AS firstPaidTime,
+              MAX(COALESCE(o.pay_time, o.create_time)) AS lastPaidTime
+            FROM smqjh_oms.oms_order o
+            WHERE COALESCE(o.delete_status, 0) = 0
+              AND COALESCE(o.is_payed, 0) = 1
+              AND o.channel_id = ?
+              AND COALESCE(o.pay_time, o.create_time) >= ?
+              AND COALESCE(o.pay_time, o.create_time) < ?
+            """;
+        return firstRow(connection, sql, enterprise.channelId(), startTime, endTime);
+    }
+
+    private ObjectNode queryMonthlyPointsSummary(Connection connection, Enterprise enterprise, String startTime, String endTime) throws SQLException {
+        String sql = """
+            SELECT
+              COALESCE(SUM(CASE WHEN COALESCE(variable_points, 0) > 0 THEN variable_points ELSE 0 END), 0) AS pointsIncrease,
+              COALESCE(SUM(CASE WHEN COALESCE(variable_points, 0) < 0 THEN variable_points ELSE 0 END), 0) AS pointsDecrease,
+              COALESCE(SUM(COALESCE(variable_points, 0)), 0) AS pointsNetChange,
+              COALESCE(SUM(COALESCE(expiry_points, 0)), 0) AS expiryPoints,
+              COUNT(1) AS recordCount
+            FROM smqjh_system.sm_points_record
+            WHERE channel_id = ?
+              AND creation_date >= ?
+              AND creation_date < ?
+            """;
+        return firstRow(connection, sql, enterprise.channelId(), startTime, endTime);
+    }
+
+    private ObjectNode queryMonthlyRechargeSummary(Connection connection, Enterprise enterprise, String startTime, String endTime) throws SQLException {
+        String sql = """
+            SELECT
+              COALESCE(SUM(COALESCE(points, 0)), 0) AS rechargePoints,
+              COALESCE(SUM(COALESCE(available_points, 0)), 0) AS availablePoints,
+              COALESCE(SUM(COALESCE(consume_points, 0)), 0) AS consumePoints,
+              COALESCE(SUM(COALESCE(expiry_points, 0)), 0) AS rechargeExpiryPoints,
+              COUNT(1) AS rechargeRecordCount
+            FROM smqjh_system.sm_points_recharge
+            WHERE channel_id = ?
+              AND create_time >= ?
+              AND create_time < ?
+            """;
+        return firstRow(connection, sql, enterprise.channelId(), startTime, endTime);
+    }
+
+    private ObjectNode queryMonthlyProductSummary(Connection connection, Enterprise enterprise, String startTime, String endTime) throws SQLException {
+        String sql = """
+            SELECT
+              COALESCE(NULLIF(oi.prod_name, ''), p.prod_name) AS productName,
+              COALESCE(NULLIF(oi.sku_name, ''), s.sku_name) AS skuName,
+              COALESCE(NULLIF(oi.spec, ''), s.spec) AS spec,
+              COALESCE(oi.price, 0) AS unitPrice,
+              COALESCE(SUM(COALESCE(oi.prod_count, 0)), 0) AS quantity,
+              COALESCE(SUM(COALESCE(oi.product_total_amount, COALESCE(oi.price, 0) * COALESCE(oi.prod_count, 0))), 0) AS productTotalAmount,
+              COALESCE(SUM(COALESCE(oi.use_score, 0)), 0) AS useScore,
+              COALESCE(SUM(COALESCE(oi.gain_score, 0)), 0) AS gainScore,
+              GROUP_CONCAT(DISTINCT oi.prod_id ORDER BY oi.prod_id SEPARATOR ',') AS prodIds,
+              GROUP_CONCAT(DISTINCT oi.sku_id ORDER BY oi.sku_id SEPARATOR ',') AS skuIds,
+              GROUP_CONCAT(DISTINCT s.sku_code ORDER BY s.sku_code SEPARATOR ',') AS skuCodes,
+              CASE WHEN MAX(p.prod_id) IS NULL OR MAX(s.sku_id) IS NULL THEN '需人工确认' ELSE '已补齐' END AS confirmStatus
+            FROM smqjh_oms.oms_order_item oi
+            INNER JOIN smqjh_oms.oms_order o ON o.order_number = oi.order_number
+            LEFT JOIN smqjh_pms.tz_prod p ON p.prod_id = oi.prod_id AND COALESCE(p.is_delete, 0) = 0
+            LEFT JOIN smqjh_pms.tz_sku s ON s.sku_id = oi.sku_id AND COALESCE(s.is_delete, 0) = 0
+            WHERE COALESCE(o.delete_status, 0) = 0
+              AND COALESCE(o.is_payed, 0) = 1
+              AND o.channel_id = ?
+              AND COALESCE(o.pay_time, o.create_time) >= ?
+              AND COALESCE(o.pay_time, o.create_time) < ?
+            GROUP BY
+              COALESCE(NULLIF(oi.prod_name, ''), p.prod_name),
+              COALESCE(NULLIF(oi.sku_name, ''), s.sku_name),
+              COALESCE(NULLIF(oi.spec, ''), s.spec),
+              COALESCE(oi.price, 0)
+            ORDER BY productTotalAmount DESC
+            LIMIT 200
+            """;
+        try (PreparedStatement statement = connection.prepareStatement(sql)) {
+            statement.setLong(1, enterprise.channelId());
+            statement.setString(2, startTime);
+            statement.setString(3, endTime);
+            statement.setQueryTimeout(8);
+            try (ResultSet rs = statement.executeQuery()) {
+                ObjectNode payload = resultSetPayload(rs, "SELECT monthly product summary FROM smqjh_oms.oms_order_item + smqjh_oms.oms_order + smqjh_pms.tz_prod/tz_sku");
+                payload.put("title", enterprise.name() + " 商品明细汇总");
+                payload.put("summary", "按已付款、未逻辑删除订单聚合商品数量、商品总额、积分字段,并尝试补齐商品/SKU 编码。");
+                return payload;
+            }
+        }
+    }
+
+    private ObjectNode firstRow(Connection connection, String sql, Object... params) throws SQLException {
+        try (PreparedStatement statement = connection.prepareStatement(sql)) {
+            for (int i = 0; i < params.length; i++) {
+                Object value = params[i];
+                if (value instanceof Number number) {
+                    statement.setLong(i + 1, number.longValue());
+                } else {
+                    statement.setString(i + 1, String.valueOf(value));
+                }
+            }
+            statement.setQueryTimeout(8);
+            try (ResultSet rs = statement.executeQuery()) {
+                if (rs.next()) {
+                    ObjectNode row = Jsons.object();
+                    ResultSetMetaData meta = rs.getMetaData();
+                    for (int i = 1; i <= meta.getColumnCount(); i++) {
+                        Object value = rs.getObject(i);
+                        if (value == null) {
+                            row.putNull(meta.getColumnLabel(i));
+                        } else {
+                            row.put(meta.getColumnLabel(i), String.valueOf(value));
+                        }
+                    }
+                    return row;
+                }
+            }
+        }
+        return Jsons.object();
+    }
+
+    private ObjectNode buildMonthlySettlementPayload(
+        Enterprise enterprise,
+        String month,
+        String startTime,
+        String endTime,
+        ObjectNode orderSummary,
+        ObjectNode pointsSummary,
+        ObjectNode rechargeSummary,
+        ObjectNode productSummary
+    ) {
+        BigDecimal productAmount = decimal(orderSummary, "productAmount");
+        BigDecimal freightAmount = decimal(orderSummary, "freightAmount");
+        BigDecimal offsetPoints = decimal(orderSummary, "offsetPoints");
+        BigDecimal cashAmount = decimal(orderSummary, "cashAmount");
+        BigDecimal payableAmount = decimal(orderSummary, "payableAmount");
+        BigDecimal settlementAmount = cashAmount.add(offsetPoints);
+        BigDecimal diffAmount = payableAmount.subtract(settlementAmount);
+        boolean needConfirm = diffAmount.abs().compareTo(new BigDecimal("0.01")) > 0 || hasManualConfirmRows(productSummary);
+
+        ObjectNode root = Jsons.object();
+        root.put("ok", true);
+        root.put("title", enterprise.name() + " " + month + " 月结预览");
+        root.put("enterpriseName", enterprise.name());
+        root.put("channelId", enterprise.channelId());
+        root.put("channelNo", enterprise.channelNo());
+        root.put("month", month);
+        root.put("startTime", startTime);
+        root.put("endTime", endTime);
+        root.put("manualConfirmStatus", needConfirm ? "需人工确认" : "核对通过");
+        root.put("summary", "已按企业渠道、付款时间、已付款订单和逻辑删除过滤生成只读预览;结果不会写入业务系统。");
+        root.put("evidence", "MySQL只读 SELECT: smqjh_oms.oms_order / oms_order_item, smqjh_system.sm_points_record / sm_points_recharge, smqjh_pms.tz_prod / tz_sku");
+        root.set("columns", Jsons.MAPPER.valueToTree(new String[] {"项目", "数值", "口径", "状态", "依据"}));
+
+        ArrayNode rows = Jsons.MAPPER.createArrayNode();
+        rows.add(summaryRow("企业", enterprise.name(), "sm_channel 月结企业配置", "已确认", "channelId=" + enterprise.channelId() + ", channelNo=" + enterprise.channelNo()));
+        rows.add(summaryRow("结算月份", month, "管理员选择月份", "已确认", startTime + " 至 " + endTime));
+        rows.add(summaryRow("已付款订单数", text(orderSummary, "paidOrderCount"), "is_payed=1 且 delete_status=0", "已统计", "smqjh_oms.oms_order"));
+        rows.add(summaryRow("商品总额", money(productAmount), "SUM(oms_order.total)", "已统计", "订单表商品总额字段"));
+        rows.add(summaryRow("总运费", money(freightAmount), "SUM(oms_order.freight_amount)", "已统计", "订单表运费字段"));
+        rows.add(summaryRow("积分抵扣金额", money(offsetPoints.negate()), "SUM(oms_order.offset_points) 按结算表负数展示", "已统计", "负数表示充值转换为积分后的抵扣金额"));
+        rows.add(summaryRow("现金支付金额", money(cashAmount), "SUM(oms_order.actual_total)", "已统计", "订单实付金额字段"));
+        rows.add(summaryRow("商品总额+运费", money(payableAmount), "商品总额 + 总运费", "已计算", "用于对账汇总小蓝表"));
+        rows.add(summaryRow("积分抵扣+现金", money(settlementAmount), "offset_points + actual_total", "已计算", "用于核对商品总额+运费"));
+        rows.add(summaryRow("核对差异", money(diffAmount), "商品总额+运费 - (积分抵扣+现金)", needConfirm ? "需人工确认" : "核对通过", "差异不为 0 或商品/SKU 缺失时人工确认"));
+        rows.add(summaryRow("本月充值积分", text(rechargeSummary, "rechargePoints"), "SUM(sm_points_recharge.points)", "已统计", "sm_points_recharge"));
+        rows.add(summaryRow("本月可用积分", text(rechargeSummary, "availablePoints"), "SUM(sm_points_recharge.available_points)", "已统计", "sm_points_recharge"));
+        rows.add(summaryRow("本月已消费积分", text(rechargeSummary, "consumePoints"), "SUM(sm_points_recharge.consume_points)", "已统计", "sm_points_recharge"));
+        rows.add(summaryRow("积分流水净变化", text(pointsSummary, "pointsNetChange"), "SUM(sm_points_record.variable_points)", "已统计", "sm_points_record"));
+        root.set("rows", rows);
+        root.put("rowCount", rows.size());
+
+        root.set("orderSummary", orderSummary);
+        root.set("pointsSummary", pointsSummary);
+        root.set("rechargeSummary", rechargeSummary);
+        root.set("productSummary", productSummary);
+        root.set("comparisonRows", productSummary.path("rows"));
+        root.set("comparisonColumns", productSummary.path("columns"));
+        root.put("comparisonTitle", "商品汇总表");
+        root.put("generatedFileHint", "前端表格区域可直接下载 CSV;后续会接入 Excel 模板生成完整月结报告。");
+        return root;
+    }
+
+    private ObjectNode summaryRow(String item, String value, String rule, String status, String evidence) {
+        ObjectNode row = Jsons.object();
+        row.put("项目", item);
+        row.put("数值", value);
+        row.put("口径", rule);
+        row.put("状态", status);
+        row.put("依据", evidence);
+        return row;
+    }
+
+    private BigDecimal decimal(ObjectNode node, String key) {
+        String value = text(node, key);
+        if (value.isBlank()) {
+            return BigDecimal.ZERO;
+        }
+        try {
+            return new BigDecimal(value);
+        } catch (NumberFormatException ignored) {
+            return BigDecimal.ZERO;
+        }
+    }
+
+    private String money(BigDecimal value) {
+        return "¥" + value.setScale(2, RoundingMode.HALF_UP).toPlainString();
+    }
+
+    private String text(ObjectNode node, String key) {
+        if (node == null || !node.hasNonNull(key)) {
+            return "";
+        }
+        return node.path(key).asText("");
+    }
+
+    private boolean hasManualConfirmRows(ObjectNode productSummary) {
+        if (productSummary == null || !productSummary.path("rows").isArray()) {
+            return false;
+        }
+        for (var row : productSummary.path("rows")) {
+            if (row.path("confirmStatus").asText("").contains("需人工确认")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Enterprise findSettlementEnterprise(String input) {
+        String normalized = input == null ? "" : input.trim().toLowerCase(Locale.ROOT);
+        if (normalized.isBlank() || "all".equals(normalized) || "全部".equals(normalized)) {
+            return null;
+        }
+        for (Enterprise enterprise : settlementEnterprises()) {
+            if (enterprise.name().toLowerCase(Locale.ROOT).contains(normalized)
+                || normalized.contains(enterprise.name().toLowerCase(Locale.ROOT))
+                || enterprise.channelNo().equals(normalized)
+                || String.valueOf(enterprise.channelId()).equals(normalized)) {
+                return enterprise;
+            }
+        }
+        return null;
+    }
+
+    private List<Enterprise> settlementEnterprises() {
+        return List.of(
+            new Enterprise(54, "1", "招商银行贵阳分行"),
+            new Enterprise(55, "2", "中数未来"),
+            new Enterprise(58, "5", "铜仁移动")
+        );
+    }
+
+    private LocalDate parseMonth(String month) {
+        if (month == null || !month.matches("\\d{4}-\\d{2}")) {
+            throw new IllegalArgumentException("month 必须是 YYYY-MM");
+        }
+        return LocalDate.parse(month + "-01", DateTimeFormatter.ISO_LOCAL_DATE);
+    }
+
+    private record Enterprise(long channelId, String channelNo, String name) {
+    }
+
     private Connection connect() throws SQLException {
         String url = "jdbc:mysql://%s:%d/%s?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
             .formatted(config.database.host, config.database.port, config.database.database);
@@ -320,7 +622,11 @@ final class ReadOnlyDatabaseClient {
         List<String> candidates = new ArrayList<>();
         addCandidate(candidates, keyword);
         addCandidate(candidates, keyword.replaceAll("\\s+", ""));
-        addCandidate(candidates, keyword.replaceAll("\\d+(?:\\.\\d+)?\\s*(?:ml|mL|ML|毫升|l|L|升)(?:\\s*[xX*×]\\s*\\d+\\s*(?:瓶|罐|箱|支|件|包)?)?", "").trim());
+        String strippedPackage = keyword
+            .replaceAll("\\d+(?:\\.\\d+)?\\s*(?:ml|mL|ML|毫升|g|克|kg|KG|L|l|升)(?:\\s*[xX*×]\\s*\\d+\\s*(?:瓶|罐|箱|盒|包|袋|件|份|支|个)?)?", " ")
+            .replaceAll("\\s+", " ")
+            .trim();
+        addCandidate(candidates, strippedPackage);
         return candidates;
     }
 
@@ -332,8 +638,8 @@ final class ReadOnlyDatabaseClient {
 
     private String normalizeProductKeyword(String keyword) {
         return (keyword == null ? "" : keyword)
-            .replaceAll("(帮我|查询|查看|当前|商品|描述|是什么|价格|多少|业务系统|里面|的)", " ")
-            .replaceAll("[,。!?、\\n\\r]", " ")
+            .replaceAll("(帮我|麻烦|查询一下|查询|查看|当前|业务系统|系统里面|系统里|后台|我方|我们的|商品库|商品表|商品|描述是什么|商品描述|描述|价格是多少|价格|定价|是多少|是什么|呢)", " ")
+            .replaceAll("[,。!?、;:,.!?;:\\n\\r]", " ")
             .replaceAll("\\s+", " ")
             .trim();
     }

+ 15 - 12
smqjh-mcp-server/src/main/java/com/smqjh/agent/mcp/ToolRegistry.java

@@ -28,18 +28,19 @@ final class ToolRegistry {
         List<ObjectNode> tools = new ArrayList<>();
         tools.add(tool("smqjh.config.get", "读取本机运行配置", "返回网关、数据库、DeepSeek、MCP 状态。敏感字段会脱敏。", objectSchema(Jsons.object())));
         tools.add(tool("smqjh.cloud.health", "网关连通检查", "调用验证码接口检查 smqjh 网关是否可达。", objectSchema(Jsons.object())));
-        tools.add(tool("smqjh.schema.search", "搜索业务表和规则", "按自然语言搜索 smqjh 业务表、字段和查询口径。", objectSchema(requiredProps("query", "string", "业务问题、表名或字段关键词"), "query")));
-        tools.add(tool("smqjh.schema.getTable", "读取业务表说明", "返回单张业务表的字段说明、口径和示例 SQL。", objectSchema(requiredProps("table", "string", "表名,例如 smqjh_oms.oms_order"), "table")));
-        tools.add(tool("smqjh.schema.businessRules", "读取业务规则", "返回订单、商品、会员、结算、充值、消费等业务口径。", objectSchema(requiredProps("topic", "string", "业务主题,例如 月结/订单/商品"), "topic")));
+        tools.add(tool("smqjh.schema.search", "搜索业务表和规则", "按自然语言搜索 smqjh 业务表、字段、业务接口和查询口径。", objectSchema(requiredProps("query", "string", "业务问题、表名或字段关键词"), "query")));
+        tools.add(tool("smqjh.schema.getTable", "读取业务表说明", "返回单张业务表的字段说明、接口来源和示例 SQL。", objectSchema(requiredProps("table", "string", "表名,例如 smqjh_oms.oms_order"), "table")));
+        tools.add(tool("smqjh.schema.businessRules", "读取业务规则", "返回订单、商品、会员、结算、充值、消费等业务口径。", objectSchema(requiredProps("topic", "string", "业务主题,例如 月结/订单/商品/充值"), "topic")));
         tools.add(tool("smqjh.database.readonly.query", "执行只读 SQL", "只允许 SELECT,自动校验危险语句、表白名单和默认 LIMIT。", objectSchema(requiredProps("sql", "string", "只读 SELECT SQL"), "sql")));
         tools.add(tool("smqjh.database.smart.query", "智能数据库查询", "输入管理员自然语言问题,由 DeepSeek 结合业务 schema 生成安全 SELECT,经白名单和只读校验后自动执行并返回表格。", objectSchema(smartQueryProps(), "question")));
-        tools.add(tool("smqjh.order.count.query", "订单统计查询", "按手机号和日期区间统计订单数量和销售额,只读查询数据库。", objectSchema(orderCountProps())));
-        tools.add(tool("smqjh.product.lookup.summary", "商品资料查询", "按商品关键词查询商品名、品牌、价格、状态和描述。", objectSchema(requiredProps("productKeyword", "string", "商品关键词"), "productKeyword")));
+        tools.add(tool("smqjh.order.count.query", "订单统计查询", "按手机号和日期区间统计订单数量、销售额、已付/未付金额,只读查询数据库。", objectSchema(orderCountProps())));
+        tools.add(tool("smqjh.product.lookup.summary", "商品资料查询", "按商品关键词查询商品名、品牌、价格、状态、SKU 和描述。", objectSchema(requiredProps("productKeyword", "string", "商品关键词"), "productKeyword")));
         tools.add(tool("smqjh.settlement.enterprise.list", "月结企业清单", "返回当前已确认的企业月结渠道清单和结算范围。", objectSchema(Jsons.object())));
         tools.add(tool("smqjh.settlement.monthly.plan", "企业月结处理计划", "根据企业和月份返回月结导出接口、参数、核对规则和人工确认条件。", objectSchema(settlementProps(), "enterprise", "month")));
-        tools.add(tool("smqjh.ai.tool.plan", "DeepSeek 工具规划", "由 MCP 侧 DeepSeek 判断管理员问题需要调用哪些工具。", objectSchema(aiPlanProps(), "request", "tools")));
-        tools.add(tool("smqjh.ai.tool.summarize", "DeepSeek 工具结果总结", "由 MCP 侧 DeepSeek 汇总工具执行结果,生成面向管理员的回答。", objectSchema(aiSummarizeProps(), "request", "observations")));
-        tools.add(tool("smqjh.ai.chat", "DeepSeek 普通对话", "由 MCP 侧 DeepSeek 处理范围内的简单问答。", objectSchema(aiChatProps(), "request")));
+        tools.add(tool("smqjh.settlement.monthly.preview", "企业月结只读预览", "根据企业和月份直接只读查询订单、商品、充值和积分流水,返回月结汇总表与商品汇总表;只标记需人工确认,不写入业务库。", objectSchema(settlementProps(), "enterprise", "month")));
+        tools.add(tool("smqjh.ai.tool.plan", "DeepSeek 工具规划", "由 MCP 调用 DeepSeek 判断管理员问题需要调用哪些工具。", objectSchema(aiPlanProps(), "request", "tools")));
+        tools.add(tool("smqjh.ai.tool.summarize", "DeepSeek 工具结果总结", "由 MCP 调用 DeepSeek 汇总工具执行结果,生成面向管理员的回答。", objectSchema(aiSummarizeProps(), "request", "observations")));
+        tools.add(tool("smqjh.ai.chat", "DeepSeek 普通对话", "由 MCP 调用 DeepSeek 处理范围内的简单问答。", objectSchema(aiChatProps(), "request")));
         return tools;
     }
 
@@ -58,6 +59,7 @@ final class ToolRegistry {
             case "smqjh.product.lookup.summary" -> database.productLookupSummary(requiredText(args, "productKeyword"));
             case "smqjh.settlement.enterprise.list" -> settlementPlanner.enterprises();
             case "smqjh.settlement.monthly.plan" -> settlementPlanner.plan(requiredText(args, "enterprise"), requiredText(args, "month"));
+            case "smqjh.settlement.monthly.preview" -> database.monthlySettlementPreview(requiredText(args, "enterprise"), requiredText(args, "month"));
             case "smqjh.ai.tool.plan" -> deepSeek.planToolUse(args);
             case "smqjh.ai.tool.summarize" -> deepSeek.summarizeToolResults(args);
             case "smqjh.ai.chat" -> deepSeek.chat(args);
@@ -91,9 +93,9 @@ final class ToolRegistry {
             ObjectNode retryArgs = Jsons.object();
             retryArgs.put("context", String.join("\n",
                 "第一次 SQL 执行失败,需要重新生成更稳妥的 SELECT。",
-                "失败 SQL" + sql,
-                "数据库错误" + firstError,
-                "要求:优先使用 schema 中明确列出的字段;如果字段不确定,用更少字段或聚合口径;仍然只输出 JSON 和单条 SELECT。"
+                "失败 SQL: " + sql,
+                "数据库错误: " + firstError,
+                "要求:优先使用 schema 中明确列出的字段;如果字段不确定,用更少字段或换关联口径;仍然只输出 JSON 和单条 SELECT。"
             ));
             ObjectNode retryPlan = deepSeek.generateReadOnlySql(question, schema.promptContext(), retryArgs);
             String retrySql = retryPlan.path("sql").asText("").trim();
@@ -200,7 +202,7 @@ final class ToolRegistry {
 
     private ObjectNode smartQueryProps() {
         ObjectNode props = Jsons.object();
-        props.set("question", prop("string", "管理员自然语言问题,例如:当前百事可乐 500ml 的商品描述是什么、查订单物流状态、某手机号下了多少单"));
+        props.set("question", prop("string", "管理员自然语言问题,例如:当前百事可乐 500ml 的商品描述是什么、查订单物流状态、某手机号下了多少单"));
         props.set("context", prop("string", "可选补充上下文,例如上轮结果、企业、月份、订单号"));
         return props;
     }
@@ -216,6 +218,7 @@ final class ToolRegistry {
         ObjectNode props = Jsons.object();
         props.set("request", prop("object", "AssistantRunRequest,包含 message、history、登录上下文等"));
         props.set("tools", prop("array", "可供 DeepSeek 选择的工具列表"));
+        props.set("observations", prop("array", "已执行工具结果,可选"));
         return props;
     }