#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Generate MTMS ↔ M18 mapping snippets from Kotlin source of truth. Usage (from repo root): python scripts/generate_m18_mapping_docs.py Outputs: docs/generated/m18-item-type-mapping.md docs/generated/m18-stsearch-types.md Re-run after changing: - modules/master/web/models/NewItemRequest.kt (ItemType / M18ItemType) - m18/model/M18MasterDataRequest.kt (StSearchType) - m18/service/M18MasterDataService.kt (udfProducttype when-branches) """ from __future__ import annotations import re from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] OUT_DIR = ROOT / "docs" / "generated" NEW_ITEM_REQUEST = ( ROOT / "src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt" ) MASTER_DATA_REQUEST = ( ROOT / "src/main/java/com/ffii/fpsms/m18/model/M18MasterDataRequest.kt" ) MASTER_DATA_SERVICE = ( ROOT / "src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt" ) INVENTORY_I18N = ( ROOT.parent / "FPSMS-frontend" / "src" / "i18n" / "zh" / "inventory.json" ) # UI labels when inventory.json is unavailable (fallback) FALLBACK_UI = { "mat": "原料", "consumables": "消耗品", "non-consumables": "非消耗品", "fg": "成品", "sfg": "半成品", "item": "貨品", "cmb": "消耗品", "wip": "半成品", "nm": "雜項及非消耗品", } # Known M18 udfProducttype values seen in the wild that are NOT in M18ItemType # (documented as gaps so ops/dev notice). KNOWN_UNMAPPED_M18_VALUES = [ ("CMB", "Seen on M18 pro.udfProducttype (e.g. MG1852). Falls through to mat."), ] def parse_kotlin_string_enum(text: str, enum_name: str) -> list[tuple[str, str]]: """Parse active (non-commented) `enum class Foo(...) { NAME("x"), ... }`.""" # Only match enum declarations that start a line (optional indent), not //enum m = re.search( rf"(?m)^[ \t]*enum class {re.escape(enum_name)}\([^)]*\)\s*\{{(.*?)^[ \t]*\}}", text, re.DOTALL, ) if not m: raise SystemExit(f"Could not find enum class {enum_name}") body = m.group(1) return re.findall(r"(\w+)\s*\(\s*\"([^\"]+)\"\s*\)", body) def parse_producttype_when_branches(service_text: str) -> list[tuple[str, str]]: """ Extract first `when (pro.udfProducttype) { M18ItemType.X.type -> ItemType.Y.type ... }` Returns list of (M18ItemTypeConst, ItemTypeConst). """ m = re.search( r"when\s*\(\s*pro\.udfProducttype\s*\)\s*\{(.*?)else\s*->\s*ItemType\.(\w+)\.type", service_text, re.DOTALL, ) if not m: raise SystemExit("Could not find udfProducttype when-branch in M18MasterDataService") body, else_item = m.group(1), m.group(2) pairs = re.findall( r"M18ItemType\.(\w+)\.type\s*->\s*ItemType\.(\w+)\.type", body, ) return pairs + [("__else__", else_item)] def load_ui_labels() -> dict[str, str]: labels = dict(FALLBACK_UI) if not INVENTORY_I18N.is_file(): return labels # Minimal JSON-ish extract of "key": "value" string pairs text = INVENTORY_I18N.read_text(encoding="utf-8") for k, v in re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', text): labels[k] = v return labels def write_item_type_doc( item_types: list[tuple[str, str]], m18_types: list[tuple[str, str]], when_pairs: list[tuple[str, str]], ui: dict[str, str], ) -> None: item_by_const = {c: v for c, v in item_types} m18_by_const = {c: v for c, v in m18_types} mapped_m18_consts = {a for a, b in when_pairs if a != "__else__"} lines: list[str] = [] lines.append("") lines.append("") lines.append("# M18 `udfProducttype` → MTMS `items.type`") lines.append("") lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_") lines.append("") lines.append("**Source of truth**") lines.append("") lines.append("- Enums: `NewItemRequest.kt` → `ItemType`, `M18ItemType`") lines.append("- Sync: `M18MasterDataService.saveProduct` / `saveProducts` (`when (pro.udfProducttype)`)") lines.append("- UI labels (inventory): `FPSMS-frontend/src/i18n/zh/inventory.json`") lines.append("") lines.append("## Sync mapping") lines.append("") lines.append("| M18 `udfProducttype` (exact string) | `M18ItemType` | MTMS `items.type` | `ItemType` | Inventory UI (zh) |") lines.append("|---|---|---|---|---|") for m18_const, item_const in when_pairs: if m18_const == "__else__": mtms_val = item_by_const.get(item_const, "?") lines.append( f"| *(any other value / empty)* | — | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |" ) continue m18_val = m18_by_const.get(m18_const, "?") mtms_val = item_by_const.get(item_const, "?") lines.append( f"| `{m18_val}` | `{m18_const}` | `{mtms_val}` | `{item_const}` | {ui.get(mtms_val, '—')} |" ) lines.append("") lines.append("## Enum inventories") lines.append("") lines.append("### `M18ItemType`") lines.append("") lines.append("| Constant | String value | Used in sync `when`? |") lines.append("|---|---|---|") for c, v in m18_types: used = "yes" if c in mapped_m18_consts else "**no**" lines.append(f"| `{c}` | `{v}` | {used} |") lines.append("") lines.append("### `ItemType` (MTMS stored values)") lines.append("") lines.append("| Constant | `items.type` | Inventory UI (zh) |") lines.append("|---|---|---|") for c, v in item_types: lines.append(f"| `{c}` | `{v}` | {ui.get(v, '—')} |") lines.append("") lines.append("## Known gaps (not auto-mapped)") lines.append("") lines.append("| M18 value seen | Effect | Notes |") lines.append("|---|---|---|") for val, note in KNOWN_UNMAPPED_M18_VALUES: lines.append(f"| `{val}` | → `mat` (else) | {note} |") lines.append("") lines.append("Frontend Settings → Items edit also offers `cmb` / `wip` / `nm` as local types;") lines.append("those are **not** written by the current M18 `udfProducttype` mapper.") lines.append("") lines.append("## Regenerate") lines.append("") lines.append("```bash") lines.append("python scripts/generate_m18_mapping_docs.py") lines.append("```") lines.append("") OUT_DIR.mkdir(parents=True, exist_ok=True) path = OUT_DIR / "m18-item-type-mapping.md" path.write_text("\n".join(lines), encoding="utf-8") print(f"Wrote {path.relative_to(ROOT)}") def write_stsearch_doc(st_types: list[tuple[str, str]]) -> None: lines: list[str] = [] lines.append("") lines.append("") lines.append("# M18 `StSearchType` (master list APIs)") lines.append("") lines.append(f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_") lines.append("") lines.append("**Source:** `m18/model/M18MasterDataRequest.kt`") lines.append("") lines.append("| Constant | `stSearch` value | Typical MTMS sync target |") lines.append("|---|---|---|") hints = { "PRODUCT": "items (+ item_uom via prices)", "VENDOR": "shop (`type=supplier`)", "CUSTOMER": "(enum present; sync usage varies)", "UNIT": "uom_conversion (+ m18 cunit)", "CURRENCY": "currency", "BOM": "bom / bom_material (udfbomforshop)", "BUSINESS_UNIT": "shop (`type=shop`)", } for c, v in st_types: lines.append(f"| `{c}` | `{v}` | {hints.get(c, '—')} |") lines.append("") path = OUT_DIR / "m18-stsearch-types.md" path.write_text("\n".join(lines), encoding="utf-8") print(f"Wrote {path.relative_to(ROOT)}") def main() -> None: new_item = NEW_ITEM_REQUEST.read_text(encoding="utf-8") master_req = MASTER_DATA_REQUEST.read_text(encoding="utf-8") service = MASTER_DATA_SERVICE.read_text(encoding="utf-8") item_types = parse_kotlin_string_enum(new_item, "ItemType") m18_types = parse_kotlin_string_enum(new_item, "M18ItemType") st_types = parse_kotlin_string_enum(master_req, "StSearchType") # StSearchType uses `value` not always matching parse — enum uses (val value: String) # Our regex still works for NAME("x") when_pairs = parse_producttype_when_branches(service) ui = load_ui_labels() write_item_type_doc(item_types, m18_types, when_pairs, ui) write_stsearch_doc(st_types) if __name__ == "__main__": main()