--- name: supplychain-pitfalls version: 1.3.0 description: supplychain/Sage 模块开发的反复踩坑教训,涉及 xls2ui、RBAC、bricks 限制、部署流程、dspy 编码规范。 trigger_conditions: - 修改 supplychain/Sage 模块的 CRUD 配置、index.ui、load_path.py - supplychain 模块报 403、undefined.length、按钮无反应 - 涉及 bricks 工具栏 bind 配置 - 开发 ensure dspy、创建用户、product_org_auth 授权 - 二级分销商管理(sub_distributors):添加管理员、重置密码、分销协议授权 - 供销协议/分销协议折扣明细(get_contract_discount_items / get_agreement_discount_items) - supplychain init.py 中的 Jinja2 注册函数涉及产品过滤或 orgid 查询 - product.providerid 字段、产品导入增量模式、providerid 回填 - JSON subtables 已定义但 index.ui 缺少对应 toolbar/bind → 需重跑 xls2ui - 合同附件管理(供销合同/分销协议) - unipay 支付模块开发(支付渠道、provider、转账充值、load_path) - Bricks VBox/HBox 子控件白屏、subwidgets 规范 - CRUD 自动生成文件 git 冲突 - __pycache__ 或 *.pyc 被误提交到 git 仓库 - wwwroot 下 .ui/.dspy 报 500 需要归因(先判断是生成文件还是 git 跟踪源文件) --- ## ⚠️ 工作流铁律(最高优先级) 0. **四阶段门禁强制**:dev → review → test → audit。每个阶段留证据,未测试禁提交。本地修改→commit→push→服务器pull + pip install + 重启→curl/浏览器冒烟。部署完成才算阶段结束,不允许口头声称通过。 1. **开发在本地,不在服务器**:本地编码→git commit→git push→服务器git pull+pip install。严禁SSH直改服务器源文件(git pull会覆盖)。 1. **必须浏览器实测**:curl 200 ≠ 功能正常。完整链路:页面加载 → 数据加载 → 点击按钮 → 弹窗内容正确渲染 → console 无错误。 2. **修改 → commit → push → 服务器 git pull → xls2ui(如改 JSON 或有 CRUD 目录缺失)→ pip install(如改 .py)→ 重启 → 浏览器测**。不允许跳过任何一步。 3. **服务器有本地修改时先 `git checkout -- .` 再 pull**。绝不 scp/sed 手动改服务器文件。 4. **测试失败要说清楚失败在哪**,不要沉默或装作通过。 5. **login 表单对程序化操作不响应时,换 real browser 登录。** 不循环重试同一种方法。 6. **CRUD 目录部署后检查**:`git pull` 后检查 xls2ui 生成的目录是否存在(如 `wwwroot/supply_contracts_list/index.ui`),若缺失则运行 `xls2ui -m models -o wwwroot json/*.json`(见坑 64)。 --- ## 坑 1-36 略(已稳定,内容不变) 内容与上一版本一致,此处省略以节省空间。 --- ## 坑 37:产品→供应商的正确链路(product_resource_supplier) **现象**:供销协议折扣明细空表,或因用错过滤字段显示了其他供应商的产品。 **根因**:三次试错: 1. ❌ `p.org_id` — 产品归属机构,大部分是'0',不等于供应商 org 2. ❌ `product_supplier_mapping.supplier_org_id` — 供销路由表,非产品→供应商直连 3. ✅ `product.resource_ref_id → product_resource_supplier.supplier_org_id` — 产品通过关联的资源找到资源供应方 **正确 JOIN**(`get_contract_discount_items`): ```python # Step 1: supplychain → 供应商 orgid async with get_sor_context(env, 'supplychain') as sor: rows = await sor.sqlExe( "SELECT s.orgid FROM suppliers s " "JOIN supply_contracts sc ON sc.supplier_id = s.id " "WHERE sc.id = ${cid}$", {'cid': contract_id}) supplier_orgid = rows[0].orgid if rows else None # Step 2: sage → product JOIN product_resource_supplier async with get_sor_context(env, 'sage') as sor: rows = await sor.sqlExe( "SELECT DISTINCT p.id as productid, p.product_name, p.product_code, " "p.price as sale_price, pc.id as category_id, pc.name as category_name " "FROM product p " "JOIN product_resource_supplier prs ON p.resource_ref_id = prs.product_resource_id " "LEFT JOIN product_category pc ON p.category_id = pc.id " "WHERE p.status = '1' AND prs.supplier_org_id = ${oid}$ AND prs.status = '1' " "ORDER BY pc.sort_order, pc.name, p.sort_order, p.product_name", {'oid': supplier_orgid}) ``` **数据模型链**: ``` product.resource_ref_id → product_resource.id → product_resource_supplier(product_resource_id, supplier_org_id) ``` **教训**:涉及产品-供应商关联时,不要猜字段名。先看模型 JSON 确认真实链路。 ## 坑 39:供销协议折扣明细 → 直接用 product.providerid 过滤(推荐) **现象**:坑 37 的 `product_resource_supplier` JOIN 过于复杂,且依赖资源路由表的及时更新。 **最终方案**:`product` 表已有 `providerid` 字段(从资源导入时自动填入 `llm.providerid`),直接用它匹配合同的 `supplier_id`。 ```python async def get_contract_discount_items(request): env = request._run_ns # 1. 合同 → supplier_id async with get_sor_context(env, 'supplychain') as sor: rows = await sor.sqlExe( "SELECT supplier_id FROM supply_contracts WHERE id = ${cid}$", {'cid': contract_id}) supplier_id = rows[0].supplier_id if rows else None if not supplier_id: return [] # 2. 产品过滤:p.providerid = supplier_id(简单直接) async with get_sor_context(env, 'sage') as sor: rows = await sor.sqlExe( "SELECT p.id as productid, p.product_name, p.product_code, " "p.price as sale_price, pc.id as category_id, pc.name as category_name " "FROM product p " "LEFT JOIN product_category pc ON p.category_id = pc.id " "WHERE p.status = '1' AND p.providerid = ${sid}$ " "ORDER BY pc.sort_order, pc.name, p.sort_order, p.product_name", {'sid': supplier_id}) ``` **providerid 来源链路**:`llm.providerid` → `load_product_category_product()` → `import_categories_and_products()` → `product.providerid` **注意**:如果产品是从旧导入(没有 providerid),先用 MySQL 回填: ```sql UPDATE product p JOIN llm l ON p.resource_ref_id = l.id SET p.providerid = l.providerid WHERE p.providerid IS NULL AND l.status = 'published'; ``` ## 坑 40:ServerEnv() 单例 ≠ request._run_ns → get_user() 返回空 **现象**:dspy/init.py 中 `env = ServerEnv(); user_id = await env.get_user()` 返回 None/AttributeError,导致 `created_by`、`sale_id` 等字段为 NULL。数据库验证:`SELECT created_by FROM discount_marketing` 全部 NULL。 **根因**:`self.y_env = DictObject()` (processorResource.py:104) 是普通 DictObject。`request._run_ns = self.run_ns` (baseProcessor.py:87) 包含了 `y_env`。`get_user()` / `get_userid()` / `get_userorgid()` 注册在 `self.y_env` (processorResource.py:347-349),最终在 `request._run_ns`。但 `ServerEnv()` 是独立单例 DictObject (serverenv.py),和 `request._run_ns` **不是同一对象**。 **修复**:`env = ServerEnv()` → `env = request._run_ns`: ```python # ❌ WRONG — get_user() 在 ServerEnv 上不存在 async def create_marketing(request, params_kw): env = ServerEnv() user_id = await env.get_user() # None! # ✅ CORRECT — 参考 generate_promo_code 的正确写法 async def create_marketing(request, params_kw): env = request._run_ns user_id = await env.get_user() ``` **注意**:`get_module_dbname` 在 `ServerEnv()` 上有 (ext.py:72 注册),`get_user` 只在 `request._run_ns` 上。但 `request._run_ns` 也通过 RF 机制拥有 `get_module_dbname`,所以统一用 `request._run_ns` 即可。 **排查**:检查所有使用 `ServerEnv()` + `get_user/get_userorgid` 的函数: ```bash grep -n 'env = ServerEnv()' init.py | while read line; do lineno=$(echo "$line" | cut -d: -f1) sed -n "$((lineno+1)),$((lineno+6))p" init.py | grep -q 'get_user' && echo "LINE $lineno: NEEDS FIX" done ``` ## 坑 41:CRUD JSON 的 `editable` 嵌套被 xls2ddl 忽略 → new_data_url 不生效 **现象**:JSON 配置 `params.editable.new_data_url`,但浏览器实际 POST 到默认 `add_xxx.dspy`,自定义端点从未调用。日志中无任何自定义端点访问记录。 **根因**:xls2ddl 模板 (tmpls.py:31) 读的是 `params.new_data_url`,不认 `params.editable.new_data_url`。嵌套 `editable` 被静默忽略,fallback 到默认值。 **修复**:将三个 URL 提升到 `params` 顶层,删除 `editable` 包装: ```json // ❌ WRONG — editable 嵌套被忽略 "params": { "editable": { "new_data_url": "{{entire_url('../api/marketing_create.dspy')}}" } } // ✅ CORRECT "params": { "new_data_url": "{{entire_url('/discount/api/marketing_create.dspy')}}", "update_data_url": "{{entire_url('/discount/api/marketing_update.dspy')}}", "delete_data_url": "{{entire_url('/discount/api/marketing_delete.dspy')}}" } ``` **验证**:改 JSON 后必须重跑 `xls2ui` 再 grep index.ui 确认: ```bash xls2ui -m models -o wwwroot/discount discount json/discount_marketing_list.json grep 'new_data_url' wwwroot/discount/discount_marketing_list/index.ui # 应输出: "new_data_url": "{{entire_url('/discount/api/marketing_create.dspy')}}" ``` ## 坑 42:`logined_userid` 双重作用 — 查询过滤 + 插入注入 **现象**:加 `"logined_userid": "sale_id"` 后列表只显示自己的记录——这正是模板的设计行为,不是 bug。 **模板逻辑** (tmpls.py): - **get** (line 180-181):追加 `filterjson['AND'].append({'field': field, 'op': '=', ...})` → 强制 `WHERE sale_id = ` - **add** (line 226-239):`ns['field'] = userid` → 插入时自动填值 **适用场景**:只有「列表归属于当前用户」的场景才用 `logined_userid`(如促销码)。不要用于只想插入时填值的场景(如营销方案的 `created_by`)——用自定义 `new_data_url` + `request._run_ns` 代替。 ## 坑 43:sor.R 字段名不匹配模型 → 返回全表+role_recs[0]取错记录 **现象**:`add_sub_distributor_admin.dspy` 给管理员分配角色后,实际得到的是 `owner.*` 而非 `reseller.admin`。 **根因**:`sor.R('role', {'role': 'reseller.admin'})` — 模型 `role` 表的字段是 `id`, `orgtypeid`, `name`,没有 `role` 字段。`sor.R` 忽略未知字段 → 等效于 `{}` → 返回全部 27 条 role → `role_recs[0]` 每次都取第一条(`owner.*`)。 **正确写法**: ```python # ❌ WRONG — 'role' 不在模型字段中 role_recs = await sor.R('role', {'role': 'reseller.admin'}) # ✅ CORRECT — 用模型实际字段 orgtypeid + name role_recs = await sor.R('role', {'orgtypeid': 'reseller', 'name': 'admin'}) ``` **教训**:`sor.R()` 的过滤字段必须匹配模型 JSON 中的 `fields[].name`。写之前用模型 JSON 验证字段名: ```bash python3 -c "import json; [print(f['name']) for f in json.load(open('models/role.json'))['fields']]" ``` ## 坑 44:不要给用户手动分配 `logined`/`any` 角色 `get_userroles()` 自动为所有登录用户追加 `['any', 'logined']`。手动在 `userrole` 表中插入 `logined` 角色是多余的,且浪费一次查询。 ```python # ❌ WRONG for role_name in ['logined', 'reseller.admin', 'reseller.operator']: ... # ✅ CORRECT for orgtypeid, role_name in [('reseller', 'admin'), ('reseller', 'operator')]: role_recs = await sor.R('role', {'orgtypeid': orgtypeid, 'name': role_name}) ``` ## 坑 45:`logined_userorgid` + `default_filterjson` 抵消自定义 OR 子查询 **现象**:自定义 `get_product.dspy` 已写 `WHERE (org_id = ${userorgid}$ OR id IN (SELECT ... FROM product_org_auth))`,但日志显示实际 SQL 末尾多了 `AND org_id = 'xxx'`,导致 OR 子查询被抵消,返回 0 行。 日志证据: ``` default_filterjson():c=[{'field': 'org_id', 'op': '=', 'var': 'org_id'}] sql="... where (org_id = ${userorgid}$ OR id IN (...)) and org_id = ${org_id}$" ``` **根因**:JSON 中 `logined_userorgid: "org_id"` → 模板注入 `ns['org_id'] = userorgid`。随后 `default_filterjson` 遍历 ns 中匹配模型字段的 key,发现 `org_id` → 追加 `AND org_id = ${org_id}$`。这个额外 AND 把前面 OR 子查询完全抵消。 **修复**:在自定义 get dspy 中,`ns['org_id'] = userorgid` 之后立即 `del ns['org_id']`: ```python ns['org_id'] = userorgid ns['userorgid'] = userorgid del ns['org_id'] # 防止 default_filterjson 生成 AND org_id=... ``` SQL 仍可用 `${userorgid}$`(保留在 ns 中),但 `default_filterjson` 不会再捡到 `org_id`。 **验证**:查看 sage 日志确认不再出现 `default_filterjson():c=[...'org_id'...]` ## 坑 38:产品资源导入的增量模式 + providerid (内容不变,略) ## 坑 46:自定义 get dspy 中 `dbname` 来源 — 跨库查询必须分别建 sqlorContext **现象**:自定义 get dspy 中直接写 `dbname = get_module_dbname('product_management')` → 但 product 表在 `sage` 库。 **正确做法**:get dspy 中 `dbname` 变量由模板注入,指向模块数据库。如果 SQL JOIN 了其他库的表(如 `product_org_auth`),直接在 SQL 中跨库引用即可(同库)。如果确实需要两个数据库连接,用 `get_sor_context`。 ## 坑 47:模块初始化字典数据 — `init/data.json` 的 appcodes 结构 模块的字典初始化数据(如下拉选项)应放在 `init/data.json`,Sage 框架会自动加载: ```json { "appcodes": [ { "parentid": "supplier_settle_cycle", "parentname": "结算周期", "items": [ {"k": "monthly", "v": "月结"}, {"k": "quarterly", "v": "季结"} ] } ] } ``` `parentid` → `appcodes_kv.parentid`,`k/v` → `appcodes_kv.k/v`。注意 `appcodes_kv` 表无 `sort_order` 字段(只有 id/parentid/k/v),排序在 SELECT 时处理。 如果数据已存在于 data.json 但数据库缺数据,可能是模块初次安装时未触发初始化。用脚本手动加载(见 `references/init-data-loader.md`)。 ## 坑 48:`data_filter` 在 JSON params 中 vs 运行时 `params_kw` — 容易混淆 JSON 中的 `data_filter` 是**模板生成时**的过滤器定义。运行时 `params_kw.get('data_filter')` 来自 HTTP 请求参数,**不是**同一个东西。 当浏览器不传 `data_filter` 参数时,`filterjson = None`,触发 `default_filterjson(fields, ns)` 回退逻辑。`default_filterjson` 遍历 ns 中所有匹配模型字段名的 key,自动生成 `AND field = value` 条件。这就是坑 45 中 `org_id` 被自动加入过滤的原因。 **教训**:自定义 get dspy 中使用 `default_filterjson` 时,确保 ns 中没有不该参与过滤的字段。必要时在调用前 `del ns['unwanted_field']`。 ## 坑 49:Bricks i18n — 客户端 tip 硬编码英文 + i18n.json 需合并到全局 详见 `references/bricks-i18n.md`。Bricks 菜单父项点击报错修复见 `references/bricks-menu-fix.md`。Organization orgname 为空兜底见 `references/orgname-null-fallback.md`。 ## 坑 50:users 表 username/nick_name 为 NULL → UiCode 下拉显示空 **现象**:自由客户分配页面中,`UiCode` 销售下拉数据显示 `{"value": "", "text": null}`,大部分销售员名字为空。 **根因**:`users` 表中 `username` 和 `nick_name` 字段可能为 NULL(早期用户或自动创建的用户未设这些字段)。查询 `u.nick_name as sale_name` 时直接返回 NULL。 **修复**:使用 `COALESCE` 回退链始终返回可显示值: ```python # ❌ 可能返回 NULL SELECT u.id, u.nick_name as sale_name FROM users u ... # ✅ COALESCE 回退:username → nick_name → id SELECT u.id, COALESCE(u.username, u.nick_name, u.id) as sale_name FROM users u ... ``` **适用场景**:任何查询 users 表用于前端显示的 SQL(下拉选项、表格列等)。同样模式可用于 `organization` 表或任何可能 NULL 的显示字段。 **注意**:`get_free_customers` 中 JOIN 目标也需纠正: - `customerid` 存 `users.id`(从 `init_free_customers.dspy` 用 `users.id` 初始化) - ❌ `JOIN organization o ON o.id = dcb.customerid` — 用户 ID 不等于机构 ID - ✅ `LEFT JOIN users u ON u.id = dcb.customerid`,显示 `COALESCE(u.username, u.nick_name, u.id) as customer_name` ## 坑 51:get_reseller_sales 销售列表过滤 — 不要为 org='0' 开全量 **现象**:`get_reseller_sales` 中 `if userorgid == '0'` 分支查全量销售员,导致 org='0' 的管理员看到其他机构的销售。用户反馈:`topc1_admin` 不应出现在 `sword`(org 0)的销售清单中。 **修复**:去掉 `userorgid == '0'` 特殊分支,统一按 `u.orgid = ${oid}$` 过滤。 ## 坑 52:`customerid` 字段语义 — 存机构ID非用户ID 详见 `references/customer-bind-org-pattern.md`。核心:`discount_customer_bind.customerid` 是 `organization.id`,不是 `users.id`。初始化时按机构去重,JOIN organization 显示 orgname。 ## 坑 38:产品资源导入的增量模式 + providerid **现象**:重复执行资源导入会覆盖手动修改的产品数据,且新导入产品缺 providerid。 **修复**(`product_management/core.py` 和 `product_management/models/product.json`): 1. `import_categories_and_products` 中 `updated_prods` → `skipped_prods`:已有产品直接跳过(`skipped_prods += 1; continue`),不再更新 2. 创建产品时写入 `providerid = prod.get('providerid', '')` 3. `product.json` 模型添加 `providerid varchar(32)` 字段 4. 各资源模块的 `load_product_category_product` 需在返回数据中包含 `providerid`(如 llmage 从 `llm.providerid` 取) 5. 服务器需执行 `ALTER TABLE product ADD COLUMN providerid varchar(32) DEFAULT NULL AFTER org_id` ## 坑 53:JSON subtables 已定义但 index.ui 缺少 toolbar/bind → 重跑 xls2ui **现象**:JSON 配置中已正确定义 `subtables`(如合同附件),但浏览器查看列表页时 toolbar 没有对应按钮,点开行展开也没有附件 tab。比如供销合同/分销协议的附件管理 subtable 在 JSON 中有定义但页面上不显示。 **根因**:`xls2ui` 生成 `index.ui` 时根据 JSON 中的 `subtables` 生成 toolbar tools 和 binds。如果 JSON 后来添加了 subtable 但没重跑 `xls2ui`,或者之前的 `xls2ui` 版本不支持该 subtable 模式,生成的 `index.ui` 就缺少对应内容。 **诊断**:对比 JSON 的 `subtables` 和生成的 `index.ui`: ```bash # JSON 中定义的 subtables python3 -c "import json; d=json.load(open('json/supply_contracts_list.json')); [print(s['title']) for s in d['params'].get('subtables',[])]" # index.ui 中的 toolbar tools grep -A2 '"name"' wwwroot/supply_contracts_list/index.ui | grep -v '^--$' # index.ui 中的 binds grep '"event"' wwwroot/supply_contracts_list/index.ui ``` **修复**:重跑 `xls2ui` 重新生成 index.ui: ```bash cd /path/to/module xls2ui -m models -o wwwroot json/.json # 示例: xls2ui -m models -o wwwroot supplychain json/supply_contracts_list.json json/distribution_agreements_list.json ``` **验证**:grep 生成的 index.ui 确认 toolbar 和 bind 都存在: ```bash grep 'contract_attach' wwwroot/supply_contracts_list/index.ui # 应输出:toolbar name + bind event + bind url 三处引用 ``` **注意**: - `xls2ui` 也会重新生成 add/get/delete dspy 文件,这些通常不需要特殊处理 - toolbar 按钮对应的图标放在 `/wwwroot/imgs/.svg`,由 Sage 框架通过 `/imgs/.svg` 路径自动发现和 serve - wwwroot 的 `imgs/` 目录和列表目录下的文件如果被 `.gitignore` 忽略,用 `git add -f` 强制添加 **部署**:纯 wwwroot 变更(index.ui、dspy、imgs,未改 .py 文件)只需服务器 `git pull`,无需 `pip install` 或重启 Sage。 ## 坑 54:bricks Tabular render({}) 参数去重 — 上传/删除后列表不刷新 **现象**:合同附件上传后后台返回成功(`{"widgettype":"Message",...}`),但附件列表不刷新,必须重新打开弹窗才能看到新文件。删除同理——确认删除后列表无变化。 **根因**:bricks `DataViewer.render(params)` 有参数去重逻辑(dataviewer.js:46-50): ```javascript async render(params) { params = this.merge_search_params(params || {}); if (params == this.old_params){ // ← 对象引用比较! return; // ← 相同则跳过! } this.old_params = params; // ... 实际加载数据 } ``` 上传后脚本调用 `tbl.render({})` → `merge_search_params({})` 返回和上次相同的参数对象 → `==` 为 true → 直接 return,不加载数据。 **修复**:调用 `render()` 前将 `old_params` 置 null 破环: ```javascript // ❌ WRONG — 相同参数被跳过 tbl.render({}); // ✅ CORRECT — 破环强制刷新 tbl.old_params = null; tbl.render({}); ``` 或者直接调用无参的 `tbl.render()`(不传 `{}`),因为 `add_record_finish` 中已验证 `this.render()` 可正常刷新。 **完整上传脚本模板**(含消息显示): ```javascript fetch(upload_url, {method:'POST', body:formData}) .then(function(r){return r.json()}) .then(function(msg){ var tbl = bricks.getWidgetById('attach_tbl'); if (tbl) { tbl.old_params = null; tbl.render({}); } self.value = null; if (msg.widgettype) bricks.widgetBuild(msg).then(function(w){if(w)w.open()}); }) .catch(function(e){ console.error('upload failed', e); }); ``` **完整删除脚本模板**: ```javascript fetch(delete_url + '?id=' + encodeURIComponent(record.id) + '&fiid=' + fiid, {method:'POST'}) .then(function(r){return r.json()}) .then(function(r){ if (r.widgettype === 'Message') { self.old_params = null; self.render({}); } else if (r.widgettype === 'Error') { alert(r.options.message); } }); ``` **适用场景**:任何在 bricks Tabular/DataViewer 上通过自定义脚本调用 `render({})` 刷新的场景(上传后刷新、删除后刷新、手动触发重载等)。`filter_form_submited`(dataviewer.js:372-382)中已有正确示范:先 `this.old_params = null; this.loader.pages = [];` 再 `this.render({})`。 ## 坑 55:`DBPools()` 无参构造 → 独立进程中 `sqlorFactory` 报 NoneType **现象**:`backend_accounting.py` 启动后报错:`AttributeError: 'NoneType' object has no attribute 'get'`,调用栈追溯到 `sqlorFactory` → `dbdesc.get('driver', dbdesc)` 中 `dbdesc` 为 None。 **修复**(优先 monkey-patch 方案,无需改第三方模块): ```python # backend_accounting.py 中,用正确配置初始化后替换 get_business_date p = ProgramPath() config = getConfig(NS={'workdir': os.getcwd(), 'ProgramPath': p}) db = DBPools(config.databases) import appbase.businessdate as _bd async def _patched_get_business_date(sor=None): async def _f(sor): sql = "select * from params where params_name = 'business_date'" recs = await sor.sqlExe(sql, {}) if len(recs) > 0: return recs[0]['params_value'] raise Exception('BusinessDateParamsError') if sor: return await _f(sor) async with db.sqlorContext(get_module_dbname('appbase')) as sor: return await _f(sor) _bd.get_business_date = _patched_get_business_date ``` **注意**:`DBPools` 不是单例模式,每个 `DBPools()` 调用都创建独立实例。独立进程(`backend_accounting.py`、cron job 等)中所有被调用的模块都必须使用已配置的实例。 **修复范围**:不仅 `businessdate.py`,任何在独立进程中被间接调用的代码都不能 new `DBPools()`。应该通过参数传入 sor,或使用 `getConfig().databases` 初始化。 **部署**:修改后需 `pip install .` + 重启对应进程(Sage 主进程和 backend_accounting 都要重启)。 ## 坑 56:product_accounting 中 resellerid=providerid → 供应商被当成分销商记账 **现象**:生产记账报错 `AccountIdNone(orgid='6fadgewjraOyvxC_EkHou', subjectname='商户采购成本')`,org 是供应商(阿里云)但缺少"商户采购成本"科目账户。 **根因**:`product_accounting()` 第1459行 PAY* 腿: ```python action='PAY*', customerid=ownerid, resellerid=providerid, providerid=providerid ``` `resellerid` 被设为 `providerid`(供应商 org)。记账系统 `get_orgid_by_trans_role` 把 `role='reseller'` 映射到 `self.resellerid` → 供应商被当成分销商来查科目账户 → 供应商没有 reseller 账户 → `AccountIdNone`。 **正确逻辑**:供应商应通过 `role='provider'` 查账户,不应出现在 `resellerid` 位置。PAY* 腿的 `resellerid` 应为上游分销商(owner),`providerid` 才是供应商: ```python # 当前(错误):resellerid=providerid → 供应商被当成分销商 action='PAY*', customerid=ownerid, resellerid=providerid, providerid=providerid # 应为:resellerid=ownerid → 平台作为分销商,provider 是供应商 action='PAY*', customerid=ownerid, resellerid=ownerid, providerid=providerid ``` **影响范围**:所有 `ownerid=0` 且 llm 有 `providerid` 的产品记账(如阿里云 qwen 系列模型)。 ## 坑 57:`product` 表列名是 `product_name` 不是 `name` → 500 **现象**:`get_reseller_reconcile.dspy` 生产环境 500,SQL 报 `Unknown column 'p.name'`。 **根因**:`product` 表的列名是 `product_name`,代码中错写成 `p.name`。`DESC product` 可确认。 **修复**:`p.name` → `p.product_name`(SELECT、GROUP BY、ORDER BY 共 3 处)。同时移除 JOIN ON 中无用的 `COLLATE utf8mb4_unicode_ci`。 **教训**:JOIN `product` 表时列名是 `product_name` 不是 `name`。 ## 坑 58:DSPY 端点公开给 Cron — PATHS_LOGINED → PATHS_ANY **现象**:cron curl dspy → 401,因注册为 `logined` 需 Session。 **修复**:`load_path.py` 中将端点从 `PATHS_LOGINED` 移到 `PATHS_ANY`,重跑 `load_path.py` + 重启 Sage。 ## 坑 59:自定义 `new_data_url` dspy 不享受 `logined_userorgid` 自动注入 **现象**:JSON 配置了 `"logined_userorgid": "ownerid"`,但新增 LLM 后 `ownerid` 为空,导致列表按 org 过滤时查不到记录。创建成功的消息弹出了,数据也入库了,但 `ownerid` 字段是 NULL/空字符串。 **根因**:`logined_userorgid` 的自动注入(`ns['ownerid'] = userorgid`)只在 xls2ui 生成的**标准** `add_xxx.dspy` 中生效。当 JSON 通过 `new_data_url` 指向**自定义 dspy 端点**时(如 `../api/llm_create.dspy`),模板引擎不会注入——自定义 dspy 必须自己处理。 **诊断**: ```python # 查看 JSON 是否用了自定义端点 grep 'new_data_url' json/llm.json # → "new_data_url": "{{entire_url('../api/llm_create.dspy')}}" # 查看 dspy 是否有手动注入 grep 'ownerid\|get_userorgid' wwwroot/api/llm_create.dspy # 没有 → 需要补! ``` **修复**:在自定义 create dspy 中手动获取并注入: ```python env = request._run_ns data = params_kw.copy() data['id'] = getID() data['ownerid'] = data.get('ownerid') or await env.get_userorgid() await sor.C('llm', data) ``` **教训**:`logined_userid`/`logined_userorgid` 只对模板生成的默认 CRUD dspy 有效。一旦用了自定义 `new_data_url`,必须在自定义端点中手动获取当前用户/机构信息。 ## 坑 60:create 函数只做拉链逻辑忘了 `sor.C()` 插入 **现象**:`add_pricing_program_timing` 调用成功返回 `{"status": "success"}`,但数据库无新记录,界面无变化。 **根因**:函数做了拉链逻辑(关旧记录:`sor.U()` 更新 `expired_date`),但漏了 `sor.C()` 插入新记录。函数逻辑链不完整。 **修复**:拉链完成后补插入: ```python async def add_pricing_program_timing(env, sor, ns): ppid = ns.get('ppid') enabled_date = ns.get('enabled_date') ns['expired_date'] = '9999-12-31' # 1. 拉链:关旧记录 if ppid and enabled_date: prev = await sor.R('pricing_program_timing', {'ppid': ppid, 'expired_date': '9999-12-31'}) if prev: await sor.U('pricing_program_timing', {'id': prev[0].id, 'expired_date': enabled_date}) # 2. 插入新记录 ← 之前漏了这一步! ns['id'] = ns.get('id') or getID() await sor.C('pricing_program_timing', ns) ``` **诊断技巧**:create 返回 success 但 DB 无数据 → 搜函数中是否有 `sor.C()`。没有就是忘了。另外注意函数的 import——如果 `getID` 等未 import,需补上。 ## 坑 62:`sqlorContext` 中硬编码 dbname → NoneType 崩溃 **现象**:`db.sqlorContext('llmage')` 或 `DBPools().sqlorContext('sage')` 导致 `'NoneType' object has no attribute 'get'`。 **根因**:dbname 硬编码字符串,配置文件中的数据库别名不一定匹配。`sqlorFactory` 中 `dbdesc = self.databases.get(dbname, None)` 找不到就返回 None → `dbdesc.get('driver')` 崩溃。 **修复**:用 `ServerEnv().get_module_dbname('模块名')` 动态获取: ```python # ❌ 硬编码 — 配置中可能无此别名 async with db.sqlorContext('llmage') as sor: async with DBPools().sqlorContext('sage') as sor: # ✅ 动态获取 lmage_dbname = ServerEnv().get_module_dbname('llmage') async with db.sqlorContext(lmage_dbname) as sor: ``` **注意**: - `ServerEnv().get_module_dbname()` 需要模块名(如 `'llmage'`, `'sage'`, `'discount'`),不是数据库连接字符串 - 如果在 dspy/init.py 中已有 `env = request._run_ns`,也可用 `env.get_module_dbname('sage')` - `DBPools()` 无参构造是空对象,必须通过 `getConfig().databases` 或 `_get_sor()` 配置后才能用 - **脚本中**`DBPools(config.databases)` 初始化后,后续 `DBPools()` 可能仍为空——取决于 DBPools 实现是否单例(当前版本非单例) **排查**:搜所有硬编码 dbname: ```bash grep -rn "sqlorContext(['\"]" --include="*.py" . ``` ## 坑 61:`rolepermission` 表是多对多桥梁,`permission` 表 path 唯一 **现象**:为 tenant 路径补充 `owner.*` 权限时,尝试插入新 `permission` 行报 `Duplicate entry for key 'permission_idx1'`。 **根因**:`permission` 表唯一索引在 `path` 字段上(不是 `(path, permtype)`)。一个 path 只能有一条 `permission` 记录。多角色授权通过在 `rolepermission` 表中为同一 `permid` 添加多个 `roleid` 实现。 **正确模式**: ```python # 1. path → permission(一个 path 只一条) perm_id = await ensure_permission(path) # 2. 多角色授权 → rolepermission(多行,同 perm_id) for role_id in [logined_id, reseller_admin_id, owner_super_id]: existing = await sor.sqlExe( 'SELECT id FROM rolepermission WHERE roleid=${rid}$ AND permid=${pid}$', {'rid': role_id, 'pid': perm_id}) if not existing: await sor.sqlExe( 'INSERT INTO rolepermission (id, roleid, permid) VALUES (${id}$, ${rid}$, ${pid}$)', {'id': getID(), 'rid': role_id, 'pid': perm_id}) ``` **诊断 SQL**: ```sql -- 查某 path 被哪些角色授权 SELECT r.orgtypeid, r.name, p.path FROM rolepermission rp JOIN permission p ON p.id = rp.permid JOIN role r ON r.id = rp.roleid WHERE p.path LIKE '/tenant%'; -- 查有 permission 但无 rolepermission 的无效路径 SELECT p.path FROM permission p LEFT JOIN rolepermission rp ON rp.permid = p.id WHERE p.path LIKE '/tenant%' AND rp.permid IS NULL; ``` **教训**:`load_path.py` 用 DB 直连模式时(非 `set_role_perm.py`),不要重复插入同一个 path 到 `permission` 表。先查是否存在,存在则只补充 `rolepermission` 关联。 ## 坑 63:Bricks VBox/HBox 子控件用 `subwidgets` 不是 `options.children` **现象**:VBox 容器页面显示空白,子控件没有渲染出来。 **根因**:子控件放在 `options.children` 中,但 bricks 规范要求放在顶层 `subwidgets` 数组里。 ```json // ❌ WRONG — children 在 options 内,被忽略 {"widgettype":"VBox","options":{"children":[{"widgettype":"Title",...}]}} // ✅ CORRECT — 顶层 subwidgets {"widgettype":"VBox","options":{"width":"100%"},"subwidgets":[{"widgettype":"Title",...}]} ``` ## 坑 64:CRUD 自动生成目录禁止提交 git **现象**:`git pull` 时报大量 untracked CRUD 文件冲突(`add_/get_/update_/delete_.dspy` + `index.ui`)。 **根因**:`xls2ui -m models -o wwwroot` 生成的是 CRUD 中间文件,不应纳入版本控制。一旦有人提交到仓库,其他人 pull 时本地同名 untracked 文件就会冲突。 **预防**: - 模块 `.gitignore` 中列出所有 CRUD 生成目录: ``` wwwroot/organization/ wwwroot/role/ wwwroot/users/ ...(每个表名目录一行) ``` - 误提交后修复:`git rm -r <目录> && git commit`,然后补 `.gitignore` **生产服务器遇到冲突的快速修复**: ```bash git reset --hard HEAD && git pull # 丢弃本地改动直接拉远程 ``` **⚠️ 致命后果:服务器上 CRUD 目录完全缺失 → 500 "invalid path"** `git pull` 只拉取 git 跟踪的文件。xls2ui 生成的 CRUD 目录(`wwwroot//`)在 `.gitignore` 中,**永远不会通过 git pull 到达服务器**。如果服务器重建、wwwroot 被清理、或首次部署未运行 `xls2ui`,所有 CRUD 页面(列表、新增、删除、更新)都会返回 500: ``` Exception: str(request.url)='.../get_supply_contracts.dspy' invalid path ``` **诊断**:检查生成目录是否存在: ```bash for d in supply_contracts_list suppliers_list sub_resellers_list ...; do ls /wwwroot/$d/index.ui 2>/dev/null || echo "MISSING: $d" done ``` **修复**:在服务器上运行 `xls2ui` 重新生成: ```bash cd /d/apitest/sage/pkgs/supplychain/json /d/apitest/sage/py3/bin/xls2ui -m ../models -o ../wwwroot supplychain *.json # 验证 ls ../wwwroot/supply_contracts_list/index.ui ``` **教训**:部署后必须检查 `xls2ui` 生成目录是否齐全,或在 `build.sh` 中包含此步骤。不可假设 `git pull` 会拉取这些 gitignore 文件。 **⚠️ 区分(坑 86)**:不要把 wwwroot 下任何 `.ui` 都当成 CRUD 中间文件。`contract_discount_setting.ui`、`agreement_discount_setting.ui`、`api/*.dspy` 等是 git 跟踪的**手写源文件**。归因 500 前先 `git ls-files | grep <文件名>` 确认,见坑 86。 ## 坑 66:product_management 多级分销记账(替代 llm_charging) **背景**:产品记账已从 `llmage/accounting.py` 迁移到 `product_management/core.py`。`llm_charging` 仅用于获取原始价格,不应承担货币转换或折扣逻辑。 **核心流程**: ``` backend_accounting.py (独立进程,nohup运行) → ProductManager.backend_accounting() → get_accounting_llmusages() (llmage) → product_accounting() (product_management fallback) → llm_charging() (只取原价) → 查 distribution_agreements + agreementdetail 折扣 → 汇率转换(env.get_exchange_rate) → 多级 ConsumeBiz → consume_accounting() ``` **tenantid 模式(dspy 中)**: ```python # ❌ 不要用 ${oid}$ 占位符 — dspy 的 sor.sqlExe 跨上下文可能失败 # ❌ tenantid = userorgid — 应该是 parentid # ✅ 用 repr() 直接拼 SQL,避免占位符 async with get_sor_context(env, 'sage') as sor3: org_recs = await sor3.sqlExe( 'SELECT parentid FROM organization WHERE id=' + repr(userorgid), {}) if org_recs and org_recs[0].parentid: params_kw.tenantid = org_recs[0].parentid ``` **多级折扣链**(在 product_accounting fallback 中): ```python CUST_DISCOUNT = 0.9 # 客户统一折扣 PROVIDER_DISCOUNT = 0.7 # 供应商 → 一级分销商 # 从 distribution_agreements + agreementdetail 查二级分销商折扣 sub_discount = 1.0 if resellerid != '0': agree_recs = await sor.sqlExe( "SELECT d.discount, a.resellerid FROM distribution_agreements a, agreementdetail d " "WHERE a.id=d.agreeid AND a.sub_reseller_id=" + repr(resellerid) + " AND a.status='1' LIMIT 1", {}) # 汇率转换:pricing_program → env.get_exchange_rate pricing_currency = pp.currency # e.g. 'USD' fx_rate = await env.get_exchange_rate(pricing_currency, 'CNY', 'buy_rate') raw_price = raw_usd * fx_rate ``` **多级 ConsumeBiz 分录**: ```python # 1) 客户 → 二级分销商 (PAY, 0.9折扣) ais.append(DictObject(action='PAY', customerid=customerid, resellerid=resellerid, ...)) # 2) 二级 → 一级 (PAY*, sub_discount) ais.append(DictObject(action='PAY*', customerid=resellerid, resellerid=parent_reseller, ...)) # 3) 一级 → 供应商 (PAY*, 0.7折扣) ais.append(DictObject(action='PAY*', customerid=parent_reseller, resellerid=providerid, ...)) await consume_accounting(sor, orderid, ais) ``` **必需的开户(accounting/openaccount.py)**: ```python # 平台自身 await openOwnerAccounts(sor, "0") # 分销商 await openResellerAccounts(sor, "0", dist_id) await openRetailRelationshipAccounts(sor, "0", "0", dist_id) # 供应商 await openProviderAccounts(sor, "0", provider_id) await openResellerAccounts(sor, "0", provider_id) # 供销关系中供应商也是 reseller # 供应商自引用账户(PAY* 腿需要 orgid=provider, org1id=provider) await openAccount(sor, "0", provider_id, cfg, org1id=provider_id) ``` **backend_accounting.py 启动注意事项**: - 移除 `sys.path` 中的 `pkgs/` 路径 - `DBPools(config.databases)` 必须先初始化 - `load_pricing()` 必须调用才能注册 `buffered_charging` - 进程用 `nohup` + `disown` 启动 **关联修复的模块级 bug**: - `supplychain/init.py:1043` — `get_contract_discount_items` → `get_agreement_discount_items` - `smssend/init.py` — `_sms_engine_instance = None` 禁用短信(测试环境) - `accounting_config` 表 — `amt_pattern` 中 `${交易金额}$ - ${交易手续费}$` 当手续费为0时报 SyntaxError,应改为 `${交易金额}$` 详见 `references/multi-tier-accounting.md`。 ## 坑 65:DSPY f-string → SyntaxError `'{' was never closed` **现象**:dspy 文件运行时爆 `SyntaxError: '{' was never closed`,但在正常 Python 中语法完全正确。 **根因**:dspy 通过 `exec()` 运行,执行引擎对 `{}` 有特殊处理(模板/Jinja2 语法冲突)。f-string 中的 `{变量}` 被模板引擎误解析,导致括号计数错乱。 **错误示例**: ```python # ❌ dspy 中爆 SyntaxError return {"message": f"错误: {e}"} exception(f'error: {e}') # ✅ 修复:用 + 拼接或 .format() return {"message": "错误: " + str(e)} exception('error: ' + str(e)) ``` **教训**:dspy 文件禁止 f-string。所有字符串拼接用 `+` 或 `.format()`。同时 dspy 也禁止 `import` 语句。 供销合同/分销协议的附件管理采用统一模式,详见 `references/contract-attach-pattern.md`。 核心组件: - `contract_attach.ui` — 附件弹窗 UI(UiFile 上传 + Tabular 列表) - `contract_attach_list.dspy` — 查询 `file` 表列出附件 - `contract_upload.dspy` — 通过 `ContractAttachMgr` 上传 - `contract_attach_delete.dspy` — 通过 `ContractAttachMgr` 删除 - `ContractAttachMgr(FileMgr)` — init.py 中的附件管理器 - `ensure_contract_folder(request, contract_id, fiid)` — 确保 filemgr folder 存在 ## 坑 67:Jinja2 .ui 模板调用 Python 函数 — 签名只接受 (request) **现象**:`.ui` 文件中 `{% set items = get_agreement_discount_items(request) %}` → 模板渲染 500,日志显示 `TypeError: get_agreement_discount_items() missing 1 required positional argument: 'params_kw'`。 **根因**:Jinja2 模板调用 ServerEnv 注册的函数时,只传一个参数 `(request)`。Python 函数签名写 `(request, params_kw)` 的话,`params_kw` 没有传入源。`params_kw` 需要通过 `request._run_ns` 获取: ```python # ❌ 签名错误 — Jinja2 只传 request async def get_items(request, params_kw): ... # ✅ 正确 async def get_items(request): params_kw = getattr(request._run_ns, 'params_kw', {}) or {} ``` **注意**:dspy 文件(`async def main()` 模式)中 `params_kw` 是全局变量。模块 Python(init.py)中没有这个全局变量,必须从 `request._run_ns` 中取。 --- ## 坑 67b:bricks PopupWindow 无法渲染 dspy 返回的 DataViewer/UrlWidget **现象**:ensure dspy 返回 `{"widgettype":"DataViewer","options":{"url":"..."}}` → PopupWindow 弹窗空白。返回 HTML meta refresh → 也不跳转。返回 302 redirect → 也不生效。 **根因**:PopupWindow 的 `urlwidget` actiontype 处理 POST 响应时,只支持有限的 widget 类型(Message、Error、VBox 等),不支持 DataViewer/UrlWidget 这种负载型 widget。HTML meta refresh 和 HTTP redirect 也不被 PopupWindow 执行。 **修复**:去掉 ensure 中间层,工具栏直接 GET 打开 CRUD 页面: ```json { "actiontype": "urlwidget", "target": "PopupWindow", "options": { "method": "GET", "url": "{{entire_url('/supplychain/supply_contracts_list')}}?supplier_id={{params_kw.get('id','')}}" } } ``` **教训**:bricks 工具栏 bind 只有 5 种 actiontype 可用(urlwidget 是最通用的)。PopupWindow 不能渲染嵌套的 DataViewer。直接 GET 目标页面是最简单的方案。 ## 坑 77:折扣设置 UI 的"已设置"状态列无法实时更新 **现象**:分销协议/供销协议的折扣设置页(`agreement_discount_setting.ui`)中,修改折扣 blur 保存后,状态列仍显示"-未设置"。 **根因**:状态列是 Jinja2 模板在服务端渲染时确定的,基于 `p.item_id` 初始值。每次修改折扣保存后,状态 Tool 不会自动重新渲染。bricks 不支持 `actiontype: "script"` 做客户端 DOM 更新。 **当前方案**:save dspy 返回 Message widget("保存成功" toast),用户关闭再打开弹窗即可看到更新后的状态。 ## 坑 78:Pipeline bricks 版本不兼容 — `DataSelector` widget 未注册 **现象**:pipeline editor 页面加载后控制台报 `widgetBuild(): DataSelector not registered`,下拉选择器不显示。 **根因**:pipeline-app 使用的 bricks.js 版本(pipeline 目录下)不支持 `DataSelector` widget。pipeline 的 `pipeline_editor/index.ui` 使用了此 widget 但 bricks 中没有注册。 **修复**:用标准 CRUD 模式(Tabular + toolbar)替代 DataSelector-based UI。 ## 坑 79:pipeline 运行时 asyncio 事件循环冲突(预存环境 BUG) **现象**:pipeline 重启后所有请求返回 500,日志:`RuntimeError: Task got Future attached to a different loop`。 **根因**:`DBPools` 单例被多个 pipeline 进程/事件循环共享。`ahserver.webapp` 在 fork 子进程后重用父进程的 DBPools 实例,`db.databases` 未刷新。 **诊断**:`python app/pipeline_app.py` 前台运行看启动日志。正常时输出 `module loaded` + `Running on http://0.0.0.0:9090`。 **临时修复**:`pkill -9 -f pipeline_app`,然后重新 `nohup python app/pipeline_app.py -p 9090 -w /d/apitest/pipeline &`。 ## 坑 68:模块 Python 代码中获取数据库上下文 — 必须用 `get_sor_context` **现象**:init.py 中 `get_agreement_discount_items(request)` 用 `DBPools().sqlorContext('sage')` → `NameError: name 'get_module_dbname' is not defined`,或 `db.sqlorContext(dbname)` → `NoneType`。 **根因**:模块 Python 代码(非 dspy 文件)获取数据库连接的正确方式是 `get_sor_context(env, 'module_name')`: ```python from sqlor.dbpools import DBPools, get_sor_context async def my_func(request): env = request._run_ns # ← 不是 ServerEnv()! async with get_sor_context(env, 'sage') as sor: # sage 产品表 rows = await sor.sqlExe('SELECT * FROM product', {}) async with get_sor_context(env, 'supplychain') as sor: # 合同明细表 rows = await sor.sqlExe('SELECT * FROM supply_contract_items', {}) ``` **错误模式对比**: ```python # ❌ DBPools() 硬编码 dbname — get_module_dbname 在 py 代码中不存在 async with DBPools().sqlorContext('sage') as sor: # ❌ _get_sor() 自定义 helper — 配置可能不完整 db, dbname = _get_sor() async with db.sqlorContext(dbname) as sor: # ✅ 标准模式 env = request._run_ns async with get_sor_context(env, 'sage') as sor: ``` **注意**: - `get_sor_context` 在 dspy 上下文中是全局函数 - 在 `init.py` 中需 `from sqlor.dbpools import get_sor_context` - `env = request._run_ns`,不是 `ServerEnv()`(坑 40) - 跨库查询要分别建 sqlorContext(坑 69) ## 坑 69:跨库 JOIN 报 1142 权限拒绝 → 分别查询各 DB 后 Python 合并 **现象**:`get_agreement_discount_items` 中用 `LEFT JOIN supplychain.distribution_agreement_items` → `SELECT command denied to user 'test'@'localhost' for table supplychain.distribution_agreement_items`。 **根因**:测试用户 `test` 只有 `sage` 库的权限,不能通过 `database.table` 跨库引用。 **修复**:分别连 `sage` 库查 products,连 `supplychain` 库查 items,Python 合并: ```python # 1. sage → products async with get_sor_context(env, 'sage') as sor: rows = await sor.sqlExe('SELECT id, product_name FROM product WHERE status=1', {}) # 2. supplychain → items async with get_sor_context(env, 'supplychain') as sor: items = await sor.sqlExe('SELECT id, productid, discount FROM distribution_agreement_items WHERE agreement_id=${aid}$', {'aid': aid}) # 3. merge in Python items_map = {r.productid: {'id': r.id, 'discount': r.discount} for r in items} ``` ## 坑 70:save dspy 重复 INSERT 报 Duplicate entry → 先查后插 **现象**:`save_agreement_discount.dspy` 每次 blur 都 `sor.C('product_org_auth')`,改两次折扣就 `IntegrityError: Duplicate entry`。 **修复**:插入前查存在: ```python existing = await sqlExe("SELECT id FROM product_org_auth WHERE product_id=${pid}$ AND org_id=${oid}$", {'pid': pid, 'oid': oid}) if not existing: await sor.C('product_org_auth', {...}) ``` ## 坑 73:get_search_*.dspy 查询错误的 DB 和表名 → UiCode 下拉为空 **现象**:分销协议产品折扣明细添加表单中,产品分类和产品 ID 下拉框为空(`bricks.UiCode.build_options: undefined.length`)。 **根因**:`get_search_prodtypeid.dspy` 查询 `product_types` 表在 `supplychain` 库 → 表不存在。`get_search_productid.dspy` 查询 `products` 表 → 实际表名是 `product`。两者都在 `sage` 库。 **修复**: ```python # ❌ supplychain.product_types — 不存在 async with DBPools().sqlorContext('supplychain') as sor: rows = await sor.sqlExe("select id as prodtypeid, type_name as prodtypeid_text from product_types", {}) # ✅ sage.product_category async with DBPools().sqlorContext('sage') as sor: rows = await sor.sqlExe( "select id as prodtypeid, name as prodtypeid_text from product_category where status='1' order by sort_order, name", {}) ``` **教训**:`get_search_*` dspy 是 UiCode 下拉控件的**唯一数据源**。写之前必确认:(1) 正确的数据库名 (2) 正确的表名 (3) 正确的列名。 --- ## 坑 74:xls2ui 生成 index.ui 时自动移除 `alter` 中的字段 → `editexclouded` 对不上 → `undefined.length` **现象**:供应合同和分销合同添加表单弹窗报 `bricks.UiCode.build_options: Cannot read properties of undefined (reading 'length')`。 **根因**:`supplier_id` 和 `sub_reseller_id` 在 CRUD JSON 的 `browserfields.alters` 中配置了 code 类型 + dataurl。xls2ui 生成 index.ui 时,自动把这两个字段从 `editexclouded` 中移除了(因为它们在 `alters` 中有 dataurl)。结果添加表单渲染这个字段时找不到 options 数据 → `undefined.length`。 **修复**:手动把被移除的字段加回 `editexclouded`,并确认 index.ui 中 editexclouded 数组包含所有不应显示在添加表单中的字段。 **教训**:xls2ui 生成后必须逐行检查 editexclouded 是否完整。`alters` 中的 dataurl 字段会被自动移除,但它们仍应在 editexclouded 中以隐藏方式传递。对比 JSON 源和生成的 index.ui: ```bash # JSON 的 editexclouded python3 -c "import json; print(json.load(open('json/suppliers_list.json'))['params']['editexclouded'])" # index.ui 的 editexclouded grep -A10 editexclouded wwwroot/suppliers_list/index.ui ``` --- ## 坑 75:Pipeline 模块放在应用根目录,不在 pkgs/ 下 **现象**:Pipeline-app 的 `pipeline_core`、`pipeline_ops` 等模块位于 `~/pipeline/` 根目录,不是 `~/pipeline/pkgs/`。试图移动到 pkgs/ 后 `pip install -e` 导致 import 路径断裂。 **根因**:pipeline-app 的 `sys.path.insert(0, root_dir)` 将 `~/pipeline/` 加入 Python path,`from pipeline_core.init import ...` 直接解析。移入 pkgs/ 后 Python 找不到。 **教训**:不要随意重组已有应用的目录结构。先 grep 确认 `sys.path` 和 import 模式。 --- ## 坑 76:Pipeline 权限注册用 `bin/init_perms.py`,不是 Sage 的 `load_path.py` **现象**:pipeline 模块 403,但 Sage load_path.py 已有。 **根因**:pipeline-app 独立端口 9090,有自己的 `/bin/init_perms.py`。每个模块路径必须在此文件中注册: ```python PERMS = [ ('/pipeline_core/pipelines/*', '产线定义API', 'pipeline_core', 'logined'), ] ``` 部署后执行 `python bin/init_perms.py` 生效。 ## 坑 69:跨库 JOIN 报 1142 权限拒绝 → 分别查询各 DB 后 Python 合并 **现象**:`get_agreement_discount_items` 中用 `LEFT JOIN supplychain.distribution_agreement_items` → `SELECT command denied to user 'test'@'localhost' for table supplychain.distribution_agreement_items`。 **根因**:测试用户 `test` 只有 `sage` 库的权限,不能通过 `database.table` 跨库引用。同一 MySQL 实例不同 database 之间 JOIN 需要额外权限。 **修复**:分别连 `sage` 库查 products,连 `supplychain` 库查 items,然后在 Python 中合并: ```python # Step 1: sage → products async with get_sor_context(env, 'sage') as sor: rows = await sor.sqlExe('SELECT id as productid, product_name FROM product WHERE status=1', {}) # Step 2: supplychain → discount items async with get_sor_context(env, 'supplychain') as sor: items = await sor.sqlExe('SELECT id, productid, discount FROM distribution_agreement_items WHERE agreement_id=${aid}$', {'aid': aid}) # Step 3: merge in Python items_map = {r.productid: {'id': r.id, 'discount': r.discount} for r in items} ``` **教训**:不要跨库 JOIN。总是分开查然后 Python 合并。 ## 坑 70:DSPY 重复执行 → 不在 save dspy 中插入 product_org_auth 之前先检查 **现象**:`save_agreement_discount.dspy` 每次 blur 都尝试 `sor.C('product_org_auth', ...)` → `IntegrityError: Duplicate entry for key 'idx_poa_unique'`。同一产品改两次折扣就崩。 **修复**:插入前先查是否存在: ```python existing = await s3.sqlExe( "SELECT id FROM product_org_auth WHERE product_id=${pid}$ AND org_id=${oid}$", {'pid': productid, 'oid': sub_reseller_id}) if not existing: await s3.C('product_org_auth', {...}) ``` **教训**:任何可能重复执行的 INSERT 都要先 `SELECT` 检查。 ## 坑 71:Pipeline 独立应用权限注册 — `/bin/init_perms.py` 不是 `load_path.py` **现象**:pipeline 模块访问报 403 `invalid path`,但 Sage 的 `load_path.py` 已配置了路径。 **根因**:pipeline-app 是独立应用(端口 9090),有自己的权限注册脚本 `/bin/init_perms.py`,不共享 Sage 的 `load_path.py`。每个模块路径必须同时在此文件中注册。 **Pipeline 权限注册模式**: ```python PERMS = [ ('/pipeline_core/', '产线管理访问', 'pipeline_core', 'logined'), ('/pipeline_core/pipelines/*', '产线定义API', 'pipeline_core', 'logined'), ('/pipeline_ops/', '运营管理', 'pipeline_ops', 'logined'), ('/', '应用首页', 'app', 'guest'), ] ``` **部署流**:修改 `init_perms.py` → 服务器 `python bin/init_perms.py` 执行。 **教训**:有独立端口的应用 = 独立权限系统。先 grep 项目的 `init_perms` / `load_path` 确认权限注册在哪里。 ## 坑 72:Sage/Pipeline 同机多端口 → Rsync 非共享代码 **现象**:pipeline 代码在 `/d/ymq/pipeline/pipeline-app/` 但实际运行在 `/d/apitest/pipeline/`(不是同一目录)。 **根因**:开发环境和测试环境的代码目录不共享。Sage 的 pkgs 模式(`pip install pkgs/xxx`)和 Pipeline 的拷贝模式(scp / 手动 sync)不同。 **Pipeline 部署**:新文件直接 scp 到服务器 `/d/apitest/pipeline/`。不要尝试 `git pull` 因为服务器 pipeline 目录不是 git repo。 **教训**:每个应用确认其部署方式后再动——不要假设所有模块都是 Sage pkgs。 **现象**:init.py 中 `get_agreement_discount_items(request)` 用 `DBPools().sqlorContext('sage')` → `NameError: name 'get_module_dbname' is not defined`,或 `db.sqlorContext(dbname)` → `NoneType`。 **根因**:模块 Python 代码(非 dspy 文件)获取数据库连接的正确方式是 `get_sor_context(env, 'module_name')`: ```python from sqlor.dbpools import DBPools, get_sor_context async def my_func(request): env = request._run_ns # ← 不是 ServerEnv()! async with get_sor_context(env, 'sage') as sor: # sage 产品表 rows = await sor.sqlExe('SELECT * FROM product', {}) async with get_sor_context(env, 'supplychain') as sor: # 合同明细表 rows = await sor.sqlExe('SELECT * FROM supply_contract_items', {}) ``` **错误模式对比**: ```python # ❌ DBPools() 硬编码 dbname — get_module_dbname 在 py 代码中不存在 async with DBPools().sqlorContext('sage') as sor: # ❌ _get_sor() 自定义 helper — 配置可能不完整 db, dbname = _get_sor() async with db.sqlorContext(dbname) as sor: # ✅ 标准模式 env = request._run_ns async with get_sor_context(env, 'sage') as sor: ``` **注意**: - `get_sor_context` 在 dspy 上下文中是全局函数 - 在 `init.py` 中需 `from sqlor.dbpools import get_sor_context` - `env = request._run_ns`,不是 `ServerEnv()`(坑 40) - 跨库查询要分别建 sqlorContext(坑 46) ## 坑 80:json2ddl `datetime DEFAULT 'NOW()'` → MySQL ERROR 1067 Invalid default value **现象**:`json2ddl mysql . | sagedb` 建表时报 `ERROR 1067 (42000): Invalid default value for 'created_at'`。生成的 DDL 为: ```sql `created_at` DATETIME DEFAULT 'NOW()' comment '创建时间' ``` **根因**:MySQL 不允许 `datetime` 类型使用 `DEFAULT 'NOW()'`(字符串字面量)。`DATETIME` 只接受 `DEFAULT CURRENT_TIMESTAMP`(无引号)或固定时间戳。而 json2ddl 对 `type: "datetime"` + `default: "NOW()"` 的组合会原样输出带引号的字符串。 **修复**:模型 JSON 中把 `type` 改为 `timestamp`,不写 `default`: ```json // ❌ WRONG — 生成 DATETIME DEFAULT 'NOW()' → 1067 错误 {"name": "created_at", "title": "创建时间", "type": "datetime", "default": "NOW()"} // ❌ WRONG — default: "CURRENT_TIMESTAMP" 也会重复 {"name": "created_at", "title": "创建时间", "type": "timestamp", "default": "CURRENT_TIMESTAMP"} // ✅ CORRECT — timestamp 类型无 default,json2ddl 自动生成 DEFAULT CURRENT_TIMESTAMP {"name": "created_at", "title": "创建时间", "type": "timestamp"} ``` **教训**:模型 JSON 中时间戳字段永远用 `"type": "timestamp"` 不写 default。json2ddl 对 timestamp 类型会自动添加 `DEFAULT CURRENT_TIMESTAMP`。 ## 坑 82:多层分销链遍历 — `sub_resellerid` 字段语义 + `create_sub_reseller` 写错表 **现象**:多层分销记账时,`product_accounting()` 构建的分销链始终截断,中间层级的分销商 PAY* 分录从未生成。 **根因(双重 bug)**: **Bug 1:`create_sub_reseller` 写错表** - 函数实际写入 `sub_distributors` 表(L351 `await sor.C("sub_distributors", rec)`) - 但 `get_distribution_chain` 查的是 `sub_resellers` 表 - 还写了不存在的 `orgid` 字段 **Bug 2:字段名错误** - 旧模型用 `sub_reseller_code`(编号字符串),不适合存储 org_id - `get_distribution_chain` SQL 查 `orgid`(不存在) **修复**: 1. 模型 `sub_resellers.json`:`sub_reseller_code` → `sub_resellerid`(VARCHAR 32,存 org_id) 2. 唯一索引:`idx_sr_code` → `idx_sr_sub_resellerid` (resellerid, sub_resellerid) 3. codes 关联:`sub_resellerid` → `organization.orgname` 4. `create_sub_reseller`:写入 `sub_resellers` 表(非 `sub_distributors`),字段 `sub_resellerid` 5. `get_distribution_chain` SQL:`SELECT sub_resellerid FROM sub_resellers` 6. JSON/UI/dspy 字段引用同步更新 7. 删除未使用的 `_generate_sub_reseller_code` 函数 8. 重新生成 DDL **字段重命名完整流程**(见 `references/field-rename-workflow.md`): 1. 模型 JSON:字段名 + 索引 + codes 关联 2. init.py:所有 SQL + create/update 函数 3. JSON 配置:editexclouded、data_filter 等 4. UI 文件:字段定义(name/title/length) 5. dspy 文件:字段定义 6. 删除孤儿函数(grep 确认无调用) 7. 重新生成 DDL 8. 验证脚本:grep 全仓库确认无残留 9. commit + push **验证脚本模板**: ```python #!/usr/bin/env python3 """验证字段重命名残留""" from pathlib import Path REPO = Path("/d/ymq/repos/") old_field = "sub_reseller_code" hits = [] for p in REPO.rglob("*"): if ".git" in p.parts or "__pycache__" in p.parts or ".pyc" == p.suffix: continue if p.is_file() and p.suffix in (".py", ".dspy", ".json", ".ui", ".sql"): txt = p.read_text(errors="ignore") if old_field in txt: hits.append(str(p.relative_to(REPO))) if hits: print(f"❌ 残留 {old_field}:", hits) exit(1) print("✅ 无残留") ``` **教训**:重命名模型字段时,必须逐层检查(模型 → DDL → init.py → JSON → UI → dspy),最后用 grep 全仓库确认无残留。 --- ## 坑 83:`__pycache__/` 被误提交 git — 反复发生 **现象**:`git log` 发现 `supplychain/__pycache__/init.cpython-310.pyc` 被提交到远端仓库。用户反馈"又提交了"。 **根因**:`.gitignore` 已有 `__pycache__/` 规则,但在 gitignore 生效**之前**提交的文件不会被自动移除。后续 commit 时如果 `git add -A` 或 IDE 自动暂存,`__pycache__/` 可能再次混入。 **修复流程**: ```bash # 1. 检查哪些 __pycache__ 被 git 跟踪 git ls-files -- "*__pycache__*" # 2. 从 git 跟踪中移除(保留本地文件) git rm --cached supplychain/__pycache__/init.cpython-310.pyc # 3. 确认 .gitignore 已有规则 grep '__pycache__' .gitignore # 应有:__pycache__/ 和 *.pyc # 4. 提交并推送 git commit -m "fix: 移除误提交的__pycache__中间文件(.gitignore已有规则)" git push ``` **预防**: - 每次提交前执行 `git status` 确认无 `__pycache__` 或 `scripts/__pycache__` 在 staged 中 - 提交前加载 `pre-commit-crud-check` 技能执行完整检查 - `.gitignore` 必须包含 `__pycache__/` 和 `*.pyc` **教训**:`.gitignore` 只能阻止**未跟踪**文件的添加。已被跟踪的文件需 `git rm --cached` 手动移除。每个模块仓库都应检查是否有历史遗留的被跟踪构建产物(`git ls-files | grep __pycache__`)。 --- ## 坑 81:数据集市初始化 — 历史数据批量导入模式 **场景**:新建数据集市模块(如 sage_datamart),需要从已有表(如 llmusage、llmusage_history)导入全量历史数据到集市表。 **模式**(见 `references/datamart-init-pattern.md`): 1. 建 `init_data.py` — 批量导入函数 + 聚合重算 2. 建 `wwwroot/cron/init_data.dspy` — 触发端点(PATHS_ANY + IP 白名单) 3. `init.py` 注册函数 + load_path.py 注册 RBAC 4. 通过 `curl http://127.0.0.1//cron/init_data.dspy` 一次性执行 **关键设计**: - 按 luid 去重(SELECT existing → skip if found) - 批量分页(LIMIT/OFFSET,每批 1000) - 先导入历史表再导入当前表 - 导入完成后按所有 distinct dates 重算聚合表 --- ## 坑 84:Sage 源码 wwwroot 不得存放模块 symlink 和测试目录 **现象**:`~/repos/sage/wwwroot/` 下出现大量模块 `wwwroot` symlink(如 `supplychain → /home/hermesai/repos/supplychain/wwwroot`),以及测试目录 `tenant/`、`tmp/`、`get_domain_info.dspy` 等。 **根因**:各模块 `build.sh` 执行 `ln -sf "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/"` 将模块 wwwroot 链接到 Sage 目录用于**部署**。但这些 symlink 不是 Sage 源码,不应留在 `~/repos/sage/wwwroot/` 中。 **清理**: ```bash cd ~/repos/sage/wwwroot # 列出所有模块 wwwroot symlink find . -maxdepth 1 -type l -exec ls -la {} \; # 删除(保留 bricks、shell_theme 等框架文件) rm -f msp rag cpcc dapi rbac uapi charge llmage unipay appbase \ filemgr discount accounting dashboard_for_sage product_management \ supplychain bugfix pricing ``` **预防**: - `build.sh` 的 symlink 是部署环节产物,应在**服务器** `wwwroot/` 下,不在开发机 `~/repos/sage/wwwroot/` - 不要在 `~/repos/sage/wwwroot` 下创建测试目录或随意放置 `.dspy` 文件 - 测试在 `~/repos//` 各自仓库内进行 **教训**:`~/repos/sage/wwwroot` 是 Sage 框架**源码**目录,只含核心 .ui/.dspy + 内置目录。模块 symlink 属于部署环境,不属于源码。 --- ## 坑 85:FileMgr filetype 溢出 → 500 → bricks "Cannot read properties of undefined (reading 'dom_element')" **现象**:供销合同/分销协议点击附件上传,弹窗提示"上传失败: Cannot read properties of undefined (reading 'dom_element')"。后台返回 500,日志显示 `DataError: (1406, "Data too long for column 'filetype' at row 1")`。 **根因**:`filemgr/filemgr.py:113` 用 `params_kw.upfile.split('.')[-1]` 提取扩展名。文件无后缀时(如 `README`、`hostname`),`split('.')` 返回整个路径字符串,远超 `file` 表的 `filetype varchar(10)` 列长 → MySQL DataError → 500 → bricks 无法解析 HTML 错误响应 → `dom_element`。 **修复**:用 `os.path.splitext` 正确提取扩展名 + `[:10]` 截断兜底: ```python # ❌ 无后缀文件 → 返回全路径 → varchar(10) 溢出 "filetype": params_kw.upfile.split('.')[-1].lower(), # ✅ 无后缀返回空字符串,超长截断 "filetype": os.path.splitext(params_kw.upfile)[1].lstrip('.').lower()[:10], ``` **验证**:`os.path.splitext('/tmp/hostname')` → `('', '')` (ext 为空);`os.path.splitext('/tmp/a.' + 'x'*20)` → 截断为 10 字符。 **教训**:`split('.')[-1]` 不是安全的后缀提取方式。Python 标准库 `os.path.splitext` 正确处理无后缀、隐藏文件、多级路径等边界情况。涉及 `file` 表写入的 FileMgr 子类(ContractAttachMgr 等)都受此影响。 **部署**:filemgr 是独立 repo,修改后需 `pip install --upgrade .` + 重启 Sage。 --- ## 坑 86:别把手写 .ui 源文件误判为 xls2ui CRUD 中间文件(用户纠正过的归因错误) **事故**:`contract_discount_setting.ui` 报 500,我错误归因为"xls2ui 生成的 CRUD 目录缺失"(坑 64),用户纠正:该文件不是 CRUD 中间文件,是供应链模块 wwwroot 下的 git 跟踪源文件。 **真相(两条独立故障,勿混为一谈)**: 1. 坑 64 的 CRUD 目录缺失 → 影响的是 `supply_contracts_list/get_supply_contracts.dspy` 等生成端点(invalid path → 500) 2. `contract_discount_setting.ui` 的 500 → 历史根因是旧版 `get_contract_discount_items()` 查了不存在的表 `sage.product_supplier_mapping`(日志 Jul 13:`except=(1146, "Table 'sage.product_supplier_mapping' doesn't exist")`,Traceback 指向 .ui 第 1 行 `{% set items = get_contract_discount_items(request) %}`)。后续提交已改用 `product.providerid` 过滤(见坑 39),修复后 200。 **判别规则(500 归因前必做)**: ```bash # 是 git 跟踪源文件还是生成文件? git ls-files | grep '<文件名>' # 有输出 = 源文件,不是 CRUD 中间产物 grep '<目录>' .gitignore # 被 ignore 的目录 = xls2ui 生成,git pull 不携带 ls -la <服务器>/wwwroot/<文件名> # 文件是否真实存在、mtime 是否陈旧 ``` CRUD 生成目录有固定形态:`wwwroot/<表名>_list/` 下 `index.ui + add_/get_/update_/delete_<表名>.dspy` 五件套。散落在 wwwroot 根或 api/ 下的 `.ui`/`.dspy` 都是手写源文件。 **排查 .ui 模板 500 的正确动作**:`grep -B2 -A10 '<文件名>\|get_contract_discount' logs/sage.log`——Jinja2 模板 500 的 Traceback 会同时出现 `.ui` 文件行号和 init.py 中被调函数的行号,能直接定位到是模板语法还是被调函数(缺表/缺列/权限)出问题。"后台没日志"通常是没 grep 对关键词。 ## Discount 模块 load_path 权限清单 新增页面后必须更新 `scripts/load_path.py` 并执行注册。完整覆盖表和 403 排查步骤见 `references/discount-load-path.md`。