Commit bbcde3e6069b61413751a843e8b2616ed31bda07

Authored by zichun
1 parent ae638619

docs: en wiki — five-pass verification audit, fix divergences inline

Audit every concrete claim in the 41 hand-written en pages against the
three primary sources (DB, source on cleanup branch, source-tree
inventory). Fix divergent claims in place; preserve framing where
verified.

Substantive corrections:

- request-lifecycle / runtime / slice 01: the metadata read sources from
  five tables/families (gdsconfigformmaster + overlays, gdsformconst,
  sysjurisdiction, sysbillnosettings, sysreport), not four. The map key
  `gdsjurisdiction` is misleading — the per-user grant read queries
  `sysjurisdiction`; `gdsjurisdiction` is the builder-side action
  catalogue. `gdsformconst`, `gdsconfigformmaster`, `gdsconfigformslave`
  are NOT tenant-scoped; they filter by form-id only.
- multi-tenancy: four metadata tables (gdsformconst, gdsmodule,
  gdsconfigformmaster, gdsconfigformslave) are an explicit exception to
  the "every table tenant-scoped" promise — `sTableNameList` strips
  sBrandsId/sSubsidiaryId from writes against them.
- sSaveProName / sSaveProNameBefore are pre/post-save HOOKS on top of
  the always-running base path (BusinessBaseServiceImpl.add/update),
  not either/or branches. Default add/update path is in
  BusinessBaseServiceImpl, not AddDelUpdCommonServiceImpl.
- cache-invalidation: redis cache is cleared synchronously in BACK via
  @CacheEvict on CleanRedisServiceImpl during save. The JMS
  CHANGE_GDS_MODULE queue triggers PRO_ERPMERGEBASEGDSMODULE (base-data
  merge), NOT cache invalidation despite the name. Cross-node
  coherence open question (no custom CacheManager bean configured).
- messaging: enumerate all 24 P2pQueue destinations grouped by intent;
  fix CHANGE_GDS_MODULE description; clarify single Consumer.java with
  24 @JmsListener methods (not 24 listener classes).
- API paths: /checkflow lowercase (mapping value, not class name);
  /procedureCall/doGenericProcedureCall (not /business/genericProcedureCall*).
- tech-stack: Druid 6 java imports + 16 yml mentions (was 25 conflated);
  fastjson per-module xlyInterface 9 (was 10); commons-lang3 39 (was 41);
  @Document classes 20 PLAT_* + 2 DIKE_TEST* (was "all PLAT_*"); xlyPersist
  activiti hit is IdGen.java (was BaseDao.java); add Springfox to
  declared-but-no-imports table; reconcile module list to 11 framework
  core + xlyPlc plugin + xlyPlatConstant utility.
- index.md: clarify xlyFace as "in build, not documented"; add xlyErpTask
  / xlyPlatTask scheduler bullet; correct MongoDB framing (caller is in
  xlyPersist with no consumers, not xlyPlat*); add xlyPlc note; extend
  backup-table OOS to cover *_copy1 / *_history / *YYYYMMDD[HHMMSS].
- deployment.md: split deployable Boot apps from library modules;
  enumerate 12 commented-out includes (was 3); remove xlyPlatConstant from
  out-of-scope Plat* list; split profile permutations by service.
- activiti.md: add xlyApi to 5.17 dependency list; replace speculative
  BPMN path hint with verified state; name actual ActivitiConfig.java;
  note act_id_* are views projecting xly users into Activiti shapes.
- api-reference/external.md: fix bearer-token validation flow (sysapibrand
  via AES-decrypted corpid, not sysapithirdtoken); /online/* are page
  renders not API execution; /pro/* mostly returns Thymeleaf views; mark
  sysapidbtodb as xlyFlow-owned; /token/getToken accepts GET and POST.
- api-reference/webhooks.md: add Swagger Docket caveat (UI shell ships but
  no Docket bean → /v2/api-docs effectively empty); flag /send/sendQw as
  stub (returns "ok").
- slices/03-report.md: fix dir path xlyEntry/com/xly/report/ →
  xlyEntry/com/xly/web/report/; reframe PrintReportControllerOld as dead
  source (file body fully commented out).
- concepts/modules-forms-vtables.md: add 22-prefix glossary table
  (gds/sys/sis/sft/ele/mft/sal/quo/acc/pur/ops/cah/sgd/ept/mit/pit/qly/
  kpi/udf/viw_/plat_/ai_/act_/qrtz_) so a maintainer can enumerate
  business-data domains at a glance.
- concepts/master-slave.md: disambiguate document-row pattern from
  DataSource master/slave (different concept, name overlap).
- proc-dispatch.md: add proc-name molds (Sp_*_BeforeSave/AfterSave/
  SaveReturn, sp_btn_*, PRO_ERPMERGE*) + function-layer paragraph
  (Fun_*/Fn_*/get_*; SQL-called, not Java-dispatched).
- concepts/index.md: schema label MySQL\nxlyweberp → xlyweberp_*.

Pass E (live behavioural traces) deferred — source/DB-side audit was
thorough; live traces best done as a follow-up sweep against a running
instance.
en/docs/api-reference/external.md
... ... @@ -31,8 +31,12 @@ The flow:
31 31 2. Look up the `sysapi` row keyed by `sApiCode` (via
32 32 `ApiServiceImpl.invoke` →
33 33 `SELECT … FROM sysapi WHERE sApiCode = #{sApiCode}`).
34   -3. If the row's `bHasToken` flag is set, validate the token against
35   - `sysapithirdtoken` (or the equivalent token store the row points at).
  34 +3. If the row's `bHasToken` flag is set, AES-decrypt the bearer token to
  35 + recover the `corpid`, then validate that `corpid` against `sysapibrand`
  36 + via `BrandServiceImpl.selectByCorpid`. If the brand row's `iLossTime`
  37 + is non-zero, also check the token's embedded timestamp hasn't expired.
  38 + (`sysapithirdtoken` is for *outbound* tokens — xly calling third-party
  39 + APIs — not for validating inbound bearer tokens here.)
36 40 4. Run the SQL template stored in `sysapi.sDataSql` with the request
37 41 body merged into the parameter map.
38 42 5. Log the call to `sysapilog`.
... ... @@ -67,7 +71,7 @@ dry-run against the test fixture in `sDataTest`.
67 71  
68 72 | Endpoint | Method | Purpose |
69 73 |---|---|---|
70   -| `/token/getToken?corpid=&corpsecret=` | POST | Issue a bearer token for an integrator's `(corpid, corpsecret)` pair. |
  74 +| `/token/getToken?corpid=&corpsecret=` | GET / POST | Issue a bearer token for an integrator's `(corpid, corpsecret)` pair. (Mapping is method-agnostic.) |
71 75  
72 76 The token returned is what `/api/invoke/{sApiCode}` expects in
73 77 `Authorization`. The full implementation is in
... ... @@ -84,13 +88,15 @@ These are smaller specialised APIs hosted in the same WAR:
84 88  
85 89 | Endpoint root | Controller | Purpose |
86 90 |---|---|---|
87   -| `/online/api/{sApiCode}` | `OnlineController` | Read-only execution of a `sysapi` row (no write). Useful for public-data endpoints. |
88   -| `/online/onlineword/{sApiCode}` | `OnlineController` | Variant returning a "word"-style template payload. |
89   -| `/online/onlinelist`, `/online/getToken` | `OnlineController` | Listing of online APIs and their token issuance. |
90   -| `/pro/get/{sProName}` | `ProContentController` | Fetch metadata about a stored procedure by name. |
91   -| `/pro/getData/{sProName}` | `ProContentController` | Execute the named stored procedure and return its result-set. |
92   -| `/pro/alert/{redisId}`, `/pro/getAlertValue/{redisId}` | `ProContentController` | Read alert/notification values keyed by Redis id. |
93   -| `/pro/executeSql` | `ProContentController` | Execute a parameterised SQL template (admin-tier). |
  91 +| `/online/api/{sApiCode}` | `OnlineController` | Renders the BACK in-browser API debug/console page for the given `sysapi` row (returns a Thymeleaf view, not API execution). |
  92 +| `/online/onlineword/{sApiCode}` | `OnlineController` | Renders the "word"-style API documentation page. |
  93 +| `/online/onlinelist` | `OnlineController` | Renders the online-API listing page. |
  94 +| `/online/getToken` | `OnlineController` | Renders the in-browser token-acquisition helper page. |
  95 +| `/pro/get/{sProName}` | `ProContentController` | Renders the BACK page that displays a stored procedure's source. |
  96 +| `/pro/getData/{sProName}` | `ProContentController` | Returns the stored procedure's source text (not its result-set). |
  97 +| `/pro/alert/{redisId}` | `ProContentController` | Renders the alert/notification display page for the given Redis key. |
  98 +| `/pro/getAlertValue/{redisId}` | `ProContentController` | Returns the value cached in Redis under `redisId`. |
  99 +| `/pro/executeSql` | `ProContentController` | Executes a `sSql` payload directly (admin-tier dev tool). |
94 100 | `/thirdparty/*` | `ThirdPartyController` | CRUD over third-party-API definitions and the `checkPartyApi` validator. Backed by `sysapithirdparty`. |
95 101 | `/thirdtoken/*` | `ThirdTokenController` | CRUD over outbound-token configs. Backed by `sysapithirdtoken`. |
96 102 | `/brand/*` | `BrandController` | CRUD over the partner-supplier list (`sysapibrand`). |
... ... @@ -110,8 +116,8 @@ These tables hold the API metadata. All carry `sBrandsId` /
110 116 | `sysapibrand` | Partner / supplier directory. |
111 117 | `sysapithirdparty` | Outbound third-party endpoint definitions. |
112 118 | `sysapithirdtoken` | Outbound third-party token configs. |
113   -| `sysapidbtodb` | DB-to-DB sync API definitions. |
114   -| `sysapidbtodblog` | DB-to-DB sync run log. |
  119 +| `sysapidbtodb` | DB-to-DB sync API definitions. **Owned by `xlyFlow`'s `DbToDbController`, not xlyApi** — listed here because the table lives in xlyApi's `sysapi.sql`. |
  120 +| `sysapidbtodblog` | DB-to-DB sync run log. Same — written by xlyFlow. |
115 121  
116 122 ## How an integrator uses this
117 123  
... ...
en/docs/api-reference/internal.md
... ... @@ -52,15 +52,17 @@ virtual tables) there is a parallel surface in
52 52 | `/treegrid/*` | `BusinessTreeGridController` | Tree-grid endpoints (the proc-backed path is implemented in this branch). |
53 53 | `/procedureCall/*` | `GenericProcedureCallController` | Generic stored-procedure invocation by name + parameters — see [generic procedure dispatch](../reference/maintainer/proc-dispatch.md). |
54 54 | `/panel/*` | `ConfigformPanelController` | Panel-layout persistence in `gdsconfigformpanel`. |
55   -| `/checkFlow/*` | `CheckFlowController` | Activiti workflow surface (approve / reject / view) — only meaningful in deployments that run a flow. |
  55 +| `/checkflow/*` | `CheckFlowController` | Activiti workflow surface (approve / reject / view) — only meaningful in deployments that run a flow. The class file is `CheckFlowController.java` (camelCase) but the `@RequestMapping` value is all-lowercase `/checkflow`. |
56 56  
57 57 ## Reporting and printing
58 58  
59 59 The print surface lives under `xlyEntry/src/main/java/com/xly/web/report/`:
60 60  
61 61 - `PrintReportController` — current jxls / iText print path.
62   -- `PrintReportControllerOld` — legacy print path retained for older
63   - templates.
  62 +- `PrintReportControllerOld.java` — file exists but its class body is
  63 + fully commented out (and the commented-out class inside is named
  64 + `PrintReportController`, not `*Old`). It is dead source kept for
  65 + reference, not an active controller.
64 66  
65 67 The frontend's "打印" / "导出" buttons hit these controllers, which load a
66 68 template from `sysreport`, run the matching view-backed query, and stream
... ...
en/docs/api-reference/messaging.md
... ... @@ -5,31 +5,73 @@ runs two message brokers, each with a different role:
5 5  
6 6 | Broker | Used for | Producer | Consumer |
7 7 |---|---|---|---|
8   -| **ActiveMQ / JMS** | Cache invalidation, in-cluster fan-out events. The metadata-change pipeline ([cache invalidation](../reference/maintainer/cache-invalidation.md)) rides on this. | `xlyErpJmsProductor` | `xlyErpJmsConsumer` |
  8 +| **ActiveMQ / JMS** | In-cluster fan-out events: base-data merge jobs (consolidating per-tenant rows into flattened lookup tables) and document-update / document-delete notifications. **Despite the historical naming of one queue, this channel is NOT used for Redis cache invalidation** — see [Cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md) for the actual cache-evict path. | `xlyErpJmsProductor` | `xlyErpJmsConsumer` |
9 9 | **RocketMQ** | Other integration flows where the ActiveMQ assumptions don't fit. | `RocketMQServiceImpl` (in `xlyBusinessService`) | (varies — service-specific) |
10 10  
11 11 This page is a pointer rather than a deep dive — exact queue names and
12 12 payloads are documented at the consumer-thread level in
13 13 `xlyErpJmsConsumer/src/main/java/com/xly/xlyerpjmsconsumer/`.
14 14  
15   -## ActiveMQ / JMS — the cache-invalidation channel
  15 +## ActiveMQ / JMS — base-data merge + fan-out channel
16 16  
17 17 Producer-side queue declarations live in
18 18 `xlyErpJmsProductor/src/main/java/com/xly/xlyerpjmsproductor/config/P2pQueue.java`.
19   -Notable destinations the framework uses today (read the file for the
20   -full list):
  19 +The full set is **24 destinations**, grouped by intent:
  20 +
  21 +### Module / control (2)
  22 +
  23 +| Constant | Purpose |
  24 +|---|---|
  25 +| `ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` | "Module metadata changed" — `ConsumerChangeGdsModuleThread` runs the stored proc `PRO_ERPMERGEBASEGDSMODULE` to merge per-tenant `gdsmodule` rows into a flattened base lookup table. **Does not invalidate Redis caches** despite the name — Redis cache eviction happens synchronously via `@CacheEvict` in BACK during save. See [Cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md). |
  26 +| `ERP_JMS_ACTIVEMQ_CHANGE_WORK_ORDER_CONTROL` | Work-order control state changed (status/aggregate flags) — fan-out for downstream recalculation. |
  27 +
  28 +### Document operations (6)
  29 +
  30 +| Constant | Purpose |
  31 +|---|---|
  32 +| `ERP_JMS_ACTIVEMQ_UPD_SALE_ORDER` / `_UPD_WORK_ORDER` / `_UPD_PRODUCTION_REPORT` | "Document was updated" notifications consumed by background workers (totals recalculation, downstream invalidations). |
  33 +| `ERP_JMS_ACTIVEMQ_DEL_SALE_ORDER` / `_DEL_WORK_ORDER` / `_DEL_PRODUCTION_REPORT` | Document-delete notifications. |
  34 +
  35 +### Element-master change fan-out (7) — `CHANGE_ELE_*`
  36 +
  37 +| Constant | Purpose |
  38 +|---|---|
  39 +| `_CHANGE_ELE_CUSTOMER` | Customer-master change. |
  40 +| `_CHANGE_ELE_EMPLOYEE` | Employee-master change. |
  41 +| `_CHANGE_ELE_MACHINE` | Shop-floor machine-master change. |
  42 +| `_CHANGE_ELE_MATERIALS` | Materials-master change. |
  43 +| `_CHANGE_ELE_PRODUCT` | Product-master change. |
  44 +| `_CHANGE_ELE_PROCESS` | Process-master change. |
  45 +| `_CHANGE_ELE_TEAM` | Team-master change. |
  46 +
  47 +### System-info / lookup-table change fan-out (9) — `CHANGE_SIS_*`
21 48  
22 49 | Constant | Purpose |
23 50 |---|---|
24   -| `ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` | "Module metadata changed" — triggers `ConsumerChangeGdsModuleThread` to bust the relevant Redis caches across nodes. See [Cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md). |
25   -| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_CUSTOMER` | Customer-master change fan-out. |
26   -| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_EMPLOYEE` | Employee-master change fan-out. |
27   -| `ERP_JMS_ACTIVEMQ_CHANGE_ELE_MACHINE` | Shop-floor machine-master change fan-out. |
28   -| `ERP_JMS_ACTIVEMQ_UPD_SALE_ORDER`, `ERP_JMS_ACTIVEMQ_UPD_WORK_ORDER`, `ERP_JMS_ACTIVEMQ_UPD_PRODUCTION_REPORT` | "Document was updated" notifications consumed by background workers (totals recalculation, downstream invalidations). |
29   -| `ERP_JMS_ACTIVEMQ_DEL_SALE_ORDER`, `ERP_JMS_ACTIVEMQ_DEL_WORK_ORDER`, `ERP_JMS_ACTIVEMQ_DEL_PRODUCTION_REPORT` | Document-delete notifications. |
30   -
31   -Each destination has a corresponding `Consumer*Thread` class under
32   -`xlyErpJmsConsumer/.../thread/` that handles the message asynchronously.
  51 +| `_CHANGE_SIS_CUSTOMER_CLASSIFY` | Customer-classification change. |
  52 +| `_CHANGE_SIS_DELIVER` | Delivery-method change. |
  53 +| `_CHANGE_SIS_FORMULA` | Calculation-formula change. |
  54 +| `_CHANGE_SIS_PAYMENT` | Payment-method change. |
  55 +| `_CHANGE_SIS_PROCESS_CLASSIFY` | Process-classification change. |
  56 +| `_CHANGE_SIS_PRODUCT_CLASSIFY` | Product-classification change. |
  57 +| `_CHANGE_SIS_SALES_MAN` | Sales-personnel change. |
  58 +| `_CHANGE_SIS_TAX` | Tax-rate change. |
  59 +| `_CHANGE_SIS_WORK_CENTER` | Work-centre change. |
  60 +
  61 +(Constant prefixes elided to `_…` after the first table — the full literal is `ERP_JMS_ACTIVEMQ_…`.)
  62 +
  63 +### Listener side
  64 +
  65 +`xlyErpJmsConsumer/.../consumer/Consumer.java` is a single class that
  66 +hosts **all 24 `@JmsListener` methods** — one per destination. Each
  67 +method dispatches the payload to a corresponding `Consumer*Thread`
  68 +class under `xlyErpJmsConsumer/.../thread/`, which executes the
  69 +domain-specific work (typically calling a `PRO_ERPMERGEBASE*` stored
  70 +proc that consolidates per-tenant rows into a flattened base lookup
  71 +table) asynchronously. There is *one* listener class with 24 methods,
  72 +*not* 24 listener classes. None of the consumer threads invoke
  73 +`@CacheEvict` or `cleanRedis*` — Redis cache invalidation is
  74 +synchronous in BACK during save, see [cache-invalidation.md](../reference/maintainer/cache-invalidation.md).
33 75  
34 76 ## RocketMQ — other flows
35 77  
... ...
en/docs/api-reference/webhooks.md
... ... @@ -21,6 +21,14 @@ http://<host>/xlyInterface/swagger-ui.html
21 21 (or the equivalent JSON descriptor at
22 22 `http://<host>/xlyInterface/v2/api-docs`).
23 23  
  24 +> **Caveat:** the project pulls the SpringFox jars but does **not**
  25 +> register a `Docket` bean (no `@EnableSwagger2` or
  26 +> `@Bean Docket api()` anywhere in `xly-src`). The `swagger-ui.html`
  27 +> shell is served from the jar's static resources, but `/v2/api-docs`
  28 +> returns an effectively empty descriptor — the UI is "the dependency
  29 +> ships" rather than "a populated try-it-out console". A maintainer
  30 +> who wants the live API listed has to add a `Docket` bean.
  31 +
24 32 ## The data-driven receiver — `/interfaceDefine/*`
25 33  
26 34 Mirror of the [external API's `/api/invoke`](external.md) pattern, but
... ... @@ -49,7 +57,7 @@ they have to match a partner&#39;s fixed URL spec.
49 57 | `/Pull` | POST | Vendor pull-pattern receiver. |
50 58 | `/getKey/{key}` | GET | Public key fetch for a partner-named `key`. |
51 59 | `/getKeyTest` | GET | Test-mode variant of `/getKey`. |
52   -| `/send/sendQw` | POST | Enterprise-WeChat (企业微信) outbound message. |
  60 +| `/send/sendQw` | POST | Enterprise-WeChat (企业微信) outbound message. **Stub on this branch** — the method body in `SendQwController` is `return "ok";`; scaffolded for token-fetch but not finished. |
53 61  
54 62 Handlers: `xlyInterface/src/main/java/com/xly/web/WX_VendorWeb.java`
55 63 and `xlyInterface/src/main/java/com/xly/wechat/test/SendQwController.java`.
... ...
en/docs/concepts/api-surface.md
... ... @@ -35,7 +35,7 @@ sacrifice clarity:
35 35  
36 36 ## What each tier looks like at runtime
37 37  
38   -- **Internal** — see [the four-table read](../reference/maintainer/runtime.md#the-four-table-read). One
  38 +- **Internal** — see [the five-key read](../reference/maintainer/runtime.md#the-five-key-read). One
39 39 endpoint (`/business/getModelBysId`) returns the entire form layout;
40 40 another (`/business/addUpdateDelBusinessData`) writes any row in any
41 41 table the metadata names. Few endpoints, generic shapes.
... ...
en/docs/concepts/index.md
... ... @@ -27,7 +27,7 @@ flowchart TB
27 27 XMSG[/"xlyMsg<br/>library"/]
28 28 end
29 29  
30   - DB[("MySQL<br/>xlyweberp")]
  30 + DB[("MySQL<br/>xlyweberp_*")]
31 31 REDIS[(Redis)]
32 32 AMQ([ActiveMQ])
33 33 XEJMSC[xlyErpJmsConsumer]
... ...
en/docs/concepts/master-slave.md
1 1 # The master / slave document pattern
2 2  
  3 +> **Two unrelated "master / slave" concepts coexist in this codebase.**
  4 +> This page is about the **document-row** pattern: one header row plus N
  5 +> detail rows for a quotation / sales order / work order. The
  6 +> **DataSource** master / slave (write-vs-read connection routing via
  7 +> `MasterDataSourceConfig` / `SlaveDataSourceConfig` in `xlyApi`, paired
  8 +> with `MasterBaseMapper.xml` / `SlaveBaseMapper.xml` in `xlyPersist`) is
  9 +> a different concept covered in the [Tech-stack HikariCP row](../reference/maintainer/tech-stack.md#3-cache-in-memory)
  10 +> and indirectly in the runtime page. The two senses overlap in name only.
  11 +
3 12 Almost every business document in xly — a quotation, a sales order, a work
4 13 order, a payment voucher — is stored as **one header row plus N detail
5 14 rows**. xly's term for this is **master / slave**. The master holds the
... ...
en/docs/concepts/modules-forms-vtables.md
... ... @@ -91,3 +91,41 @@ documented in [Slice 1](../slices/01-hello-world.md) — knows how to
91 91 render any module / form / virtual-table combination. There is no
92 92 per-module Java code. PMs creating new modules are creating new rows;
93 93 they are not creating new code paths.
  94 +
  95 +## Business-data table prefixes
  96 +
  97 +The wiki treats business modules as illustrations rather than subjects,
  98 +but the schema names them in a regular pattern. A maintainer can map
  99 +any business-data table to its domain by the three-letter prefix:
  100 +
  101 +| Prefix | Domain | Sample tables (live count) |
  102 +|---|---|---|
  103 +| `gds` | Framework metadata (modules, forms, fields, permissions, parameters) | `gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`, `gdsjurisdiction`, `gdsroute`, `gdsformconst`, `gdsparameter`, … |
  104 +| `sys` | Framework system (numbering, jurisdiction grants, reports, search, billing settings) — distinct from `gds*` "definition" tier | `sysjurisdiction`, `sysbillnosettings`, `sysreport`, `syssearch`, `sysapi`, `SysSystemSettings`, … (66 tables) |
  105 +| `sis` | Shared lookup tables / classifiers backing dropdowns | `sisbank`, `siscolor`, `sisversionflow`, `sisjurisdictionclassify`, … (78 tables) |
  106 +| `sft` | Login-session / group-permission link tables | `sftlogininfo*`, `sftlogininfojurisdictiongroup`, … (8 tables) |
  107 +| `ele` | Master data ("element"): customer, employee, machine, materials, product, process, semigoods, costframe | `elecustomer*`, `eleemployee*`, `elemachine*`, `elematerials*`, `eleproduct*`, … (88 tables) |
  108 +| `mft` | Manufacturing: work-order, production-plan, production-report | `mftworkordermaster`, `mftproductionplan*`, `mftproductionreport*`, … (72 tables) |
  109 +| `sal` | Sales | `salsalesordermaster`, `salsalesorderslave`, `salsalesorderprocess`, … (65 tables) |
  110 +| `quo` | Quotation | `quoquotationmaster`, `quoquotationslave`, `quoquotationcalc_tmp`, … (12 tables) |
  111 +| `acc` | Accounting | `accordercostanalysis`, `accordercostanalysisoperation`, … (31 tables) |
  112 +| `pur` | Purchasing | `purpurchaseapply`, `purpurchasearrive`, `purpurchasechecking`, … (28 tables) |
  113 +| `ops` | Outside-processing / outsourcing | `opsoutsidearrive`, `opsoutsidechecking`, `opsoutsideinstore`, … (23 tables) |
  114 +| `cah` | Cashier / financial | `cahcashierinit`, `cahcostchange`, `cahpayment`, `cahreceipt`, … (22 tables) |
  115 +| `sgd` | Semi-goods (半成品) | `sgdsemigoodscheck`, `sgdsemigoodsinstore`, `sgdsemigoodsmatchbill`, … (21 tables) |
  116 +| `ept` | Equipment / machine fixed assets | `eptmachinefixedborrow`, `eptmachinefixedchange`, `eptmachinefixedinstore`, … (21 tables) |
  117 +| `mit` | Materials inventory transactions | `mitmaterialsadjust`, `mitmaterialscheck`, `mitmaterialsinstore`, … (19 tables) |
  118 +| `pit` | Product inventory transactions | `pitproductadjust`, `pitproductbarcode`, `pitproductcheck`, `pitproductinstore`, … (18 tables) |
  119 +| `qly` | Quality testing | `qlycomematerialstest`, `qlyproducttest`, `qlyprocesstest`, … (8 tables) |
  120 +| `kpi` | KPI tracking | `kpimaster`, `kpidetail`, `kpimoduleuserday`, … (7 tables) |
  121 +| `udf` | User-defined / generic-voucher framework | `udfaccountno`, `udfvouchermaster`, `udfvouchertemplatemaster`, … (5 tables) |
  122 +| `viw_` / `Viw_` | Database **views** (case inconsistent across schema) | `viw_mftworkorderprocess`, `viw_corebusinessreport`, `viw_accordercostanalysisnew`, … (311 views in total) |
  123 +| `plat_` | B2B printing-platform layer (out of scope per [index](../index.md#whats-out-of-scope)) | 92 tables — not documented here |
  124 +| `ai_` | AI / LLM features (out of scope) | 7 tables — not documented here |
  125 +| `act_`, `qrtz_` | Third-party schemas (Activiti workflow, Quartz scheduler) | covered transitively under [Activiti](../reference/maintainer/activiti.md) and [tech-stack Quartz](../reference/maintainer/tech-stack.md#4-workflow-scheduling) |
  126 +
  127 +The business-domain prefixes (`ele`, `mft`, `sal`, `quo`, `acc`, `pur`,
  128 +`ops`, `cah`, `sgd`, `ept`, `mit`, `pit`, `qly`, `kpi`, `udf`) and
  129 +their slaves all follow the same metadata-driven runtime — there is no
  130 +per-prefix Java code, just rows in `gdsconfigformmaster` /
  131 +`gdsconfigformslave` pointing at each backing table or view.
... ...
en/docs/concepts/multi-tenancy.md
... ... @@ -16,10 +16,16 @@ two-paragraph summary you can link from anywhere.
16 16 | **`sSubsidiaryId`** (子公司ID) | almost every business row | per-row | the user's session |
17 17 | **`sVersionFlowId`** (版本流程ID) | `gdsmodule` only | per-module | the user's edition (against `sisversionflow`) |
18 18  
19   -Per-row scoping is universal: both `sBrandsId` and `sSubsidiaryId` appear
20   -on essentially every business-data table and every framework-metadata
21   -table. The convention is "if a row represents tenant-owned state, both
22   -columns are present."
  19 +Per-row scoping is universal across business-data tables: both
  20 +`sBrandsId` and `sSubsidiaryId` appear on essentially every one. Most
  21 +framework-metadata tables also carry the columns, but four of them
  22 +(`gdsformconst`, `gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`)
  23 +are an explicit exception — `BusinessBaseServiceImpl.sTableNameList`
  24 +(lines 162-169) lists them as "不需要公司子公司的表" and lines 1078-1084
  25 +strip `sBrandsId`/`sSubsidiaryId` from the write payload for those
  26 +tables. In practice they hold a single sentinel tenant value shared
  27 +across all customers. Convention: "if a row represents tenant-owned
  28 +state, both columns are present *and populated from the session*."
23 29  
24 30 Per-module gating (`sVersionFlowId`) is the opposite — it lives on
25 31 `gdsmodule` only. So edition gating is a one-time filter at module-
... ...
en/docs/concepts/request-lifecycle.md
... ... @@ -16,8 +16,12 @@ variations on a theme.
16 16 │ 3. SPA decides which module to load → calls /business/... │
17 17 └──────────────────────────────────────────────────────────────────────┘
18 18
19   - │ GET /xlyEntry/business/getModelBysId/{moduleId}
20   - │ ?sModelsId={moduleId}
  19 + │ GET /xlyEntry/business/getModelBysId/{sModelsId}
  20 + │ ?sModelsId={sModelsId}
  21 + │ (the module's id appears in BOTH path and query —
  22 + │ the controller binds the path variable, but the
  23 + │ service reads sModelsId from the @RequestParam map,
  24 + │ so the SPA must include it in the query string too)
21 25
22 26 ┌──────────────────────────────────────────────────────────────────────┐
23 27 │ xlyEntry — BusinessBaseController.getModelBysId() │
... ... @@ -25,22 +29,36 @@ variations on a theme.
25 29 │ ┌──────────────────────────────────────────────────────────────┐ │
26 30 │ │ RequestAddParamUtil.addParams(params, userInfo) │ │
27 31 │ │ → sBrandsId, sSubsidiaryId, sUserId, sLanguage, … │ │
28   -│ │ → tenant scope is now baked into every downstream query │ │
  32 +│ │ (16 keys total — see runtime.md) │ │
  33 +│ │ → tenant scope is now AVAILABLE for any downstream query │ │
  34 +│ │ that wants it. Framework-metadata reads filter by │ │
  35 +│ │ form-id only; per-tenant overlays + business data │ │
  36 +│ │ reads filter by sBrandsId/sSubsidiaryId. │ │
29 37 │ └──────────────────────────────────────────────────────────────┘ │
30 38 │ │
31 39 │ BusinessBaseService.getModelBysId(map) │
32 40 │ │ │
33   -│ ├── load gdsmodule row (the module) │
34   -│ ├── load gdsconfigformmaster row(s) │
35   -│ │ (joined to module via sParentId) │
36   -│ ├── load gdsconfigformslave rows │
37   -│ │ (joined to form-master via sParentId) │
38   -│ ├── merge gdsconfigformpersonalize (per tenant) │
39   -│ ├── merge gdsconfigformcustomslave (per tenant) │
40   -│ ├── load gdsjurisdiction (skipped for ADMIN) │
41   -│ ├── load gdsformconst (form-level constants) │
42   -│ ├── load sysbillnosettings (document-numbering) │
43   -│ └── load sysreport rows linked to this form │
  41 +│ ├── 1. formData │
  42 +│ │ └── gdsconfigformmaster (filtered by │
  43 +│ │ sParentId = sModelsId; gdsmodule itself │
  44 +│ │ is *not* SELECT-ed, only referenced by id) │
  45 +│ │ + LEFT JOIN gdsconfigformpersonalize │
  46 +│ │ (per-tenant overlay) │
  47 +│ │ + per-master gdsconfigformslave │
  48 +│ │ + per-master gdsconfigformcustomslave │
  49 +│ │ (per-tenant overlay) │
  50 +│ ├── 2. gdsformconst (filtered by sParentId only; │
  51 +│ │ NOT tenant-scoped; sLanguage selects which │
  52 +│ │ label column to return) │
  53 +│ ├── 3. sysjurisdiction (per-user/group grants joined │
  54 +│ │ to sftlogininfojurisdictiongroup + │
  55 +│ │ sisjurisdictionclassify; skipped for ADMIN. │
  56 +│ │ Returned under map key `gdsjurisdiction` — │
  57 +│ │ misleading name, the gdsjurisdiction table is │
  58 +│ │ the builder-side action catalogue, not what's │
  59 +│ │ read here) │
  60 +│ ├── 4. sysbillnosettings (per-tenant, per-form) │
  61 +│ └── 5. sysreport (per-tenant, per-form) │
44 62 └──────────────────────────────────────────────────────────────────────┘
45 63
46 64 │ Returns one composite map:
... ... @@ -75,11 +93,11 @@ variations on a theme.
75 93  
76 94 | Key | Source | Used by the SPA for |
77 95 |---|---|---|
78   -| `formData` | `gdsmodule` ⋈ `gdsconfigformmaster` ⋈ `gdsconfigformslave` (+ overlays) | The form layout itself — every field, control, label, validation rule |
79   -| `gdsformconst` | `gdsformconst` rows scoped by tenant + language | Form-level constants — labels, defaults, dropdown text |
80   -| `gdsjurisdiction` | `gdsjurisdiction` rows for the user's role | Per-button and per-data permissions |
81   -| `billnosetting` | `sysbillnosettings` row for this module | Document-numbering rules (work-order numbers, quotation numbers) |
82   -| `report` | `sysreport` rows linked to this form | Print templates (Excel via jxls, PDF via iText) |
  96 +| `formData` | `gdsconfigformmaster` (filtered by `sParentId = sModelsId`) ⋈ `gdsconfigformpersonalize` (per-tenant overlay); per master row, `gdsconfigformslave` + `gdsconfigformcustomslave` overlays. `gdsmodule` is referenced only by id. | The form layout itself — every field, control, label, validation rule |
  97 +| `gdsformconst` | `gdsformconst` rows filtered by `sParentId` only — NOT tenant-scoped; `sLanguage` selects which label column to return | Form-level constants — labels, defaults, dropdown text |
  98 +| `gdsjurisdiction` | `sysjurisdiction` rows for the user (or for the user's group via `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify`); skipped for ADMIN. The map-key name `gdsjurisdiction` is misleading — that table is the builder-side action *catalogue*, not what's read here. | Per-button and per-data permissions |
  99 +| `billnosetting` | `sysbillnosettings` row for this module (per-tenant) | Document-numbering rules (work-order numbers, quotation numbers) |
  100 +| `report` | `sysreport` rows linked to this form (per-tenant) | Print templates (Excel via jxls, PDF via iText) |
83 101  
84 102 ## What's *not* in this lifecycle
85 103  
... ...
en/docs/index.md
... ... @@ -35,15 +35,18 @@ which Reference chapter you go deep on.
35 35  
36 36 - The B2B printing-platform layer (`plat_*` tables, all `xlyPlat*` modules **except** `xlyPlatConstant` — see below).
37 37 - AI / LLM features (`ai_*` tables, `AiController`) — too new, still moving.
38   -- Face recognition (`xlyFace`) — niche.
  38 +- Face recognition (`xlyFace`) — niche; still an active include in `settings.gradle` (built and deployed) but intentionally undocumented here.
39 39 - File-management module (`xlyFile`) and serial-port module (`xlyRxtx`) — niche / hardware-adjacent.
  40 +- Scheduler modules (`xlyErpTask`, `xlyPlatTask`) — commented out in `settings.gradle`; cron / Quartz wiring is not part of the wiki's framework runtime.
40 41 - Test scaffolding modules (`xlyTestService`, `xlyTestController`) — historical, not part of the framework runtime.
41 42 - Per-tenant schema drift between `xlyweberp_*` databases — wiki targets one schema.
42   -- Backup tables (`*_bak`, `*0302`, etc.).
43   -- The MongoDB document store (`spring.data.mongodb.uri` in the yaml profiles, document classes under `xlyEntity/.../mongo/`). Every `@Document` class is `PLAT_*`-named and every `MongoTemplate` caller lives in an `xlyPlat*` module — so MongoDB is part of the plat tier above. The framework layer this wiki covers is MySQL-only.
  43 +- Backup tables (`*_bak`, `*0302`, `*_copy1`, `*_history`, `*YYYYMMDD[HHMMSS]`-suffixed snapshots, etc.) — the auto-catalog generates a page for each because they exist on disk; the prose pages don't cover them as a family. ~56 such tables in the live schema.
  44 +- The MongoDB document store (`spring.data.mongodb.uri` in the yaml profiles, document classes under `xlyEntity/.../mongo/`). Of 22 `@Document` classes there, 20 are `PLAT_*`-named — the only outliers are two `DIKE_TEST*` scratch classes. The single `MongoTemplate` caller is `xlyPersist/.../dao/platmongo/BaseMongoDao` (the `dao/platmongo/` package gives away its plat-tier intent), which has no in-tree consumers on the cleanup branch — the `xlyPlat*` modules that used to extend it are all commented out of `settings.gradle`. The framework layer this wiki covers is MySQL-only; the Mongo wiring stays compiled but dormant.
44 45  
45 46 > **Note on `xlyPlatConstant`.** It carries the `xlyPlat*` prefix but is in scope: `xlyPersist` imports two utility classes from it (`com.xly.xlyplatconstant.contant.thread.MultiThreadServer`, `com.xly.xlyplatconstant.contant.TimeContant`). Treat it as a misnamed shared-utility module, not a platform-tier module.
46 47  
  48 +> **Note on `xlyPlc`.** The PLC / hardware-bridge plugin is in scope as the canonical example of how a non-core module hooks into the framework. See [Slice 06 — Hardware](slices/06-hardware.md).
  49 +
47 50 ## How to fix something in this wiki
48 51  
49 52 Edit the markdown file. That is the wiki. Static HTML is generated from these `.md`
... ...
en/docs/reference/maintainer/activiti.md
... ... @@ -14,7 +14,7 @@ The dependency tree carries **two** Activiti versions:
14 14  
15 15 | Module | Version | Notes |
16 16 |---|---|---|
17   -| `xlyPersist` | `org.activiti:activiti-engine:5.17.0` | Older 5.x line |
  17 +| `xlyPersist`, `xlyApi` | `org.activiti:activiti-engine:5.17.0` | Older 5.x line — declared in both modules |
18 18 | `xlyFlow` | `org.activiti:activiti-spring-boot-starter-rest-api:6.0.0`, `activiti-json-converter:6.0.0` | Newer 6.0 line |
19 19  
20 20 This is a real version mismatch. Activiti's 5.x and 6.x schemas overlap
... ... @@ -26,9 +26,9 @@ but diverge in some `act_*` tables and migration paths. Possibilities:
26 26 3. Both are in the classpath but only one is initialised at runtime.
27 27  
28 28 A future maintainer attacking this should: (a) remove the unused
29   -version to avoid confusion, (b) document which version the live
30   -schema uses, (c) verify the `act_*` table layout matches that version
31   -exactly.
  29 +version from both `xlyPersist` and `xlyApi` to avoid confusion,
  30 +(b) document which version the live schema uses, (c) verify the
  31 +`act_*` table layout matches that version exactly.
32 32  
33 33 One extra code fact matters in this branch: `xlyFlow/build.gradle` pulls
34 34 in the Activiti 6 starter, but `xlyFlow/src/main/java/com/xly/XlyFlowApplicationBoot.java`
... ... @@ -37,10 +37,14 @@ does not currently present `xlyFlow` as a clearly runnable standalone app.
37 37  
38 38 ## The `act_*` schema
39 39  
40   -The framework ships the expected Activiti `act_*` tables (deployment,
41   -process-definition, runtime task, history etc.) — they are present even
42   -in deployments that don't yet run a flow. They populate only when a BPMN
43   -process is deployed and a process instance is started.
  40 +The framework ships the expected Activiti `act_*` schema — 24 base
  41 +tables (deployment, process-definition, runtime task, history etc.)
  42 +plus 3 *views* (`act_id_user`, `act_id_group`, `act_id_membership`).
  43 +The base tables populate only when a BPMN process is deployed and a
  44 +process instance is started. The identity views are notable: xly does
  45 +not maintain real Activiti identity tables; it projects its own
  46 +user/group schema into the `act_id_*` shapes via views, so Activiti
  47 +sees xly's logins as if they were native Activiti users.
44 48  
45 49 ## xly's wrapper layer
46 50  
... ... @@ -69,12 +73,21 @@ eventually completes.
69 73 fleshed out):
70 74  
71 75 - `xlyFlow`'s `pom`-equivalent gradle build pulls in Activiti 6.0.
72   -- The Spring Boot config for Activiti's process engine.
  76 +- `xlyFlow/src/main/java/com/xly/activiti/config/ActivitiConfig.java` —
  77 + `@Configuration` implementing `ProcessEngineConfigurationConfigurer`,
  78 + the Spring Boot wire-up for Activiti's process engine.
73 79 - `CheckFlowController` in `xlyEntry/com/xly/web/businessweb/` is one
74 80 surface the SPA hits to drive workflow (approve / reject / view).
75   -- BPMN process definitions, when present, live under `xlyFlow/src/main/resources/`
76   - (a `processes/` subdirectory or similar). Whether anything ships in
77   - the codebase depends on the build profile.
  81 + Note: the URL prefix is `/checkflow` (lowercase), not the camelCase
  82 + class name.
  83 +- `xlyFlow/src/main/java/com/xly/XlyFlowApplicationBoot.java` is fully
  84 + commented out on this branch — the workflow code is consumed as a
  85 + library through xlyEntry rather than as a standalone runnable.
  86 +- **No BPMN definitions ship in this repo** under
  87 + `xlyFlow/src/main/resources/` (no `processes/` subdir, no `*.bpmn*`
  88 + files). Deployments must supply them at runtime, e.g. via the
  89 + Activiti modeler whose static assets live at
  90 + `xlyFlow/src/main/resources/static/modeler/`.
78 91  
79 92 ## What's needed to make Activiti work
80 93  
... ...
en/docs/reference/maintainer/cache-invalidation.md
1 1 # Cache invalidation on metadata change
2 2  
3 3 When a PM saves a change in BACK — adds a column to a form, updates a
4   -permission, registers a new module — every running node has to drop
5   -its cached interpretation of the old metadata. xly does this through
6   -JMS, not by polling.
  4 +permission, registers a new module — the framework drops the cached
  5 +interpretation of the old metadata. **The cache-clear is synchronous
  6 +in the BACK process via Spring's `@CacheEvict`**, NOT a JMS fan-out.
  7 +A separate JMS path with similarly-named classes exists for a
  8 +different purpose (base-data merge); the two are easy to confuse and
  9 +this page calls them out explicitly.
7 10  
8   -## The path
  11 +## The actual cache-invalidation path (synchronous, in-process)
9 12  
10 13 ```
11 14 PM saves in BACK
12 15
13 16
14   -BACK controller writes the changed gds_* row
  17 +BACK controller (e.g. /business/addUpdateDelBusinessData) calls
  18 +BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData
15 19
16 20
17   -Controller publishes a JMS "module changed" message
  21 +Save service calls businessCleanRedisData.delCleanRedisData(...)
  22 + (e.g., BusinessBaseServiceImpl.java:1122, 1224, 1375, 1441, 1597, 1677)
18 23
19 24
20   -Every node's xlyErpJmsConsumer receives it
  25 +BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<sTable>, ...)
  26 + dispatches to one of the named cleaners on CleanRedisServiceImpl
21 27
22 28
23   -ConsumerChangeGdsModuleThread.run() clears the relevant Redis keys
  29 +CleanRedisServiceImpl.cleanRedisByTableNameGdsModle() (or similar)
  30 + fires @CacheEvict against a fixed list of named cache regions
24 31
25 32
26   -Next /business/getModelBysId call on any node re-reads the table
27   - and re-populates the cache with the new value
  33 +Spring CacheManager evicts the named entries
  34 + │
  35 + ▼
  36 +Next /business/getModelBysId call re-reads from DB and re-populates
  37 + the cache.
28 38 ```
29 39  
30   -The handler is in
31   -`xlyErpJmsConsumer/src/main/java/com/xly/xlyerpjmsconsumer/thread/ConsumerChangeGdsModuleThread.java`.
32   -
33   -## Why JMS, not poll-and-bust
34   -
35   -xly often runs across multiple nodes (xlyEntry, xlyApi, xlyInterface
36   -each on their own JVM, sometimes scaled horizontally). Polling for
37   -"has the metadata changed?" would either be slow (the change isn't
38   -visible until the next poll) or chatty (constant heartbeats). JMS
39   -fans out the invalidation to every node within milliseconds.
40   -
41   -xly uses both **ActiveMQ** and **RocketMQ** in the codebase, but the
42   -metadata-change path documented here is the **ActiveMQ / JMS** one:
43   -`xlyErpJmsConsumer` listens on `P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE`
44   -with `@JmsListener`, and `ConsumerChangeGdsModuleThread` handles the
45   -cache-bust work. `RocketMQServiceImpl` exists for other integration flows.
46   -
47   -## Which keys get cleared
  40 +The cleaner methods are in
  41 +`xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java`.
  42 +A representative one — invoked when `gdsmodule` rows change — evicts
  43 +17 cache regions in a single call:
48 44  
49   -The Redis cache holds:
50   -
51   -- Module metadata by `sId`.
52   -- Form metadata by `sId`.
53   -- Field-list slaves keyed by form `sId`.
54   -- Per-tenant overlay merges (a derived cache).
55   -- Permission rules per (module, role).
  45 +```
  46 +@CacheEvict(value = {
  47 + "getGdsmoduleTree", "getGdsmoduleList", "getModuleTreePro",
  48 + "getSysjurisdictionTreePro", "getsDisplayTypeAll",
  49 + "businessBaseServiceGetMenuList", "getBuMenu", "getMenu",
  50 + "getsAuthsId", "businessCommonServicegetModulelistAll",
  51 + "gdsmoduleById", "getSaveProName", "businessParameterGetParameter",
  52 + "getPrcName", "getKpiModelByUser", "getUserByFromId",
  53 + "getUserByActionId", "getModuleTreeProAll"
  54 +}, allEntries = true)
  55 +public void cleanRedisByTableNameGdsModle() { … }
  56 +```
56 57  
57   -The consumer thread receives the changed row's IDs and clears each
58   -cache key family that could plausibly include it. **Over-invalidating
59   -is the safe option here** — the cost of an extra DB read on the next
60   -request is far smaller than the cost of serving stale metadata.
  58 +Other table-named cleaners on the same class evict the regions
  59 +relevant to `gdsconfigformmaster`, `gdsconfigformslave`,
  60 +`gdsconfigtbmaster`, `gdsformconst`, `gdsjurisdiction`,
  61 +`gdsconfigcharmaster`, login-info, billnosetting, kpimaster,
  62 +`SysSystemSettings`, etc.
  63 +
  64 +## What the JMS `CHANGE_GDS_MODULE` queue actually does (NOT cache-bust)
  65 +
  66 +The framework has a JMS queue
  67 +`P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` and a consumer thread
  68 +`ConsumerChangeGdsModuleThread`, both of which sound like they should
  69 +be doing cache invalidation — but they don't.
  70 +`ConsumerChangeGdsModuleThread.run()` resolves a
  71 +`changeGdsModuleService` bean (`ChangeGdsModuleServiceImpl`) and calls
  72 +`changeTableData(sGdsModuleId, sJobId)`, which invokes the stored
  73 +procedure `PRO_ERPMERGEBASEGDSMODULE` (via `proDao.proErpMergeBaseGdsModule`,
  74 +mapped in `ProMapper.xml`). That proc consolidates per-tenant
  75 +`gdsmodule` rows into a flattened "base" lookup table — a base-data
  76 +merge job, not a cache evict. A `grep` of `xlyErpJmsConsumer/` for
  77 +`@CacheEvict` or `cleanRedis*` returns zero hits — the consumer side
  78 +clears nothing in Redis.
  79 +
  80 +The same goes for the other 23 `ERP_JMS_ACTIVEMQ_*` queues in
  81 +[`P2pQueue.java`](../../api-reference/messaging.md): each one drives a
  82 +domain-specific base-data merge or fan-out work item, not cache
  83 +invalidation.
  84 +
  85 +## The cross-node coherence question (open)
  86 +
  87 +`@EnableCaching` is on `EntryApplicationBoot.java:22` and
  88 +`ApiApplicationBoot.java:24`. **No custom `CacheManager` bean is
  89 +declared anywhere in the in-scope source** (no `RedisCacheManager`,
  90 +no `@Bean CacheManager`-returning method, no `implements CacheManager`,
  91 +no `spring.cache.*` property in any `application*.yml`). Spring Boot
  92 +2.2.5 will then auto-pick a `CacheManager` based on what's on the
  93 +classpath; the most likely outcome with `spring-boot-starter-data-redis`
  94 +present is that Spring auto-configures Redis-backed caching, which
  95 +would make `@CacheEvict` clear the shared Redis store and therefore
  96 +fan out across nodes implicitly. **This has not been confirmed by live
  97 +inspection of a running node** — it's the natural reading of the
  98 +config but worth verifying when a deployment is available. If the
  99 +auto-picked manager is in fact `ConcurrentMapCacheManager` (in-memory,
  100 +per-JVM), the multi-node coherence story is broken under this code
  101 +path and would need a separate fix.
61 102  
62 103 ## When you change metadata directly via SQL
63 104  
64   -Inserts/updates done through MyBatis or BACK *trigger* the JMS event.
65   -Inserts/updates done by an engineer running raw `UPDATE gdsmodule SET
66   -...` against the production DB do **not** trigger it. The cache will
67   -serve stale metadata until either:
  105 +Inserts/updates done through MyBatis or BACK trigger
  106 +`businessCleanRedisData.delCleanRedisData*`. Raw `UPDATE gdsmodule SET …`
  107 +against the DB does **not** trigger any cleaner. The cache will serve
  108 +stale metadata until either:
68 109  
69 110 1. The cache TTL expires (check the cache config for the actual TTL).
70   -2. A bounce of the application servers.
71   -3. A manual JMS message is sent (see `BusinessCleanRedisDataImpl` in
72   - `xlyBusinessService`).
73   -
74   -The third option is the supported workaround; option (2) is the
75   -brute-force fallback.
  111 +2. A bounce of the application servers (one node at a time if the
  112 + cache is local; once if shared).
  113 +3. A manual call to one of the
  114 + `BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<table>, …)`
  115 + methods is invoked from inside the application (e.g., via a
  116 + maintenance endpoint). Note this clears whatever the local
  117 + `CacheManager` is bound to; if that turns out to be in-memory,
  118 + the cleanup must run on every node.
76 119  
77 120 ## Common bug: the cache is the bug
78 121  
79 122 When something looks like "I changed it but the page still shows the
80 123 old value", check (in this order):
81 124  
82   -1. Did the change actually commit? (Confirm with `SELECT` against
83   - the DB.)
84   -2. Is the JMS broker reachable from the BACK node? (If not, the
85   - invalidation event silently isn't published.)
86   -3. Are all consumer nodes running? (If a node is paused, it'll serve
87   - stale metadata until restarted.)
88   -4. Did the change happen via raw SQL? (Then no JMS event was
89   - published — manually trigger it.)
90   -
91   -The "five-table read" of [Slice 1](../../slices/01-hello-world.md)
  125 +1. Did the change actually commit? (Confirm with `SELECT` against the DB.)
  126 +2. Did the change go through a path that invokes
  127 + `BusinessCleanRedisData`? (Direct DB writes or controllers that
  128 + bypass `BusinessBaseServiceImpl` won't.)
  129 +3. Is the cache shared across nodes (Redis-backed) or local
  130 + (`ConcurrentMapCacheManager`)? Confirm by inspecting the active
  131 + `CacheManager` bean on a running node.
  132 +4. If the cache is local, did every node get the eviction call?
  133 +
  134 +The five-key composite returned by
  135 +[`getModelBysId` in Slice 1](../../slices/01-hello-world.md)
92 136 re-runs from the cache; understanding which layer is stale is the key
93 137 to the bug.
... ...
en/docs/reference/maintainer/deployment.md
... ... @@ -6,14 +6,29 @@ as dependencies by `xlyEntry`.
6 6  
7 7 ## The main modules
8 8  
9   -| Service / module | Role | Code-backed notes |
10   -|---|---|---|
11   -| **xlyEntry** | Main runtime and builder/admin surface. Hosts `/business/*`, `/gdsmodule/*`, `/gdsconfigform/*`, `/gdsconfigtb/*`, reporting, login, and other framework controllers. | Depends on `xlyManage`, `xlyBusinessService`, and `xlyFlow`. |
12   -| **xlyApi** | API-oriented module for `/api/*`, `/online/*`, `/pro/*`, `/thirdparty/*`, and related endpoints. | Separate Spring Boot application class in `ApiApplicationBoot`. |
13   -| **xlyInterface** | External-integration module with Swagger dependencies and third-party integration code. | Separate Spring Boot application class in `InterfaceApplicationBoot`. |
14   -| **xlyPlc** | Shop-floor PLC bridge ([Slice 6](../../slices/06-hardware.md)). | Separate Spring Boot application class in `PlcApplicationBoot`. |
15   -| **xlyFace** | Optional face-recognition module. | Separate module; still present in the Gradle build. |
16   -| **xlyFlow** | Workflow / Activiti code and controllers. | Present as its own module, but in this branch `XlyFlowApplicationBoot` is commented out, so treat it as code that is currently consumed through `xlyEntry` rather than a clearly runnable standalone app. |
  9 +### Deployable Spring Boot applications
  10 +
  11 +| Service / module | Role | Default profile / port | Boot class |
  12 +|---|---|---|---|
  13 +| **xlyEntry** | Main runtime and builder/admin surface. Hosts `/business/*`, `/gdsmodule/*`, `/gdsconfigform/*`, `/gdsconfigtb/*`, reporting, login, and other framework controllers. | `dev` → 8080, context `/xlyEntry` | `EntryApplicationBoot` |
  14 +| **xlyApi** | API-oriented module for `/api/*`, `/online/*`, `/pro/*`, `/thirdparty/*`, and related endpoints. | `local` (default in repo) → 8090, dev/win/linux → 8080, context `/xlyApi` | `ApiApplicationBoot` |
  15 +| **xlyInterface** | External-integration module with Swagger dependencies and third-party integration code. | `dev` → 8080, context `/xlyInterface` | `InterfaceApplicationBoot` |
  16 +| **xlyPlc** | Shop-floor PLC bridge ([Slice 6](../../slices/06-hardware.md)). | `dev` → 8000, named profiles (15S, S10, T0, T1, CT, yt, pro) → 8080, context `/xlyEntry` *(shares xlyEntry's context-path)* | `PlcApplicationBoot` |
  17 +| **xlyFace** | Face-recognition module. In build (`settings.gradle` keeps it active per user) but **out of documentation scope** for this wiki. | `win` (default in repo) → 8080, local → 8091, context `/xlyFace` | `XlyFaceApplicationBoot` |
  18 +| **xlyErpJmsConsumer** | JMS consumer worker. Has a Boot main but no `application*.yml` of its own — runtime config inherits from peer services. | n/a (inherits) | `JmsConsumerApplicationBoot` |
  19 +
  20 +### Library modules (in `settings.gradle`, not standalone runnable)
  21 +
  22 +| Module | Role |
  23 +|---|---|
  24 +| **xlyManage** | Backend metadata-management services (`Gds*ServiceImpl` family); pulled into xlyEntry. |
  25 +| **xlyBusinessService** | Business-logic service tier (`BusinessBaseServiceImpl` and ~100 sibling `*ServiceImpl` classes); pulled into xlyEntry. |
  26 +| **xlyFlow** | Workflow / Activiti code. `XlyFlowApplicationBoot.java` is fully commented out on this branch; consumed as a library through xlyEntry. Also shares context-path `/xlyEntry`. |
  27 +| **xlyEntity** | Shared entity / DTO classes (~83 Java files, including 22 Mongo `@Document` classes). |
  28 +| **xlyPersist** | Persistence helpers (DAOs, MyBatis mapper XMLs, `RequestAddParamUtil`, etc.). |
  29 +| **xlyMsg** | Notification helpers (DingTalk, WeChat, email); no Boot main. |
  30 +| **xlyErpJmsProductor** | JMS producer code (queue declarations in `P2pQueue.java`); no Boot main. |
  31 +| **xlyPlatConstant** | Shared utility constants (`MultiThreadServer`, `TimeContant`) consumed by `xlyPersist`. The single active Plat* module. |
17 32  
18 33 Each has its own `application.yml` + several `application-<profile>.yml`
19 34 files. The active profile is selected at startup via
... ... @@ -21,33 +36,41 @@ files. The active profile is selected at startup via
21 36  
22 37 ## Disabled in `settings.gradle`
23 38  
24   -```
25   -//include 'xlyErpTask'
26   -//include 'xlyRxtx'
27   -//include 'xlyFile'
28   -```
29   -
30   -Three modules are present on disk but excluded from the active Gradle build:
  39 +The cleanup branch comments out 12 `include` lines. Three are non-Plat
  40 +modules present on disk:
31 41  
32 42 - `xlyErpTask` — long-running background tasks.
33   -- `xlyRxtx` — native serial-port library. Disabled when xlyPlc doesn't
34   - need direct serial access (some press models use TCP/Ethernet
  43 +- `xlyRxtx` — native serial-port library. May be re-enabled when xlyPlc
  44 + needs direct serial access (some press models use TCP/Ethernet
35 45 instead).
36   -- `xlyFile` — older file-management module, superseded by Aliyun OSS
37   - integration in `xlyPlatFileUpload`.
  46 +- `xlyFile` — older file-management module, superseded by
  47 + `xlyPlatFileUpload` (also commented out).
  48 +
  49 +The remaining nine commented-out includes are `xlyTestService`,
  50 +`xlyTestController`, and the full `xlyPlat*` family except
  51 +`xlyPlatConstant` — i.e. `xlyPlatTask`, `xlyPlatJmsProductor`,
  52 +`xlyPlatJmsConsumer`, `xlyPlatReportForm`, `xlyPlatFileUpload`,
  53 +`xlyPlatMarketingService`, `xlyPlatUserService`, `xlyPlatSmsService`,
  54 +`xlyPlatMerchantController`, `xlyPlatWebsocket`, `xlyPlatPayService`,
  55 +`xlyPlatCainiaoWaybillSevice`. (`xlyTestService` / `xlyTestController`
  56 +directories are not on disk; only `TestController.java` exists inside
  57 +`xlyEntry/.../businessweb/` as a stub.)
38 58  
39 59 A maintainer cleaning up the codebase should consider whether to delete
40   -these or keep them as historical reference. They take up disk space but
41   -do not affect the build.
  60 +the on-disk-but-excluded `xlyErpTask` / `xlyRxtx` / `xlyFile`
  61 +directories or keep them as historical reference. They take up disk
  62 +space but do not affect the build.
42 63  
43 64 ## Plat* family
44 65  
45 66 The `xlyPlat*` modules (`xlyPlatMerchantController`, `xlyPlatUserService`,
46 67 `xlyPlatPayService`, `xlyPlatMarketingService`, `xlyPlatCainiaoWaybillSevice`,
47 68 `xlyPlatSmsService`, `xlyPlatReportForm`, `xlyPlatFileUpload`,
48   -`xlyPlatJmsConsumer`/`Productor`, `xlyPlatTask`, `xlyPlatWebsocket`,
49   -`xlyPlatConstant`) are the **B2B printing-platform layer** and remain
50   -out-of-scope for this wiki.
  69 +`xlyPlatJmsConsumer`/`Productor`, `xlyPlatTask`, `xlyPlatWebsocket`)
  70 +are the **B2B printing-platform layer** and remain out-of-scope for
  71 +this wiki. The single exception is `xlyPlatConstant`, which is still
  72 +`include`d in `settings.gradle` and consumed as a shared constants
  73 +utility by `xlyPersist` (`MultiThreadServer`, `TimeContant`).
51 74  
52 75 ## How services find each other
53 76  
... ... @@ -75,14 +98,25 @@ are deployment details rather than code-backed facts in this repository.
75 98  
76 99 ## Profile permutations
77 100  
78   -`application-saas.yml`, `application-linux.yml`, `application-win.yml`,
79   -`application-15S.yml`, `application-S10.yml`, `application-pro.yml`,
80   -`application-T0.yml`, `application-T1.yml`, … cover combinations of:
81   -
82   -- Operating system (linux / win)
83   -- Customer category (saas, 15S, S10, …)
84   -- Environment (dev, pro)
85   -- Press-model (for xlyPlc)
  101 +Profiles split by service:
  102 +
  103 +- **xlyEntry**: `dev`, `local`, `win`, `linux`, `15s`, `s10`, `saas`,
  104 + `bgj` (lowercase). `dev` is the in-repo default.
  105 +- **xlyApi**: `local` (default in repo), `dev`, `linux`, `win`.
  106 +- **xlyInterface**: `dev` only.
  107 +- **xlyFlow**: `dev` (empty file).
  108 +- **xlyFace**: `win` (default), `dev`, `linux`, `local`.
  109 +- **xlyPlc**: `dev` (default) plus 7 press-model profiles
  110 + (`15S`, `S10`, `T0`, `T1`, `CT`, `yt`, `pro` — uppercase / mixed-case,
  111 + distinct from xlyEntry's lowercase `15s` / `s10`).
  112 +
  113 +The press-model profiles (`T0`, `T1`, `CT`, `yt`, `pro`, `15S`, `S10`)
  114 +are **xlyPlc-specific** — they don't exist for the other services. The
  115 +cross-service profiles cover combinations of:
  116 +
  117 +- Operating system (`linux` / `win`)
  118 +- Environment (`dev`, `local`, `saas`, `bgj`)
  119 +- Customer/edition (`15s`, `s10` for xlyEntry)
86 120  
87 121 A given deployment selects exactly one profile per service. The
88 122 mapping from "this customer" → "these profiles" lives in deployment
... ...
en/docs/reference/maintainer/proc-dispatch.md
1 1 # Generic procedure dispatch
2 2  
3   -When `gdsmodule.sSaveProName` (or `sDeleteProName`, `sCalcProName`,
4   -`sProcName`, `sSaveProNameBefore`) is **non-empty**, the framework
5   -invokes the named stored procedure instead of falling through to its
6   -default Add/Update path. The same machinery handles button-press
7   -calculations and on-demand custom logic.
  3 +When the metadata names a stored procedure — via columns like
  4 +`gdsmodule.sSaveProName`, `sSaveProNameBefore`, `sDeleteProName`,
  5 +`sCalcProName`, `sProcName`, or `gdsconfigformslave.sButtonParam` —
  6 +the framework dispatches that proc by name. `sSaveProName` and
  7 +`sSaveProNameBefore` are **hooks**: they run as post-save / pre-save
  8 +phases on top of the always-running base add/update path
  9 +(`BusinessBaseServiceImpl.addBusinessData` /
  10 +`updateBusinessData`), invoked by `checkUpdate(...,"sSaveProName")`
  11 +at `BusinessBaseServiceImpl.java:1824` and `:1778`. The other
  12 +columns drive on-demand calls: `sCalcProName` for button-press
  13 +calculations, `sProcName` for custom-fetch flows, etc. The same
  14 +generic-dispatch machinery handles all of them.
8 15  
9 16 The handler is `GenericProcedureCallController` in
10 17 `xlyEntry/com/xly/web/businessweb/`.
11 18  
12 19 ## Endpoint shape
13 20  
14   -The frontend POSTs to a URL under `/business/genericProcedureCall*`
15   -with:
  21 +The frontend POSTs to `/procedureCall/doGenericProcedureCall` with:
16 22  
17 23 - The proc name (often resolved from `gdsmodule` or `gdsconfigformslave.sButtonParam`).
18 24 - The parameter values (frontend supplies them, the framework injects
... ... @@ -79,6 +85,40 @@ These are templates an engineer fills in to author a new proc — they
79 85 are not used by the runtime to generate procs on the fly. See [SQL
80 86 templates](sql-templates.md) for the loader and the placeholder syntax.
81 87  
  88 +## Proc-name molds in the live schema
  89 +
  90 +The 1687 procedures in the live DB cluster around a few naming molds
  91 +beyond the bare `Sp_*` family:
  92 +
  93 +| Mold | Approx count | Role |
  94 +|---|---:|---|
  95 +| `Sp_*` | most | The dominant family, dispatched by `sSaveProName` / `sCalcProName` / `sProcName` etc. |
  96 +| `Sp_*_BeforeSave` | ~62 | Pre-save hooks. Pair with `sSaveProNameBefore`. |
  97 +| `Sp_*_AfterSave` / `Sp_*_SaveReturn` | ~62 / ~54 | Post-save hooks; `_SaveReturn` writes back into the parent transaction. |
  98 +| `Sp_*_Calc` | ~178 | Calculation procs invoked by button-press flow (`sCalcProName` / `sButtonParam`). |
  99 +| `sp_btn_*` | ~65 | Button-event sub-family — typically `sp_btn_calc*` / `sp_btn_validate*` (lowercase by convention). |
  100 +| `PRO_ERPMERGE*` | ~11 | Data-migration utilities. **Not dispatched by the runtime** — engineer-only. |
  101 +| `PRO_*` (other) | ~12 | Other one-off utilities. |
  102 +| `Get_*`, `del_*`, `Cal*`, `Tj_*` | small handfuls | Legacy / domain-specific helpers. Not part of the generic-dispatch contract. |
  103 +
  104 +A typo in any of the dispatched columns gets an `Sp_*`-shaped target,
  105 +so other molds never resolve via `sSaveProName` / `sCalcProName` etc.
  106 +The non-`Sp_*` procs are reachable only via direct invocation in
  107 +mapper XML or other procs.
  108 +
  109 +## The function layer
  110 +
  111 +The schema also ships **177 user-defined functions** following parallel
  112 +naming molds: `Fun_*` (~150), `Fn_*` (~8), `get_*` (~10).
  113 +
  114 +These are **not Java-dispatched**. They are invoked from inside other
  115 +procedures, view definitions, and mapper-XML SELECT statements. There
  116 +is no `gdsmodule.sFunctionName` column or analogous metadata —
  117 +functions are picked up by the SQL that mentions them. A maintainer
  118 +investigating a slow report should grep procs and views for `Fun_*` /
  119 +`Fn_*` / `get_*` references; the framework's Java side does not see
  120 +them at all.
  121 +
82 122 ## Failure modes to watch
83 123  
84 124 1. **Mismatched parameter order.** Generic dispatch binds positionally;
... ...
en/docs/reference/maintainer/runtime.md
... ... @@ -16,14 +16,14 @@ controllers and services that carry most of the generic form runtime.
16 16 | `BusinessTreeGridController` | `web/businessweb/` | Tree-grid endpoints. In this branch, the proc-backed path is implemented; the plain `getTreeGrid` service method is still a stub. | `/treegrid/getTreeGridByPro/{formId}` |
17 17 | `GenericProcedureCallController` | `web/businessweb/` | Generic stored-procedure invocation by name + parameters. | `/procedureCall/doGenericProcedureCall` |
18 18 | `ConfigformPanelController` | `web/businessweb/` | Panel-layout persistence in `gdsconfigformpanel`. | `/panel/get/{sFormId}`, `/panel/save/{sFormId}` |
19   -| `CheckFlowController` | `web/businessweb/` | Activiti workflow surface (approve / reject / view) — only meaningful when workflow is deployed. | endpoints under `/checkFlow/*` |
  19 +| `CheckFlowController` | `web/businessweb/` | Activiti workflow surface (approve / reject / view) — only meaningful when workflow is deployed. | endpoints under `/checkflow/*` (the class file is `CheckFlowController.java` in camelCase but the `@RequestMapping` value is all-lowercase) |
20 20  
21 21 Note that the controllers split across **two packages**: `businessweb/`
22 22 hosts the runtime-facing endpoints, while `systemweb/` hosts the
23 23 metadata-CRUD endpoints used by the builder side. Both compile into the
24 24 same `xlyEntry` WAR.
25 25  
26   -## The four-table read
  26 +## The five-key read
27 27  
28 28 For any metadata-driven module, the request lifecycle (see
29 29 [Concepts → request lifecycle](../../concepts/request-lifecycle.md))
... ... @@ -31,11 +31,23 @@ boils down to:
31 31  
32 32 ```java
33 33 public Map<String, Object> getModelBysId(Map<String, Object> map) {
34   - List<Map<String, Object>> formList = this.getModelConfigByModleId(map); // 1. join gdsmodule⋈form-master⋈form-slave
35   - List<Map<String, Object>> fList = businessGdsconfigformsService.getFormconstData(qMap); // 2. gdsformconst
36   - List<Map<String, Object>> jList = businessGdsconfigformsService.getJurisdictionData(qMap); // 3. gdsjurisdiction (skipped for ADMIN)
37   - Map<String, Object> billnosettingMap = businessGdsconfigformsService.getBillnosettingData(param); // 4. sysbillnosettings
38   - List<Map<String, Object>> reportList = printReportService.getReportData(qMap); // 5. sysreport
  34 + // 1. formData — gdsconfigformmaster filtered by sParentId=sModelsId,
  35 + // LEFT JOIN gdsconfigformpersonalize (per-tenant), then for each
  36 + // master row load its gdsconfigformslave + gdsconfigformcustomslave
  37 + // overlays. gdsmodule itself is referenced only by id, not SELECT-ed.
  38 + List<Map<String, Object>> formList = this.getModelConfigByModleId(map);
  39 + // 2. gdsformconst — by sParentId only; sLanguage selects which label
  40 + // column to return; NOT tenant-scoped.
  41 + List<Map<String, Object>> fList = businessGdsconfigformsService.getFormconstData(qMap);
  42 + // 3. sysjurisdiction (per-user grants joined to sftlogininfojurisdictiongroup
  43 + // + sisjurisdictionclassify); skipped for ADMIN.
  44 + // Returned under map key `gdsjurisdiction` despite the source table
  45 + // being sysjurisdiction.
  46 + List<Map<String, Object>> jList = businessGdsconfigformsService.getJurisdictionData(qMap);
  47 + // 4. sysbillnosettings (per-tenant, per-form).
  48 + Map<String, Object> billnosettingMap = businessGdsconfigformsService.getBillnosettingData(param);
  49 + // 5. sysreport (per-tenant, per-form).
  50 + List<Map<String, Object>> reportList = printReportService.getReportData(qMap);
39 51 return composite(formList, fList, jList, billnosettingMap, reportList);
40 52 }
41 53 ```
... ... @@ -47,11 +59,11 @@ rest of the runtime is variations on it.
47 59  
48 60 | Key | Source | Frontend uses for |
49 61 |---|---|---|
50   -| `formData` | `gdsmodule` ⋈ `gdsconfigformmaster` ⋈ `gdsconfigformslave` (+ overlays) | Form layout |
51   -| `gdsformconst` | `gdsformconst` | Form-level constants, dropdown labels |
52   -| `gdsjurisdiction` | `gdsjurisdiction` | Per-button / per-data permissions |
53   -| `billnosetting` | `sysbillnosettings` | Document numbering rules |
54   -| `report` | `sysreport` | Print templates |
  62 +| `formData` | `gdsconfigformmaster` (filtered by `sParentId = sModelsId`) ⋈ `gdsconfigformpersonalize` overlay; per master row, `gdsconfigformslave` + `gdsconfigformcustomslave`. `gdsmodule` is referenced only as the id source, not joined. | Form layout |
  63 +| `gdsformconst` | `gdsformconst` (filtered by `sParentId` only; `sLanguage` selects which label column to return; NOT tenant-scoped) | Form-level constants, dropdown labels |
  64 +| `gdsjurisdiction` | `sysjurisdiction` (joined to `sftlogininfojurisdictiongroup` + `sisjurisdictionclassify` for per-user/group grants); skipped for ADMIN. **Note:** the map-key name `gdsjurisdiction` is misleading — `gdsjurisdiction` is the builder-side action *catalogue* table; the per-user *grant* read here actually queries `sysjurisdiction`. | Per-button / per-data permissions |
  65 +| `billnosetting` | `sysbillnosettings` (per-tenant, per-form) | Document numbering rules |
  66 +| `report` | `sysreport` (per-tenant, per-form) | Print templates |
55 67  
56 68 ## The save endpoint
57 69  
... ... @@ -67,9 +79,22 @@ map per row, in this shape:
67 79 }
68 80 ```
69 81  
70   -When `gdsmodule.sSaveProName` is empty, the framework's default
71   -Add/Update path runs — `AddDelUpdCommonServiceImpl.java`. When it's
72   -populated, the named stored proc is invoked instead.
  82 +The base add/update path always runs through
  83 +`BusinessBaseServiceImpl.addBusinessData` / `updateBusinessData`
  84 +(`xlyBusinessService/.../BusinessBaseServiceImpl.java:1014` and
  85 +`:1250`), which delegate to `businessBaseDao.add(map)` /
  86 +`businessBaseDao.update(map)` against the table named by `sTable`.
  87 +`gdsmodule.sSaveProName` (and its sibling `sSaveProNameBefore`) is
  88 +**not** an either/or branch that swaps the path; it names an extra
  89 +stored proc that runs as a post-save (or pre-save) hook on top of
  90 +the base path, dispatched by `checkUpdate(...,"sSaveProName")` at
  91 +`BusinessBaseServiceImpl.java:1824` and `CheckSaveServiceImpl.java`.
  92 +`AddDelUpdCommonServiceImpl` (`@Service("addDelUpdCommonService")`)
  93 +is a separate utility of reusable `insertByMap`/`updateByMap`/
  94 +`delByMap`/`addBatch` helpers used by domain services
  95 +(work-order-plan, oee, many-quo, order-procurement, etc.); it is
  96 +**not** the runtime's default add/update path for
  97 +`addUpdateDelBusinessData`.
73 98  
74 99 ## Multi-tenant boundary
75 100  
... ... @@ -95,12 +120,21 @@ A new controller method that doesn&#39;t call `RequestAddParamUtil` is a
95 120  
96 121 Two flagged in slices that belong here permanently:
97 122  
98   -1. **`sTable` validation in `addUpdateDelBusinessData`.** The frontend
99   - names the target table directly. The runtime *must* cross-check
100   - that the supplied table is one of the form's authorised backing
101   - tables, or it's a privilege-escalation surface. Confirm the check
102   - exists; if it doesn't, raise it as a security ticket. Tracked in
103   - the [Slice 1 v2 follow-up](../../slices/01-hello-world.md#open-verification-items).
  123 +1. **`sTable` validation in `addUpdateDelBusinessData` — CONFIRMED MISSING.**
  124 + The frontend names the target table directly and the runtime does
  125 + **not** cross-check that the supplied table is one of the form's
  126 + authorised backing tables. `BusinessBaseServiceImpl.sTableNameList`
  127 + (lines 162-169) is a multi-tenant scope-bypass list (the four
  128 + framework-metadata tables that are global, so `sBrandsId` /
  129 + `sSubsidiaryId` are stripped from writes against them — see the
  130 + `//不需要公司子公司的表` comment at line 165), not a backing-table
  131 + whitelist. The hardcoded special case at line 1768
  132 + (`mftproductionplanslave`) is the only module/table cross-check
  133 + in the whole flow. Mitigations exist (tenant scoping via
  134 + `RequestAddParamUtil`, optional pre/post-save proc validation),
  135 + but none of them is a backing-table whitelist. See
  136 + [Slice 1 follow-up](../../slices/01-hello-world.md#open-verification-items)
  137 + for the full trace.
104 138 2. **ADMIN bypasses permissions.**
105 139 `BusinessBaseServiceImpl` skips the `gdsjurisdiction`
106 140 load entirely for `UserType.ADMIN`. ADMIN account governance must
... ... @@ -108,7 +142,11 @@ Two flagged in slices that belong here permanently:
108 142  
109 143 ## Cache invalidation
110 144  
111   -When BACK saves a metadata change, a JMS message fires and
112   -`ConsumerChangeGdsModuleThread` (in `xlyErpJmsConsumer`) clears the
113   -cached metadata on every running node. See [cache invalidation on
114   -metadata change](cache-invalidation.md).
  145 +When BACK saves a metadata change, the save service synchronously
  146 +calls `BusinessCleanRedisData.delCleanRedisData*`, which fires
  147 +`@CacheEvict` on the relevant cache regions in `CleanRedisServiceImpl`.
  148 +A separate JMS path (`ConsumerChangeGdsModuleThread`) exists with a
  149 +similar name but does base-data merging via stored proc, not cache
  150 +invalidation. See [cache invalidation on metadata change](cache-invalidation.md)
  151 +for the full story (including the open question about cross-node
  152 +coherence).
... ...
en/docs/reference/maintainer/tech-stack.md
1 1 # Tech stack
2 2  
3   -A library inventory for the **in-scope** framework modules
4   -(`xlyEntry`, `xlyApi`, `xlyManage`, `xlyBusinessService`, `xlyPersist`,
5   -`xlyEntity`, `xlyFlow`, `xlyPlc`, `xlyInterface`, `xlyMsg`,
6   -`xlyErpJms*`).
7   -
8   -The plat tier (`xlyPlat*` modules), `xlyFace`, and AI libraries are
  3 +A library inventory for the **in-scope** framework — 11 framework-core
  4 +modules (`xlyEntry`, `xlyApi`, `xlyManage`, `xlyBusinessService`,
  5 +`xlyPersist`, `xlyEntity`, `xlyFlow`, `xlyInterface`, `xlyMsg`,
  6 +`xlyErpJmsProductor`, `xlyErpJmsConsumer`), one plugin (`xlyPlc`), and
  7 +one shared utility (`xlyPlatConstant` — load-bearing for `xlyPersist`'s
  8 +use of `MultiThreadServer` and `TimeContant`, despite the `Plat*`
  9 +naming).
  10 +
  11 +The other plat-tier modules (`xlyPlat*` except `xlyPlatConstant`),
  12 +`xlyFace` (in build, out of documentation scope), and AI libraries are
9 13 [out of scope](../../index.md#whats-out-of-scope) and are not listed
10 14 here.
11 15  
... ... @@ -49,7 +53,7 @@ page records facts only.
49 53 | MySQL Connector/J | 8.0.13 | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | yaml `spring.datasource.driverClassName: com.mysql.cj.jdbc.Driver` (e.g., `xlyEntry/.../application-local.yml:127`). |
50 54 | MSSQL JDBC | sqljdbc4 3.0 (Maven) + `mssql-jdbc-6.2.2.jre8.jar` (local jar in `xlyFlow/`, `xlyInterface/`) | `xlyApi/build.gradle`, `xlyInterface/build.gradle`, `xlyFlow/build.gradle` | 5 files: 3 in `xlyFlow/src/`, 2 in `xlyInterface/src/`. |
51 55 | Oracle JDBC | `ojdbc6-11.2.0.4.jar` (local jar in `xlyFlow/`) | `xlyFlow/build.gradle` | 2 files in `xlyFlow/src/`. |
52   -| Druid | `druid-spring-boot-starter` 1.2.16; `druid` 1.2.16 | `xlyPersist/build.gradle`, `xlyApi/build.gradle` | 25 files import `com.alibaba.druid.*` (xlyEntry=8, xlyPlc=8, xlyFlow=5, xlyBusinessService=2, xlyInterface=2). yaml: `xlyEntry/.../application-local.yml:126` sets `spring.datasource.type: com.alibaba.druid.pool.DruidDataSource`; lines 308-313 configure the `/druid/*` stat-view servlet. |
  56 +| Druid | `druid-spring-boot-starter` 1.2.16; `druid` 1.2.16 | `xlyPersist/build.gradle`, `xlyApi/build.gradle` | 6 Java files import `com.alibaba.druid.*` (xlyBusinessService=2, xlyFlow=3, xlyInterface=1). 16 `application-*.yml` files reference Druid configuration (xlyEntry=8 yml profiles, xlyPlc=8 yml profiles). yaml: `xlyEntry/.../application-local.yml:126` sets `spring.datasource.type: com.alibaba.druid.pool.DruidDataSource`; lines 308-313 configure the `/druid/*` stat-view servlet. |
53 57 | HikariCP | 4.0.3 | `xlyApi/build.gradle` | 8 files reference `com.zaxxer.hikari` (xlyApi=6, xlyInterface=2). Java config: `xlyApi/.../api/config/MasterDataSourceConfig.java`, `SlaveDataSourceConfig.java`. yaml: `xlyApi/.../application-{local,dev,linux,win}.yml`. |
54 58 | Flyway | 5.2.1 | `xlyPersist/build.gradle` | No Java imports. Configured via yaml `spring.flyway.*` (e.g., `xlyEntry/.../application-local.yml:316-327`) with `enabled: false`. Migration scripts at `xlyEntry/src/main/resources/flyway/V*__*.sql`. |
55 59 | PageHelper | 4.1.1 | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 19 files import `com.github.pagehelper.*`. yaml `pagehelper.helperDialect: mysql` at `xlyEntry/.../application-local.yml:427`. |
... ... @@ -68,7 +72,7 @@ page records facts only.
68 72  
69 73 | Library | Version | Where | In-scope source references |
70 74 |---|---|---|---|
71   -| Activiti Engine | 5.17.0 | `xlyPersist/build.gradle`, `xlyApi/build.gradle`; consumed by `xlyFlow` | 35 files import `org.activiti.*` (xlyFlow=32, plus 1 each in xlyPersist, xlyApi, xlyEntry — the xlyEntry hit is the `SecurityAutoConfiguration` exclusion in `EntryApplicationBoot.java`; the xlyApi hit is `IdGen.java`; the xlyPersist hit is `BaseDao.java`). The version skew with the 6.0 modeler libs is documented in [Activiti integration](activiti.md). |
  75 +| Activiti Engine | 5.17.0 | `xlyPersist/build.gradle`, `xlyApi/build.gradle`; consumed by `xlyFlow` | 35 files import `org.activiti.*` (xlyFlow=32, plus 1 each in xlyPersist, xlyApi, xlyEntry — the xlyEntry hit is the `SecurityAutoConfiguration` exclusion in `EntryApplicationBoot.java`; the xlyApi and xlyPersist hits are both `IdGen.java`, near-identical crypto-utility copies that import `org.activiti.engine.identity.User`). The version skew with the 6.0 modeler libs is documented in [Activiti integration](activiti.md). |
72 76 | Activiti Spring Boot REST API | 6.0.0 | `xlyFlow/build.gradle` | Consumed via Spring Boot autoconfig + REST endpoints under `xlyFlow`. |
73 77 | Activiti JSON Converter | 6.0.0 | `xlyFlow/build.gradle` | (Used by xlyFlow's modeler save path.) |
74 78 | Quartz | 2.3.0 | `xlyFlow/build.gradle` | 16 files import `org.quartz.*` (xlyEntry=8, xlyFlow=8). yaml: `xlyEntry/.../application-local.yml:329-365` configures `spring.quartz.*` with JDBC JobStore (`qrtz_*` tables), `instanceName: xlyflowScheduler`. |
... ... @@ -139,10 +143,10 @@ third-party code.
139 143  
140 144 | Library | Version | Where | In-scope source references |
141 145 |---|---|---|---|
142   -| FastJson | 1.2.15 (`xlyPersist`, `xlyApi`) / 1.2.60 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 84 files import `com.alibaba.fastjson.*` across all in-scope modules (xlyBusinessService=39, xlyEntry=11, xlyInterface=10, xlyPersist=9, xlyFlow=6, xlyMsg=5, xlyApi=4). |
  146 +| FastJson | 1.2.15 (`xlyPersist`, `xlyApi`) / 1.2.60 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 83 files import `com.alibaba.fastjson.*` across in-scope modules (xlyBusinessService=39, xlyEntry=11, xlyInterface=9, xlyPersist=9, xlyFlow=6, xlyMsg=5, xlyApi=4). |
143 147 | Jackson | `jackson-databind` 2.9.7 (`xlyFlow` explicit) + transitive via Spring | `xlyFlow/build.gradle` | 22 files import `com.fasterxml.jackson.*` (xlyFlow=8, xlyInterface=9, xlyApi=2, xlyPersist=2, xlyEntry=1). |
144 148 | Hutool | `hutool-all` 5.6.5 (`xlyPersist`) / 5.8.5 (`xlyApi`, `xlyFlow`) | `xlyPersist/build.gradle`, `xlyApi/build.gradle`, `xlyFlow/build.gradle` | 271 files import `cn.hutool.*` across every in-scope module (xlyBusinessService=93, xlyFlow=47, xlyApi=37, xlyPersist=33, xlyEntry=25, xlyInterface=23, xlyMsg=10, xlyManage=2, xlyPlc=1). |
145   -| commons-lang3 | 3.6 (`xlyPersist`) / 3.8.1 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyFlow/build.gradle` | 41 files import `org.apache.commons.lang3.*` (xlyFlow=24, xlyPersist=8, xlyEntry=3, xlyApi=2, xlyBusinessService=3, xlyMsg=1). |
  149 +| commons-lang3 | 3.6 (`xlyPersist`) / 3.8.1 (`xlyFlow`) | `xlyPersist/build.gradle`, `xlyFlow/build.gradle` | 39 files import `org.apache.commons.lang3.*` (xlyFlow=23, xlyPersist=7, xlyEntry=3, xlyApi=2, xlyBusinessService=3, xlyMsg=1). |
146 150 | commons-collections4 | 4.1 | `xlyPersist/build.gradle` | 1 file in `xlyBusinessService/src/`. |
147 151 | Groovy | `groovy-all` 3.0.2 | `xlyPersist/build.gradle`, `xlyApi/build.gradle` | 5 Java files import `groovy.util.logging.Slf4j` (xlyPersist=3, xlyApi=2). The annotation is from Groovy's runtime; the Java files using it appear to be vestigial — the import is present but the annotation does not affect Java compilation. |
148 152 | Struts2 JSON plugin | 2.5.30 | `xlyPersist/build.gradle` | 1 file: `xlyPersist/src/main/java/com/xly/utils/FeedPage.java`. The framework otherwise runs on Spring MVC. |
... ... @@ -205,6 +209,7 @@ since been removed, or may be vestigial.
205 209 | commons-pool2 2.8.0 | `xlyPersist/build.gradle` | No direct imports. Likely transitive support for Jedis or similar. |
206 210 | Baidu SDK (`baidu-sdk-1.4.5.jar`, local) | `xlyInterface/build.gradle` | No `com.baidu` imports found. |
207 211 | `mchange-commons-java` 0.2.11 | `xlyFlow/build.gradle` | No direct imports. |
  212 +| Springfox (`springfox-swagger-ui` 2.9.2 + `springfox-swagger2` 2.9.2) | `xlyInterface/build.gradle` | No direct Java imports. Consumed via Spring Boot auto-config to serve `/swagger-ui.html` for the xlyInterface tier. Cited from [API Reference / webhooks](../../api-reference/webhooks.md). |
208 213  
209 214 ## Notable version skew & local jars
210 215  
... ... @@ -228,5 +233,5 @@ Pulled directly from the `build.gradle` files. Each is a fact, not a recommendat
228 233  
229 234 - The plat tier (`xlyPlat*` modules) and dependencies declared only there — out of scope per [index](../../index.md#whats-out-of-scope).
230 235 - AI / LLM libraries (`com.theokanning.openai-gpt3-java:service` 0.11.1 and `com.unfbx:chatgpt-java` 1.0.8 in `xlyApi/build.gradle`) — out of scope.
231   -- The MongoDB starter declared in `xlyEntity/build.gradle` (`spring-boot-starter-data-mongodb` 2.2.5). The `xlyEntity` module contains 22 `@Document`-annotated classes under `xlyentity/mongo/`, all named `PLAT_*`. A grep for `MongoTemplate` and `MongoRepository` in in-scope modules returned only `xlyPersist/.../dao/platmongo/BaseMongoDao.java` (which serves the plat tier); no in-scope module invokes Mongo APIs. See the [out-of-scope note in index](../../index.md#whats-out-of-scope).
  236 +- The MongoDB starter declared in `xlyEntity/build.gradle` (`spring-boot-starter-data-mongodb` 2.2.5). The `xlyEntity` module contains 22 `@Document`-annotated classes under `xlyentity/mongo/` — 20 named `PLAT_*` plus 2 `DIKE_TEST*` test fixtures. A grep for `MongoTemplate` and `MongoRepository` in in-scope modules returned only `xlyPersist/.../dao/platmongo/BaseMongoDao.java` (which serves the plat tier); no in-scope module invokes Mongo APIs. See the [out-of-scope note in index](../../index.md#whats-out-of-scope).
232 237 - `xlyFace` — out of scope.
... ...
en/docs/slices/01-hello-world.md
... ... @@ -105,16 +105,21 @@ returns a single composite map with five keys:
105 105  
106 106 | Key | Source | What it contains |
107 107 |---|---|---|
108   -| `formData` | `gdsmodule` ⋈ `gdsconfigformmaster` ⋈ `gdsconfigformslave` (+ personalize) | The form layout — the spine. |
109   -| `gdsformconst` | `gdsformconst` rows scoped by `sBrandsId`/`sSubsidiaryId`/language | Form-level constants — labels, defaults, dropdown text. |
110   -| `gdsjurisdiction` | `gdsjurisdiction` rows for the user's role | Per-button and per-data permissions. **Skipped for `ADMIN` users** (line 196-198) — admins see everything. |
111   -| `billnosetting` | `sysbillnosettings` row for this module | Document-numbering rule (irrelevant for `gdsformconst` but always loaded). |
112   -| `report` | print templates linked to this form | Printable-report definitions, if any. |
113   -
114   -**Multi-tenancy** is enforced inside this read: every sub-query is scoped by
115   -`sBrandsId` (manufacturer) and `sSubsidiaryId` (subsidiary), pulled from the
116   -authenticated user's session via `RequestAddParamUtil.me().addParams()`.
117   -Tenants do not see each other's metadata.
  108 +| `formData` | `gdsconfigformmaster` (filtered by `sParentId = sModelsId`) ⋈ `gdsconfigformpersonalize` (per-tenant overlay); per master row, `gdsconfigformslave` + `gdsconfigformcustomslave` overlays. `gdsmodule` is referenced only by id (sub-selected from the slave queries to resolve `sActiveName`), not joined into the master read. | The form layout — the spine. |
  109 +| `gdsformconst` | `gdsformconst` rows filtered by `sParentId` only. **Not tenant-scoped** — the row identifies the form, and `sLanguage` selects which label column to return. | Form-level constants — labels, defaults, dropdown text. |
  110 +| `gdsjurisdiction` | `sysjurisdiction` rows for the user's role (joined to `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify`). **Skipped for `ADMIN` users** (line 196-198) — admins see everything. **Note:** the map-key name `gdsjurisdiction` is misleading — `gdsjurisdiction` is the builder-side action *catalogue* table; the per-user grant read here actually queries `sysjurisdiction`. | Per-button and per-data permissions. |
  111 +| `billnosetting` | `sysbillnosettings` row for this module (per-tenant) | Document-numbering rule (irrelevant for `gdsformconst` but always loaded). |
  112 +| `report` | `sysreport` rows linked to this form (per-tenant) | Printable-report definitions, if any. |
  113 +
  114 +**Multi-tenancy** is enforced where it matters: tenant-scoped reads
  115 +(`gdsconfigformpersonalize`, `gdsconfigformcustomslave`,
  116 +`sysbillnosettings`, `sysreport`) all filter by `sBrandsId` (manufacturer)
  117 +and `sSubsidiaryId` (subsidiary), pulled from the authenticated user's
  118 +session via `RequestAddParamUtil.me().addParams()`. The framework-metadata
  119 +tables themselves (`gdsconfigformmaster`, `gdsconfigformslave`,
  120 +`gdsformconst`) are global — they are filtered by form-id only. So a
  121 +tenant cannot see another tenant's *personalized overlays* or
  122 +*business data*, but the underlying form definition is shared.
118 123  
119 124 ### 3. SPA → server (initial data load)
120 125  
... ... @@ -166,17 +171,23 @@ POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
166 171 per-module write-API; the metadata-driven UI generates the payload from
167 172 `gdsconfigformmaster.sTbName` and the `gdsconfigformslave` field list.
168 173  
169   -- **What the framework does with empty `sSaveProName`:** the runtime takes
170   - the default Add/Update path implemented in
171   - `xlyBusinessService/.../AddDelUpdCommonServiceImpl.java`. That path
172   - generates parameterised `INSERT`/`UPDATE`/`DELETE` against the table
173   - named in the payload — no stored procedure invoked.
174   -
175   -- **What happens when `sSaveProName` is non-empty:** the runtime invokes
176   - the named stored procedure (the SQL templates under
177   - `xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql` are
178   - the *scaffold* engineers fill in when authoring such procs). That path
179   - is exercised by Slice 2 (workflow), not this one.
  174 +- **What the framework does with `sSaveProName`:** the base add/update
  175 + path always runs through
  176 + `BusinessBaseServiceImpl.addBusinessData` / `updateBusinessData`
  177 + (`xlyBusinessService/.../BusinessBaseServiceImpl.java:1014` and
  178 + `:1250`), which delegate to `businessBaseDao.add(map)` /
  179 + `businessBaseDao.update(map)` against the table named by `sTable`.
  180 + `sSaveProName` (and its sibling `sSaveProNameBefore`) is **not** an
  181 + either/or branch that swaps the path — it names an extra stored proc
  182 + that the framework runs as a post-save (or pre-save) hook on top of
  183 + the base path, dispatched by `checkUpdate(...,"sSaveProName")` at
  184 + `BusinessBaseServiceImpl.java:1824`. For Slice 1's `gdsformconst`,
  185 + `sSaveProName` is empty, so only the base path runs. (The SQL
  186 + templates under
  187 + `xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql`
  188 + are the *scaffold* engineers fill in when authoring such hooks.) The
  189 + workflow-gated path with a non-empty `sSaveProName` is exercised by
  190 + Slice 2.
180 191  
181 192 > **Open verification (would require an actual save):** capturing the live
182 193 > request body, the response body, and the resulting SQL in `syslog4j`
... ... @@ -193,8 +204,11 @@ POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
193 204 > `sTableNameList` (`BusinessBaseServiceImpl.java:162-169` — only
194 205 > `gdsformconst`, `gdsmodule`, `gdsconfigformmaster`, `gdsconfigformslave`)
195 206 > is consulted by some branches (e.g., line 1078, 1338, 1423, 1464) but
196   -> only as a *cache-invalidation* gate, not as a "is this table authorised
197   -> for this form?" gate. The hardcoded special case at line 1768
  207 +> only as a *multi-tenant scope-bypass* gate (those four tables are global
  208 +> framework metadata, so `sBrandsId`/`sSubsidiaryId` are stripped from
  209 +> writes against them — see the `//不需要公司子公司的表` comment at
  210 +> `BusinessBaseServiceImpl.java:165`). It is **not** a "is this table
  211 +> authorised for this form?" gate. The hardcoded special case at line 1768
198 212 > (`mftproductionplanslave`) is the only module/table cross-check in the
199 213 > whole flow. Mitigations that exist:
200 214 >
... ... @@ -228,7 +242,7 @@ End of trace.
228 242  
229 243 - [The data-driven thesis](../concepts/thesis.md) — why xly stores layouts as data.
230 244 - [Modules, forms, virtual tables](../concepts/modules-forms-vtables.md) — the three core nouns.
231   -- [The metadata-driven request lifecycle](../concepts/request-lifecycle.md) — the four-table read + the three-key result map.
  245 +- [The metadata-driven request lifecycle](../concepts/request-lifecycle.md) — the metadata read + the five-key result map.
232 246 - [Master / slave document pattern](../concepts/master-slave.md) — `gdsconfigformmaster`/`slave` is itself an instance of the pattern.
233 247 - [No-FK, semantic-FK reality](../concepts/semantic-fk.md) — `gdsconfigformmaster.sParentId = gdsmodule.sId` is a semantic FK, not enforced.
234 248  
... ...
en/docs/slices/03-report.md
... ... @@ -131,10 +131,13 @@ PDF-via-iText. The mechanism is separate from the grid:
131 131  
132 132 - `getModelBysId` returns the `report` array, populated from `sysreport`
133 133 rows linked to the form via `sFormId`.
134   -- The frontend's "打印" / "导出" buttons hit `xlyEntry/com/xly/report/`
135   - controllers (`PrintReportController`, `PrintReportControllerOld`),
136   - which load a jxls / iText template, run the same view-backed query
137   - with a "fetch all rows" wrapper, and stream a binary file back.
  134 +- The frontend's "打印" / "导出" buttons hit
  135 + `xlyEntry/src/main/java/com/xly/web/report/` — `PrintReportController`
  136 + is the live class. (`PrintReportControllerOld.java` exists in the
  137 + same directory but its class body is fully commented out, dead source.)
  138 + The controller loads a jxls / iText template, runs the same
  139 + view-backed query with a "fetch all rows" wrapper, and streams a
  140 + binary file back.
138 141 - This module (`工单工序明细`) has no template attached, so we don't
139 142 exercise the print path here. **A future revision of this slice should
140 143 pick a module that *does* — `print template` is a chapter of its own.**
... ...