Commit 1ac5825c authored by 段廷瑞's avatar 段廷瑞

pref: 行业知识,SQL及问题排查能力内置

parent 5cdb8136
......@@ -65,5 +65,7 @@ bin
*.zip
/AppHome/AgentChat
/AppHome/Agent/history
/AppHome/Agent
/AppHome/Agent/*
!/AppHome/Agent/dialect/
!/AppHome/Agent/industry/
/AppHome/Agent/config/AgentConfig.json
......@@ -45,6 +45,18 @@
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="NPOI, Version=2.5.1.0, Culture=neutral, PublicKeyToken=0df73ec7942b34e1, processorArchitecture=MSIL">
<HintPath>..\packages\NPOI.2.5.1\lib\net45\NPOI.dll</HintPath>
</Reference>
<Reference Include="NPOI.OOXML, Version=2.5.1.0, Culture=neutral, PublicKeyToken=0df73ec7942b34e1, processorArchitecture=MSIL">
<HintPath>..\packages\NPOI.2.5.1\lib\net45\NPOI.OOXML.dll</HintPath>
</Reference>
<Reference Include="NPOI.OpenXml4Net, Version=2.5.1.0, Culture=neutral, PublicKeyToken=0df73ec7942b34e1, processorArchitecture=MSIL">
<HintPath>..\packages\NPOI.2.5.1\lib\net45\NPOI.OpenXml4Net.dll</HintPath>
</Reference>
<Reference Include="NPOI.OpenXmlFormats, Version=2.5.1.0, Culture=neutral, PublicKeyToken=0df73ec7942b34e1, processorArchitecture=MSIL">
<HintPath>..\packages\NPOI.2.5.1\lib\net45\NPOI.OpenXmlFormats.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
......@@ -54,8 +66,10 @@
<Compile Include="AgentEngineLogicTests.cs" />
<Compile Include="FailoverPolicyTests.cs" />
<Compile Include="KbServiceSandboxTests.cs" />
<Compile Include="DialectServiceTests.cs" />
<Compile Include="EmbeddingClientTests.cs" />
<Compile Include="ToolPermissionTests.cs" />
<Compile Include="TextEncodingTests.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tools\Tools.csproj">
......
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using Tools.MyAgent.Agent;
using Tools.MyAgent.Agent.Tools;
using Tools.MyAgent.Kb;
namespace AgentTest
{
/// <summary>
/// 方言迁移 P2 测试:DialectService(INDEX 解析/摘要预算/沙箱)+ d_* 工具(d_read/d_grep/d_list)+
/// PromptBuilder 方言层注入。覆盖迁移方案 §8.1 用例。
/// </summary>
public sealed class DialectServiceTests : TestRunner
{
private string _root; // 临时 agentRoot(含 dialect/ + industry/)
private DialectService _ds;
public override void SetUp()
{
_root = Path.Combine(Path.GetTempPath(), "agenttest_dialect_" + Guid.NewGuid().ToString("N"));
string dialect = Path.Combine(_root, "dialect");
string industry = Path.Combine(_root, "industry");
string shoe = Path.Combine(industry, "鞋业");
Directory.CreateDirectory(Path.Combine(dialect, "erp", "cases"));
Directory.CreateDirectory(Path.Combine(dialect, "sql-core"));
Directory.CreateDirectory(Path.Combine(shoe, "01-品类认知"));
Directory.CreateDirectory(Path.Combine(shoe, "06-专业术语"));
File.WriteAllText(Path.Combine(dialect, "INDEX.md"),
"# 产品排查方言\n行业简介:产品线排查方言。\n" +
"## 01-排查方法论\n" +
"- troubleshoot-core/five-steps.md:排查五步法(触发词:排查、报错、根因)\n" +
"## 02-SQL 规范\n" +
"- sql-core/sql-templates.md:四库查询模板(触发词:加列、执行计划)\n");
File.WriteAllText(Path.Combine(dialect, "erp", "winforms.md"), "# WinForms 排查\n库存不对先查 view_MaterialStockDocFlow 流水。\n");
File.WriteAllText(Path.Combine(dialect, "sql-core", "sql-templates.md"), "# 模板\nSELECT 1;\n");
File.WriteAllText(Path.Combine(shoe, "INDEX.md"),
"# 鞋业(别名:制鞋、鞋类)\n行业简介:制鞋行业基础知识。\n" +
"## 01-品类认知\n" +
"- 运动鞋.md:跑鞋/篮球鞋(触发词:运动鞋、跑鞋)\n" +
"## 06-专业术语\n" +
"- 术语表.md:裁片/针数(触发词:术语、唛架)\n");
File.WriteAllText(Path.Combine(shoe, "01-品类认知", "运动鞋.md"), "# 运动鞋\n飞织鞋面,EVA 中底。\n");
File.WriteAllText(Path.Combine(shoe, "06-专业术语", "术语表.md"), "# 术语表\n唛架:裁剪排版图。\n");
_ds = new DialectService(_root);
}
public override void TearDown()
{
try { Directory.Delete(_root, true); } catch { }
}
// ── DialectService ──
[Test]
public void ScanDialect_ParsesIndex()
{
var d = _ds.ScanDialect();
Assert.Equal("产品排查方言", d.Name);
Assert.Equal(2, d.Topics.Count, "方言主题数");
var t = d.Topics[0];
Assert.Equal("01-排查方法论", t.Category);
Assert.Equal("troubleshoot-core/five-steps.md", t.File);
Assert.Equal("five-steps", t.Title);
Assert.Equal("排查五步法", t.Description);
Assert.True(t.Triggers.Contains("报错"), "触发词应解析");
Assert.True(t.Triggers.Contains("根因"));
}
[Test]
public void ScanIndustries_ParsesAliases()
{
var inds = _ds.ScanIndustries().ToList();
Assert.Equal(1, inds.Count, "行业数");
var ind = inds[0];
Assert.Equal("鞋业", ind.Name);
Assert.Equal(2, ind.Aliases.Length, "别名数");
Assert.Equal("制鞋", ind.Aliases[0]);
Assert.Equal("鞋类", ind.Aliases[1]);
Assert.True(ind.Topics.Any(x => x.File == "01-品类认知/运动鞋.md"), "行业主题路径应补全大类前缀");
}
[Test]
public void BuildSummary_UnderBudget_AndHasHeader()
{
string summary = _ds.BuildSummary();
Assert.True(summary.Contains("产品方言与行业知识"), "摘要应有路由头");
Assert.True(summary.Contains("产品排查方言"), "摘要应含方言导航");
Assert.True(summary.Contains("鞋业"), "摘要应含行业导航");
Assert.True(summary.Contains("别名:制鞋"), "摘要应含别名");
Assert.True(summary.Contains("触发词"), "摘要应含触发词提示");
Assert.True(System.Text.Encoding.UTF8.GetByteCount(summary) <= 4096, "摘要预算(宽松校验,实际 2KB 预算内)");
}
[Test]
public void BuildSummary_MissingIndex_NoThrow()
{
var ds2 = new DialectService(Path.Combine(Path.GetTempPath(), "agenttest_missing_" + Guid.NewGuid().ToString("N")));
Assert.False(ds2.IsAvailable, "目录缺失应不可用");
Assert.Equal("", ds2.BuildSummary(), "无内置知识时应返回空(不注入空层)");
}
[Test]
public void ResolveSafe_ValidAndBoundary()
{
string abs = _ds.ResolveSafe("dialect/erp/winforms.md");
Assert.NotNull(abs, "合法路径应解析");
Assert.True(abs.EndsWith("winforms.md", StringComparison.OrdinalIgnoreCase));
string abs2 = _ds.ResolveSafe("industry/鞋业/01-品类认知/运动鞋.md");
Assert.NotNull(abs2, "中文行业路径应解析");
Assert.Null(_ds.ResolveSafe("erp/winforms.md"), "缺少前缀应拒绝");
Assert.Null(_ds.ResolveSafe("dialect/../../secret.txt"), "../ 穿越应拒绝");
Assert.Null(_ds.ResolveSafe("dialect/" + _root.Replace('\\', '/') + "/x"), "绝对路径应拒绝");
Assert.Null(_ds.ResolveSafe("dialect"), "目录非文件也返回 null(ResolveSafe 仅文件语义)");
}
[Test]
public void EnumerateFiles_CoversBothRoots()
{
var files = _ds.EnumerateFiles().ToList();
Assert.True(files.Any(f => f == "dialect/erp/winforms.md"), "应枚举方言文件");
Assert.True(files.Any(f => f.StartsWith("industry/鞋业/", StringComparison.Ordinal)), "应枚举行业文件");
Assert.False(files.Any(f => f.StartsWith("dialect/../", StringComparison.Ordinal)), "路径应规范化");
}
// ── d_* 工具 ──
private static ToolContext MakeCtx(DialectService ds)
{
var kb = new KbService();
return new ToolContext(kb, null) { Dialect = ds };
}
[Test]
public void DRead_ValidFile()
{
var tool = new DialectReadTool();
string result = tool.Execute(JObject.Parse("{'path':'dialect/erp/winforms.md'}"), MakeCtx(_ds));
Assert.True(result.Contains("view_MaterialStockDocFlow"), "应读到文件内容");
Assert.True(result.Contains("== dialect/erp/winforms.md"), "应带路径头");
}
[Test]
public void DRead_MissingFile_GivesCandidates()
{
var tool = new DialectReadTool();
string result = tool.Execute(JObject.Parse("{'path':'dialect/erp/winforms'}"), MakeCtx(_ds));
Assert.True(result.Contains("winforms.md"), "缺扩展名应给出候选");
}
[Test]
public void DRead_Traversal_Rejected()
{
var tool = new DialectReadTool();
string result = tool.Execute(JObject.Parse("{'path':'dialect/../../secret'}"), MakeCtx(_ds));
Assert.True(result.Contains("不存在") || result.Contains("非法"), "越界应拒绝");
}
[Test]
public void DGrep_HitsAndMiss()
{
var tool = new DialectGrepTool();
string hit = tool.Execute(JObject.Parse("{'query':'库存不对'}"), MakeCtx(_ds));
Assert.True(hit.Contains("dialect/erp/winforms.md"), "应命中方言文件");
string miss = tool.Execute(JObject.Parse("{'query':'不存在的关键字xyz'}"), MakeCtx(_ds));
Assert.True(miss.Contains("未找到"), "未命中应明确说明");
}
[Test]
public void DGrep_GlobScope()
{
var tool = new DialectGrepTool();
string hit = tool.Execute(JObject.Parse("{'query':'飞织','glob':'industry/鞋业'}"), MakeCtx(_ds));
Assert.True(hit.Contains("运动鞋.md"), "限定行业目录应命中行业文件");
}
[Test]
public void DList_Roots()
{
var tool = new DialectListTool();
string result = tool.Execute(JObject.Parse("{}"), MakeCtx(_ds));
Assert.True(result.Contains("dialect/"), "应列出方言根");
Assert.True(result.Contains("industry/"), "应列出行业根");
Assert.True(result.Contains("erp"), "应含 erp 目录");
Assert.True(result.Contains("鞋业"), "应含行业目录");
}
[Test]
public void DList_Subdir()
{
var tool = new DialectListTool();
string result = tool.Execute(JObject.Parse("{'path':'dialect/erp'}"), MakeCtx(_ds));
Assert.True(result.Contains("winforms.md"), "子目录应列出文件");
}
// ── PromptBuilder 方言层 ──
[Test]
public void PromptBuilder_InjectsDialectLayer()
{
var kb = new KbService();
var pb = new PromptBuilder { Dialect = _ds };
string msg = pb.BuildSystemMessage(kb);
Assert.True(msg.Contains("产品方言与行业知识"), "应注入方言层");
Assert.True(msg.Contains("d_read"), "应提示 d_read 精读");
Assert.True(msg.Contains("安全规则"), "安全层仍在");
}
[Test]
public void PromptBuilder_NoDialect_NoLayer()
{
var kb = new KbService();
var pb = new PromptBuilder(); // Dialect = null
string msg = pb.BuildSystemMessage(kb);
Assert.False(msg.Contains("产品方言与行业知识"), "未配置方言服务不应注入");
}
// ── 真实内置知识集成(AppHome 随仓库存在时)──
[Test]
public void RealAppHome_DialectAndIndustry_Available()
{
string agentRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\AppHome\Agent");
if (!Directory.Exists(agentRoot)) return; // 仓库外运行则跳过(不视为失败)
var ds = new DialectService(agentRoot);
Assert.True(ds.IsAvailable, "仓库内 AppHome/Agent 应含 dialect 或 industry");
var d = ds.ScanDialect();
Assert.True(d.Topics.Count >= 10, "真实方言 INDEX 应有 ≥10 主题(实际 " + d.Topics.Count + ")");
Assert.True(d.Topics.Any(t => t.File == "erp/winforms.md"), "应含 erp/winforms.md");
var inds = ds.ScanIndustries().ToList();
Assert.Equal(1, inds.Count, "行业应只有鞋业一个");
Assert.Equal("鞋业", inds[0].Name);
Assert.True(inds[0].Topics.Count >= 15, "鞋业 INDEX 应有 ≥15 主题(实际 " + inds[0].Topics.Count + ")");
Assert.True(inds[0].Topics.Any(t => t.File == "01-品类认知/运动鞋.md"), "应含运动鞋主题(路径补全)");
Assert.True(inds[0].Topics.Any(t => t.File == "01-品类认知/劳保鞋.md"), "应含劳保鞋主题(路径补全)");
Assert.True(inds[0].Topics.Any(t => t.File == "01-品类认知/童鞋.md"), "应含童鞋主题(路径补全)");
string summary = ds.BuildSummary();
Assert.True(System.Text.Encoding.UTF8.GetByteCount(summary) <= 2048, "真实摘要应在 2KB 预算内");
// 真实内容精读
var tool = new DialectReadTool();
string r = tool.Execute(JObject.Parse("{'path':'industry/鞋业/01-品类认知/劳保鞋.md'}"), MakeCtx(ds));
Assert.True(r.Contains("GB 21148"), "劳保鞋应含国标知识");
r = tool.Execute(JObject.Parse("{'path':'dialect/erp/winforms.md'}"), MakeCtx(ds));
Assert.True(r.Contains("SysBusinessModel"), "winforms 方言应含链路查询");
}
}
}
using System;
using System.IO;
using System.Linq;
using System.Text;
using Tools.MyAgent.Kb;
using Tools.MyUtils;
namespace AgentTest
{
/// <summary>
/// 编码识别与文件读取增强测试(迁移自源 AgentTests.ReadTextFile_Utf8Bom / ReadTextFile_Gb2312Fallback,
/// 并扩展:BOM/无 BOM 多编码判定、magic bytes 类型识别、编解码往返、Read 大小闸门、SplitLines、工作表前缀)。
/// </summary>
public sealed class TextEncodingTests : TestRunner
{
private string _dir;
public override void SetUp()
{
_dir = Path.Combine(Path.GetTempPath(), "agenttest_enc_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public override void TearDown()
{
try { Directory.Delete(_dir, true); } catch { }
}
private string TempFile(string name)
{
return Path.Combine(_dir, name);
}
// ── ReadTextFile 迁移用例(源 AgentTests:128/140)──
[Test]
public void ReadTextFile_Utf8Bom()
{
string f = TempFile("u8.txt");
try
{
File.WriteAllText(f, "中文内容abc", new UTF8Encoding(true));
Assert.Equal("中文内容abc", KbService.ReadTextFile(f));
}
finally { File.Delete(f); }
}
[Test]
public void ReadTextFile_Gb2312Fallback()
{
string f = TempFile("gb.txt");
try
{
// 用 GB2312 编码写入(无 BOM 的 UTF-8 解码将失败 → 回退 GB18030/GB2312)
var gb = Encoding.GetEncoding("GB2312");
File.WriteAllBytes(f, gb.GetBytes("报销流程说明123"));
string text = KbService.ReadTextFile(f);
Assert.True(text.Contains("报销流程说明"), "GB2312 文件应被正确识别解码,实际:" + text);
}
finally { File.Delete(f); }
}
// ── DetectEncoding:BOM 判定 ──
[Test]
public void DetectEncoding_Utf8Bom()
{
var enc = TextEncoding.DetectEncoding(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("中文")).ToArray());
Assert.Equal(65001, enc.CodePage, "UTF-8 BOM 应判为 UTF-8");
}
[Test]
public void DetectEncoding_Utf8NoBom()
{
var enc = TextEncoding.DetectEncoding(Encoding.UTF8.GetBytes("中文内容abc"));
Assert.Equal(65001, enc.CodePage, "无 BOM 合法 UTF-8 应判为 UTF-8");
}
[Test]
public void DetectEncoding_Utf16LeBom()
{
byte[] bytes = { 0xFF, 0xFE, 0x2D, 0x4E };
Assert.Equal(1200, TextEncoding.DetectEncoding(bytes).CodePage, "FF FE 应判为 UTF-16 LE");
}
[Test]
public void DetectEncoding_Utf16BeBom()
{
byte[] bytes = { 0xFE, 0xFF, 0x4E, 0x2D };
Assert.Equal(1201, TextEncoding.DetectEncoding(bytes).CodePage, "FE FF 应判为 UTF-16 BE");
}
[Test]
public void DetectEncoding_Utf32LeBom()
{
byte[] bytes = { 0xFF, 0xFE, 0x00, 0x00, 0x2D, 0x4E, 0x00, 0x00 };
Assert.Equal(12000, TextEncoding.DetectEncoding(bytes).CodePage, "FF FE 00 00 应判为 UTF-32 LE(先于 UTF-16 LE)");
}
[Test]
public void DetectEncoding_Utf32BeBom()
{
byte[] bytes = { 0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x4E, 0x2D };
Assert.Equal(12001, TextEncoding.DetectEncoding(bytes).CodePage, "00 00 FE FF 应判为 UTF-32 BE");
}
// ── DetectEncoding:无 BOM UTF-16 启发式 ──
[Test]
public void DetectEncoding_Utf16LeNoBom()
{
byte[] bytes = Encoding.Unicode.GetBytes("中文内容abc"); // 无 BOM 的 UTF-16 LE(中文为主)
Assert.Equal(1200, TextEncoding.DetectEncoding(bytes).CodePage, "无 BOM UTF-16 LE(中文内容)应启发式判定");
}
[Test]
public void DetectEncoding_Utf16BeNoBom()
{
byte[] bytes = Encoding.BigEndianUnicode.GetBytes("中文内容abc");
Assert.Equal(1201, TextEncoding.DetectEncoding(bytes).CodePage, "无 BOM UTF-16 BE(中文内容)应启发式判定");
}
[Test]
public void DetectEncoding_Ascii_DoesNotTriggerUtf16()
{
// 纯 ASCII(UTF-8 单字节,无 NUL):不应误判为 UTF-16,也不应判为 GB18030 以外编码
byte[] bytes = Encoding.ASCII.GetBytes("hello world 123");
var enc = TextEncoding.DetectEncoding(bytes);
Assert.Equal(65001, enc.CodePage, "纯 ASCII 应判为 UTF-8(GB18030 解码结果一致)");
}
// ── DetectEncoding:严格 UTF-8 校验拒绝 ──
[Test]
public void DetectEncoding_Utf8_Overlong_Rejected()
{
byte[] bytes = { 0xC0, 0xAF }; // overlong 2 字节(/ 的 2 字节编码)
var enc = TextEncoding.DetectEncoding(bytes);
Assert.False(enc.CodePage == 65001, "overlong 序列不应判为 UTF-8");
}
[Test]
public void DetectEncoding_Utf8_BadContinuation_Rejected()
{
byte[] bytes = { 0xE4, 0xB8, 0x41 }; // 中 的首字节 + 非法续字节 0x41
var enc = TextEncoding.DetectEncoding(bytes);
Assert.False(enc.CodePage == 65001, "非法续字节不应判为 UTF-8");
}
[Test]
public void DetectEncoding_Utf8_Surrogate_Rejected()
{
byte[] bytes = { 0xED, 0xA0, 0x80 }; // UTF-16 代理区(D800)的 UTF-8 编码
var enc = TextEncoding.DetectEncoding(bytes);
Assert.False(enc.CodePage == 65001, "代理区编码不应判为 UTF-8");
}
[Test]
public void DetectEncoding_Utf8_Truncated_Rejected()
{
byte[] bytes = { 0xE4, 0xB8 }; // 截断的 3 字节序列
var enc = TextEncoding.DetectEncoding(bytes);
Assert.False(enc.CodePage == 65001, "截断序列不应判为 UTF-8");
}
// ── DetectEncoding:GB18030 / 回退 ──
[Test]
public void DetectEncoding_Gb2312()
{
var gb = Encoding.GetEncoding("GB2312");
byte[] bytes = gb.GetBytes("报销流程说明123");
Assert.Equal(54936, TextEncoding.DetectEncoding(bytes).CodePage, "GB2312 中文应回退判为 GB18030(超集)");
}
[Test]
public void DetectEncoding_Gb18030()
{
// 生僻字 U+20000(𠀀):GB18030 四字节 95 32 82 36,GB2312 无法编码
byte[] bytes = { 0x95, 0x32, 0x82, 0x36 };
var enc = TextEncoding.DetectEncoding(bytes);
Assert.Equal(54936, enc.CodePage, "GB18030 生僻字四字节序列应判为 GB18030");
Assert.Equal("\uD840\uDC00", enc.GetString(bytes), "GB18030 解码生僻字应正确");
}
[Test]
public void DetectEncoding_Binary_FallsBackToDefault()
{
// 0xFF 在 GB18030 中非法、无 NUL(不触发 UTF-16 启发式)→ 最终回退系统 ANSI
byte[] bytes = { 0xFF, 0xFF, 0x11, 0x22 };
Assert.Equal(Encoding.Default.CodePage, TextEncoding.DetectEncoding(bytes).CodePage, "二进制内容应回退系统 ANSI");
}
[Test]
public void DetectEncoding_Empty()
{
Assert.Equal(65001, TextEncoding.DetectEncoding(new byte[0]).CodePage, "空字节应返回 UTF-8 无 BOM");
Assert.Equal(65001, TextEncoding.DetectEncoding(null).CodePage, "null 应返回 UTF-8 无 BOM");
}
// ── ReadAllTextAuto / ReadTextFile:多编码整读 ──
[Test]
public void ReadAllTextAuto_RoundTrip()
{
string text = "中文内容 abc 123。";
var cases = new System.Collections.Generic.Dictionary<string, Encoding>
{
{ "rt_u8bom.txt", new UTF8Encoding(true) },
{ "rt_u8nobom.txt", new UTF8Encoding(false) },
{ "rt_u16le.txt", new UnicodeEncoding(false, true) },
{ "rt_u16be.txt", new UnicodeEncoding(true, true) },
{ "rt_u32le.txt", new UTF32Encoding(false, true) },
{ "rt_u32be.txt", new UTF32Encoding(true, true) },
{ "rt_gb.txt", Encoding.GetEncoding("GB2312") },
};
foreach (var kv in cases)
{
string f = TempFile(kv.Key);
try
{
File.WriteAllBytes(f, kv.Value.GetPreamble().Concat(kv.Value.GetBytes(text)).ToArray());
string actual = TextEncoding.ReadAllTextAuto(f);
Assert.Equal(text, actual, kv.Key + " 往返读取应一致");
Assert.Equal(text, KbService.ReadTextFile(f), kv.Key + " KbService.ReadTextFile 应一致");
}
finally { File.Delete(f); }
}
}
[Test]
public void ReadAllTextAuto_Utf16LeNoBom()
{
string f = TempFile("u16lenobom.txt");
try
{
File.WriteAllBytes(f, Encoding.Unicode.GetBytes("中文内容abc"));
Assert.Equal("中文内容abc", TextEncoding.ReadAllTextAuto(f), "无 BOM UTF-16 LE 应正确读取");
Assert.Equal("中文内容abc", KbService.ReadTextFile(f), "KbService 无 BOM UTF-16 LE 应正确读取");
}
finally { File.Delete(f); }
}
[Test]
public void ReadAllTextAuto_EmptyFile()
{
string f = TempFile("empty.txt");
try
{
File.WriteAllBytes(f, new byte[0]);
Assert.Equal("", TextEncoding.ReadAllTextAuto(f), "空文件应返回空串");
}
finally { File.Delete(f); }
}
[Test]
public void ReadAllTextAuto_MissingFile_ReturnsNull()
{
Assert.Null(TextEncoding.ReadAllTextAuto(Path.Combine(_dir, "not_exist.txt")), "文件不存在应返回 null");
Assert.Null(KbService.ReadTextFile(Path.Combine(_dir, "not_exist.txt")), "KbService 文件不存在应返回 null");
}
// ── GetFileType:magic bytes ──
[Test]
public void GetFileType_MagicBytes()
{
// 无扩展名文件 → 强制走 magic bytes 分支(扩展名非空时 GetFileType 直接返回扩展名,见 GetFileType_WithExtension)
var cases = new System.Collections.Generic.Dictionary<byte[], string>
{
{ TextEncoding.FromHex("89504E470D0A1A0A0000000D"), "png" }, // PNG
{ TextEncoding.FromHex("FFD8FFE000104A4649460001"), "jpg" }, // JPEG
{ TextEncoding.FromHex("474946383961000000"), "gif" }, // GIF89a
{ TextEncoding.FromHex("424D363400000000"), "bmp" }, // BM
{ TextEncoding.FromHex("49492A0008000000"), "tiff" }, // II*
{ TextEncoding.FromHex("3842505301000000"), "psd" }, // 8BPS
{ TextEncoding.FromHex("255044462D312E340A25E2E3CF"), "pdf" }, // %PDF-1.4
{ TextEncoding.FromHex("504B0304140006000800"), "zip/xlsx/docx" }, // PK zip
{ TextEncoding.FromHex("D0CF11E0A1B11AE100"), "xls/doc" }, // OLE2
{ TextEncoding.FromHex("526172211A0700CF9073"), "rar" }, // RAR
{ TextEncoding.FromHex("7B5C727466315C616E7369"), "rtf" }, // {\rtf1\ansi
{ TextEncoding.FromHex("3C3F786D6C2076657273696F6E"), "xml" }, // <?xml version
{ TextEncoding.FromHex("68746D6C3E0D0A3C68656164"), "html" }, // <html>\r\n<head
{ TextEncoding.FromHex("4D5A9000030000000400"), "exe/dll/drv/vxd/sys/ocx/vbx" }, // MZ
};
int i = 0;
foreach (var kv in cases)
{
string f = TempFile("noext_" + (i++));
try
{
File.WriteAllBytes(f, kv.Key);
Assert.Equal(kv.Value, TextEncoding.GetFileType(f), "文件头 " + TextEncoding.ToHex(kv.Key, 4) + " 应判为 " + kv.Value);
}
finally { File.Delete(f); }
}
}
[Test]
public void GetFileType_NoExtension_UsesMagicBytes()
{
string f = TempFile("noext_png");
try
{
File.WriteAllBytes(f, TextEncoding.FromHex("89504E470D0A1A0A0000000D"));
Assert.Equal("png", TextEncoding.GetFileType(f), "无扩展名应通过 magic bytes 判为 png");
}
finally { File.Delete(f); }
}
[Test]
public void GetFileType_WithExtension_ReturnsExtension()
{
string f = TempFile("data.TXT");
try
{
File.WriteAllText(f, "hello");
Assert.Equal("txt", TextEncoding.GetFileType(f), "扩展名非空应直接返回扩展名");
}
finally { File.Delete(f); }
}
[Test]
public void GetFileType_UnknownNoExt()
{
string f = TempFile("noext_unknown");
try
{
File.WriteAllBytes(f, new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77 });
Assert.Equal("unknown", TextEncoding.GetFileType(f));
}
finally { File.Delete(f); }
}
[Test]
public void GetFileType_MissingFile()
{
Assert.Equal("unknown", TextEncoding.GetFileType(Path.Combine(_dir, "missing")), "文件不存在应返回 unknown");
}
// ── 编解码工具 ──
[Test]
public void Codec_Utf8_Unicode_Gb2312_RoundTrip()
{
string s = "编码工具测试 123";
Assert.Equal(s, TextEncoding.UTF8(TextEncoding.UTF8(s)));
Assert.Equal(s, TextEncoding.Unicode(TextEncoding.Unicode(s)));
Assert.Equal(s, TextEncoding.GB2312(TextEncoding.GB2312(s)));
// 中文 Windows ANSI = GBK:GB2312 编码字节应与 GetEncoding("gb2312") 一致(byte[] 用 ToHex 比较,避免 ToString 恒等的无效断言)
Assert.Equal(TextEncoding.ToHex(Encoding.GetEncoding("gb2312").GetBytes(s)),
TextEncoding.ToHex(TextEncoding.GB2312(s)),
"GB2312 编码字节应与 GetEncoding(gb2312) 一致");
}
[Test]
public void Hex_RoundTrip()
{
byte[] data = { 0x00, 0x01, 0x0A, 0xFF, 0x80, 0x2E };
Assert.Equal("00010AFF802E", TextEncoding.ToHex(data));
// byte[] 比较用 ToHex 归一,避免 Assert.Equal 的 ToString 恒等("System.Byte[]") 造成无效断言
Assert.Equal(TextEncoding.ToHex(data), TextEncoding.ToHex(TextEncoding.FromHex("00010AFF802E")), "FromHex→ToHex 往返应一致");
Assert.Equal(TextEncoding.ToHex(data), TextEncoding.ToHex(TextEncoding.FromHex(" 00010AFF802E ")), "应忽略首尾空白");
Assert.Equal("", TextEncoding.ToHex(null), "null 应返回空串");
Assert.Equal(0, TextEncoding.FromHex("").Length, "空串应返回空数组");
Assert.Throws<FormatException>(() => TextEncoding.FromHex("ABC"), "奇数长度应抛 FormatException");
}
// ── SplitLines 统一行切分 ──
[Test]
public void SplitLines_AllNewlineStyles()
{
string[] a = KbService.SplitLines("a\r\nb\rc\nd");
Assert.Equal(4, a.Length, "CRLF/CR/LF 应归一为 4 行");
Assert.Equal("a", a[0]);
Assert.Equal("b", a[1]);
Assert.Equal("c", a[2]);
Assert.Equal("d", a[3]);
Assert.Equal(1, KbService.SplitLines(null).Length, "null 应归一为单行空串");
Assert.Equal(1, KbService.SplitLines("").Length);
Assert.Equal(3, KbService.SplitLines("\r\n\r\n").Length, "连续空行应保留");
}
// ── ReadExcel 工作表名证据前缀 ──
[Test]
public void ReadExcel_SheetNamePrefix()
{
string f = TempFile("book.xlsx");
try
{
var wb = new NPOI.XSSF.UserModel.XSSFWorkbook();
var sh = wb.CreateSheet("销售明细");
sh.CreateRow(0).CreateCell(0).SetCellValue("日期");
sh.CreateRow(1).CreateCell(0).SetCellValue("2024-01-01");
using (var fs = File.Create(f))
wb.Write(fs);
wb.Close();
string text = KbService.ReadExcel(f);
Assert.True(text.StartsWith("【工作表:销售明细】"), "输出应带工作表名证据前缀,实际:" + (text ?? "(null)").Substring(0, Math.Min(30, (text ?? "").Length)));
Assert.True(text.Contains("日期"), "单元格内容应正常提取");
}
finally { try { File.Delete(f); } catch { } }
}
// ── Read 文件大小闸门(> 30MB 拒绝整读,稀疏文件不占真实磁盘)──
[Test]
public void Read_SizeGate_RejectsOversize()
{
string f = TempFile("big.txt");
try
{
using (var fs = new FileStream(f, FileMode.CreateNew, FileAccess.Write))
fs.SetLength(31L * 1024 * 1024); // 31MB 稀疏文件 > 30MB 闸门
var kb = new KbService();
kb.SetRoot(_dir);
string result = kb.Read("big.txt", 1, 10);
Assert.True(result.Contains("文件过大"), "超限文件应拒绝整读并给出提示,实际:" + result);
Assert.True(result.Contains("kb_grep"), "提示应给出替代路径 kb_grep");
}
finally { try { File.Delete(f); } catch { } }
}
[Test]
public void Read_SmallFile_ReadsNormally()
{
string f = TempFile("small.txt");
try
{
File.WriteAllText(f, "第一行\n第二行\n第三行");
var kb = new KbService();
kb.SetRoot(_dir);
string result = kb.Read("small.txt", 1, 2);
Assert.True(result.Contains("第一行"), "小文件应正常读取,实际:" + result);
Assert.True(result.Contains("第二行"));
Assert.False(result.Contains("第三行"), "limit=2 不应读到第三行");
}
finally { File.Delete(f); }
}
// ── Read 提示语(图片 / 解析失败 → kb_view_image)──
[Test]
public void Read_ImageFile_HintsKbViewImage()
{
string f = TempFile("pic.png");
try
{
File.WriteAllBytes(f, TextEncoding.FromHex("89504E470D0A1A0A0000000D"));
var kb = new KbService();
kb.SetRoot(_dir);
string result = kb.Read("pic.png", 1, 10);
Assert.True(result.Contains("kb_view_image"), "图片文件应提示 kb_view_image,实际:" + result);
}
finally { File.Delete(f); }
}
[Test]
public void Read_UnparsableFile_HintsKbViewImage()
{
string f = TempFile("broken.docx");
try
{
File.WriteAllBytes(f, new byte[] { 0x00, 0xFF, 0x11, 0x00 }); // 损坏的 docx(非 zip):解析器返回 null
var kb = new KbService();
kb.SetRoot(_dir);
string result = kb.Read("broken.docx", 1, 10);
Assert.True(result.Contains("kb_view_image"), "不可解析文件应提示 kb_view_image,实际:" + result);
}
finally { File.Delete(f); }
}
}
}
# 产品排查方言(内化版)
行业简介:Ftt 产品线排查方言——桌面端业务链路(窗口→业务类→SQL/SP→数据库)、审批引擎、SQL 规范与热点归因模式,供 SQL 排查与问题归因对齐产品事实。适用:ERP(U6_*、风行等)、PDM(PDM_LN、LNCenterApi)、MES(MES_*、mes_geshiwei)桌面端与 cloud Api 服务端。
## 01-排查方法论
- troubleshoot-core/five-steps.md:排查五步法:复现取证/链路定位/模式对比/单一假设/根因修复(触发词:排查、报错、数据不对、超时、闪退、定位问题、怎么查、根因、证据)
- troubleshoot-core/output-spec.md:排查输出规范:一句话根因+证据链+影响范围(触发词:输出规范、根因一句话、证据链、修复建议)
## 02-SQL 规范
- sql-core/sql-templates.md:四库通用查询模板(触发词:加列、索引、执行计划、分页、核对查询、幂等)
- sql-core/sql-style.md:SQL 格式规范(触发词:格式化、CRLF、别名、注释头)
- sql-core/sql-scripts.md:脚本生成规范(触发词:生成脚本、落盘、脚本命名)
## 03-ERP 排查链路
- erp/winforms.md:桌面端排查链路(触发词:库存不对、报表数据不对、单据状态、提交报错、程序异常、性能、约束、App.Run.Log)
- erp/verification-flow.md:审批流程引擎(触发词:审批不触发、审批流程、VF_FlowMap、在审、已审、审批节点)
- erp/sql-script-location.md:SQL 脚本存放规范(触发词:脚本存放、2999-01-01、GB2312、AddTable、存储过程命名)
- erp/special-modules.md:报表类模块 SQL 获取三途径(触发词:自定义报表、FrmRPTView、动态表结构、TableIdentic、报表列空)
- erp/grid-filter.md:网格过滤联动机制(触发词:过滤不联动、复选框过滤、PivotGrid、GridView)
- erp/parent-provider.md:父子单据联动(触发词:产生了任务、跟单、ParentProvider、A单据产生B任务)
- erp/aspnet.md:Api 服务端排查(触发词:Api、接口报错、ApiLog、LNCenterApi、FttData.Api、接口慢)
- erp/cases/hotspots.md:ERP 热点归因案例(触发词:数量变0、库存多扣、二次保存、缓存失效、提交报错、8114)
## 04-MES 排查方言
- mes/sql-templates-mes.md:MES 查询模板(触发词:MES、报工、检验、成型配套、排程、生产计划、尺码明细)
- mes/cases/hotspots.md:MES 热点归因案例(触发词:成型配套、合计行小于明细)
## 05-PDM 排查方言
- pdm/cases/hotspots.md:PDM 热点归因案例(触发词:PDM、材料汇总、报表慢、风险评估)
## 06-常见问题
> 本地沉淀区:排查确认的典型问题经维护者整理追加到本大类(内置目录只读,禁止智能体直接写入)。
# ASP.NET 排查(数据错误 / 程序异常 / 性能 / 约束 / SQL 日志)
> 适用:cloud(FttData.Api)、LNCenterApi。
> 项目事实(数据库连接、ModuleName 路由机制、App_Sys_Operation_Log 表结构)从知识库检索获取。
> 排查任一步因信息不足卡住 → 向用户索要关键信息(入参/日志文件),或建议补强(开启 SqlLog / 加完整异常日志)。
> 通用方法论见 dialect/troubleshoot-core(五步法 / 输出规范)。
## 取证工具
- 查库:`db_query`(SELECT-only)/ `db_schema`,需先在设置中预存连接(connectionId)。
- 无预置连接时:把 SQL 给用户在 SSMS 执行,取回结果再分析。
- 读代码:源码检索(知识库内代码分析文档)。
## ASP.NET 数据错误排查
```
1. 查操作日志:
SELECT TOP 10 * FROM App_Sys_Operation_Log
WHERE Action LIKE '%关键词%' ORDER BY OperateDate DESC
→ 获取 RequestParams(入参 JSON)+ ResponseParams(出参 JSON)
2. 定位源码 → Controller/{名称}Controller.cs → Action 方法
3. 找到 SP → Action 中 "Exec dbo.SP_xxx" 调用 → 提取 SP 名和参数
4. db_query 复现 → 用日志入参执行 SP → 比对结果 vs 日志出参
→ 一致但数据错 = SP 逻辑问题 / 不一致 = 参数绑定/序列化问题
```
## ASP.NET 程序异常排查
```
1. 查 App_Sys_Operation_Log WHERE ResponseMessage != '' → 获取错误
2. 分类:参数校验失败 / 500 内部错误 / 序列化失败 / 数据库连接失败
3. 卡住处理(ResponseMessage 为空 / ApiLog 缺失 / 现象模糊):
→ 向用户索要入参与 ApiLog 文件,建议开启 SqlLog / 加完整异常日志
```
## ASP.NET 性能问题排查
```
1. SELECT TOP 20 * FROM App_Sys_Operation_Log ORDER BY SpendTime DESC
2. 找到对应 SP → SSMS 执行计划 → 缺失索引/参数嗅探/锁等待/无分页
```
## 数据库约束异常排查(ASP.NET 侧)
```
1. 获取完整错误 → App_Sys_Operation_Log 的 ResponseMessage
2. 提取约束名和表名 → 找出违反约束的数据
3. 追踪数据来源 → 业务链路上溯到写入点
常见类型:违反唯一性约束(主键/唯一索引冲突)/ 值检查约束(CHECK)/ 外键约束
```
## ASP.NET SQL 日志排查(ApiLog)
```
API 请求日志位于:{程序目录}/ApiLog/versionXXX/Controller_yyyyMMdd.log
排查流程:
1. 确定时间范围和接口:
→ 让用户提供或检索 ApiLog 目录:ls ApiLog/versionPM/TaskController_20260701.log
→ 或用 grep "start:接口名" 搜索
2. 定位具体请求:
→ grep "traceId=xxx" ApiLog/versionXXX/*.log
→ 或 grep "user=用户名" ApiLog/versionXXX/*.log
→ 或 grep "-- start:Controller.Action" ApiLog/versionXXX/*.log
3. 分析请求链路:
→ 从 -- start 到 -- end 之间的所有 SQL 按执行时间排列
→ 每条 SQL 格式:SQL语句; -- 参数名=值 -- 耗时ms, 行数, 模块
4. 排查异常:
→ grep "失败" ApiLog/versionXXX/*.log
→ grep "错误:" ApiLog/versionXXX/*.log
5. 慢查询分析:
→ grep -E "\-\- [0-9]{4,}ms" ApiLog/versionXXX/*.log
```
日志格式:
```
-----------------------------------------------------
-- start:Controller.Action (request={...}) [时间] user=用户 ip=IP traceId=GUID
SELECT * FROM Table WHERE Col=@Param; -- @Param='Value' -- 45ms, 12 rows, Module
EXEC SP_Name @P1, @P2; -- @P1=1, @P2='text' -- 230ms, Module
-- end: Controller.Action 总耗时=Nms SQL数=N 成功/失败 [时间]
```
**配置**(`WebAppSettings.config`):
```xml
<add key="SqlLog:Enabled" value="true" /> <!-- 开关 -->
<add key="SqlLog:SlowQueryThresholdMs" value="0" /> <!-- 慢查询阈值,0=全部 -->
<add key="SqlLog:MaxSqlLength" value="4000" /> <!-- SQL截断长度 -->
<add key="SqlLog:RetentionDays" value="30" /> <!-- 保留天数 -->
```
**技术架构摘要**:
| 组件 | 作用 |
|------|------|
| `SqlTraceAttribute` | ActionFilter — 请求开始时创建缓冲区,结束时写文件 |
| `SqlTraceContext` | HttpContext.Items 中存储 TraceId + `List<string>` 缓冲区 |
| `SqlLogHelper.Log()` | 格式化 SQL 行并追加到缓冲区 |
| `SqlLogWriter` | ThreadPool 异步写入,按文件路径 lock 防交错 |
| `SqlLogContextWrapper` | 包装 `IDbContext`,拦截 `Sql()` 和 `MultiResultSql` |
| `SqlLogCommandWrapper` | 包装 `IDbCommand`,拦截所有 QueryMany/Execute 等方法 |
**文件位置**:`ApiLog/versionXXX/Controller_yyyyMMdd.log`,位于程序 bin 目录下,例如:
`FttData.Api\bin\ApiLog\versionPM\TaskController_20260701.log`
**排查技巧**:
- 请求块的 `-- start` 行包含完整请求参数(复杂对象以 JSON 序列化)
- 每个 SQL 行末尾 `-- @param=value` 显示实际参数值
- `traceId` 可在不同请求间唯一标识,关联同一请求的所有 SQL
- 文件按天滚动,过期自动清理(默认保留 30 天)
- 日志写入失败不影响主业务(静默忽略)
# ERP 产品线热点问题统计(归因模式库)
> 每次排查确认根因后由维护者追加;智能体遇到相似症状时优先比对本库模式,再按五步法取证。
> 通用桌面端排查链路(窗口→业务类→SQL/SP→数据库)见 dialect/erp/winforms.md。
> 最后更新:2026-08-24
## 采购单 / 辅料采购合同(frmEditPurchaseDoc / ucPurchaseShip / PurchaseShipMaterial.xml)
### 二次保存提示「采购数量大于未采数量(0)+可多采数量(0)」(已采统计自包含)
- **分类**:数据错误(校验口径)
- **根因**:`SelectByDocTree` 的 has 子查询未排除当前单据 → 首次保存后 AfterDataSave 重载把当前单自身计入 HasPurchaseCount → 未采=需求−已采(含自身)=0 → 二次保存 checkPurchaseCountInput 必拦
- **修复**:has 子查询加 `AND a.DocTreeID <> @DocTreeID`
- **归因模式**:**自包含统计**——校验口径的聚合查询把「当前正在处理的单据」计入了统计基数。排查时先核对统计 SQL 是否排除自身。
### 保存后提交,明细采购数量变 0 / 数量不守恒(155→4.3278)
- **分类**:数据错误
- **根因**:AfterDataSave 中「移除明细缓存 + 数据库重载 + 重建合并视图」,用新 DataRow 实例替换明细数据集,而 GridSpliceDetailMerge 分摊缓存(m_hasComputeTotal/m_hasSingleCol)按 DataRow 哈希键控且从不失效 → 重载后下次保存/提交以 0 为基数分摊/截断 → 采购数量清零
- **修复**:缓存失配检测重建(IsDetailCacheValid/RebuildDetailCache)+ SetTotalDetailMerge 清空三缓存
- **归因模式**:**按对象哈希键控的缓存不清理**——重载/重建行实例后缓存命中错误基线。排查「保存后数量变化」先找按 DataRow/对象实例做键的缓存。
### 保存检查错误:表头中不存在列 LastModifiedDate 或 LastModifiedMemo
- **分类**:程序异常(日志级,非致命)
- **根因**:Update 保存表头行取自列表查询 SelectObject,PurchaseDoc.xml 列表查询缺 LastModifiedMemo 列 → ObjectModefiedHelper 并发修改检查静默失效(多模块长期存在)
- **归因模式**:**XML 列表查询缺列导致框架特性静默失效**——报错指向框架机制(并发检查)时,先核对 ConfigFile XML 的列是否齐全。
### 保存异常:无法访问已释放的对象 ucRoundDeskShow
- **分类**:程序异常
- **根因**:消息提醒平台模板数≤3 时 Init() Remove 了承载 ucRoundDeskShow1 的 tabPage1(Remove 释放子控件),DbAsynClient 异步回调仍 Invoke 访问 → ObjectDisposedException
- **归因模式**:**异步回调访问已释放控件**——Remove/Dispose 控件后,异步回调未取消仍 Invoke。排查「无法访问已释放的对象」先找异步回调生命周期。
## 样品单提交(ucObjectSubmit / SP_Sync_NoticeToMesOnSubmit)
### 样品单提交报错:未将对象引用设置到对象的实例(MES 同步链路失败)
- **分类**:程序异常
- **根因**:① 真实错误 = 样品单的 EachExpend 非法数字文本,FTT_MES.SP_Sync_U6NoticeToStyleMaterial `Convert(Decimal(18,6), EachExpend)` 抛 SQL 8114(varchar→numeric);② 表象错误 = 事务失败后 DBCommand.Dispose() 仍调 GetCurrentTransactionLevel() 在失败 SqlTransaction 上执行 SELECT @@TRANCOUNT → SqlHelper.PrepareCommand 抛 NullReferenceException,**掩盖真实错误**
- **修复**:SP 加固(Convert→Try_Convert);DBCommand.Dispose 失败路径直接 Rollback(不再查 @@TRANCOUNT);日志增强(SqlException.Procedure/LineNumber 定位)
- **归因模式**:**表象错误掩盖真实错误**——提交报错先看堆栈是否是框架清理路径(Dispose/事务回滚)抛的异常,真实错误常在更早的 SQL 层(SqlException.Procedure/LineNumber 直接定位)。
## 风行正式订单简易版(UOrderDocImport)
### 数量修改同步到正式订单的触发时机(仅手动【更新订单信息】,提交不触发)
- **分类**:其他(功能改动方案/模块分析)
- **根因**:同步仅挂在【更新订单信息】链接(linkLabel3_LinkClicked → Update() 尾部 exec SP_ImportToOrderDoc_Change);保存走重写版 Save()、提交走 _EditForm.Save() 多态,均不触发同步;另 SP 依赖日志字段导致增删尺码不同步
- **归因模式**:**多入口保存路径不一致**——「保存能同步/提交不能」类问题,先枚举所有保存入口(保存/提交/审核/异步),比对同步钩子挂点。
## 仓库管理-出库/调拨
### 物料出库库存多扣(现存量与流水不符)
- **分类**:数据错误
- **根因**:出库/调拨 SP(up_StockOutSubmit/up_StockAssignSubmit)按 Factory+Warehouse+MaterialID+BatchNo 松条件扣减现存量,不按选料库存行(MCSID);BatchNo=NULL 时唯一约束失效可同键多行 → N 倍多扣;提交/撤回条件不对称;找不到库存行时插负行;调拨确认/取消到料入方不同步;StockOutDetail 无 MCSID 字段
- **归因模式**:**扣减键过松**——库存多扣先核对扣减条件是否含明细行唯一键(MCSID),再核对提交/撤回对称性。
## 样品单-物料表选择物料(frmDataImportFilter)
### 选择物料按款式+颜色筛选 + 颜色兼容匹配(去掉-x后缀)
- **分类**:数据错误(筛选漏数据)+ 功能优化
- **根因**:filter 只对 PDMSample 页签启用,样品单/正式订单页签不按款式+颜色过滤;颜色过滤精确等值导致 云雾白-3 与 云雾白 互不匹配
- **修复**:非 StyleBOM 调用方且 Filter_StyleCode/Filter_ColorName 非空时启用过滤;双方去掉末尾"-数字"后缀后比较
- **归因模式**:**筛选条件语义不一致**——「选不到数据/漏数据」先核对过滤口径(等值 vs 兼容、后缀归一化)与页签覆盖范围。
# 网格过滤联动机制(GridView / PivotGrid)
> WinForms 客户端有两种分析表格,均支持复选框过滤。但普通表格有**联动效果**(如部门过滤后,工艺过滤框只显示该部门的工艺),多维表格原生无联动,需额外实现。
## 普通分析表格(GridView)— GridViewFilterHelper
**入口**:`U.WinCtl.OpCtl.UP4XtraGrid.AddToGrid(grdXtrMainGrid, dsDetail, busObj)` → 自动附加 `GridViewFilterHelper`
**核心文件**:
| 文件 | 位置 | 作用 |
|------|------|------|
| `GridViewFilterHelper.cs` | `U.WinCtl\OpCtl\` | 复选框过滤 + 联动核心类 |
| `UP4XtraGrid.cs` | `U.WinCtl\OpCtl\` | 在 `createGridView` 中调用 `SetGridViewFilterCheckedListModel` |
**调用链**:
```
UP4XtraGrid.AddToGrid()
→ AddToGridEx()
→ createGridView()
→ GridViewFilterHelper.SetGridViewFilterCheckedListModel(gridView) // 第 226 行
→ new GridViewFilterHelper(gridView) // 构造即注册事件
```
**联动原理**:
```
用户点击列头过滤图标
→ ShowFilterPopupCheckedListBox 事件触发
→ 判断 FilterValues 是否为空:
├── 不为空(已有过滤):直接 return,让 DevExpress 用内置缓存值(已反映过滤)
└── 为空(首次或无过滤):
→ 遍历 gridView 中所有**可见行**(gridView.RowCount)
→ 对每行调用 GetRowCellValue / GetRowCellDisplayText
→ SortedDictionary 去重排序
→ 填充 e.CheckedComboBox.Items
```
**联动关键**:每次遍历的是 `gridView` 中**当前可见的行**(已被其他列过滤筛选后的结果),天然实现了级联过滤效果。
**其他调用点**(共 6 处,模式相同):
- `OPEditXtraGrid.cs:405`
- `DefaultXGridView.cs:559`
- `frmViewDocDataLink.linkData.cs:248`
- `frmViewDocDataLink.cs:1407`
- `frmViewDocDataLink.cs:1673`
## 多维分析表格(PivotGrid)— PivotGridHelper + CustomFilterPopupItems
**入口**:`XWinLib.PivotGrid.PivotGridHelper.LoadPivotGridData(pivGridCtl, dsDetail.Tables[0], busObj, _PivotColList);`
**核心文件**:
| 文件 | 位置 | 作用 |
|------|------|------|
| `PivotGridHelper.cs` | `XWinLib\PivotGrid\` | PivotGrid 数据加载 + 过滤联动(**已实现**) |
| `PivotGridHelper_U10.cs` | `XWinLib\PivotGrid\` | U10 版本(**未实现联动**) |
| `PivotGridEx.cs` | `XWinLib\PivotGrid\` | PivotGrid 控件包装器(继承 DevExpress PivotGridControl) |
| `GridViewFilterHelper.cs` | `U.WinCtl\OpCtl\` | 参考实现(仅 GridView,非 PivotGrid) |
**联动实现**(`PivotGridHelper.cs`,`addDataToCtl` 方法中注册):
```
用户点击 FilterArea 字段的过滤下拉框
→ DevExpress 触发 CustomFilterPopupItems 事件
→ GridCtl_CustomFilterPopupItems 事件处理器:
→ ① 检查其他字段是否有激活的过滤条件(FilterValues.HasFilter)
→ 无 → 保持默认行为(显示全部值)
→ 有 → 继续
→ ② 获取底层 DataTable(pivotGrid.DataSource → DataView → DataTable)
→ ③ 为每个其他字段构建 DataTable.Select 过滤表达式(BuildFilterExpression)
- Included 类型: [FieldName] IN (val1, val2, ...)
- Excluded 类型:[FieldName] NOT IN (val1, val2, ...)
- 值格式化:字符串加引号 + 转义、DateTime 用 #...#、数值直接用 ToString
→ ④ 组合过滤条件(AND 连接),dt.Select(combinedFilter) 交叉过滤
→ ⑤ 从过滤后行中提取当前字段去重值(HashSet<string>)
→ ⑥ 从 e.Items 中移除不在去重集合中的项(倒序遍历 RemoveAt)
```
**关键差异(GridView vs PivotGrid)**:
| 维度 | GridViewFilterHelper | PivotGrid CustomFilterPopupItems |
|------|----------------------|----------------------------------|
| 联动机制 | 遍历可见行(天然联动) | DataTable.Select 手动交叉过滤 |
| 事件 | `ShowFilterPopupCheckedListBox` | `CustomFilterPopupItems` |
| 数据源 | `gridView.GetRowCellValue()` | `pivotGrid.DataSource` → DataView → DataTable |
| 值获取 | 按行索引逐行读取 | DataTable.Select 批量过滤后取列值 |
| 勾选状态 | 手动跟踪 `checkedItems` 列表 | DevExpress 原生管理 |
**注意事项**:
- `field.FilterValues.Values` 返回 `object[]` 数组(非 IList),使用 `.Length` 而非 `.Count`
- `PivotGridHelper_U10.cs`(U10 版本)尚未实现联动过滤,如需为 U10 版本表单添加联动,参考主版本 `PivotGridHelper.cs` 的实现
- `FilterArea` 字段的过滤才是复选框形式,RowArea/ColumnArea/DataArea 字段不走此逻辑
**其他项目已有类似实现**:
- `PDM_LN` 和 `YB_FX` 的 `PivotGridHelper.cs` 中已订阅 `CustomFilterPopupItems` 事件,但过滤值操作被注释掉(仅配置了弹出选项)
## 排查步骤
当遇到「多维表格过滤不联动」或「过滤下拉框显示值不对」时:
```
1. 确认表格类型:
→ 普通表格(GridView)→ 检查 GridViewFilterHelper 是否已附加
→ 多维表格(PivotGrid)→ 检查 PivotGridHelper 中 CustomFilterPopupItems 是否已注册
2. 多维表格联动排查:
→ 确认 LoadPivotGridData 是否被调用
→ 确认 addDataToCtl 中是否有 gridCtl.CustomFilterPopupItems += GridCtl_CustomFilterPopupItems
→ 检查 BuildFilterExpression 中 field.FilterValues.Values 的数据类型
→ 检查 DataTable.Select 过滤表达式中值格式化是否正确(字符串需加引号并转义)
3. 性能排查:
→ 数据量大时 DataTable.Select 可能较慢(每次弹出下拉框都全表扫描)
→ 可考虑缓存过滤结果或使用 DataView.RowFilter 替代
```
## 相关文件速查
```
网格过滤功能涉及文件:
├── U.WinCtl\OpCtl\GridViewFilterHelper.cs ← 普通表格复选框过滤联动(完整实现)
├── U.WinCtl\OpCtl\UP4XtraGrid.cs ← 普通表格数据绑定入口(调用 GridViewFilterHelper)
├── XWinLib\PivotGrid\PivotGridHelper.cs ← 多维表格数据加载 + 过滤联动(已实现)
├── XWinLib\PivotGrid\PivotGridHelper_U10.cs ← 多维表格 U10 版本(联动未实现)
├── XWinLib\PivotGrid\PivotGridEx.cs ← 多维表格控件包装器
└── XWinLib\Share\XmlCfgHelper.cs ← PivotGrid 列配置读取(ColPivotList)
```
# 父子单据联动机制(ParentProvider)
> 排查"为什么A单据产生了B任务"。当遇到"单据A保存后异常产生了单据B的任务/通知"类问题时,这是 ParentProvider 跟单联动机制在起作用。
**核心组件**:
| 组件 | 作用 | 位置 |
|------|------|------|
| `[ParentProvider]` 特性 | 声明式定义"谁是当前类的父级" | 业务类上方(AllowMultiple=true) |
| `IDataLinkObjRule.ParentDocType` | 运行时决定"当前数据属于哪个父级" | 业务类的属性实现 |
| `ORMappingByAttHelper.GetParentProviderAttByType()` | 路由方法:DocType → 匹配的 ParentProvider 特性 | `U.Rule/Share/ORMappingByAttHelper.cs` |
| `ObjectModefiedHelper.AfterSaveDate()` | 执行UPDATE:更新父级表的修改时间和状态 | `U.Rule/Share/ObjectModefiedHelper.cs` |
**工作链路**:
```
UI 保存 → BusinessObject.SaveObjectData()
→ ModefiedHelper.AfterSaveDate(busObj, ...)
→ pDocType = busObj.ParentDocType // ① 获取运行时父级类型
→ att = GetParentProviderAttByType(busObj, pDocType) // ② 路由匹配
→ if (att != null)
UPDATE {att.ProviderTableName} // ③ 更新父级表
SET B_MODIFIED_DATE, B_MODIFIED_MEMO='FollowDoc'
WHERE {att.KeyFieldName} IN (keys)
→ 跟单系统检测到父级表变动 // ④ 级联通知
→ 触发父级单据的跟单逻辑 → 产生任务
```
**排查步骤**:
```
1. 确认当前单据的 ParentDocType 返回值
→ 查看业务类中 ParentDocType get 属性
→ 不同业务场景可能返回不同的 DocType(如 UStockAssignTwo 按 StockAssignType 分支)
2. 查看类上所有 [ParentProvider] 特性声明
→ 在源码中检索(知识库内代码分析文档;本地工程目录需用户协助定位)
→ 注意:可能有被注释掉的旧特性(如 //[ParentProvider(OrderSendProject, ...)])
3. 校验路由匹配逻辑
→ 打开 ORMappingByAttHelper.GetParentProviderAttByType()
→ 检查是否存在硬编码 fallback(else if att.DocType == OrderSendProject)
→ 预期:ParentDocType 返回值应精确匹配某个 [ParentProvider] 的 DocType
→ 异常:ParentDocType 返回的 DocType 无对应特性,但被 else fallback 捕获
4. 确认父级表 UPDATE 是否执行
→ 在 AfterSaveDate 中查看 att.ForeingKeyField 是否为 null(null 时直接 return)
→ 外键字段存在于子表明细中时才会收集 keys 并执行 UPDATE
5. 追踪跟单通知链
→ 父级表被 UPDATE 后,跟单系统检测到变更
→ 通知父级单据 → 触发父级自身的跟单 → 产生任务
```
**常见问题模式**:
| 模式 | 症状 | 根因 | 修复 |
|------|------|------|------|
| **硬编码 fallback** | ParentDocType 返回 A,但路由到了 B 父级 | GetParentProviderAttByType 中 else if 无条件匹配 | 改为精确匹配优先,仅单父级类启用降级 |
| **注释残留** | 代码注释掉了一个 [ParentProvider],但运行时仍生效 | DLL 未重新编译/部署 | 检查 bin 目录中的 DLL 是否最新 |
| **ParentDocType 分支遗漏** | 新业务场景走 default 分支返回了错误的 DocType | ParentDocType 的 if/switch 未覆盖新场景 | 新增业务场景分支 |
| **外键字段为 null** | AfterSaveDate 不执行 UPDATE,但跟单仍被触发 | 问题不在 ParentProvider 链路上 | 检查是否有其他触发跟单的路径 |
**关键文件(修改时需谨慎)**:
| 文件 | 说明 |
|------|------|
| `U.Model/Att/ParentProviderAttribute.cs` | 特性定义(禁止随意修改) |
| `U.IFace/IDataLinkObjRule.cs` | ParentDocType 接口定义(禁止修改) |
| `U.Rule/Share/ORMappingByAttHelper.cs:1297` | **核心路由方法**(修改影响所有业务类) |
| `U.Rule/Share/ObjectModefiedHelper.cs:324-380` | **执行UPDATE**(修改影响所有单据保存) |
> **修改 ORMappingByAttHelper.GetParentProviderAttByType() 必须做全量影响分析**:在源码中检索 `[ParentProvider.*OrderSendProject` 找出所有声明了该父级的类,逐一验证其 ParentDocType 返回值能否精确匹配。
# 报表类模块 SQL 获取(三途径 — 按 SysFunction.Winform 分流)
> 旧范式认为报表模块"不走标准窗口加载、SysFunction 无记录"——实际**全部模块在 SysFunction 中都有记录**。区别在于 Winform 类型不同,SQL 定义来源也因此不同。
## 前置查询(所有途径共用)
```sql
SELECT sm.Name AS ModuleName, sf.Name AS WindowType,
sf.BusinessObject, sf.Winform, sf.AssemblyName
FROM SysBusinessModel sm
JOIN SysFunction sf ON sf.ModelID = sm.ID
WHERE sm.Name LIKE '%关键词%'
```
取得 `Winform` 和 `BusinessObject` 后,按以下三途径分流:
```
Winform = U.WinClientDoc.FrmRPTView → 途径 A:自定义报表
Winform = U.WinClient.SysSetting.FrmEditPreviewView → 途径 B:动态表结构定义
其他 Winform(Name = Form 无 View / 标准 View+编辑窗口) → 途径 C:普通业务模块
```
---
## 途径 A:自定义报表(Winform = FrmRPTView)
**特征**:`Winform = U.WinClientDoc.FrmRPTView`,`BusinessObject = U.Rule.Doc.Schedule`。基于存储过程,支持列头合并和字段级权限控制。
**SQL 定位**:四个表均通过 `ReportCode` 关联,由 `Schedule` 引擎逻辑按模块名或 ID 匹配。
| 表 | 作用 | 关键字段 |
|----|------|----------|
| `Sys_RPT_TableDef` | 主表 | `ReportCode`(主键)、`ReportName`(报表名/菜单名)、`CommandType`(一般是 `SP`)、`CommandText`(一般是存储过程名称) |
| `Sys_RPT_GridColumn` | 列配置 | `ReportCode`、`Caption`(显示名称)、`FieldName`(字段名)、`DataType`(字段类型)、`BandCode`(合并列头 UID) |
| `Sys_RPT_GridBand` | 列头合并 | `BandCode`(关联 GridColumn)、`Caption`(合并列头显示名称) |
| `Sys_RPT_Auth` | 权限控制 | `ReportCode`、`FieldName`(字段名)、`AuthCode`(`Edit` 可编辑 / `Visible` 可见)、`UserID`(用户 ID,仅该用户有此权限) |
### 排查步骤
```
1. 确认 Winform = FrmRPTView → 此模块为自定义报表
2. 通过模块名反查 ReportCode(db_query,无连接则给用户 SSMS 执行):
SELECT ReportCode, ReportName, CommandType, CommandText
FROM Sys_RPT_TableDef
WHERE ReportName LIKE '%模块名%'
→ 得到 ReportCode + CommandText(SP 名称)
3. 分析列配置(含合并列头):
SELECT gc.Caption, gc.FieldName, gc.DataType, gc.BandCode,
gb.Caption AS BandCaption
FROM Sys_RPT_GridColumn gc
LEFT JOIN Sys_RPT_GridBand gb ON gb.BandCode = gc.BandCode
WHERE gc.ReportCode = @ReportCode
ORDER BY gc.SortNo
4. 检查权限:
SELECT FieldName, AuthCode, UserID
FROM Sys_RPT_Auth
WHERE ReportCode = @ReportCode
→ AuthCode = 'Edit' 可编辑,'Visible' 可见
→ UserID 非空表示仅该用户有此权限
5. 数据排查 → 提取 CommandText(SP 名)→ db_query 执行
→ 用界面实际参数调用 SP,比对结果
→ 检查:SP 内部逻辑 / 参数过滤 / 权限过滤
6. 常见问题:
- SP 逻辑缺陷(关联/过滤/聚合错误)
- GridColumn 中 FieldName 与 SP 返回列名不匹配 → 列显示空
- GridBand 中 BandCode 写错 → 列头合并失效
- Sys_RPT_Auth 中 AuthCode 配置错误 → 用户看不到列或无法编辑
- 权限冲突(同一字段多条 Auth 记录,UserID 条件互斥)
```
---
## 途径 B:动态表结构定义(Winform = FrmEditPreviewView)
**特征**:`Winform = U.WinClient.SysSetting.FrmEditPreviewView`,`BusinessObject = U.Rule.SysSetting.UPreviewView`(或 UP4 变体,如 `UP4.Rule.SysSetting.UP4PreviewView`)。基于 SQL 动态生成表格。
**SQL 定位**:`UPreviewView` 引擎通过菜单名(`SysBusinessModel.Name`)匹配 `TableIdentic.TableText`。
| 表 | 作用 | 关键字段 |
|----|------|----------|
| `TableIdentic` | 主表 | `TableText`(表描述/模块名)、`SqlString`(查询 SQL 语句) |
| `TableDef` | 列定义 | `FieldName`(字段名)、`FieldType`(字段类型)、`Description`(显示名称) |
### 排查步骤
```
1. 确认 Winform = FrmEditPreviewView → 此模块为动态表结构定义
2. 通过模块名查 TableIdentic(db_query):
SELECT * FROM TableIdentic WHERE TableText LIKE '%模块名%'
→ 得到 SqlString(查询 SQL)
3. 分析列定义:
SELECT FieldName, FieldType, Description
FROM TableDef
WHERE TableIdenticID = @ID
ORDER BY SortNo
4. 数据排查 → 提取 SqlString → db_query 执行
→ 检查:WHERE 过滤条件 / JOIN 关联逻辑 / 子查询 / 聚合计算
5. 常见问题:
- SqlString 中硬编码了错误的过滤条件
- 关联表字段名拼写错误
- TableDef 中 FieldType 与实际查询列类型不匹配
```
---
## 途径 C:普通业务模块(其他 Winform)
**特征**:`Winform` 不是 `FrmRPTView` 或 `FrmEditPreviewView`。不走报表引擎,SQL 定义在 `ConfigFile` XML 中。
又分两类:
- **标准列表窗口**:`Name = View` 存在 → 按常规 dialect/erp/winforms.md 排查链路
- **直接弹窗基础模块**:`Name = Form` 但无 `View` → 按 dialect/erp/winforms.md 通用链路(窗口 → 业务类 → ConfigFile XML Sqls)排查
---
## 三途径速查表
```
SysBusinessModel JOIN SysFunction
↓ 取得 Winform
├── FrmRPTView → 途径 A → Sys_RPT_TableDef.CommandText(SP)
├── FrmEditPreviewView → 途径 B → TableIdentic.SqlString(SQL)
└── 其他 → 途径 C → ConfigFile XML Sqls 节点
├── Name = View 存在 → 标准列表窗口(dialect/erp/winforms.md)
└── Name = Form 无 View → 直接弹窗基础模块(dialect/erp/winforms.md)
```
# SQL 脚本存放位置与项目规范(公司方言)
> 通用规范(格式化 CRLF、脚本命名、可重复执行模板、落盘 `Agent/output/sql/`)见 `dialect/sql-core`(sql-style.md / sql-scripts.md)。
> 本文件只讲**公司/项目级**约定:脚本在项目内的存放位置、命名、数据库说明、变更注释、编码。
## 职责分工(与 dialect/sql-core 的 sql-scripts.md 区分)
- `dialect/sql-core/sql-scripts.md` = **生成规范**:agent 生成脚本时的命名/幂等/模板,落盘 `Agent/output/sql/`(kb_write 沙箱内,agent 可直接写)。
- 本文件 = **交付位置规范**:脚本最终应放入项目仓库的 `{项目}/开发文档/数据库脚本/`。agent 无该项目目录写权限,**交付脚本时向用户说明应存放的位置与命名,由用户放置**(或用户确认后自行复制)。
- 两者不冲突:agent 先按 sql-scripts.md 生成到沙箱,交付时按本文件告知用户归档位置。
## 一、脚本存放位置(核心原则)
所有 SQL 脚本统一存放在对应项目的开发脚本目录下:
```
{项目根目录}/开发文档/数据库脚本/
```
> 路径定位:按当前分析的项目确认路径。如排查 `U6_FD` 的问题,脚本应放到 `d:\working\source\U6_FD\开发文档\数据库脚本\`。
### 1.1 普通脚本(建表、加列、加模块、DML 等)
按**日期创建文件夹**,按**序号 + 功能**命名:
```
{项目}/开发文档/数据库脚本/
├── yyyy-MM-dd/ ← 日期文件夹(脚本执行日期)
│ ├── 01.AddTable_xxx.sql ← 建表
│ ├── 02.AddTableColumn_xxx.sql ← 加列
│ ├── 03.AddModule_xxx.sql ← 添加模块/窗口注册
│ ├── 04.SP_xxx.sql ← 新增存储过程(非改动的存储过程)
│ ├── 05.Data_xxx.sql ← 数据修复/迁移脚本
│ └── ...
```
**规则**:
- 日期格式统一用 `yyyy-MM-dd`(如 `2026-07-30`)
- **序号从 `01` 开始**,两位数字,按执行顺序递增
- 功能名用英文 + 下划线,简洁描述脚本用途
- 同一批次(同一天)的脚本放在同一个日期文件夹下
- 序号决定执行顺序:先建表 → 加列 → 加模块 → 存储过程 → 数据修复
**示例**:
```
2026-07-30/
├── 01.AddTable_AIQC_Inspector.sql
├── 02.AddTableColumn_SysFunction_MapXMLName.sql
├── 03.AddModule_行为引导报表.sql
└── 04.Data_Fix_DuplicateRecords.sql
```
### 1.2 视图 / 存储过程 / 函数(可复用对象)
存放在 `2099-01-01` 或 `2999-01-01` 文件夹下(表示「永不过期」的持久化对象),按类型分子目录:
```
{项目}/开发文档/数据库脚本/
├── 2099-01-01/ (或 2999-01-01/)
│ ├── 20.VW/ ← 视图(View)
│ ├── 30.SP/ ← 存储过程(Stored Procedure)
│ └── 30.FN/ ← 函数(Function)
```
> **2099 vs 2999 选择**:优先与该项目**已有**的特殊日期文件夹保持一致。
| 项目 | 使用 |
|------|------|
| cloud | `2999-01-01` |
| PDM_LN | `2999-01-01` |
| LNCenterApi | `2999-01-01` |
| U6_HY | `2999-01-01` |
| MES_OneMix | `2099-01-01` |
| mes_geshiwei | `2099-01-01` |
| u6_huachuan | `2099-01-01` |
**若该项目尚无此目录**:新建 `2999-01-01`(默认)。
### 1.3 视图 / 存储过程命名
```
格式:{yyyyMMdd}{序号}_{对象名称}.sql
其中:
yyyyMMdd = 修改日期(8位,如 20260730)
序号 = 当天序号(3位从001开始,如超过999用4位)
对象名称 = VW_ViewName / SP_ProcName / FN_FuncName
```
**示例**:
```
2099-01-01/30.SP/20260730001.SP_MES_GetPartQRInfoForSubmit.sql
2099-01-01/20.VW/20260730001.VW_MX_SoleItemDetail.sql
2099-01-01/30.FN/20260730001.FN_ExtractDecimalNumber.sql
```
### 1.4 命名冲突处理
如果该对象名称(如 `VW_MX_SoleItemDetail`)在目标文件夹下已**存在文件**:
1. **更新前缀**:将旧文件名中的 `yyyyMMddNNN` 前缀改为本次修改日期和最新序号
2. **更新内容**:将文件内容更新为本次修改后的最新版本
3. **旧文件不保留**:每次一个对象只保留**最新一个**版本文件(Git 历史可追溯旧版本)
**示例**:
```
旧文件:20260303001.VW_MX_SoleItemDetail.sql
新序号:20260730003
旧文件 → 重命名为 → 20260730003.VW_MX_SoleItemDetail.sql,同时更新文件内容
```
**注意**:重命名前需检查旧文件的 `yyyyMMddNNN` 前缀序号是否已被当天其他文件占用,跳过已用序号。
---
## 二、脚本内容规范
### 2.1 可重复执行(强制规则)
**所有 SQL 脚本必须能够无条件重复执行,不会因重复运行而报错。**
#### 普通脚本(DDL / DML)
```sql
/* ✅ 建表:先判断再创建 */
IF OBJECT_ID('dbo.TableName', 'U') IS NULL
BEGIN
CREATE TABLE [dbo].[TableName] ( ... );
END
GO
/* ✅ 加列:先判断再添加 */
IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('dbo.TableName') AND name = 'ColumnName')
BEGIN
ALTER TABLE [dbo].[TableName] ADD [ColumnName] INT NULL;
END
GO
/* ✅ 加模块:先判断再插入 */
IF NOT EXISTS (SELECT * FROM dbo.App_SysBusinessModel WHERE Name = '模块名称')
BEGIN
INSERT INTO dbo.App_SysBusinessModel (...) VALUES (...);
END
GO
```
**完整示例**:
```sql
/* 建表:可重复执行 */
IF OBJECT_ID('dbo.AIQC_Inspector', 'U') IS NULL
BEGIN
CREATE TABLE [dbo].[AIQC_Inspector] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[LineName] NVARCHAR(50) NOT NULL,
[DeviceCode] NVARCHAR(50) NOT NULL,
[UserID] NVARCHAR(50) NOT NULL,
[UserName] NVARCHAR(100) NOT NULL,
[Remark] NVARCHAR(500) NULL
);
/* 表说明 */
EXEC dbo.sp_tools_AddTableDescription @TableName = N'AIQC_Inspector', @Desctription = N'流水线与质检员映射关系表';
/* 列说明 */
EXEC dbo.sp_tools_AddTableClumnDescription @TableName = N'AIQC_Inspector', @ClumnName = N'Id', @Desctription = N'自增主键';
EXEC dbo.sp_tools_AddTableClumnDescription @TableName = N'AIQC_Inspector', @ClumnName = N'LineName', @Desctription = N'流水线名称';
END
ELSE
BEGIN
PRINT '表 [dbo].[AIQC_Inspector] 已存在,跳过建表。';
END
GO
```
#### 视图 / 存储过程 / 函数
```sql
/* ✅ 存储过程:先删除再创建 */
IF OBJECT_ID('dbo.SP_ProcName') IS NOT NULL
DROP PROCEDURE [dbo].[SP_ProcName];
GO
CREATE PROCEDURE [dbo].[SP_ProcName] ...
GO
/* ✅ 视图:先删除再创建 */
IF OBJECT_ID('dbo.VW_ViewName') IS NOT NULL
DROP VIEW [dbo].[VW_ViewName];
GO
CREATE VIEW [dbo].[VW_ViewName] ...
GO
```
### 2.2 建表 / 加列必须同步数据库说明(强制规则)
**建表时**必须调用 `sp_tools_AddTableDescription` 添加表说明,调用 `sp_tools_AddTableClumnDescription` 添加每一列的说明。
**加列时**必须调用 `sp_tools_AddTableClumnDescription` 添加列说明:
```sql
/* 加列(使用项目提供的存储过程,已内置重复检查) */
EXEC dbo.sp_tools_AddTableClumn @TableName = 'SysFunction', @ClumnName = 'MapXMLName', @TypeDefinition = 'nvarchar(500)', @Desctription = '归属XML';
GO
/* 或手动写法——自行检查时也需添加说明 */
IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('dbo.SysFunction') AND name = 'MapXMLName')
BEGIN
ALTER TABLE [dbo].[SysFunction] ADD [MapXMLName] NVARCHAR(500) NULL;
EXEC dbo.sp_tools_AddTableClumnDescription @TableName = N'SysFunction', @ClumnName = N'MapXMLName', @Desctription = N'归属XML';
END
GO
```
> **注意**:`sp_tools_AddTableClumn` 是项目自定义存储过程,内部已判断列是否存在,可安全重复执行。若项目无此 SP,需自行用 `IF NOT EXISTS` 包装。
### 2.3 存储过程 / 视图:最小化改动 + 注释规范
**原则**:只改需要改的部分,保持原有逻辑不变。在文件顶部添加变更说明注释块。
#### 文件头部注释块(顶部)
> 模板对标 SSMS 生成脚本格式,使用 `/* ===== */` 风格的可视分隔块。
```sql
/* ===== CREATE PROCEDURE: dbo.SP_MES_GetPartQRInfoForSubmit ===== */
/* 变更说明:[任务编号]变更内容摘要 */
/* 负责人:duan | 2026-07-30 14:25:30 */
/* ===== */
IF OBJECT_ID('dbo.SP_MES_GetPartQRInfoForSubmit') IS NOT NULL
DROP PROCEDURE [dbo].[SP_MES_GetPartQRInfoForSubmit];
GO
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
-- =============================================
-- Author: 原始作者
-- Create date: 原始创建日期
-- Description: 原始功能描述
-- 变更:[任务编号]变更内容摘要 2026-07-30 14:25:30 duan
-- =============================================
CREATE PROCEDURE [dbo].[SP_MES_GetPartQRInfoForSubmit]
...
GO
```
#### 变更注释格式
| 元素 | 格式 | 示例 |
|------|------|------|
| **有任务编号** | `-- 变更:{任务编号}{描述} {时间} {负责人}` | `-- 变更:#24680 修正颜色匹配逻辑 2026-07-30 14:25:30 duan` |
| **无任务编号** | `-- 变更:{描述} {时间} {负责人}` | `-- 变更:修正颜色匹配逻辑,改为模糊匹配 2026-07-30 14:25:30 duan` |
| **时间精度** | `yyyy-MM-dd HH:mm:ss`(精确到秒) | `2026-07-30 14:25:30` |
#### 视图注释模板
```sql
/* ===== CREATE VIEW: dbo.VW_MX_SoleItemDetail ===== */
/* 变更说明:#24680 修正鞋底物料明细视图,新增 MaterialHardness 字段 */
/* 负责人:duan | 2026-07-30 14:25:30 */
/* ===== */
IF OBJECT_ID('dbo.VW_MX_SoleItemDetail') IS NOT NULL
DROP VIEW [dbo].[VW_MX_SoleItemDetail];
GO
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
-- =============================================
-- Author: lxj
-- Create date: 2025-04-22 17:00:06
-- Description: 鞋底物料明细信息(合并配方之后)
-- 变更:#24680 新增 MaterialHardness 字段 2026-07-30 14:25:30 duan
-- =============================================
CREATE VIEW [dbo].[VW_MX_SoleItemDetail]
AS
...
GO
```
### 2.4 格式规范继承
- 脚本中的 SQL 语句格式遵循 `dialect/sql-core` 的 sql-style.md(关键字大写/缩进/CRLF)
- **注释规范**:顶部声明注释块(`/* ===== */` 块和 `-- =====` 分隔线)用 `--` 单行注释;SQL 语句内部注释用 `/* */` 块注释
- **XML 修改注释**:ConfigFile XML 或任何 .xml 文件内的修改,注释统一使用 `<!-- -->` XML 标准注释(不是 `/* */`);禁止在 XML 中使用 SQL 注释风格
- 保持与项目现有脚本风格一致
### 2.5 编码:GB2312(强制)
**所有项目内落盘的 `.sql` 文件必须使用 GB2312(GBK)编码**,保持与 SQL Server 默认排序规则兼容。
> 桌面端注意:kb_write / d_write 默认写 UTF-8。生成 `.sql` 后需转换为 GB2312:
> - 有 Python 环境:读 UTF-8 → 写 GBK(`open(path, 'w', encoding='gbk')`)
> - 无 Python:请用户用文本编辑器(Notepad++/VS)将文件另存为「ANSI」编码
> - 交付脚本时明确提示用户核对编码
### 2.6 常见脚本清单速查
| 脚本类型 | 存放位置 | 命名格式 | 可重复执行策略 |
|----------|----------|----------|---------------|
| 建表 | `{日期}/0X.AddTable_xxx.sql` | 序号 + AddTable + 表名 | `IF OBJECT_ID IS NULL` → `CREATE` |
| 加列 | `{日期}/0X.AddTableColumn_xxx.sql` | 序号 + AddTableColumn + 描述 | `IF NOT EXISTS sys.columns` → `ALTER` |
| 加模块/窗口 | `{日期}/0X.AddModule_xxx.sql` | 序号 + AddModule + 模块名 | `IF NOT EXISTS App_SysBusinessModel` → `INSERT` |
| 数据修复 | `{日期}/0X.Data_xxx.sql` | 序号 + Data + 描述 | 按具体情况用临时表 + 条件判断 |
| 建存储过程 | `{2999}/30.SP/{yyyymmdd}{NNN}.SP_Name.sql` | 日期序号 + SP + 名称 | `IF EXISTS` → `DROP` → `CREATE` |
| 建视图 | `{2999}/20.VW/{yyyymmdd}{NNN}.VW_Name.sql` | 日期序号 + VW + 名称 | `IF EXISTS` → `DROP` → `CREATE` |
| 建函数 | `{2999}/30.FN/{yyyymmdd}{NNN}.FN_Name.sql` | 日期序号 + FN + 名称 | `IF EXISTS` → `DROP` → `CREATE` |
# 审批流程引擎(VerificationFlow)
> ERP/PDM/MES 通用。每个需要审批的单据类型,通过枚举 `VerificationFlowType` 注册两个自定义特性:`[DataEditForm]` 定义编辑窗口和业务类,`[VerificationFlow]` 定义审批数据元信息。
## 核心组件
| 组件 | 作用 | 位置 |
|------|------|------|
| `VerificationFlowType` 枚举 | 注册所有审批类型,每个枚举值绑定两个特性 | `U.Model/Enum/VerificationFlowType.cs` |
| `[DataEditForm]` 特性 | 定义编辑窗口(全限定名)、业务类(全限定名)、DLL 名 | `U.Model/Att/DataEditFormAttribute.cs` |
| `[VerificationFlow]` 特性 | 定义审批名称、DocType、主表名、取数函数、主键名 | `U.Model/Att/VerificationFlowAttribute.cs` |
| `ORMappingByAttHelper` | 反射读取特性的路由方法 | `U.Rule/Share/ORMappingByAttHelper.cs` |
| `FlowMapHelper` | 审批流程提交/审批/回退的核心引擎 | `U.Rule/VF/FlowMapHelper.cs` |
| `IVerificationFlow` 接口 | 业务类必须实现此接口才能进入审批流 | `U.IFace/IVerificationFlow.cs` |
| `IVerificationFlowEx` 接口 | 扩展接口(审核前后业务逻辑校验) | `U.IFace/IVerificationFlow.cs` |
| `frmViewDataInVFlow` | 审批主界面("数据在审"列表 + "已审列表") | `U.WinVerificationFlow/frmViewDataInVFlow.cs` |
| `frmEditAuditingText` | 审批意见录入弹窗 | `U.WinVerificationFlow/frmEditAuditingText.cs` |
| `frmEditTaskNode` | 审批流程节点编辑 | `U.WinVerificationFlow/frmEditTaskNode.cs` |
| `EditHelper.Audit()` | 业务编辑窗口内嵌审批入口 | `U.WinBaseLib/EditHelper.cs` |
---
## 一、VerificationFlowType 枚举(审批注册中心)
每个审批单据类型在 `VerificationFlowType` 枚举中占一个值,同时绑定两个特性:
```csharp
[DataEditForm("窗口类全限定名", "业务类全限定名", "所在的DLL名")]
[VerificationFlow("审批名称", DocType = DocType.StockIn, DocTableName = "StockIn",
DocSqlFunName = "fu_VF_StockIn(61)", DocKeyName = "ID")]
StockInFabric = 51, // 枚举值(存入数据库 VerificationFlowType 列)
```
### [DataEditForm] 特性参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `FormFullName` | 编辑窗口全限定名 | `"U.WinClientWH.frmEditStockIn"` |
| `BusRuleFullName` | 业务类全限定名 | `"U.Rule.WH.UStockIn"` |
| `FormAssemblyName` | 窗口所在 DLL | `"U.WinClientWH.dll"` |
| `RuleCreateParams` | 业务类构造函数参数(可选) | `new object[] { "61" }` |
| `RuleAssemblyName` | 业务类所在 DLL(可选,默认 `"U.Rule.DLL"`) | `"U.Rule.DLL"` |
| `IsLinkEditForm` | 是否联动编辑窗口(默认 `true`) | `true` / `false` |
### [VerificationFlow] 特性参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `Description` | 审批模块名称(中文显示) | `"面料入库单"` |
| `DocType` | 单据类型枚举值 | `DocType.StockIn` |
| `DocTableName` | 单据主表名 | `"StockIn"` |
| `DocSqlFunName` | 取数表值函数(带参数) | `"fu_VF_StockIn(61)"` |
| `DocKeyName` | 单据主键列名(默认 `"ID"`) | `"ID"` / `"UID"` |
| `MapXmlFileName` | 自定义 XML 配置(默认等于 `DocTableName`) | `"StockIn"` |
---
## 二、运行时链路
### 2.1 单据提交 → 进入审批
```
业务编辑窗口保存
→ FrmEditBaseLink → ObjectModefiedHelper
→ IVerificationFlow 接口已实现 (U.IFace)
→ FlowMapHelper.SubmitData()
→ 从 VerificationFlowType 枚举反射取 [VerificationFlow] 特性 → DocType/DocTableName/DocKeyName/DocSqlFunName
→ 查 VF_FlowMap 表确认该类型启用了审批流程
→ 查 VF_FlowMapNode 找起始节点 (PrevID = -1)
→ 写入 VF_DataInFlow 表(审批实例记录)
→ 存储过程 up_VF_SubmitDataToFlow
```
### 2.2 审批主界面 → 查看待审/已审数据
```
frmViewDataInVFlow 打开
→ 左树加载:FlowMapHelper.GetWaitAuditingVFlow()
→ fu_VF_GetWaitAuditingVFlow(userID) 返回当前用户有权限的审批类型
→ 选中审批类型枚举值 → 反射取 [VerificationFlow] 特性
→ 待审列表:FlowMapHelper.GetAllowVFDocData(flowType)
→ 拼接 SQL = fu_VF_GetAllowAuditingDoc(flowType, docType, userID)
+ DocSqlFunName(取数函数,如 fu_VF_StockIn(61))
→ JOIN ON DocKeyName = DocInstanceKey
→ 已审列表:FlowMapHelper.GetHasAuditingDocData(flowType, beginDate, endDate)
→ fu_VF_GetHasAuditingDoc(docType, userID, beginDate, endDate)
+ DocSqlFunName
```
### 2.3 审批界面 → 打开单据查看详情
```
审批列表双击/点"详情"按钮
→ showDocDataDetail()
→ 反射取 [DataEditForm] 特性 → FormFullName + BusRuleFullName + FormAssemblyName
→ DllFactory.CreateInstance(BusRuleFullName, RuleCreateParams, RuleAssemblyName) → 创建业务类实例
→ IsLinkEditForm ?
true → DllFactory.DispLinkEditForm(OptType, FormFullName, FormAssemblyName, rule, grdCtl)
false → DllFactory.DispEditFormEx(OptType, FormFullName, FormAssemblyName, rule, grdCtl)
```
### 2.4 审批决议
```
frmEditAuditingText → 审批意见(通过/不通过)
→ FlowMapHelper.SubmitFlowNodeAuditing()
→ 加载 VF_VersionCompare 条件约束
→ 检查是否有下一级审批节点 → 若有则流转,若无可结束审批
→ 存储过程 up_VF_NodeAuditing
```
---
## 三、数据库表结构
### 核心表
| 表名 | 说明 | 关键列 |
|------|------|--------|
| `VerificationFlowType` | 审批类型主数据表(与枚举对应) | `ID`, `Name`, `Code`, `DocType`, `DocTableName`, `DocSqlFunName`, `DocKeyName` |
| `VF_FlowMap` | 审批流程模板(一个审批类型可有多个版本) | `ID`, `VerificationFlowType`, `IsRuning`(当前启用哪个), `StartEndAuditing`, `EndAuditingPassword`, `IsReverseAudit` |
| `VF_FlowMapNode` | 审批流程节点(节点链 = 审批链路) | `ID`, `FlowMapID`, `Name`, `FlowNodeType`(1=起始/2=业务/3=结束), `PrevID`, `NextID`, `FilterName`(条件约束名), `LimitDepartment` |
| `VF_FlowMapNodeUser` | 节点审批人 | `ID`, `FlowMapNodeID`, `UserID`, `LimitDepartment` |
| `VF_DataInFlow` | 审批实例(每个进入审批的单据一条记录) | `ID`, `DocType`, `DocInstanceKey`, `DocSubmitUserID`, `FlowCheckState`, `EndCheckDate` |
| `VF_AuditingText` | 审批决议记录 | `ID`, `DataInFlowID`, `FlowMapNodeID`, `AuditingText`, `AuditingDate`, `AuditingUserID`, `FlowAuditingState`(0=未审/1=通过/2=不通过) |
| `VF_VersionCompare` | 审批条件约束(版本对比/条件分支审批) | `ID`, `VerificationFlowType`, `Name`, `FilterString`(SQL WHERE 子句), `TableAlias`, `Description` |
### 关键存储过程/函数
| 对象 | 说明 |
|------|------|
| `up_VF_SubmitDataToFlow` | 单据提交时写入审批流 |
| `up_VF_NodeAuditing` | 执行审批决议(通过/不通过) |
| `up_GetVFFlowMapID` | 获取指定审批类型的 FlowMapID |
| `up_VF_ClearInFlowData` | 清除在审数据 |
| `fu_VF_GetWaitAuditingVFlow(userID)` | 当前用户有权限的审批类型列表 |
| `fu_VF_GetAllowAuditingDoc(flowType, docType, userID)` | 当前用户待审单据列表 |
| `fu_VF_GetHasAuditingDoc(docType, userID, begin, end)` | 已审核单据列表 |
| `fu_VF_GetAllowAuditingUser(auditingTextID)` | 当前节点有权审批的用户 |
| `fu_VF_DataInFlowProcessing(docType, instanceKey)` | 单据当前审批进程(节点链+决议状态) |
| `fu_VF_*`(取数表值函数) | 各审批类型的取数函数(如 `fu_VF_StockIn(61)`),返回该类型单据的完整数据 |
| `fu_CheckAllowCancelFlow(flowType, docType, instanceKey)` | 判断是否允许撤回审批 |
---
## 四、审批条件分流(VersionCompare)
通过 `VF_VersionCompare` 表配置条件约束,实现同一审批类型内的分支路由:
```
VF_FlowMapNode 节点
→ FlowNodeType = 2(业务节点)
→ FilterName 非空 → 查 VF_VersionCompare 中 Name 匹配的记录
→ FilterString = SQL WHERE 子句(如 "MaterialTypeID = 61")
→ 用 DocSqlFunName + FilterString 检查当前单据是否满足条件
→ 满足 → 进入该节点的审批人审核
→ 不满足 → 递归找下一节点(existsNextFlowMap)
```
---
## 五、打印控制(VerificationPrintControl)
审批引擎还提供打印控制功能:
| 组件 | 对应 |
|------|------|
| `VerificationPrintControl` 枚举 | 打印控制类型(结构与 `VerificationFlowType` 类似) |
| `frmViewPrintControl` | 打印控制界面 |
---
## 六、排查步骤
当遇到「审批不触发」或「审批流程不对」时:
```
1. 确认单据是否注册审批:
→ 检查业务类是否实现 IVerificationFlow 接口
→ 检查 VerificationFlowType get 属性返回的枚举值是否正确
→ 检查 VerificationFlowType 枚举值上是否有 [DataEditForm] + [VerificationFlow] 特性
2. 确认审批流程是否启用:
→ 查 VF_FlowMap 表:IsRuning = 1 且 VerificationFlowType 匹配(db_query)
→ 无记录 → 审批未启用
→ 有记录但有节点 → 进入步骤 3
3. 确认审批节点配置:
→ VF_FlowMapNode 必须有 FlowNodeType = 2(业务节点)且有 FlowNodeType = 3(结束节点)
→ 每个业务节点必须在 VF_FlowMapNodeUser 中配置审批人
→ 起始节点(PrevID = -1)必须存在
4. 确认审批提交逻辑:
→ FlowMapHelper.SubmitData() 中 CheckFlowMapIsRuning
→ 检查 VF_VersionCompare 中的条件约束(FilterString)是否过滤掉了当前单据
→ 若条件约束过滤掉所有节点,且 isFirstFlow = false → 审批不触发
5. 确认取数 SQL:
→ [VerificationFlow] 特性的 DocSqlFunName = 表值函数名 + 参数
→ 用 db_query 执行该函数(无连接则给用户 SSMS 执行),确认返回正确的结果集
→ 检查 DocKeyName 是否与函数返回的列名匹配
6. 审批界面打不开单据:
→ 检查 [DataEditForm] 特性的三个参数正确性
→ 检查 DLL 是否已部署到运行目录
→ 检查业务类构造函数参数是否匹配 RuleCreateParams
→ 检查 IsLinkEditForm 是否正确(大部分为 true,少数为 false)
7. 审批分支/条件不对:
→ 查 VF_VersionCompare 中 FilterString 是否正确
→ 查 VF_FlowMapNode.FilterName 是否与 VF_VersionCompare.Name 匹配
→ 用 db_query 以 DocSqlFunName + FilterString 验证数据匹配
```
## 常见问题模式
| 模式 | 症状 | 根因 | 修复 |
|------|------|------|------|
| 未实现接口 | 保存后不进审批 | 业务类未实现 `IVerificationFlow` | 实现接口 + 返回正确 VerificationFlowType |
| 审批流程未启用 | VF_FlowMap 中该类型 IsRuning=0 | 管理员未启用该审批流 | 审批管理 → 启用该类型的流程模板 |
| 节点无审批人 | 流程中某节点无人可审 | VF_FlowMapNodeUser 中未配置 | 为节点添加审批用户 |
| 条件约束过严 | 单据进不了审批 | VF_VersionCompare.FilterString 过滤掉数据 | 检查 FilterString 与数据是否匹配 |
| 取数函数不对 | 审批列表显示数据为空/不全 | DocSqlFunName 写错或函数不存在 | 检查函数的正确名称和参数 |
| IsLinkEditForm 设错 | 审批界面打开单据白屏/null | IsLinkEditForm 与实际窗口类型不符 | 标准编辑窗口=true,树形/属性窗口=false |
| 枚举值 hardcode | 新增审批类型但编译后不生效 | 枚举值与数据库不一致 | 确保 VerificationFlowType 表中有对应 ID |
## 关键文件(修改时需谨慎)
| 文件 | 说明 |
|------|------|
| `U.Model/Enum/VerificationFlowType.cs` | 审批注册中心,新增类型在此添加枚举值+两个特性 |
| `U.Model/Att/DataEditFormAttribute.cs` | DataEditForm 特性定义(禁止随意修改) |
| `U.Model/Att/VerificationFlowAttribute.cs` | VerificationFlow 特性定义(禁止随意修改) |
| `U.IFace/IVerificationFlow.cs` | 审批接口(禁止修改) |
| `U.Rule/Share/ORMappingByAttHelper.cs` | **反射路由方法**(修改影响所有审批类型) |
| `U.Rule/VF/FlowMapHelper.cs` | **审批核心引擎**(修改影响所有审批提交/审批行为) |
| `U.WinVerificationFlow/frmViewDataInVFlow.cs` | **审批主界面**(修改影响所有审批类型的交互) |
| `U.WinBaseLib/EditHelper.cs` | 业务窗口内嵌审批入口(`Audit()`) |
# WinForms 排查(数据错误 / 程序异常 / 性能 / 约束)
> 适用:ERP / PDM / MES 桌面端(F1, U6_*, PDM_LN, MES_*, mes_geshiwei, PDM_MES, Ftt_Common 等)。
> 项目事实(数据库连接、模块加载机制)从知识库检索获取(kb_find / kb_grep / kb_read)。
> 排查任一步因信息不足卡住 → 按 dialect/troubleshoot-core 五步法第 1 步收集证据:向用户索要关键信息(报错全文/操作步骤/日志文件),或建议补强(加异常堆栈日志 / 调试打点)。
## 取证工具
- 查库:`db_query`(SELECT-only)/ `db_schema`,需先在设置中预存连接(connectionId)。
- 无预置连接时:把 SQL 给用户在 SSMS 执行,取回结果再分析。
- 读代码:源码检索(知识库内代码分析文档;本地工程目录需用户协助定位)。
## WinForms 数据错误排查
### 快速分流
```
库存类问题(入库/出库/调拨/盘点/结存/进销存)
→ 先查 `view_MaterialStockDocFlow` 流水视图定界
→ 按 MaterialID + Warehouse + 日期范围查流水,比对各单据类型汇总数 vs 界面展示
→ 确认差异后再按单据类型追踪到具体源表:
入库 → StockIn + StockInDetail
出库 → StockOut + StockOutDetail
调拨 → StockAssign + StockAssignDetail
盘点 → StockBalance + StockCheck + StockCheckDetail
→ 仍无法定位 → 走下方通用流程步骤 1
```
### 通用排查流程
```
1. 确认问题窗口
→ 让用户提供:哪个模块的哪个窗口?
→ 查数据库:
SELECT sm.Name AS ModuleName, sf.Name AS WindowType,
sf.BusinessObject, sf.Winform, sf.AssemblyName
FROM SysBusinessModel sm
JOIN SysFunction sf ON sf.ModelID = sm.ID
WHERE sm.Name LIKE '%关键词%'
→ 得到:AssemblyName(项目), BusinessObject(业务类), Winform(窗口类)
→ 按 Winform 值分流:
- FrmRPTView → 途径 A:自定义报表(读 dialect/erp/special-modules.md)
- FrmEditPreviewView → 途径 B:动态表结构定义(读 dialect/erp/special-modules.md)
- 其他 → 途径 C:普通业务模块(继续下方步骤 2)
2. 定位源码
→ BusinessObject 全限定名 = 项目下文件夹路径 + 文件名
→ 如 U.WinClientDR.BO.DR_BO_Order → U.WinClientDR 项目下 BO/DR_BO_Order.cs
3. 找到配置 SQL
→ 查看业务类中 OBJECT_xx 常量 → 定位 ConfigFile XML
→ 找到 Sqls 节点中问题相关的 SQL(按 Name 匹配)
4. 数据库验证
→ 截取 SQL + 从 temp/(日期)App.Run.Log 获取实际参数
→ db_query 执行(无连接则给用户 SSMS 执行),比对结果与界面展示
→ 分析:过滤条件 / JOIN / GROUP BY / 子查询
5. 根因判定
→ SQL 逻辑缺陷 / 数据脏数据 / 并发覆盖 / XML 配置错误 / 复制后遗症
```
## WinForms 程序异常排查
```
1. 获取错误 → temp/(日期)App.Run.Log → Exception 段落 → 堆栈最上层
2. 分类处理:
空引用 → 构造方法/Load 事件/数据查询后未初始化 → 数据库返回 NULL / XML 缺节点
反射失败 → SysFunction 中信息是否正确 / DLL 是否部署 / 复制后是否重命名完整
类型转换 → ConfigFile Columns 节点类型 vs 数据库实际字段类型
3. 卡住处理(信息不足 / 无堆栈 / 现象模糊 / 日志缺失):
→ 向用户索要关键信息(报错全文/复现步骤/App.Run.Log)
→ 建议补强:异常堆栈日志 / 调试打点(先确认再改代码)
```
## WinForms 性能问题排查
```
1. 定位慢环节 → 列表打开/编辑加载/保存?→ 查看日志耗时
2. 列表慢 → 定位查询 SQL → SSMS 执行计划 → 缺失索引/全表扫描
3. 保存慢 → 检查存储过程/触发器/级联更新
```
## 数据库约束异常排查(WinForms 侧)
```
1. 获取完整错误 → temp/(日期)App.Run.Log 中的异常信息
2. 提取约束名和表名 → 找出违反约束的数据
3. 追踪数据来源 → 业务链路上溯到写入点
常见类型:违反唯一性约束(主键/唯一索引冲突)/ 值检查约束(CHECK)/ 外键约束
```
# MES 产品线热点问题统计(归因模式库)
> 涵盖:MES_*, mes_geshiwei, PDM_MES。每次排查确认根因后由维护者追加。
> 通用桌面端排查链路(窗口→业务类→SQL/SP→数据库)见 dialect/erp/winforms.md。
> 最后更新:2026-08-14
## 成型配套汇总查询(Summary_PartTaskReport / GetStyleIndexNo_CXPT)
### 合计行小于明细行之和
- **分类**:数据错误
- **根因**:合计 UNION 按 `GROUP BY SizeName, SizeSN` 生成 + 最终 `PIVOT (MAX(Qty))` 折叠,多指令查询时同尺码多行被 MAX 吞减(SN 为指令内序号,跨指令不唯一)
- **归因模式**:**聚合键跨上下文不唯一**——UNION 合计与明细用同一 GROUP BY 键,但键只在单指令内唯一;多指令场景被 MAX/折叠吞数。排查「合计<明细」先核对聚合键的唯一性域。
# MES 查询模板(产品线方言)
> 四库通用模板见 `dialect/sql-core/sql-templates.md`;本文件仅 MES 产品线专属查询。
> 表前缀速查:`MES_*` MES 制造执行 / `Order*` ERP 订单 / `Factory*` 工厂车间 / `OrderDocImport*` 订单导入。
## 生产订单-订单关联(查指令的款式/车间)
```sql
SELECT ppd.IndexNo, ppd.StyleCode, a.CustomStyleCode,
b.WorkSectionID, ws.Name AS SectionName
FROM MES_ProductionPlanDetail ppd
INNER JOIN OrderDocImportPlanDetail b ON b.ID = ppd.OrderDetailID
INNER JOIN OrderDocImportDetail a ON a.ID = b.OrderDocImportDetailID
LEFT JOIN FactoryWorkSection ws ON ws.ID = b.WorkSectionID
WHERE ppd.IndexNo LIKE '%关键词%'
AND (a.StyleCode LIKE '%关键词%' OR a.CustomStyleCode LIKE '%关键词%')
```
## 检验批次汇总(查不良明细)
```sql
SELECT ir.ID, ir.InspectionQty, qid.QIItemName,
ird.Qty AS DefectQty, wt.Name AS TeamName
FROM MES_InspectionReport ir
INNER JOIN MES_InspectionReportDetail ird ON ird.InspectionReportID = ir.ID
INNER JOIN MES_QIDetail qid ON qid.ID = ird.QIDetailID
LEFT JOIN FactoryWorkShopTeam wt ON wt.ID = ir.TeamID
WHERE ir.CreateDate >= @StartDate
```
## 尺码明细(查指令的尺码数量)
```sql
SELECT sd.SizeName, sd.Qty, ppd.IndexNo
FROM MES_ProductionPlanSizeDetail sd
INNER JOIN MES_ProductionPlanDetail ppd ON ppd.ID = sd.ProductionPlanDetailID
```
## 订单附加数据(预补数等)
```sql
SELECT ad.AddCount, tp.Name AS AddTypeName
FROM OrderDocImportDetailAddData ad
INNER JOIN FTT_U6.dbo.OrderAddDataType tp ON tp.ID = ad.OrderAddDataTypeID
INNER JOIN OrderDocImportDetail a ON a.ID = ad.OrderDocImportDetailID
```
## 注意
- MES 库与 ERP 库跨库关联时显式写库名(如 `FTT_U6.dbo.OrderAddDataType`),先确认当前连接库。
- 跨库查询需确认账号有对应库权限;无权限时分别查询后在结果侧关联。
# PDM 产品线热点问题统计(归因模式库)
> 涵盖:PDM_LN, LNCenterApi。每次排查确认根因后由维护者追加。
> 通用桌面端排查链路(窗口→业务类→SQL/SP→数据库)见 dialect/erp/winforms.md。
> 最后更新:2026-08-14
## 季节材料汇总表(RPT_SessionMaterialPrice)
### 报表查询慢(>3分钟)
- **分类**:性能
- **根因**:① SP 主查询 3 个相关子查询(STUFF+FOR XML PATH)对无索引临时表全表扫描拼接,工作量=分组数×行数×3(单季度约 8.9 亿次),单季查询数分钟;② SP 返回后客户端用 BeginInvoke+递归串行按批(每批 100 ID)查询 `Sys_StyleConditionDetail`/`Sys_ReportComment`,几十批串行,额外约 30 秒
- **修复**:① 临时表建覆盖索引 + 三个拼接列预去重,SP 提速约 120 倍(>5 分钟→2.5 秒);② 客户端改为一次性拆批 + 受限并发(最多 8 并发);③ 拼接索引键改 MD5 hash,拼接键 UPPER+RTRIM+全角转半角规范化对齐 collation,消除 nvarchar(8000) 索引键超长风险
- **归因模式**:**XML PATH 拼接全表扫描 + 串行批查询**——报表慢先看两个层面:SP 内拼接子查询是否打爆临时表;客户端回填是否串行按批查询。优化方向:覆盖索引/预去重/受限并发。
## 高危风险评估(RK_RiskAssessment)
### 更新模式追加明细保存时 @RiskAssessmentID=0(明细外键没拿到主单ID)
- **分类**:数据错误
- **根因**:明细外键由框架 `SaveDataByObjectDataType` 用主单 ID(mainID)回填,mainID 来自 `_ObjectDataList[BaseData]["ID"]`;更新模式下该行 ID 失效(re-fetch 失败 / 阶段切换 drNext 陈旧未复位 / OptType 误入新增分支),导致 mainID=0 透传成 @RiskAssessmentID=0
- **归因模式**:**主单 ID 回填失效**——「新增正常、更新/编辑模式保存外键为 0」先检查主单 ID 获取路径在更新模式下是否失效(re-fetch/阶段切换/OptType 分支)。
# SQL 脚本生成规范
## 生成与落盘
- 模型生成变更类 SQL 时,产出 `.sql` 脚本文件,落盘到 `Agent/output/sql/`(kb_write 沙箱)。
- **不直接对业务库执行**变更语句(SELECT 查询用 db_query / 结构分析用 db_schema / 计划用 SSMS)。
- 文件名规范:`<动作>_<对象>_<描述>.sql`,如 `AddColumn_Orders_Remark.sql`、`FixData_OrderStatus.sql`。
## 内容规范
1. 顶部注释头(见 sql-style.md:名称/用途/执行人/日期/可重复执行)。
2. 变更语句**幂等**(IF NOT EXISTS / 先查后改)。
3. 数据修正类脚本先给影响行数查询,再给变更语句,最后给验证查询。
4. 脚本用 CRLF 换行,UTF-8 编码。
## 模板
```sql
-- 脚本名称:FixData_OrderStatus
-- 用途:修正订单状态异常数据
-- 执行人:xxx 日期:2024-09-01
-- 可重复执行:是
-- 1. 影响范围核对
SELECT OrderNo, Status FROM dbo.Orders WHERE Status = 'X';
-- 2. 修正
UPDATE dbo.Orders SET Status = 'Y' WHERE Status = 'X';
-- 3. 验证
SELECT OrderNo, Status FROM dbo.Orders WHERE Status = 'X'; -- 期望 0 行
```
## 方言差异提示
- sqlserver:IDENTITY/新增列用 `ALTER TABLE ... ADD` + 幂等判断;NVARCHAR。
- sqlite:`ALTER TABLE ADD COLUMN` 有限制(不能加主键/非空默认复杂值);先 PRAGMA 检查。
- mysql/postgres:类型/引号差异按库适配。
# SQL 格式化规范
## 换行
- **默认以 CRLF 换行**(Windows 标准;与公司既有脚本一致)。
- 禁止在同一文件混用 LF/CRLF。
## 结构
- 关键字大写:`SELECT`、`FROM`、`WHERE`、`JOIN`、`GROUP BY`、`ORDER BY`、`INSERT`、`UPDATE`、`ALTER`。
- 单条语句内缩进 4 空格;子查询/JOIN 条件换行对齐。
- 每张表一个别名(可读性优先):`FROM dbo.Orders o`。
## 注释头(脚本必带)
```sql
-- 脚本名称:AddColumn_Orders_Remark
-- 用途:订单表增加备注列
-- 执行人:xxx 日期:2024-09-01
-- 可重复执行:是(IF NOT EXISTS 幂等)
```
- 变更类脚本(加列/改表/改数据)必须写注释头:名称/用途/执行人/日期/是否可重复执行。
- 查询类(SELECT)不强制,但复杂查询建议注明用途。
## 其它
- 行尾不留空格;文件末尾一个换行。
- 字符串用单引号;标识符含保留字时用方括号(sqlserver)或双引号(其他库)。
- 禁止 `SELECT *` 之外的无条件 UPDATE/DELETE(先查影响行)。
# SQL 查询模板(四库通用)
> 可重复执行原则:所有脚本/查询必须以幂等方式编写——重复执行结果一致、不报错、不产生重复数据。
## 加列(可重复执行)
```sql
-- sqlserver:先查再改(存在才加)
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.Orders') AND name = 'Remark')
ALTER TABLE dbo.Orders ADD Remark NVARCHAR(200) NULL;
```
```sql
-- sqlite:PRAGMA 查询列名,不存在才 ALTER
SELECT COUNT(*) FROM pragma_table_info('Orders') WHERE name = 'Remark';
-- 结果 = 0 时执行:
ALTER TABLE Orders ADD COLUMN Remark TEXT;
```
## 更新前先查(核对影响行)
```sql
SELECT COUNT(*) FROM dbo.Orders WHERE Status = 'X'; -- 先看影响范围
```
## 报表/核对查询
```sql
SELECT o.OrderNo, o.Amount, SUM(d.Qty) AS TotalQty
FROM dbo.Orders o
LEFT JOIN dbo.OrderDetails d ON d.OrderId = o.Id
WHERE o.OrderDate >= '2024-01-01'
GROUP BY o.OrderNo, o.Amount
HAVING o.Amount <> SUM(d.Qty) -- 核对金额与明细不一致
ORDER BY o.OrderNo;
```
## 分页(sqlserver OFFSET-FETCH / sqlite LIMIT)
```sql
SELECT * FROM dbo.Orders ORDER BY Id OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY; -- sqlserver
SELECT * FROM Orders ORDER BY Id LIMIT 50 OFFSET 0; -- sqlite/mysql/postgres
```
## 执行计划(定位慢查询)
```sql
EXPLAIN QUERY PLAN SELECT ... ; -- sqlite
EXPLAIN SELECT ...; -- mysql
EXPLAIN ANALYZE SELECT ...; -- postgres
-- sqlserver 用 SSMS 执行计划(或 db_query 验证数据分布)
```
# 排查五步法
> 对齐系统化调试方法论(根因先行,禁止症状修复)。
## 第 1 步:复现与证据
- 完整读报错信息:堆栈全文、行号、文件路径、错误码(错误信息常含直接答案)。
- 稳定复现:能否稳定触发?触发步骤是什么?每次都出现吗?
- 不能复现 → 收集更多数据(日志/截图/现场),**不猜**。
- 检查近期变更:代码提交、配置、数据、环境差异。
## 第 2 步:链路定位(桌面端业务链路)
```
窗口/界面 → 业务类/Controller → SQL/SP → 数据库
```
- 沿链路逐层取证(`db_query` / `db_schema` / 文件读取)。
- **每一层边界记录**:什么数据进入、什么数据出去、状态/配置是否传递。
- 一次取证找出**在哪一层断**,再深入那一层(不要全链路乱翻)。
## 第 3 步:模式对比
- 找可用的同类链路/相似功能作为对照(「什么能用、和坏的很像」)。
- 对照参考实现时**完整读**(不扫读),列出每一个差异(哪怕很小)。
- 不假设「那个差异不可能有关」。
## 第 4 步:单一假设 + 最小验证
- 明确写出假设:「我认为 X 是根因,因为 Y」。
- 用**最小改动**验证一个变量;一次只验证一个假设。
- 验证不通过 → 形成新假设,**不要在失败修复上叠加修复**。
## 第 5 步:根因修复
- 先造**失败用例**(自动化测试优先;无框架则一段复现脚本)——必须在修复前有。
- 一次只改一处(无「顺手优化」、无捆绑重构)。
- 验证:用例通过?其他用例没坏?问题真正消失?
- **已试 3 次仍失败 → 停止并质疑架构**:每个修复都暴露新耦合/新问题、修复需「大重构」、每修一个又出新的 → 与用户讨论是否重构架构,而非继续第 4 次硬试。
## 环境/外部因素
若系统排查确认是环境、时序或外部因素(95% 的「无根因」是调查不完整,先自查):
1. 记录已调查内容;2. 做合理处理(重试/超时/明确报错);3. 加监控便于后续定位。
# 排查输出规范
回答排查类问题时,按以下结构输出:
```markdown
【任务类型】数据错误 / 程序异常 / 性能 / 约束 / 模块分析
【追踪链路】窗口/接口 → 业务类/Controller → SQL/SP → 数据库
【根因定位】一句话根本原因(必须基于证据,禁止臆断)
【证据链】取证过程(哪一层、用了什么工具、得到什么)
【修复建议】具体修改方案(含文件/配置/SQL)
【影响范围】可能受影响的关联功能
【测试数据】验证用关键记录 / 测试 SQL
```
## 要求
- **根因一句话**:能说清「什么环节、因为什么、导致什么」。
- **证据链可回溯**:每步结论标注来源(文件路径/查询结果/计划),读者能复现。
- **不确定要明说**:证据不足时明确「尚未确认 X」,给下一步取证建议,不编造。
- **修复建议可执行**:具体到文件/字段/SQL;附验证方式(失败用例先造)。
- 排查完的典型案例可经用户确认后整理进 dialect 热点案例(内置只读,由维护者落盘)。
# 凉鞋与拖鞋
## 品类细分
- **EVA 注塑拖鞋**:一体注塑成型,轻量防水,居家/沙滩/酒店用。
- **PVC 发泡拖鞋**:成本低,冬季凉拖/浴室拖。
- **凉鞋**:帮面(织带/皮革/超纤)+ 大底(EVA/橡胶),分平底与坡跟。
- **沙滩鞋/涉水鞋**:速干面料+排水结构+防滑底。
## 工艺特点
- **注塑成型**:模具合模注射(EVA 交联发泡或 PVC),一双一双成型,模具成本高但单双成本低。
- **冷粘**:凉鞋帮面与大底胶粘,款式变化灵活。
- 大底防滑纹路设计(排水槽/人字纹)是卖点与质检点。
## 业务要点
- 季节性强(夏季爆量),产能集中在 Q1-Q2 备货。
- 注塑款模具管理重要(模具编号/寿命/存放)。
- 尺码跨度大(成人 34-46),码重成本差异小。
# 劳保鞋
## 品类细分
- **安全鞋**:防砸(钢包头/复合包头,耐冲击≥200J 等级)、防刺穿(防刺中底)。
- **绝缘鞋**:电绝缘(6kV/10kV 等级),禁止金属部件外露。
- **防静电鞋**:表面电阻/体积电阻控制,用于电子/化工车间。
- **耐油/耐酸碱鞋**:特种材质鞋底与帮面。
- **户外/矿山劳保靴**:高帮、耐磨耐折。
## 标准与认证(国内)
- **GB 21148**《足部防护 安全鞋》:防砸/防刺穿/防静电/电绝缘等安全性能通用要求。
- 需第三方检测报告(SGS/CTI 等),出欧盟 CE 需 EN ISO 20345 系列。
- 钢包头:GB 21148 规定冲击能量 200J 与压缩 15kN 试验。
## 工艺特点
- 帮面常用:头层牛皮/二层牛皮(耐摩擦)、超纤、帆布(轻便款)。
- 大底:橡胶/聚氨酯(PU)双密度(中底缓震+外底耐磨防滑)。
- 成型:冷粘或硫化(橡胶大底硫化结合强度高,耐油款常用)。
- 中底防刺层:钢板/凯夫拉(Kevlar)复合中底。
## 业务要点
- 证书与检测报告是接单必要条件(客户验厂/验货重点)。
- 鞋码偏大(欧码),内长标注重要。
- 单量稳定但品类固定,翻单率高。
# 皮鞋与休闲鞋
## 品类细分
- **正装皮鞋**:头层牛皮帮面、真皮/橡胶大底、固特异沿条工艺(可换底)。
- **休闲皮鞋**:胶粘工艺为主,超纤/二层皮帮面,EVA/橡胶底。
- **商务休闲鞋**:轻量软底,飞织+皮革拼接。
## 材料
- 帮面:头层牛皮(粒面/修面)、二层牛皮(剖层贴膜)、超纤(PU 涂层的仿皮,物性稳定)、PU 革。
- 内里:猪皮/羊皮里(透气)、超纤里、织物里。
- 大底:真皮底(固特异)、橡胶、PU、TPU(耐磨止滑)。
## 工艺特点
- **固特异(Goodyear)**:沿条+缝线+软木填充,可换底、耐穿,工艺链长(绷帮→沿条→缝线→上底)。
- **胶粘(Cemented)**:绷帮后直接胶粘大底,主流工艺,周期短。
- **绷帮**:鞋面套楦定型,决定鞋型美观与合脚度(绷帮不良→歪楦/开胶)。
## 业务要点
- 真皮采购有批差/色差(缸差),同款多批次需预留配色余量。
- 皮鞋尺码偏大,内长标注;半码需求多。
- 固特异款交期长(工艺链长),报价高。
# 童鞋
## 品类细分
- **学步鞋(1~3 岁)**:软底、轻量、易弯折,鞋头宽敞保护脚趾。
- **机能鞋(3~6 岁)**:足弓支撑、后跟加固,纠正步态。
- **中大童鞋(6~12 岁)**:接近成人鞋结构,运动款为主(校园/日常)。
- **婴幼鞋(<1 岁)**:防掉跟、无鞋带,装饰件最少化。
## 安全标准(国内重点)
- **GB 30585**《儿童鞋安全技术规范》:物理安全(小附件抗拉、锐边)与化学安全(六价铬、可分解致癌芳香胺、甲醛、重金属限量)。
- 小附件(鞋带扣/装饰件)拉力 ≥70N 测试,防脱落误吞。
- 童鞋号型按 GB/T 3293(内长制),鞋内腔尺寸必须标注。
## 工艺特点
- 成型:注塑一体成型占比高(EVA/TPU/橡胶注塑),成本低、无胶水污染。
- 帮面:网布/飞织/超纤/帆布,避免小颗粒装饰。
- 大底:EVA(轻)或橡胶(防滑),学步鞋强调 1/3 处易弯折。
## 业务要点
- 检测报告(GB 30585 全套)是商超/电商平台入驻门槛。
- 款式多、单款量小、翻单快,对开发与排产柔性要求高。
- 尺码分段(段码)管理:婴幼段/小童段/中童段/大童段,BOM 按段码区分。
# 运动鞋
## 品类细分
- **跑步鞋**:缓震中底(EVA/TPU/超临界发泡),网布/飞织鞋面,轻量化。专业跑鞋分缓震/支撑/竞速。
- **篮球鞋**:高帮包裹、防侧翻(TPU 抗扭转片)、耐磨大底(橡胶/人字纹)。
- **足球鞋**:鞋钉(FG/SG/AG/TF 按场地)、触球面材料(袋鼠皮/超纤/合成革)。
- **综训/休闲运动鞋**:通用缓震+稳定结构,鞋面网布+合成革拼接。
## 工艺特点
- 主流成型路线:**冷粘**(占多数,鞋面+大底胶粘,工艺灵活)与**注塑**(PVC/TPU/EVA 一体成型,多用于童鞋/拖鞋)。
- 飞织鞋面:一体织造无裁片,减少针车工序,需热熔贴合支撑片。
- 大底常见组合:EVA 中底 + 橡胶外底(耐磨防滑)。
## 业务要点
- 开发节奏快(季度上新),样品确认(确认样/船头样)是关键节点。
- 尺码体系:欧码(EU)/美码(US)/英码(UK)换算,半码/整码分布影响 BOM 与排产。
- 配色多(每款多色),材料单耗按配色分别核算。
# 包装与入库
## 包装流程
- **配对**:左右脚配对(同码同色,色差/号码核对),补单件。
- **装盒**:内衬/纸盒/防潮剂;盒标(款号/颜色/尺码/条码)。
- **装箱**:箱规(每箱双数,如 12/24 双)、外箱唛(毛净重/箱号/款号)。
- **吊牌/三标**:洗标/主标/尺码标齐备(三标错漏属外观不良)。
## 入库
- 成品仓按款/色/码/批次入库,批次追溯(生产批→原材料批)。
- 尾数管理:订单完成后未销完的零头数量(库存尾数/生产尾数),需单独标识与处理。
## 排查要点(数据不对时)
- 箱规与实际装箱不符 → 出货数量差异(装盒/装箱环节)。
- 配对错误(鸳鸯鞋)→ 质检漏检(OQC 抽检比例不足)。
- 批次混放 → 追溯断链(先入库先出库原则)。
# 成型工艺
> 鞋类最后成型(贴合/上底),三大路线决定设备、胶水、交期与成本结构。
## 冷粘(Cemented)——主流
- 流程:鞋面套楦(绷帮)→ 刷处理剂/胶水(鞋面+大底)→ 烘箱活化 → 贴合 → 压合 → 冷定型。
- 特点:工艺灵活、款式切换快;胶水环保要求高(水性胶替代油性胶)。
- 常见:运动鞋、休闲鞋、凉鞋。
## 硫化(Vulcanized)
- 流程:生胶大底贴鞋面 → 硫化罐加温加压(硫磺交联)→ 橡胶熟化成型。
- 特点:橡胶大底与鞋面结合强度高、耐油耐磨;工艺链长、周期长。
- 常见:劳保鞋(耐油款)、帆布硫化鞋(经典款)。
## 注塑(Injection)
- 流程:模具合模,PVC/TPU/EVA 注射成型,大底与鞋面一体或直接成型。
- 特点:无胶水、效率高、成本低;模具投入大。
- 常见:童鞋、拖鞋、凉鞋、劳保鞋(PU 注塑底)。
## 绷帮(Lasting)
- 鞋面套楦定型工序(三大路线共用前置),决定鞋型与合脚度。
- 常见方式:手工绷帮/机器绷帮(绷帮机);后跟定型/包头定型。
## 排查要点(质量问题)
- 开胶:处理剂/胶水工艺参数(温湿度、活化温度)或大底材质污染。
- 歪楦/不对称:绷帮不正、楦头配对错误。
- 溢胶/缺胶:贴合对位、压合力与时间。
- 成型产能:成型线节拍 = 瓶颈,排产与交期核心。
# 裁断与针车
## 裁断
- **裁片**:按唛架排版裁剪出的面料片(鞋面/内里/衬片)。
- **唛架(Marker)**:裁剪排版图,决定面料利用率(利用率=裁片面积/用料面积)。
- **裁断设备**:液压裁断机(刀模冲裁)/电脑裁床(多层)。
- 要点:刀模管理(编号/寿命)、错裁/色差片隔离、余料利用。
## 针车(鞋面缝制)
- **针数**:每英寸针数(针织密度指标,影响手感与强度,常规 8~12 针/英寸)。
- 工序:合缝(帮片拼接)→ 压线 → 包边 → 装饰线 → 缝标(洗标/主标/尺码标)。
- 设备:单针/双针平缝机、花样机、包边机。
- 要点:针距均匀、线色匹配、跳针/断针记录(IPQC 巡检点)。
## 排查要点(数据不对时)
- 裁片损耗:实际裁断用量 vs BOM 单耗(损耗率是否漏加/多加,行业常见 3%~8%)。
- 针车产能:瓶颈工序负载(针车工位数量×效率 vs 排产)。
- 色差:不同缸次面料混裁混缝 → 同双色差(IQC 与仓库批次追溯)。
# BOM 与单耗
## 核心概念
- **BOM(物料清单)**:鞋款的结构化物料清单——面料/辅料/配件/大底/中底/鞋垫/包装,含单耗与用量单位。
- **单耗**:单双鞋的物料用量(如鞋面材料按平方英尺/码/米,大底按双,织带按米)。
- **损耗率**:裁断损耗/缝制损耗计入材料成本(行业常见 3%~8%,按面料弹性与排版;皮料按伤残率更高)。
- **BOM 版本**:样品 BOM → 确认样 BOM → 量产 BOM,版本与变更记录(ECN)管理。
## 核算链路
```
BOM(面料/辅料单耗×单价) + 工价(工序×工价) + 制费分摊 + 损耗加成
→ 制造成本 → + 管理/销售费用 → 完全成本 → 报价
```
## 排查要点(数据不对时)
- **单耗 vs 实际用量**:BOM 单耗与裁剪实际用量是否一致(损耗率是否漏加/多加)。
- **单价来源**:面料单价是否按最新采购价(价差未更新 → 成本虚高/虚低)。
- **工价版本**:工序工价表是否与当前工艺单版本一致。
- **分摊口径**:制费分摊基数(产量 vs 工时)是否与成本中心配置一致。
- **BOM 缺失/错挂**:配色多时按配色 BOM 区分,错挂导致材料计划偏差。
# 鞋底材料
## 大底/中底材料
- **RB(橡胶)**:耐磨、止滑、耐油,外底常用(人字纹/水波纹);硫化工艺用生胶。
- **EVA**:轻量、缓震,中底主流(发泡密度决定软硬度);成本低,压缩永久变形偏大。
- **PU(聚氨酯)**:缓震回弹好、耐疲劳,中底/整底;怕水解(储存环境要求)。
- **TPU**:高回弹、耐折、耐磨,中底/外底/结构件(抗扭转片);成本高。
- **MD(Phylon)**:EVA 二次发泡(射坯发泡),轻量回弹好,运动鞋中底。
- **PVC**:低成本,拖鞋/凉鞋注塑;低温变硬。
## 结构件
- 抗扭转片(TPU/碳板)、防刺穿中底(钢板/凯夫拉——劳保鞋)、鞋垫(EVA/乳胶/ortholite)。
## 排查要点(数据不对时)
- 单耗:大底按双计(一双两件:左+右),模具腔数决定单位产出。
- 密度/硬度规格:发泡密度波动影响克重与缓震(物性测试项)。
- 橡胶 vs 发泡类物料管理差异:橡胶需防硫化时间超期,EVA 发泡片需熟化期。
# 鞋面材料
## 织物类
- **网布(Mesh)**:透气,运动鞋鞋面主材;按纱线分涤纶网布/尼龙网布,强度与透气权衡。
- **飞织(Flyknit)**:一体织造鞋面,无裁片、轻量、贴合;需热熔丝/支撑片补强。
- **帆布**:棉/涤帆布,硫化鞋/休闲鞋经典材料,耐磨耐折。
## 皮革类
- **头层牛皮**:粒面/修面,正装皮鞋;需注意缸差与伤残。
- **二层牛皮**:剖层贴膜(PU 涂层),性价比。
- **超纤(Microfiber)**:PU 涂层的仿皮,物性稳定、色牢度好、环保,运动/休闲大量使用。
- **PU 革/合成革**:低成本,需关注耐磨与低温耐折。
## 辅料
- 织带/松紧带/鞋带、五金(鞋眼/扣具/装饰件——童鞋小附件拉力要求)、热熔胶膜(补强/贴合)、衬片(港宝:后跟定型片)。
## 排查要点(数据不对时)
- 单耗:鞋面材料单耗按唛架排版核算(利用率),实际裁断用量 vs BOM 差异定位损耗口径。
- 色差:不同缸次/批次混用(缸差),IQC 检验记录与仓库批次追溯。
- 缩水率:面料水洗/热缩参数影响裁片尺寸(工艺单参数核对)。
# 报价与成本
## 核心概念
- **料(直接材料)**:鞋面/内里/大底/中底/辅料/包装成本 = 单耗 × 单价。BOM 是核算基准。
- **工(直接人工)**:工价 × 标准工时(按工序:裁断/针车/成型/包装)。
- **费(制造费用)**:车间折旧/水电/管理分摊,按产量或工时摊入。
- **损耗率**:裁断损耗/缝制损耗计入材料成本(常见 3%~8%,皮料更高)。
- **报价口径**:FOB(离岸价)不含运费保险;CIF 含。报价 = 料 + 工 + 费 + 毛利 + 税费。
## 鞋业核算链路
```
BOM(面料/辅料/大底单耗×单价) + 工价(工序×工价,按成型路线差异) + 制费分摊 + 损耗加成
→ 制造成本 → + 管理/销售费用 → 完全成本 → 报价
```
## 排查要点(数据不对时)
- **单耗 vs 实际用量**:BOM 单耗与裁剪实际用量是否一致(损耗率是否漏加/多加)。
- **单价来源**:面料单价是否按最新采购价(价差未更新 → 成本虚高/虚低)。
- **工价版本**:工序工价表是否与当前工艺单版本一致(成型路线变化影响工价)。
- **分摊口径**:制费分摊基数(产量 vs 工时)是否与成本中心配置一致。
- **汇率**:外购件按采购币种折算是否使用正确汇率日期。
# 接单与订单评审
## 流程要点
1. **接单**:客户订单(款号/颜色/尺码/数量/单价/交期/贸易条款)。
2. **评审**:产能可接?物料齐套?报价覆盖成本?交期可行?(鞋业加评:材料到位周期、模具状态、检测证书齐备)
3. **下单**:订单转工单,生成 BOM 与工艺单(含缩水率/唛架/工序工价)。
## 鞋业评审要点
- **模具**:注塑款模具是否就绪(新模/修模状态);硫化款鞋底模具腔数。
- **材料**:特殊材料(真皮/飞织纱线/防刺中底)采购周期长,需提前备料确认。
- **检测**:劳保鞋/童鞋需检测报告(GB 21148 / GB 30585),出证周期 2~4 周,订单评审必须覆盖。
- **配色**:多配色订单按配色分别评审材料齐套。
## 排查要点
- 订单未流转:查评审状态字段(待评审/评审通过/已下单)与审批节点。
- 数量差异:接单数量 vs 工单数量 vs 出货数量核对(尾数/损耗加成口径)。
- 交期冲突:同款多订单产能重叠 → 排产冲突。
# 翻单与交期
## 核心概念
- **翻单**:老款/老订单再次下单生产(材料与模具齐备,交期短)。
- **交期承诺**:下单日 → 打样 → 备料 → 生产 → 出货(制鞋典型周期 45~90 天;翻单可缩短至 30~45 天)。
- **产能与排产**:按产线/工序产能排产;瓶颈工序(针车/成型)决定节拍。
- **齐套**:面料/辅料/大底/配件齐套后才投产(缺料 = 排产延迟主因)。
- **跟催**:交期预警(逾期/临期清单)→ 催料/调产能/插单。
## 排查要点(交期延误)
- **缺口定位**:交期延误先定位「承诺交期 vs 实际」差在哪一环(打样/备料/生产/出货)。
- **齐套率**:物料齐套率不足 → 查采购到货计划与缺料清单。
- **产能占用**:插单/急单占用瓶颈产能 → 查排产甘特/产能负载。
- **工序异常**:返工/停线 → 查质量问题与设备故障记录。
- **数据口径**:交期承诺是否按「工作日 vs 自然日」计算(口径不一致是常见根因)。
## 关键术语
- 交期(Delivery Date)/ 承诺交期(Promise Date)
- 齐套率(Kit Rate):物料备齐订单比例
- 瓶颈工序:限制整线产能的工序(针车/成型)
- 插单:临时插入高优先订单
# 检验体系
## 核心环节
- **IQC(来料检验)**:面料/辅料/大底进厂检验(色差、克重、缩水率、瑕疵、物性)。
- **IPQC(制程检验)**:裁断/针车/成型各工序巡检(针距、对称度、粘合、绷帮)。
- **OQC(出货检验)**:成品抽检(AQL 标准、功能/外观/包装)。
- **返工率/不良率**:按批次/款号统计,异常时定位到工序与班次。
- **客诉**:售后投诉按「批次 → 工序 → 原料」追溯。
## 排查要点(质量问题)
- **不良集中度**:按款/颜色/尺码/日期/班次分组,定位集中点(单一因素主导 → 该环节问题)。
- **色差/克重差异**:面料缸差(批次混用)→ 查 IQC 记录与仓库批次追溯。
- **尺寸偏差**:缩水率未按面料实测调整 → 查工艺单缩水参数。
- **缝制瑕疵**:针距/断针 → 查 IPQC 巡检记录与针车工位。
- **开胶/脱底**:成型工艺参数(活化温度/压合力)或大底材质污染 → 查成型巡检记录。
- **追溯链**:成品 → 出货单 → 生产批次 → 采购单 → 原料批(反向查证)。
## 关键术语
- AQL:可接受质量水平(抽样标准,如 AQL 2.5/4.0)
- 疵点:面料瑕疵(竹节/色斑/断纱)
- 三标:洗标/主标/尺码标(错漏属外观不良)
# 物性测试
> 鞋类材料与成品的关键物性测试项(实验室/第三方检测),影响接单、验货与客诉归因。
## 材料级
- **耐折(耐弯折)**:帮面/大底反复弯折(如 4 万次/6 万次),开裂/断裂判定。
- **耐磨**:大底/外底磨耗(DIN 磨耗/阿克隆磨耗),劳保鞋与运动鞋重点。
- **剥离强度**:鞋面与大底粘合牢度(开胶归因的核心指标)。
- **拉伸/撕裂强度**:面料/超纤/织带。
- **止滑(防滑)**:大底动/静摩擦系数,出口欧美需测。
## 成品级
- **整鞋耐折**:成品鞋动态弯折测试(屈挠次数)。
- **耐黄变**:浅色材料/大底光照黄变。
- **低温耐折**:-10℃/-20℃ 弯折(冬季鞋/劳保鞋)。
- **安全性能**(劳保鞋):防砸(200J 冲击)、防刺穿(1100N)、电绝缘(6kV/10kV)。
- **化学安全**(童鞋):六价铬、甲醛、可分解致癌芳香胺、重金属(GB 30585)。
## 排查要点
- 客诉「开胶/断底/掉色」→ 先查对应物性测试报告与生产批次测试记录。
- 材料批次差异:同供应商不同批次物性波动 → IQC 抽样测试频率不足。
- 测试标准口径:客户标准 vs 国标(GB)vs 国际(ISO/EN/ASTM)不一致是常见争议点。
# 鞋业专业术语
## 生产工艺
- **裁片**:按唛架排版裁剪出的面料片(鞋面/内里/衬片)。
- **唛架(Marker)**:裁剪排版图,决定面料利用率。
- **针数**:每英寸针数(针织密度指标,影响手感与强度)。
- **针车**:鞋面缝制工序(合缝/压线/包边)。
- **绷帮(Lasting)**:鞋面套楦定型工序,决定鞋型与合脚度。
- **成型**:鞋类最后成型(贴合/上底),分冷粘/硫化/注塑三路线。
- **冷粘**:鞋面与大底胶粘成型(主流工艺)。
- **硫化**:生胶大底加温加压熟化成型(耐油耐磨,劳保鞋常用)。
- **注塑**:PVC/TPU/EVA 模具注射成型(童鞋/拖鞋/凉鞋)。
- **港宝**:后跟定型片(热熔胶/皮革材质)。
- **飞织(Flyknit)**:一体织造鞋面技术。
## 材料
- **网布**:透气织造面料,运动鞋鞋面主材。
- **超纤**:PU 涂层的仿皮材料,物性稳定。
- **RB**:橡胶大底(耐磨止滑)。
- **EVA / MD / TPU / PU**:中底材料(发泡/二次发泡/高回弹/聚氨酯)。
- **缸差**:同色不同缸次的色差。
## 计划与贸易
- **BOM**:物料清单(面料/辅料/大底结构与用量)。
- **单耗**:单双鞋的物料用量。
- **MOQ**:最小起订量(Minimum Order Quantity)。
- **FOB / CIF**:贸易术语(离岸价/到岸价含运费保险)。
- **在制**:生产进行中的在产数量。
- **尾数**:订单完成后未销完的零头数量(库存尾数/生产尾数)。
- **翻单**:老款/老订单再次下单生产。
- **齐套**:物料备齐可投产。
- **三标**:洗标/主标/尺码标。
## 质量
- **AQL**:可接受质量水平(抽检标准)。
- **IQC / IPQC / OQC**:来料/制程/出货检验。
- **疵点**:材料瑕疵(色斑/断纱/竹节)。
- **物性测试**:耐折/耐磨/剥离/止滑等物理性能测试。
- **GB 21148**:劳保安全鞋国家标准。
- **GB 30585**:儿童鞋安全技术规范。
# 常见问题(本地沉淀区)
> 本目录为鞋业行业问答沉淀区:智能体在行业问答中确认的典型问题,由维护者整理成 Markdown 主题文件放入本目录,并在 `../INDEX.md` 的 07-常见问题 大类登记(文件名 + 一句话说明 + 触发词)。
>
> 当前为空:暂无沉淀条目。
>
> 注意:内置目录只读,智能体禁止直接写入;沉淀由维护者(人工)执行。
# 鞋业(别名:制鞋、鞋类、鞋服)
行业简介:制鞋行业基础知识——品类认知(运动鞋/劳保鞋/童鞋等)、生产工艺(裁断/针车/成型)、材料与 BOM、业务与贸易、质量管理与专业术语,供排查与需求理解对齐业务语境。适用:运动鞋、劳保鞋、童鞋、皮鞋、凉拖鞋等各类鞋生产制造企业。
## 01-品类认知
- 运动鞋.md:跑鞋/篮球鞋/足球鞋/综训鞋(触发词:运动鞋、跑鞋、篮球鞋、飞织、综训)
- 劳保鞋.md:安全鞋/防砸防刺/绝缘/钢包头(触发词:劳保鞋、安全鞋、钢包头、防刺穿、绝缘鞋)
- 童鞋.md:学步鞋/机能鞋/中大童(触发词:童鞋、学步鞋、机能鞋、小附件、GB 30585)
- 皮鞋与休闲鞋.md:真皮/超纤/固特异/胶粘(触发词:皮鞋、真皮、超纤、固特异、休闲鞋)
- 凉鞋与拖鞋.md:EVA 注塑/PVC 发泡(触发词:凉鞋、拖鞋、EVA、注塑、沙滩鞋)
## 02-生产工艺
- 裁断与针车.md:裁片/唛架/针数/鞋面针车(触发词:裁断、针车、裁片、唛架、针数、鞋面)
- 成型工艺.md:冷粘/硫化/注塑/绷帮(触发词:成型、冷粘、硫化、注塑、绷帮、上底)
- 包装与入库.md:配对/装盒/箱规(触发词:包装、配对、装盒、箱规、入库)
## 03-材料与BOM
- 鞋面材料.md:网布/飞织/皮革/超纤/PU(触发词:鞋面、网布、飞织、超纤、PU、皮革)
- 鞋底材料.md:RB/EVA/PU/TPU/橡胶/MD(触发词:鞋底、大底、EVA、TPU、RB、橡胶、MD)
- BOM与单耗.md:单耗/损耗率/用量核算(触发词:BOM、单耗、损耗、用量、核算)
## 04-业务与贸易
- 接单与订单评审.md:订单评审/下单(触发词:接单、订单评审、报价、翻单、下单)
- 报价与成本.md:料工费/报价口径(触发词:成本、核算、料工费、报价、FOB、CIF)
- 翻单与交期.md:翻单/齐套/交期(触发词:交期、延误、跟催、齐套、尾数、翻单)
## 05-质量管理
- 检验体系.md:IQC/IPQC/OQC/AQL(触发词:质量、IQC、IPQC、OQC、返工、客诉、AQL)
- 物性测试.md:耐折/耐磨/剥离/止滑(触发词:物性、耐折、耐磨、剥离、止滑、拉力)
## 06-专业术语
- 术语表.md:裁片/针数/唛架/绷帮/翻单/齐套(触发词:术语、MOQ、FOB、唛架、绷帮、翻单)
## 07-常见问题
> 本地沉淀区:行业问答中确认的常见问题经维护者整理追加到本大类(内置目录只读,禁止智能体直接写入)。
......@@ -196,6 +196,25 @@ namespace Ftt.PatchPack
#endregion
#region 内置方言/行业知识(Agent/dialect、Agent/industry,随程序发布)
// 轻量智能体内置知识:产品排查方言(dialect)+ 行业知识(industry),只读、随程序发布。
// 新增/修改的内置知识进入增量补丁,解包到客户端 {ExeName}\Agent\ 下(时间窗同 XML,默认 30 天)。
var dialectPath = Path.Combine(AbsoluteRootDirectory, RelativeAppFilePath, "Agent", "dialect");
if (System.IO.Directory.Exists(dialectPath))
{
valid.AddRange(GetValidPatchFile(dialectPath, "*.*", SearchOption.AllDirectories, dialectPath, $"{ExeName}\\Agent\\dialect", PatchTimeFrame_Xml));
}
var industryPath = Path.Combine(AbsoluteRootDirectory, RelativeAppFilePath, "Agent", "industry");
if (System.IO.Directory.Exists(industryPath))
{
valid.AddRange(GetValidPatchFile(industryPath, "*.*", SearchOption.AllDirectories, industryPath, $"{ExeName}\\Agent\\industry", PatchTimeFrame_Xml));
}
#endregion
#region 额外附加的文件.
foreach (var fileName in ContainFiles)
......
......@@ -23,6 +23,7 @@ namespace Tools.MyAgent.Agent
private IOpenAiChatClient _client = new OpenAiCompatibleClient();
private readonly KbService _kb;
private readonly PromptBuilder _prompts = new PromptBuilder();
private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry)
private CancellationTokenSource _cts;
/// <summary>上下文 token 预算(由配置注入;默认 32000)</summary>
......@@ -138,6 +139,9 @@ namespace Tools.MyAgent.Agent
_kb = kb;
_backupClient = new OpenAiCompatibleClient();
_failover = new FailoverPolicy(_client, _backupClient);
// 内置方言/行业知识:随程序发布(AppDomain.BaseDirectory + Agent),目录缺失时自动降级为空
_dialect = new DialectService(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent"));
_prompts.Dialect = _dialect;
}
/// <summary>应用配置(ApiKey/BaseUrl/Model/Temperature/MaxTokens/代理/上下文预算)</summary>
......@@ -920,6 +924,7 @@ namespace Tools.MyAgent.Agent
{
CancelToken = ct,
DbConnections = DbConnections,
Dialect = _dialect,
};
string result = tool.Execute(args, ctx);
sw.Stop();
......
......@@ -18,6 +18,9 @@ namespace Tools.MyAgent.Agent
/// <summary>系统提示词启用开关(由设置注入;仅启用后才注入 CustomLayer)</summary>
public bool SystemPromptEnabled { get; set; }
/// <summary>内置方言/行业知识服务(产品排查方言 + 鞋业行业知识;由引擎注入,可空)</summary>
public DialectService Dialect { get; set; }
public string BuildSystemMessage(KbService kb)
{
// B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制),
......@@ -29,6 +32,8 @@ namespace Tools.MyAgent.Agent
sb.AppendLine();
sb.AppendLine(BuildKbLayer(kb));
sb.AppendLine();
sb.AppendLine(BuildDialectLayer());
sb.AppendLine();
// 自定义系统提示词:仅启用开关打开时注入(关闭时即使输入框有内容也不注入)
if (SystemPromptEnabled && !string.IsNullOrWhiteSpace(CustomLayer))
{
......@@ -155,6 +160,23 @@ namespace Tools.MyAgent.Agent
return sb.ToString();
}
/// <summary>
/// §2.2b 方言层(动态,随内置知识注入):产品排查方言 + 鞋业行业知识导航摘要。
/// 触发词路由:排查/业务类问题先按摘要命中 → d_grep 定位 → d_read 精读(禁止全量读取)。
/// </summary>
private string BuildDialectLayer()
{
if (Dialect == null || !Dialect.IsAvailable) return "";
try
{
return Dialect.BuildSummary();
}
catch
{
return ""; // 内置知识损坏不影响智能体可用性
}
}
/// <summary>搜索范围描述(空 = 整个知识库根目录)</summary>
private static string ScopeText(KbService kb)
{
......
......@@ -355,7 +355,8 @@ namespace Tools.MyAgent.Agent.Tools
return Truncate(sb.ToString());
}
private static IDbConnection OpenConnection(string type, string connection)
/// <summary>打开非 sqlserver 数据库连接(sqlserver 走平台 SmpDB,见 ExecuteQuery);供 DbSchemaTool 复用</summary>
internal static IDbConnection OpenConnection(string type, string connection)
{
switch (type)
{
......
using Newtonsoft.Json.Linq;
using System;
using System.Data;
using System.Text;
using Tools.MyData;
namespace Tools.MyAgent.Agent.Tools
{
/// <summary>
/// db_schema(轻量版,迁移自 Common.Agent):只读结构分析——表/列/索引/主键/视图定义。
/// <para>sqlserver 走平台 SmpDB 数据访问层;sqlite 用 sqlite_master + PRAGMA;mysql/postgres 用 information_schema。
/// 与 db_query 同一连接体系(connectionId 预存连接优先)。辅助 SQL 排查:表名/列名不确定时先查结构再写查询。</para>
/// </summary>
internal sealed class DbSchemaTool : ITool
{
public string Name => "db_schema";
public string Description =>
"查看数据库结构(表清单/表列/索引/主键/视图),用于了解表结构辅助写查询与排查(只读)。" +
"排查查询报错(表名/列名错误)或不确定字段名时先用本工具确认结构。";
public string ParametersJson => @"{'type':'object',
'properties':{
'type':{'type':'string','enum':['sqlserver','mysql','postgres','sqlite'],'description':'数据库类型'},
'connectionId':{'type':'string','description':" + ToolJson.Q("预存连接 ID(设置中配置,如 sqlserver_erp);指定后无需再传 connection,连接串不回显") + @"},
'connection':{'type':'string','description':" + ToolJson.Q("连接字符串(用户提供,仅当未指定 connectionId 时使用);sqlite 可直接给数据库文件路径") + @"},
'object':{'type':'string','enum':['table','view'],'description':'对象类型,默认 table'},
'name':{'type':'string','description':" + ToolJson.Q("对象名(表/视图);缺省列出全部该类对象") + @"}
},
'required':['type']}";
public bool RequiresKb => false;
public bool IsDefault => false;
public string Describe(JObject args) => $"查看表结构:{args["name"]?.Value<string>() ?? "(全部)"}";
public string Execute(JObject args, ToolContext ctx)
{
string type = (args["type"]?.Value<string>() ?? "").Trim().ToLowerInvariant();
string obj = (args["object"]?.Value<string>() ?? "table").Trim().ToLowerInvariant();
string name = (args["name"]?.Value<string>() ?? "").Trim();
string connection = null;
string error = null;
if (!DbQueryTool.ResolveConnection(args, ctx, ref connection, ref error))
return error;
try
{
return type == "sqlserver"
? SqlServerSchema(connection, obj, name)
: InfoSchema(type, connection, obj, name);
}
catch (Exception ex) { return $"错误:{ex.Message}"; }
}
/// <summary>sqlserver:平台 SmpDB 执行 INFORMATION_SCHEMA 查询(只读 SELECT)</summary>
private static string SqlServerSchema(string connection, string obj, string name)
{
var sb = new StringBuilder();
using (var db = new SmpDB())
{
if (string.IsNullOrEmpty(name))
{
string sql = obj == "view"
? "SELECT TABLE_NAME AS Name FROM INFORMATION_SCHEMA.VIEWS ORDER BY TABLE_NAME"
: "SELECT TABLE_NAME AS Name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME";
var dt = db.GetDataTable(connection, sql, "schema", false);
if (dt.Rows.Count == 0) return "(无" + (obj == "view" ? "视图" : "表") + ")";
foreach (DataRow r in dt.Rows)
sb.AppendLine(r["Name"]?.ToString() ?? "");
return sb.ToString();
}
// 指定对象:先确认存在,再列列定义
string typeFilter = obj == "view" ? "VIEW" : "BASE TABLE";
string chk = "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME='" + name.Replace("'", "''")
+ "' AND TABLE_TYPE='" + typeFilter + "'";
var exist = db.GetDataTable(connection, chk, "schema", false);
if (exist.Rows.Count == 0)
return $"未找到 {(obj == "view" ? "视图" : "表")}:{name}(可先不带 name 列出全部表清单)";
string cols = "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='"
+ name.Replace("'", "''") + "' ORDER BY ORDINAL_POSITION";
var dtc = db.GetDataTable(connection, cols, "schema", false);
sb.AppendLine($"== {name}({dtc.Rows.Count} 列)==");
foreach (DataRow r in dtc.Rows)
{
string dtype = r["DATA_TYPE"]?.ToString() ?? "";
if (r["CHARACTER_MAXIMUM_LENGTH"] != null && r["CHARACTER_MAXIMUM_LENGTH"] != DBNull.Value)
dtype += "(" + r["CHARACTER_MAXIMUM_LENGTH"] + ")";
sb.AppendLine($" {r["COLUMN_NAME"]} {dtype} {(r["IS_NULLABLE"]?.ToString() ?? "")}");
}
// 索引/主键(sqlserver 视图无索引)
if (obj != "view")
{
string idx = "SELECT i.name AS IndexName, i.is_primary_key AS IsPK, " +
"STUFF((SELECT ',' + c.name FROM sys.index_columns ic " +
"JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id " +
"WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 1, '') AS Cols " +
"FROM sys.indexes i WHERE i.object_id = OBJECT_ID('" + name.Replace("'", "''") + "') AND i.name IS NOT NULL ORDER BY i.index_id";
var dti = db.GetDataTable(connection, idx, "schema", false);
if (dti.Rows.Count > 0)
{
sb.AppendLine("索引:");
foreach (DataRow r in dti.Rows)
sb.AppendLine($" {r["IndexName"]}{(Convert.ToInt32(r["IsPK"]) == 1 ? "(主键)" : "")}:{r["Cols"]}");
}
}
return sb.ToString();
}
}
/// <summary>sqlite:sqlite_master + PRAGMA;mysql/postgres:information_schema</summary>
private static string InfoSchema(string type, string connection, string obj, string name)
{
if (type == "sqlite")
return SqliteSchema(connection, obj, name);
var sb = new StringBuilder();
using (var conn = DbQueryTool.OpenConnection(type, connection))
using (var cmd = conn.CreateCommand())
{
conn.Open();
cmd.CommandText = string.IsNullOrEmpty(name)
? "SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS ORDER BY TABLE_NAME, ORDINAL_POSITION"
: "SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='"
+ name.Replace("'", "''") + "' ORDER BY TABLE_NAME, ORDINAL_POSITION";
string last = null;
bool found = false;
using (var rd = cmd.ExecuteReader())
{
while (rd.Read())
{
string t = rd.GetString(0);
if (last != t)
{
last = t;
sb.AppendLine($"== {t} ==");
found = true;
}
sb.AppendLine($" {rd.GetString(1)} {rd.GetString(2)} {(rd.IsDBNull(3) ? "" : rd.GetString(3))}");
}
}
if (!found) return $"未找到对象:{name}";
}
return sb.ToString();
}
private static string SqliteSchema(string connection, string obj, string name)
{
var sb = new StringBuilder();
using (var conn = DbQueryTool.OpenConnection("sqlite", connection))
using (var cmd = conn.CreateCommand())
{
conn.Open();
string typeFilter = obj == "view" ? "view" : "table";
cmd.CommandText = $"SELECT name, sql FROM sqlite_master WHERE type='{typeFilter}' AND name NOT LIKE 'sqlite_%' ORDER BY name";
using (var rd = cmd.ExecuteReader())
{
bool found = false;
while (rd.Read())
{
string tname = rd.GetString(0);
if (!string.IsNullOrEmpty(name) && !tname.Equals(name, StringComparison.OrdinalIgnoreCase)) continue;
found = true;
sb.AppendLine($"== {tname} ==");
if (obj == "view")
{
string sql = rd.IsDBNull(1) ? "" : rd.GetString(1);
sb.AppendLine(Truncate(sql, 1500));
continue;
}
using (var cmd2 = conn.CreateCommand())
{
cmd2.CommandText = $"PRAGMA table_info(\"{tname.Replace("\"", "\"\"")}\")";
using (var rd2 = cmd2.ExecuteReader())
while (rd2.Read())
sb.AppendLine($" {rd2.GetString(1)} {rd2.GetString(2)}{(rd2.GetInt32(5) == 1 ? " PK" : "")}");
}
using (var cmd2 = conn.CreateCommand())
{
cmd2.CommandText = $"PRAGMA index_list(\"{tname.Replace("\"", "\"\"")}\")";
using (var rd2 = cmd2.ExecuteReader())
while (rd2.Read())
sb.AppendLine($" 索引:{rd2.GetString(1)}({(rd2.GetInt32(2) == 1 ? "唯一" : "普通")})");
}
}
if (!found) return $"未找到 {(obj == "view" ? "视图" : "表")}:{name}(可先不带 name 列出全部)";
}
}
return sb.Length > 0 ? sb.ToString() : "(无表)";
}
private static string Truncate(string s, int max)
=> s == null ? "" : (s.Length <= max ? s : s.Substring(0, max) + "\n…(已截断)");
}
}
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Tools.MyAgent.Agent.Tools
{
/// <summary>
/// 内置方言读取工具(d_* 系列):只读访问程序目录 Agent/dialect/(产品排查方言)与 Agent/industry/(行业知识)。
/// <para>沙箱:路径必须以 "dialect/" 或 "industry/" 开头,经 DialectService.ResolveSafe 校验(越界/符号链接拒绝),
/// 与用户知识库(kb_*)完全隔离。默认启用(排查能力是核心目标)。</para>
/// </summary>
internal sealed class DialectListTool : ITool
{
public string Name => "d_list";
public string Description =>
"列出内置产品方言与行业知识目录下的文件/子目录(路径前缀:dialect/ 或 industry/),用于了解内置知识组织结构。" +
"dialect/ = 产品排查方言(sql-core/troubleshoot-core/erp/mes/pdm),industry/ = 鞋业行业知识。";
public string ParametersJson => @"{'type':'object',
'properties':{
'path':{'type':'string','description':" + ToolJson.Q("内置知识内相对路径,如 'dialect'、'dialect/erp'、'industry/鞋业';默认 '.'(列出 dialect 与 industry 两级)") + @"},
'recursive':{'type':'boolean','description':" + ToolJson.Q("是否递归列出子目录,默认 false") + @"}
}}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => $"浏览内置知识:{args["path"]?.Value<string>() ?? "."}";
public string Execute(JObject args, ToolContext ctx)
{
var dialect = ctx.Dialect;
if (dialect == null || !dialect.IsAvailable)
return "错误:内置方言/行业知识目录不可用(程序目录下 Agent/dialect、Agent/industry)。";
string path = args["path"]?.Value<string>() ?? ".";
bool recursive = args["recursive"]?.Value<bool>() ?? false;
try
{
if (path == "." || path == "dialect" || path == "industry")
{
var sb = new StringBuilder();
if (path == "." || path == "dialect")
sb.AppendLine("dialect/(产品排查方言):").Append(ListDir(dialect.DialectRoot, recursive, "dialect"));
if (path == "." || path == "industry")
sb.AppendLine("industry/(行业知识):").Append(ListDir(dialect.IndustryRoot, recursive, "industry"));
return sb.ToString();
}
string abs = dialect.ResolveSafe(path);
if (abs == null || !Directory.Exists(abs))
return $"错误:路径不存在或非法:{path}(仅允许 dialect/ 与 industry/ 前缀,拒绝越界)。";
return ListDir(abs, recursive, path.Split('/')[0]);
}
catch (Exception ex) { return $"错误:{ex.Message}"; }
}
private static string ListDir(string abs, bool recursive, string prefix)
{
if (string.IsNullOrEmpty(abs) || !Directory.Exists(abs)) return "(目录不可用)\n";
var sb = new StringBuilder();
foreach (var dir in Directory.GetDirectories(abs).OrderBy(d => d, StringComparer.Ordinal))
{
sb.AppendLine(" [目录] " + Path.GetFileName(dir));
if (recursive)
{
string rel = prefix + "/" + Path.GetFileName(dir);
string inner = ListDir(dir, true, rel);
foreach (var l in inner.Split('\n').Where(x => x.Length > 0))
sb.AppendLine(" " + l.TrimEnd());
}
}
foreach (var f in Directory.GetFiles(abs, "*.*").OrderBy(f => f, StringComparer.Ordinal))
sb.AppendLine(" " + Path.GetFileName(f));
return sb.ToString();
}
}
/// <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件与上下文片段</summary>
internal sealed class DialectGrepTool : ITool
{
public string Name => "d_grep";
public string Description =>
"在内置产品方言与行业知识文件内容中搜索关键字(限 *.md 文本),返回命中文件与上下文片段。" +
"排查/业务类问题先按触发词用本工具定位,再用 d_read 精读。";
public string ParametersJson => @"{'type':'object',
'properties':{
'query':{'type':'string','description':" + ToolJson.Q("搜索关键字(支持正则,如 '审批不触发'、'库存(不对|多扣)')") + @"},
'glob':{'type':'string','description':" + ToolJson.Q("限定路径前缀,如 'dialect/erp'、'industry/鞋业';不填搜索全部内置知识") + @"},
'maxResults':{'type':'integer','description':'最多返回命中条数','minimum':1,'maximum':30}
},
'required':['query']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => $"内置知识检索:{args["query"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
var dialect = ctx.Dialect;
if (dialect == null || !dialect.IsAvailable)
return "错误:内置方言/行业知识目录不可用。";
string query = args["query"]?.Value<string>() ?? "";
if (string.IsNullOrEmpty(query)) return "错误:缺少 query 参数。";
string glob = (args["glob"]?.Value<string>() ?? "").Trim().TrimEnd('/').Replace('\\', '/');
int max = args["maxResults"]?.Value<int>() ?? 20;
if (max < 1) max = 1;
if (max > 30) max = 30;
try
{
var regex = new System.Text.RegularExpressions.Regex(query,
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
var hits = new List<string>();
foreach (var rel in dialect.EnumerateFiles())
{
if (glob.Length > 0 && !rel.StartsWith(glob + "/", StringComparison.OrdinalIgnoreCase)
&& !rel.Equals(glob, StringComparison.OrdinalIgnoreCase)) continue;
string abs = dialect.ResolveSafe(rel);
if (abs == null || !File.Exists(abs)) continue;
string[] lines;
try { lines = File.ReadAllLines(abs, Encoding.UTF8); }
catch { continue; }
foreach (var line in lines)
{
if (line.Length > 300) continue; // 跳过超长行(通常是代码块)
if (regex.IsMatch(line))
{
hits.Add($"- {rel}:{line.Trim().TrimStart('#', ' ', '>', '-', '*')}");
if (hits.Count >= max) break;
}
}
if (hits.Count >= max) break;
}
if (hits.Count == 0)
return $"(内置知识中未找到「{query}」;可换关键词,或先 d_list 浏览目录结构)";
return $"命中 {hits.Count} 条:\n" + string.Join("\n", hits);
}
catch (Exception ex) { return $"错误:{ex.Message}"; }
}
}
/// <summary>d_read:读取内置方言/行业知识文件内容(UTF-8 文本,≤300 行/次)</summary>
internal sealed class DialectReadTool : ITool
{
public string Name => "d_read";
public string Description =>
"读取内置产品方言或行业知识文件的文本内容(只读)。" +
"路径格式:dialect/&lt;主题&gt;/&lt;文件&gt;(如 dialect/erp/winforms.md)或 industry/&lt;行业&gt;/&lt;大类&gt;/&lt;文件&gt;(如 industry/鞋业/01-品类认知/运动鞋.md)。";
public string ParametersJson => @"{'type':'object',
'properties':{
'path':{'type':'string','description':" + ToolJson.Q("内置知识相对路径,如 'dialect/erp/winforms.md'、'industry/鞋业/06-专业术语/术语表.md'") + @"},
'offset':{'type':'integer','description':'起始行号,默认 1','minimum':1},
'limit':{'type':'integer','description':'最多读取行数,默认 200,最大 300','minimum':1,'maximum':300}
},
'required':['path']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => $"读取内置知识:{args["path"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
var dialect = ctx.Dialect;
if (dialect == null || !dialect.IsAvailable)
return "错误:内置方言/行业知识目录不可用。";
string path = args["path"]?.Value<string>() ?? "";
if (string.IsNullOrEmpty(path))
return "错误:缺少 path 参数(如 'dialect/erp/winforms.md'、'industry/鞋业/术语表' 或 'industry/鞋业/06-专业术语/术语表.md')。";
int offset = args["offset"]?.Value<int>() ?? 1;
int limit = args["limit"]?.Value<int>() ?? 200;
if (offset < 1) offset = 1;
if (limit < 1) limit = 1;
if (limit > 300) limit = 300;
string abs = dialect.ResolveSafe(path);
if (abs == null || !File.Exists(abs))
{
// #32 风格参数补全:给出候选文件名,便于模型二次确认
var cands = dialect.EnumerateFiles()
.Where(f => f.IndexOf(path, StringComparison.OrdinalIgnoreCase) >= 0
|| path.IndexOf(Path.GetFileNameWithoutExtension(f), StringComparison.OrdinalIgnoreCase) >= 0)
.Take(8).ToList();
if (cands.Count > 0)
return $"错误:文件不存在:{path}。相近的内置知识文件:\n- " + string.Join("\n- ", cands)
+ "\n(请从候选中选择或给出完整路径)";
return $"错误:文件不存在或路径非法:{path}(仅允许 dialect/ 与 industry/ 前缀,拒绝越界)。";
}
try
{
var lines = File.ReadAllLines(abs, Encoding.UTF8);
int start = Math.Min(offset - 1, Math.Max(0, lines.Length - 1));
int count = Math.Min(limit, lines.Length - start);
var sb = new StringBuilder();
sb.AppendLine($"== {path}(共 {lines.Length} 行,显示 {start + 1}-{start + count} 行)==");
for (int i = start; i < start + count; i++)
sb.AppendLine(lines[i]);
if (start + count < lines.Length)
sb.AppendLine($"…(剩余 {lines.Length - start - count} 行未显示,可 offset={start + count + 1} 续读)");
return sb.ToString();
}
catch (Exception ex) { return $"错误:{ex.Message}"; }
}
}
}
......@@ -26,6 +26,9 @@ namespace Tools.MyAgent.Agent.Tools
/// <summary>预存数据库连接串(ID → 连接串,已解密明文;来自 AgentConfig.DbConnections)</summary>
public IReadOnlyDictionary<string, string> DbConnections { get; set; }
/// <summary>内置方言/行业知识服务(d_* 工具沙箱;程序目录 Agent/dialect + Agent/industry)</summary>
public DialectService Dialect { get; set; }
public ToolContext(KbService kb, Action<string> onFileProduced)
{
Kb = kb;
......@@ -155,9 +158,14 @@ namespace Tools.MyAgent.Agent.Tools
Register(new KbWriteTool());
Register(new KbWriteKbTool());
Register(new DbQueryTool());
Register(new DbSchemaTool());
Register(new DocExportTool());
Register(new ImageSearchTool());
Register(new ModulePatchTool());
// 内置方言/行业知识(d_* 系列,只读沙箱,默认启用)
Register(new DialectListTool());
Register(new DialectGrepTool());
Register(new DialectReadTool());
}
/// <summary>全部已注册工具(下发顺序)</summary>
......
......@@ -53,6 +53,12 @@
this.txtSharePassword = new System.Windows.Forms.TextBox();
this.btnTestShare = new System.Windows.Forms.Button();
this.tabAdv = new System.Windows.Forms.TabPage();
this.tabAdvSub = new System.Windows.Forms.TabControl();
this.tabSubGen = new System.Windows.Forms.TabPage();
this.tabSubStrategy = new System.Windows.Forms.TabPage();
this.tabSubSem = new System.Windows.Forms.TabPage();
this.tabSubFail = new System.Windows.Forms.TabPage();
this.tabSubOpt = new System.Windows.Forms.TabPage();
this.grpGen = new System.Windows.Forms.GroupBox();
this.lblTemp = new System.Windows.Forms.Label();
this.numTemp = new System.Windows.Forms.NumericUpDown();
......@@ -85,7 +91,6 @@
this.cmbRole = new System.Windows.Forms.ComboBox();
this.txtCustomPrompt = new System.Windows.Forms.TextBox();
this.lblPromptHint = new System.Windows.Forms.Label();
this.tabOther = new System.Windows.Forms.TabPage();
this.grpOpt = new System.Windows.Forms.GroupBox();
this.chkOpt = new System.Windows.Forms.CheckBox();
this.chkImageInput = new System.Windows.Forms.CheckBox();
......@@ -105,6 +110,12 @@
this.grpModel.SuspendLayout();
this.grpKb.SuspendLayout();
this.tabAdv.SuspendLayout();
this.tabAdvSub.SuspendLayout();
this.tabSubGen.SuspendLayout();
this.tabSubStrategy.SuspendLayout();
this.tabSubSem.SuspendLayout();
this.tabSubFail.SuspendLayout();
this.tabSubOpt.SuspendLayout();
this.grpGen.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numTemp)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxTokens)).BeginInit();
......@@ -115,7 +126,6 @@
this.grpSem.SuspendLayout();
this.tabPrompt.SuspendLayout();
this.grpPrompt.SuspendLayout();
this.tabOther.SuspendLayout();
this.grpOpt.SuspendLayout();
this.grpFail.SuspendLayout();
this.SuspendLayout();
......@@ -125,7 +135,6 @@
this.tabMain.Controls.Add(this.tabBasic);
this.tabMain.Controls.Add(this.tabAdv);
this.tabMain.Controls.Add(this.tabPrompt);
this.tabMain.Controls.Add(this.tabOther);
this.tabMain.Location = new System.Drawing.Point(12, 12);
this.tabMain.Name = "tabMain";
this.tabMain.SelectedIndex = 0;
......@@ -378,10 +387,7 @@
//
// tabAdv
//
this.tabAdv.AutoScroll = true;
this.tabAdv.Controls.Add(this.grpGen);
this.tabAdv.Controls.Add(this.grpStrategy);
this.tabAdv.Controls.Add(this.grpSem);
this.tabAdv.Controls.Add(this.tabAdvSub);
this.tabAdv.Location = new System.Drawing.Point(4, 26);
this.tabAdv.Name = "tabAdv";
this.tabAdv.Padding = new System.Windows.Forms.Padding(3);
......@@ -390,6 +396,74 @@
this.tabAdv.Text = "高级配置";
this.tabAdv.UseVisualStyleBackColor = true;
//
// tabAdvSub
//
this.tabAdvSub.Controls.Add(this.tabSubGen);
this.tabAdvSub.Controls.Add(this.tabSubStrategy);
this.tabAdvSub.Controls.Add(this.tabSubSem);
this.tabAdvSub.Controls.Add(this.tabSubFail);
this.tabAdvSub.Controls.Add(this.tabSubOpt);
this.tabAdvSub.Location = new System.Drawing.Point(3, 3);
this.tabAdvSub.Name = "tabAdvSub";
this.tabAdvSub.SelectedIndex = 0;
this.tabAdvSub.Size = new System.Drawing.Size(606, 354);
this.tabAdvSub.TabIndex = 0;
//
// tabSubGen
//
this.tabSubGen.Controls.Add(this.grpGen);
this.tabSubGen.Location = new System.Drawing.Point(4, 26);
this.tabSubGen.Name = "tabSubGen";
this.tabSubGen.Padding = new System.Windows.Forms.Padding(3);
this.tabSubGen.Size = new System.Drawing.Size(598, 324);
this.tabSubGen.TabIndex = 0;
this.tabSubGen.Text = "生成参数";
this.tabSubGen.UseVisualStyleBackColor = true;
//
// tabSubStrategy
//
this.tabSubStrategy.Controls.Add(this.grpStrategy);
this.tabSubStrategy.Location = new System.Drawing.Point(4, 26);
this.tabSubStrategy.Name = "tabSubStrategy";
this.tabSubStrategy.Padding = new System.Windows.Forms.Padding(3);
this.tabSubStrategy.Size = new System.Drawing.Size(598, 324);
this.tabSubStrategy.TabIndex = 1;
this.tabSubStrategy.Text = "策略参数";
this.tabSubStrategy.UseVisualStyleBackColor = true;
//
// tabSubSem
//
this.tabSubSem.Controls.Add(this.grpSem);
this.tabSubSem.Location = new System.Drawing.Point(4, 26);
this.tabSubSem.Name = "tabSubSem";
this.tabSubSem.Padding = new System.Windows.Forms.Padding(3);
this.tabSubSem.Size = new System.Drawing.Size(598, 324);
this.tabSubSem.TabIndex = 2;
this.tabSubSem.Text = "语义检索";
this.tabSubSem.UseVisualStyleBackColor = true;
//
// tabSubFail
//
this.tabSubFail.Controls.Add(this.grpFail);
this.tabSubFail.Location = new System.Drawing.Point(4, 26);
this.tabSubFail.Name = "tabSubFail";
this.tabSubFail.Padding = new System.Windows.Forms.Padding(3);
this.tabSubFail.Size = new System.Drawing.Size(598, 324);
this.tabSubFail.TabIndex = 3;
this.tabSubFail.Text = "故障转移";
this.tabSubFail.UseVisualStyleBackColor = true;
//
// tabSubOpt
//
this.tabSubOpt.Controls.Add(this.grpOpt);
this.tabSubOpt.Location = new System.Drawing.Point(4, 26);
this.tabSubOpt.Name = "tabSubOpt";
this.tabSubOpt.Padding = new System.Windows.Forms.Padding(3);
this.tabSubOpt.Size = new System.Drawing.Size(598, 324);
this.tabSubOpt.TabIndex = 4;
this.tabSubOpt.Text = "可选能力";
this.tabSubOpt.UseVisualStyleBackColor = true;
//
// grpGen
//
this.grpGen.Controls.Add(this.lblTemp);
......@@ -401,9 +475,9 @@
this.grpGen.Controls.Add(this.numContext);
this.grpGen.Controls.Add(this.lblContextHint);
this.grpGen.Controls.Add(this.chkProxy);
this.grpGen.Location = new System.Drawing.Point(8, 12);
this.grpGen.Location = new System.Drawing.Point(4, 8);
this.grpGen.Name = "grpGen";
this.grpGen.Size = new System.Drawing.Size(596, 128);
this.grpGen.Size = new System.Drawing.Size(590, 128);
this.grpGen.TabIndex = 0;
this.grpGen.TabStop = false;
this.grpGen.Text = "生成参数";
......@@ -552,9 +626,9 @@
this.grpSem.Controls.Add(this.lblEmbKey);
this.grpSem.Controls.Add(this.txtEmbKey);
this.grpSem.Controls.Add(this.lblEmbHint);
this.grpSem.Location = new System.Drawing.Point(8, 148);
this.grpSem.Location = new System.Drawing.Point(4, 8);
this.grpSem.Name = "grpSem";
this.grpSem.Size = new System.Drawing.Size(596, 128);
this.grpSem.Size = new System.Drawing.Size(590, 128);
this.grpSem.TabIndex = 1;
this.grpSem.TabStop = false;
this.grpSem.Text = "语义检索(向量)";
......@@ -641,9 +715,9 @@
this.grpStrategy.Controls.Add(this.numToolRounds);
this.grpStrategy.Controls.Add(this.lblHistory);
this.grpStrategy.Controls.Add(this.numHistory);
this.grpStrategy.Location = new System.Drawing.Point(8, 284);
this.grpStrategy.Location = new System.Drawing.Point(4, 8);
this.grpStrategy.Name = "grpStrategy";
this.grpStrategy.Size = new System.Drawing.Size(596, 92);
this.grpStrategy.Size = new System.Drawing.Size(590, 92);
this.grpStrategy.TabIndex = 2;
this.grpStrategy.TabStop = false;
this.grpStrategy.Text = "策略参数";
......@@ -801,27 +875,14 @@
this.lblPromptHint.TabIndex = 1;
this.lblPromptHint.Text = "启用开关开启后,下方提示词作为追加层注入(位于安全层之前),未启用则不注入;切换角色会用所选角色的模板覆盖输入框内容(已修改时会先确认)。角色模板库位于 Agen" +
"t/prompts/roles.json,可直接编辑新增角色;基础层提示词模板位于 Agent/prompts 目录(*.md)。";
//
// tabOther
//
this.tabOther.AutoScroll = true;
this.tabOther.Controls.Add(this.grpOpt);
this.tabOther.Controls.Add(this.grpFail);
this.tabOther.Location = new System.Drawing.Point(4, 26);
this.tabOther.Name = "tabOther";
this.tabOther.Padding = new System.Windows.Forms.Padding(3);
this.tabOther.Size = new System.Drawing.Size(612, 360);
this.tabOther.TabIndex = 3;
this.tabOther.Text = "其他";
this.tabOther.UseVisualStyleBackColor = true;
//
// grpOpt
//
this.grpOpt.Controls.Add(this.chkOpt);
this.grpOpt.Controls.Add(this.chkImageInput);
this.grpOpt.Location = new System.Drawing.Point(8, 12);
this.grpOpt.Location = new System.Drawing.Point(4, 8);
this.grpOpt.Name = "grpOpt";
this.grpOpt.Size = new System.Drawing.Size(596, 92);
this.grpOpt.Size = new System.Drawing.Size(590, 216);
this.grpOpt.TabIndex = 0;
this.grpOpt.TabStop = false;
this.grpOpt.Text = "可选能力";
......@@ -858,9 +919,9 @@
this.grpFail.Controls.Add(this.lblBKey);
this.grpFail.Controls.Add(this.txtBKey);
this.grpFail.Controls.Add(this.lblFailHint);
this.grpFail.Location = new System.Drawing.Point(8, 112);
this.grpFail.Location = new System.Drawing.Point(4, 8);
this.grpFail.Name = "grpFail";
this.grpFail.Size = new System.Drawing.Size(596, 168);
this.grpFail.Size = new System.Drawing.Size(590, 168);
this.grpFail.TabIndex = 2;
this.grpFail.TabStop = false;
this.grpFail.Text = "故障转移(主模型连续失败自动切备用)";
......@@ -984,6 +1045,8 @@
this.grpKb.ResumeLayout(false);
this.grpKb.PerformLayout();
this.tabAdv.ResumeLayout(false);
this.tabAdvSub.ResumeLayout(false);
this.tabSubGen.ResumeLayout(false);
this.grpGen.ResumeLayout(false);
this.grpGen.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.numTemp)).EndInit();
......@@ -991,19 +1054,22 @@
((System.ComponentModel.ISupportInitialize)(this.numContext)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numToolRounds)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numHistory)).EndInit();
this.tabSubStrategy.ResumeLayout(false);
this.grpStrategy.ResumeLayout(false);
this.grpStrategy.PerformLayout();
this.tabSubSem.ResumeLayout(false);
this.grpSem.ResumeLayout(false);
this.grpSem.PerformLayout();
this.tabSubFail.ResumeLayout(false);
this.grpFail.ResumeLayout(false);
this.grpFail.PerformLayout();
this.tabPrompt.ResumeLayout(false);
this.tabPrompt.PerformLayout();
this.grpPrompt.ResumeLayout(false);
this.grpPrompt.PerformLayout();
this.tabOther.ResumeLayout(false);
this.tabSubOpt.ResumeLayout(false);
this.grpOpt.ResumeLayout(false);
this.grpOpt.PerformLayout();
this.grpFail.ResumeLayout(false);
this.grpFail.PerformLayout();
this.ResumeLayout(false);
}
......@@ -1035,6 +1101,12 @@
private System.Windows.Forms.TextBox txtSharePassword;
private System.Windows.Forms.Button btnTestShare;
private System.Windows.Forms.TabPage tabAdv;
private System.Windows.Forms.TabControl tabAdvSub;
private System.Windows.Forms.TabPage tabSubGen;
private System.Windows.Forms.TabPage tabSubStrategy;
private System.Windows.Forms.TabPage tabSubSem;
private System.Windows.Forms.TabPage tabSubFail;
private System.Windows.Forms.TabPage tabSubOpt;
private System.Windows.Forms.GroupBox grpGen;
private System.Windows.Forms.Label lblTemp;
private System.Windows.Forms.NumericUpDown numTemp;
......@@ -1067,7 +1139,6 @@
private System.Windows.Forms.ComboBox cmbRole;
private System.Windows.Forms.TextBox txtCustomPrompt;
private System.Windows.Forms.Label lblPromptHint;
private System.Windows.Forms.TabPage tabOther;
private System.Windows.Forms.GroupBox grpOpt;
private System.Windows.Forms.CheckBox chkOpt;
private System.Windows.Forms.CheckBox chkImageInput;
......
......@@ -13,8 +13,8 @@ namespace Tools.MyAgent.Forms
{
/// <summary>
/// 智能体设置弹窗(选项卡一体化,纯 UI):
/// <para>基础配置(模型/知识库)、高级配置(生成参数/语义检索)、
/// 提示词(启用开关 + 角色模板 + 自定义系统提示词)、其他(可选能力/故障转移),无二次弹窗。</para>
/// <para>基础配置(模型/知识库)、高级配置(二级页:生成参数/策略参数/语义检索/故障转移/可选能力)、
/// 提示词(启用开关 + 角色模板 + 自定义系统提示词)、数据库连接(预存连接串),无二次弹窗。</para>
/// <para>控件布局由 AgentSettingsForm.Designer.cs(设计器)实现;本文件仅含逻辑:
/// 配置绑定(BindConfig)、布局联动(ApplyProviderMode / ApplyKbMode)、交互事件与保存回写。</para>
/// <para>保存语义:「保存」一次性回写全部改动;「取消」放弃全部改动。</para>
......@@ -139,7 +139,7 @@ namespace Tools.MyAgent.Forms
private TextBox txtToolRoles;
/// <summary>在「其他」页可选能力组追加工具权限输入区(角色白名单,一行一规则)</summary>
/// <summary>在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)</summary>
private void BuildToolPermissionUi()
{
var lbl = new Label
......@@ -251,11 +251,11 @@ namespace Tools.MyAgent.Forms
chkPromptEnabled.Checked = _config.SystemPromptEnabled;
PopulateRoles();
// 其他:可选能力
// 可选能力页:可选工具开关
chkOpt.Checked = _config.EnableOptionalTools;
chkImageInput.Checked = _config.EnableImageInput; // v2.2 多模态:图片提问开关
// 其他:工具级权限(P3,角色白名单)
// 可选能力页:工具级权限(P3,角色白名单)
txtToolRoles.Lines = ToolRoleRulesToLines(_config.ToolRoleAllow);
// 数据库连接(P0):预存连接串(内存明文态)回填表格
......@@ -264,7 +264,7 @@ namespace Tools.MyAgent.Forms
foreach (var kv in _config.DbConnections.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
dgvDbConn.Rows.Add(kv.Key, kv.Value);
// 其他:故障转移
// 故障转移页
chkFail.Checked = _config.EnableFailover;
txtBUrl.Text = _config.BackupBaseUrl;
txtBModel.Text = _config.BackupModel;
......@@ -562,10 +562,10 @@ namespace Tools.MyAgent.Forms
_config.SystemPromptEnabled = chkPromptEnabled.Checked;
_config.RoleTemplateId = (cmbRole.SelectedItem as RoleItem)?.Id ?? "";
_config.CustomSystemPrompt = txtCustomPrompt.Text.Trim();
// 其他:可选能力
// 可选能力页:可选工具开关
_config.EnableOptionalTools = chkOpt.Checked;
_config.EnableImageInput = chkImageInput.Checked; // v2.2 多模态:图片提问开关
// 其他:工具级权限(P3)
// 可选能力页:工具级权限(P3)
_config.ToolRoleAllow = ParseToolRoleRules(txtToolRoles.Text);
// 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var conns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
......@@ -577,7 +577,7 @@ namespace Tools.MyAgent.Forms
conns[id] = (r.Cells[1].Value?.ToString() ?? "").Trim();
}
_config.DbConnections = conns;
// 其他:故障转移
// 故障转移页
_config.EnableFailover = chkFail.Checked;
_config.BackupBaseUrl = txtBUrl.Text.Trim();
_config.BackupModel = string.IsNullOrEmpty(txtBModel.Text.Trim()) ? "qwen2.5" : txtBModel.Text.Trim();
......
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Tools.MyAgent.Kb
{
/// <summary>
/// 方言服务(轻量化内化版):扫描程序目录 Agent/dialect/(产品排查方言)与 Agent/industry/(行业知识),
/// 解析 INDEX.md 导航(行业名/别名/大类/主题/触发词),生成折叠摘要(触发词路由,≤2KB 预算),提供沙箱解析。
/// <para>相对源 Common.Agent 的 ModuleService/IndustryService 裁剪:去掉多公司层、module.json 动态开关、
/// 依赖/冲突/回滚、启用过滤与写入机制——单公司内化、内置只读。</para>
/// INDEX.md 格式约定(与源兼容):
/// <code>
/// # 鞋业(别名:制鞋、鞋类)
/// 行业简介:…
/// ## 01-品类认知
/// - 运动鞋.md:跑鞋/篮球鞋(触发词:运动鞋、跑鞋)
/// </code>
/// </summary>
public sealed class DialectService
{
private readonly string _dialectRoot;
private readonly string _industryRoot;
private DialectInfo _dialectCache;
private List<DialectInfo> _industryCache;
/// <summary>行业/方言根目录是否可用(任一存在即视为可用)</summary>
public bool IsAvailable
=> (!string.IsNullOrEmpty(_dialectRoot) && Directory.Exists(_dialectRoot))
|| (!string.IsNullOrEmpty(_industryRoot) && Directory.Exists(_industryRoot));
/// <summary>产品方言根目录</summary>
public string DialectRoot => _dialectRoot;
/// <summary>行业知识根目录</summary>
public string IndustryRoot => _industryRoot;
public DialectService(string agentRoot)
{
_dialectRoot = string.IsNullOrEmpty(agentRoot) ? "" : Path.Combine(agentRoot, "dialect");
_industryRoot = string.IsNullOrEmpty(agentRoot) ? "" : Path.Combine(agentRoot, "industry");
}
/// <summary>单个主题(大类 + 文件 + 说明 + 触发词)</summary>
public sealed class DialectTopic
{
public string Category = ""; // 大类名,如 01-品类认知
public string File = ""; // 主题文件名(可含子路径),如 运动鞋.md、erp/winforms.md
public string Title = ""; // 主题名(去 .md)
public string Description = "";
public string[] Triggers = Array.Empty<string>();
}
/// <summary>单个方言/行业(目录 + INDEX.md 解析结果)</summary>
public sealed class DialectInfo
{
public string Name = ""; // 名称(行业名/方言名)
public string Intro = "";
public string[] Aliases = Array.Empty<string>();
public List<DialectTopic> Topics = new List<DialectTopic>();
public string Root = ""; // 该导航对应的根目录
}
/// <summary>产品方言导航(dialect/INDEX.md)</summary>
public DialectInfo ScanDialect()
{
if (_dialectCache != null) return _dialectCache;
var info = new DialectInfo { Name = "产品排查方言", Root = _dialectRoot };
string indexFile = Path.Combine(_dialectRoot, "INDEX.md");
if (File.Exists(indexFile))
ParseIndex(indexFile, info);
_dialectCache = info;
return info;
}
/// <summary>全部行业导航(industry/&lt;行业&gt;/INDEX.md)</summary>
public IEnumerable<DialectInfo> ScanIndustries()
{
if (_industryCache != null) return _industryCache;
var list = new List<DialectInfo>();
if (Directory.Exists(_industryRoot))
{
foreach (var dir in Directory.GetDirectories(_industryRoot))
{
string indexFile = Path.Combine(dir, "INDEX.md");
if (!File.Exists(indexFile)) continue;
var ind = new DialectInfo { Name = Path.GetFileName(dir), Root = dir };
ParseIndex(indexFile, ind);
list.Add(ind);
}
}
_industryCache = list;
return list;
}
private static void ParseIndex(string indexFile, DialectInfo ind)
{
string category = "";
foreach (var raw in File.ReadAllLines(indexFile, Encoding.UTF8))
{
string line = (raw ?? "").Trim();
if (line.StartsWith("## ", StringComparison.Ordinal)) { category = line.Substring(3).Trim(); continue; }
if (line.StartsWith("# ") && !line.StartsWith("## "))
{
ind.Intro = line.Substring(2).Trim();
// 别名解析:# 行业名(别名:鞋服、服装、箱包)
int ap = ind.Intro.IndexOf("(别名:", StringComparison.Ordinal);
if (ap > 0)
{
string rest = ind.Intro.Substring(ap + "(别名:".Length);
int end = rest.IndexOf(')');
if (end > 0)
ind.Aliases = rest.Substring(0, end)
.Split(new[] { '、', ',', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
}
continue;
}
if (line.StartsWith("- ") && line.IndexOf(".md", StringComparison.OrdinalIgnoreCase) > 0)
{
string body = line.Substring(2).Trim();
int sep = body.IndexOfAny(new[] { ':', ':' });
if (sep <= 0) continue;
string file = body.Substring(0, sep).Trim();
string rest = body.Substring(sep + 1).Trim();
// 主题路径补全:File 含子路径(如 dialect 的 "erp/winforms.md")则原样保留;
// 纯文件名(行业 INDEX,文件在大类目录下)补全为 "{Category}/{File}",便于模型直接构造 d_read 路径
if (file.IndexOf('/') < 0 && file.IndexOf('\\') < 0 && category.Length > 0)
file = category + "/" + file;
var topic = new DialectTopic { Category = category, File = file, Title = Path.GetFileNameWithoutExtension(file) };
int tg = rest.IndexOf("(触发词:", StringComparison.Ordinal);
if (tg > 0)
{
topic.Description = rest.Substring(0, tg).Trim();
string trigPart = rest.Substring(tg + "(触发词:".Length);
int end = trigPart.IndexOf(')');
if (end > 0) trigPart = trigPart.Substring(0, end);
topic.Triggers = trigPart.Split(new[] { '、', ',', ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
}
else topic.Description = rest;
ind.Topics.Add(topic);
}
}
}
/// <summary>
/// 折叠摘要(产品方言 + 全部行业,总计 ≤2KB):名称 + 别名 + 大类列表 + 汇总触发词(去重截断)。
/// 主题级详情不注入,模型需要时 d_read 精读。
/// </summary>
public string BuildSummary()
{
var parts = new List<string>();
var dialect = ScanDialect();
if (dialect.Topics.Count > 0)
parts.Add(BuildInfoLine(dialect, "产品排查方言"));
foreach (var ind in ScanIndustries())
parts.Add(BuildInfoLine(ind, ind.Name));
if (parts.Count == 0) return ""; // 无任何内置知识导航:不注入空层
var sb = new StringBuilder();
sb.AppendLine("【产品方言与行业知识】(内置只读知识,随程序发布;业务语境问题先按触发词路由 → d_grep 定位 → d_read 精读,禁止一次性全量读取)");
const int totalBudget = 2048; // 摘要总预算
int used = Encoding.UTF8.GetByteCount(sb.ToString());
foreach (var line in parts)
{
int lineBytes = Encoding.UTF8.GetByteCount(line + "\n");
if (used + lineBytes > totalBudget) break;
sb.AppendLine(line);
used += lineBytes;
}
return sb.ToString();
}
private static string BuildInfoLine(DialectInfo ind, string displayName)
{
string line = "- " + displayName;
if (ind.Topics.Count > 0)
{
var cats = ind.Topics.Select(t => t.Category).Where(c => c.Length > 0).Distinct();
if (cats.Any()) line += "(" + string.Join("/", cats) + ")";
var trigs = ind.Topics.SelectMany(t => t.Triggers).Distinct().Take(12).ToList();
if (trigs.Count > 0) line += ":" + string.Join("、", trigs);
}
if (ind.Aliases.Length > 0)
line += "(别名:" + string.Join("、", ind.Aliases) + ")";
return TruncateBytes(line, 1024);
}
private static string TruncateBytes(string s, int maxBytes)
{
var sb = new StringBuilder();
foreach (char c in s)
{
if (Encoding.UTF8.GetByteCount(sb.ToString() + c) > maxBytes) break;
sb.Append(c);
}
return sb.ToString() + (sb.Length < s.Length ? "…" : "");
}
/// <summary>
/// 沙箱解析 d_* 工具路径:"dialect/..." 或 "industry/..."(相对各自根目录);
/// 越界/符号链接/不存在返回 null。复用 KbService.ResolveIn(NormPath + IsWithinRoot + reparse point 防护)。
/// </summary>
public string ResolveSafe(string path)
{
if (string.IsNullOrEmpty(path)) return null;
string p = path.Replace('/', '\\');
if (p.StartsWith("dialect\\", StringComparison.OrdinalIgnoreCase))
return ResolveIn(_dialectRoot, p.Substring("dialect\\".Length));
if (p.StartsWith("industry\\", StringComparison.OrdinalIgnoreCase))
return ResolveIn(_industryRoot, p.Substring("industry\\".Length));
return null;
}
private static string ResolveIn(string root, string relative)
{
try
{
return KbService.ResolveIn(root, relative, "内置方言目录不可用。");
}
catch { return null; }
}
/// <summary>递归枚举内置知识文件(*.md),返回 "dialect/..." 或 "industry/..." 形式路径;供 d_list/d_grep 使用</summary>
public IEnumerable<string> EnumerateFiles()
{
foreach (var f in EnumerateMd(_dialectRoot, "dialect"))
yield return f;
foreach (var f in EnumerateMd(_industryRoot, "industry"))
yield return f;
}
private static IEnumerable<string> EnumerateMd(string root, string prefix)
{
if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) yield break;
foreach (var f in Directory.GetFiles(root, "*.md", SearchOption.AllDirectories))
{
string rel = f.Substring(root.Length).TrimStart('\\', '/').Replace('\\', '/');
yield return prefix + "/" + rel;
}
}
/// <summary>缓存失效:目录外部变动后调用,下次扫描重扫</summary>
public void Invalidate() { _dialectCache = null; _industryCache = null; }
}
}
......@@ -36,6 +36,7 @@ namespace Tools.MyAgent.Kb
private const int MaxScanFileSize = 4 * 1024 * 1024; // 内容检索跳过 > 4MB 的文件
private const int MaxToolOutput = 8000; // 工具返回文本上限(字符)
private const int MaxReadChars = 12000; // kb_read 单次返回上限(字符)
internal const long MaxReadFileSize = 30L * 1024 * 1024; // kb_read/file_read 单文件大小闸门:> 30MB 拒绝整读(防 OOM)
/// <summary>检索索引(SQLite,含 docx/xlsx 内容;未就绪时检索回退全盘扫描)</summary>
private KbIndex _index;
......@@ -1323,13 +1324,20 @@ namespace Tools.MyAgent.Kb
if (limit < 1) limit = 1;
if (limit > 500) limit = 500;
// 文件大小闸门:提取前先看大小,避免超大文件整读进内存(OOM/卡死)——
// 检索类已有 MaxScanFileSize 闸门,读取路径此前缺失(漏洞修复)
var fi = new FileInfo(fullPath);
if (fi.Length > MaxReadFileSize)
return $"错误:文件过大({fi.Length / 1024 / 1024}MB,上限 {MaxReadFileSize / 1024 / 1024}MB),已跳过整读。"
+ $"请改用 kb_grep 检索定位相关内容,或把文件拆分成小块后再读。";
string ext = Path.GetExtension(fullPath).ToLowerInvariant();
string content = null;
if (ext == ".ppt")
return $"暂不支持旧版 .ppt 二进制格式:{relPath}。可另存为 .pptx 后使用。";
else if (IsImageExtension(ext))
return $"图片文件:{relPath}(当前不支持直接读取图片内容,可描述图片或先查看文件名)。";
return $"图片文件:{relPath}(当前不支持直接读取图片内容;可改用 kb_view_image 把图片作为附图发送给视觉模型识别(图片提问),或启用「图片提问」后手动发送)";
else
{
// 统一走解析器注册表(X1):新格式只需注册 IFileParser,无需改本分支
......@@ -1338,11 +1346,11 @@ namespace Tools.MyAgent.Kb
}
if (content == null)
return $"二进制、扫描件(无文字层)或不可解析的文件:{relPath}。";
return $"二进制、扫描件(无文字层)或不可解析的文件:{relPath}。扫描件可改用 kb_view_image 把页面作为图片发送给视觉模型识别(图片提问,无需 OCR)。";
if (content.StartsWith("错误"))
return content; // 提取层的业务错误(如工作表不存在)直接透传
var lines = content.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var lines = SplitLines(content);
if (offset > lines.Length) offset = lines.Length;
int start = offset;
int end = Math.Min(lines.Length, offset + limit - 1);
......@@ -1371,33 +1379,19 @@ namespace Tools.MyAgent.Kb
}
/// <summary>
/// 读取文本文件(自动识别 UTF-8/GB2312 等编码,兼容中文 Windows)
/// 读取文本文件(自动识别 UTF-8/UTF-16/UTF-32/GB18030/系统 ANSI 等编码,兼容中文 Windows;多编码兼容见 TextEncoding)
/// </summary>
internal static string ReadTextFile(string path)
{
try
{
byte[] bytes = File.ReadAllBytes(path);
if (bytes.Length == 0) return "";
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return new UTF8Encoding(false).GetString(bytes, 3, bytes.Length - 3);
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2);
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
return Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2);
try
{
return new UTF8Encoding(false, true).GetString(bytes);
}
catch (DecoderFallbackException)
{
return Encoding.GetEncoding("GB2312").GetString(bytes);
}
}
catch
{
return null;
}
return TextEncoding.ReadAllTextAuto(path);
}
/// <summary>
/// 统一行切分:任意换行风格(CRLF/CR/LF)归一为 \n 后切行,供各读取工具(file_read/module_read/industry_read/kb_read)共用
/// </summary>
internal static string[] SplitLines(string content)
{
return (content ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
}
/// <summary>提取 docx 文本(段落 + 表格单元格)</summary>
......@@ -1457,7 +1451,9 @@ namespace Tools.MyAgent.Kb
NPOI.SS.UserModel.ISheet sheetObj = ResolveSheet(wb, sheet);
if (sheetObj == null)
return $"错误:工作表不存在:{sheet ?? "0"}(本文件共 {wb.NumberOfSheets} 个工作表)";
return ReadSheet(sheetObj, maxRow: 500, maxCol: 30);
// P2 证据出处:输出头部标注工作表名(与索引的【表名】前缀一致,模型可据此引用)
string body = ReadSheet(sheetObj, maxRow: 500, maxCol: 30);
return $"【工作表:{sheetObj.SheetName}】\n" + body;
}
finally
{
......
using System;
using System.IO;
using System.Text;
namespace Tools.MyUtils
{
/// <summary>
/// 文本编码识别与编解码工具(纯静态、无状态,SRP:编码/类型识别独立成类,供 KbService 与 Tools 各模块复用)。
/// <para>能力:BOM/无 BOM 多编码自动识别(UTF-8/16/32、GB18030/GB2312、系统 ANSI)、自动编码读取、
/// magic bytes 文件类型识别、UTF8/Unicode/GB2312/Hex 编解码。</para>
/// <para>兼容 .NET Framework 4.8(GB18030 = codepage 54936 原生支持,零新增依赖)。</para>
/// </summary>
public static class TextEncoding
{
/// <summary>UTF-8 无 BOM(严格校验失败回退用)</summary>
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
/// <summary>UTF-8 带 BOM(BOM 由调用方按 BomLength 剥离)</summary>
private static readonly Encoding Utf8Bom = new UTF8Encoding(true);
/// <summary>UTF-32 LE(BOM 由调用方按 BomLength 剥离)</summary>
private static readonly Encoding Utf32Le = new UTF32Encoding(false, true);
/// <summary>UTF-32 BE</summary>
private static readonly Encoding Utf32Be = new UTF32Encoding(true, true);
/// <summary>GB18030(GB2312 超集:1:1 兼容 GB2312,并覆盖 GBK/GB18030 生僻字;codepage 54936)</summary>
private static readonly Encoding Gb18030 = Encoding.GetEncoding(54936);
/// <summary>无 BOM UTF-16 启发式检测的采样字节数</summary>
private const int Utf16ProbeBytes = 4096;
/// <summary>无 BOM UTF-16 启发式判定阈值:一侧 NUL 占比 ≥ 高阈值且另一侧 ≤ 低阈值才判定(保守,防误判)</summary>
private const double NulRatioHigh = 0.30;
private const double NulRatioLow = 0.01;
/// <summary>GB18030 合法性采样字节数</summary>
private const int Gb18030ProbeBytes = 4096;
// ────────────────────────── 编码识别 ──────────────────────────
/// <summary>
/// 自动识别字节流的文本编码(需求 #1:多编码兼容)。
/// <para>识别顺序(按可靠性递减):UTF-8 BOM → UTF-32 LE/BE BOM → UTF-16 LE/BE BOM →
/// 无 BOM 严格 UTF-8 序列校验(含 overlong/代理区/超范围拒绝)→ 无 BOM UTF-16 NUL 分布启发式 →
/// GB18030(GB2312 超集)→ 系统 ANSI(Encoding.Default)。</para>
/// </summary>
/// <param name="bytes">文件原始字节(null/空返回 UTF-8 无 BOM,与空文本语义一致)</param>
/// <returns>识别出的编码;UTF-16/32 带 BOM 时返回对应带 BOM 的编码实例(BOM 剥离见 BomLength)</returns>
public static Encoding DetectEncoding(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
return Utf8NoBom;
// 1. UTF-8 BOM:EF BB BF
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return Utf8Bom;
// 2. UTF-32 LE BOM:FF FE 00 00(须先于 UTF-16 LE 判断:以其 FF FE 前缀开头)
if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0x00 && bytes[3] == 0x00)
return Utf32Le;
// 3. UTF-32 BE BOM:00 00 FE FF
if (bytes.Length >= 4 && bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0xFE && bytes[3] == 0xFF)
return Utf32Be;
// 4. UTF-16 LE BOM:FF FE
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
return Encoding.Unicode;
// 5. UTF-16 BE BOM:FE FF
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
return Encoding.BigEndianUnicode;
// 6. 无 BOM:全量严格 UTF-8 序列校验(含 overlong/代理区/超 U+10FFFF 拒绝)
if (IsValidUtf8(bytes))
return Utf8NoBom;
// 7. 无 BOM:UTF-16 NUL 分布启发式(ASCII 字符高字节为 0x00:LE 中 NUL 集中在奇数下标,BE 中在偶数下标)
Encoding utf16 = DetectUtf16ByNulDistribution(bytes);
if (utf16 != null)
return utf16;
// 8. 回退 GB18030(GB2312 超集,覆盖生僻字);采样不可解码(二进制)时最终回退系统 ANSI
return IsValidGb18030Sample(bytes) ? Gb18030 : Encoding.Default;
}
/// <summary>
/// 自动识别编码并读取整个文本文件(返回 null 表示读取失败或文件不存在)。
/// <para>BOM 自动剥离;所有候选编码均以 ReplacementFallback 解码(DetectEncoding 已保证字节可解码),无解码异常路径。</para>
/// </summary>
public static string ReadAllTextAuto(string path)
{
try
{
byte[] bytes = File.ReadAllBytes(path);
if (bytes.Length == 0) return "";
Encoding enc = DetectEncoding(bytes);
int bomLen = BomLength(enc, bytes);
return enc.GetString(bytes, bomLen, bytes.Length - bomLen);
}
catch
{
return null;
}
}
/// <summary>BOM 字节数(按 DetectEncoding 结果剥离;无 BOM 返回 0)</summary>
internal static int BomLength(Encoding enc, byte[] bytes)
{
if (bytes == null || bytes.Length < 2) return 0;
int cp = enc != null ? enc.CodePage : 0;
if (cp == 65001 && bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return 3; // UTF-8
if (cp == 12000 && bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0x00 && bytes[3] == 0x00)
return 4; // UTF-32 LE
if (cp == 12001 && bytes.Length >= 4 && bytes[0] == 0x00 && bytes[1] == 0x00 && bytes[2] == 0xFE && bytes[3] == 0xFF)
return 4; // UTF-32 BE
if (cp == 1200 && bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
return 2; // UTF-16 LE
if (cp == 1201 && bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
return 2; // UTF-16 BE
return 0;
}
// ────────────────────────── 文件类型识别 ──────────────────────────
/// <summary>
/// magic bytes 文件类型识别(无扩展名/扩展名可疑时辅助判定)。
/// <para>扩展名非空时直接返回扩展名(源行为,保持兼容);扩展名为空时读取文件头 7 字节判定
/// (bmp/jpg/png/gif/tiff/psd/xls/zip/xlsx/docx/rar/rtf/xml/html/exe/pdf 共 16 类)。</para>
/// <para>说明:xls 与 doc(OLE2)、zip 与 xlsx/docx(PK)文件头相同,无法仅凭头部细分,返回复合标识。</para>
/// </summary>
/// <param name="path">文件路径</param>
/// <returns>类型标识;文件不存在或无法识别返回 "unknown"</returns>
public static string GetFileType(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
return "unknown";
var fileInfo = new FileInfo(path);
// 扩展名不为空,直接返回扩展名(去点,小写归一)
if (!string.IsNullOrEmpty(fileInfo.Extension))
return fileInfo.Extension.TrimStart('.').ToLowerInvariant();
// 扩展名为空:按最长文件头(pdf 7 字节)读取判定
try
{
byte[] head = new byte[7];
int n;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
n = fs.Read(head, 0, head.Length);
string hex = ToHex(head, n);
if (hex.StartsWith("424D")) return "bmp"; // 2 字节
if (hex.StartsWith("FFD8FF")) return "jpg"; // 3 字节
if (hex.StartsWith("89504E47")) return "png"; // 4 字节
if (hex.StartsWith("47494638")) return "gif";
if (hex.StartsWith("49492A00")) return "tiff";
if (hex.StartsWith("38425053")) return "psd";
if (hex.StartsWith("D0CF11E0")) return "xls/doc"; // OLE2 复合文档
if (hex.StartsWith("504B0304")) return "zip/xlsx/docx"; // PK zip
if (hex.StartsWith("52617221")) return "rar";
if (hex.StartsWith("7B5C727466")) return "rtf"; // 5 字节
if (hex.StartsWith("3C3F786D6C")) return "xml";
if (hex.StartsWith("68746D6C3E")) return "html";
if (hex.StartsWith("4D5A900003")) return "exe/dll/drv/vxd/sys/ocx/vbx";
if (hex.StartsWith("255044462D312E")) return "pdf"; // 7 字节
return "unknown";
}
catch
{
return "unknown";
}
}
// ────────────────────────── 编解码工具 ──────────────────────────
/// <summary>byte[] 转 string:UTF-8</summary>
public static string UTF8(byte[] bytes)
{
return Encoding.UTF8.GetString(bytes ?? new byte[0]);
}
/// <summary>string 转 byte[]:UTF-8</summary>
public static byte[] UTF8(string s)
{
return Encoding.UTF8.GetBytes(s ?? "");
}
/// <summary>byte[] 转 string:Unicode(UTF-16 LE)</summary>
public static string Unicode(byte[] bytes)
{
return Encoding.Unicode.GetString(bytes ?? new byte[0]);
}
/// <summary>string 转 byte[]:Unicode(UTF-16 LE)</summary>
public static byte[] Unicode(string s)
{
return Encoding.Unicode.GetBytes(s ?? "");
}
/// <summary>byte[] 转 string:GB2312</summary>
public static string GB2312(byte[] bytes)
{
return Encoding.GetEncoding("gb2312").GetString(bytes ?? new byte[0]);
}
/// <summary>string 转 byte[]:GB2312(超出 GB2312 字符集范围的字符由编码器替换)</summary>
public static byte[] GB2312(string s)
{
return Encoding.GetEncoding("gb2312").GetBytes(s ?? "");
}
/// <summary>byte[] 转 16 进制字符串(count 省略 = 全部字节;每字节两位大写)</summary>
public static string ToHex(byte[] bytes, int count = -1)
{
if (bytes == null) return "";
if (count < 0 || count > bytes.Length) count = bytes.Length;
var sb = new StringBuilder(count * 2);
for (int i = 0; i < count; i++)
sb.Append(bytes[i].ToString("X2"));
return sb.ToString();
}
/// <summary>16 进制字符串转 byte[](忽略首尾空白;非法字符/奇数长度抛 FormatException)</summary>
public static byte[] FromHex(string hex)
{
if (string.IsNullOrEmpty(hex)) return new byte[0];
hex = hex.Trim();
if (hex.Length % 2 != 0)
throw new FormatException("十六进制字符串长度必须为偶数:" + hex);
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return result;
}
// ────────────────────────── 内部判定 ──────────────────────────
/// <summary>
/// 全量严格 UTF-8 序列校验(无 BOM 判定核心)。
/// <para>拒绝:0x80-0xC1/0xF5-0xFF 非法首字节、截断序列、非 10xxxxxx 续字节、
/// overlong(C0/C1、E0 9x、F0 8x)、UTF-16 代理区(ED A0-BF)、超 U+10FFFF(F4 90+)。</para>
/// </summary>
private static bool IsValidUtf8(byte[] bytes)
{
int i = 0, n = bytes.Length;
while (i < n)
{
byte b = bytes[i];
if (b < 0x80) { i++; continue; }
int need;
if (b >= 0xC2 && b <= 0xDF) need = 1; // 2 字节(0xC0/0xC1 = overlong,排除)
else if (b >= 0xE0 && b <= 0xEF) need = 2; // 3 字节
else if (b >= 0xF0 && b <= 0xF4) need = 3; // 4 字节(0xF5+ 超 U+10FFFF,排除)
else return false; // 0x80-0xC1 或 0xF5-0xFF
if (i + need >= n) return false; // 序列截断
for (int k = 1; k <= need; k++)
if ((bytes[i + k] & 0xC0) != 0x80) return false; // 续字节必须 10xxxxxx
// overlong / 代理区 / 超范围(need == 1 时 b ∈ 0xC2-0xDF,overlong 0xC0/0xC1 已排除)
if (need == 2 && b == 0xE0 && bytes[i + 1] < 0xA0) return false; // E0 80-9F = overlong 3 字节
if (need == 2 && b == 0xED && bytes[i + 1] >= 0xA0) return false; // ED A0-BF = UTF-16 代理区
if (need == 3 && b == 0xF0 && bytes[i + 1] < 0x90) return false; // F0 80-8F = overlong 4 字节
if (need == 3 && b == 0xF4 && bytes[i + 1] >= 0x90) return false; // F4 90+ = 超 U+10FFFF
i += need + 1;
}
return true;
}
/// <summary>
/// 无 BOM UTF-16 启发式:采样前 4KB,统计偶数/奇数下标 NUL 占比。
/// <para>UTF-16 LE 中 ASCII 字符高字节(奇数下标)为 0x00;UTF-16 BE 中高字节(偶数下标)为 0x00。
/// 一侧占比 ≥ 30% 且另一侧 ≤ 1% 才判定(保守阈值:纯 ASCII/GBK 中文两侧均近 0,不触发)。</para>
/// </summary>
private static Encoding DetectUtf16ByNulDistribution(byte[] bytes)
{
int n = Math.Min(bytes.Length, Utf16ProbeBytes) & ~1; // 偶数对齐
if (n < 4) return null;
int evenNul = 0, oddNul = 0, total = n / 2;
for (int i = 0; i < n; i += 2)
{
if (bytes[i] == 0) evenNul++;
if (bytes[i + 1] == 0) oddNul++;
}
double evenRatio = (double)evenNul / total;
double oddRatio = (double)oddNul / total;
if (oddRatio >= NulRatioHigh && evenRatio <= NulRatioLow)
return Encoding.Unicode; // LE:NUL 集中在奇数下标(高字节)
if (evenRatio >= NulRatioHigh && oddRatio <= NulRatioLow)
return Encoding.BigEndianUnicode; // BE:NUL 集中在偶数下标(高字节)
return null;
}
/// <summary>
/// GB18030 合法性采样(前 4KB):单字节 0x00-0x7F;双字节 0x81-0xFE + 0x40-0xFE(≠0x7F);
/// 四字节 0x81-0xFE + 0x30-0x39 + 0x81-0xFE + 0x30-0x39(生僻字扩展区)。
/// <para>采样边界处不完整序列视为合法(避免误伤);0x80/0xFF 等非法字节判定为二进制。</para>
/// </summary>
private static bool IsValidGb18030Sample(byte[] bytes)
{
int n = Math.Min(bytes.Length, Gb18030ProbeBytes);
int i = 0;
while (i < n)
{
byte b = bytes[i];
if (b < 0x80) { i++; continue; } // ASCII 单字节
if (b < 0x81 || b > 0xFE) return false; // 0x80 / 0xFF 非法
if (i + 1 >= n) return true; // 采样边界截断:视为合法
byte b2 = bytes[i + 1];
if (b2 >= 0x40 && b2 <= 0xFE && b2 != 0x7F) { i += 2; continue; } // 双字节
if (b2 >= 0x30 && b2 <= 0x39)
{
if (i + 3 >= n) return true; // 采样边界截断
byte b3 = bytes[i + 2], b4 = bytes[i + 3];
if (b3 >= 0x81 && b3 <= 0xFE && b4 >= 0x30 && b4 <= 0x39) { i += 4; continue; } // 四字节
}
return false; // 非法序列
}
return true;
}
}
}
......@@ -173,6 +173,8 @@
<Compile Include="MyAgent\Agent\Tools\ITool.cs" />
<Compile Include="MyAgent\Agent\Tools\KbTools.cs" />
<Compile Include="MyAgent\Agent\Tools\DbQueryTool.cs" />
<Compile Include="MyAgent\Agent\Tools\DbSchemaTool.cs" />
<Compile Include="MyAgent\Agent\Tools\DialectTools.cs" />
<Compile Include="MyAgent\Agent\Tools\DocExportTool.cs" />
<Compile Include="MyAgent\Agent\Tools\ImageSearchTool.cs" />
<Compile Include="MyAgent\Config\AgentConfig.cs" />
......@@ -194,6 +196,7 @@
<SubType>Form</SubType>
</Compile>
<Compile Include="MyAgent\Kb\KbConfig.cs" />
<Compile Include="MyAgent\Kb\DialectService.cs" />
<Compile Include="MyAgent\Kb\KbService.cs" />
<Compile Include="MyAgent\Kb\KbIndex.cs" />
<Compile Include="MyAgent\Kb\SynonymIndex.cs" />
......@@ -272,6 +275,7 @@
<Compile Include="MyUtils\SegPicture.cs" />
<Compile Include="MyUtils\TextBoxPlaceholder.cs" />
<Compile Include="MyUtils\TimeWatch.cs" />
<Compile Include="MyUtils\TextEncoding.cs" />
<Compile Include="UI\Popups\wEditEvaluation.cs">
<SubType>Form</SubType>
</Compile>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment