Blitz Report Development / Migration
Migrate WebADI Integrators
migrate-webadi
Convert a customer's Oracle WebADI integrators, gathered by the Blitz Report Custom Reports Converter (Include WebADI Integrators), into Blitz Uploads - inventory the gathered zip, generate a form analysis document per integrator from its definition, decide between a template of a shipped upload and a new upload, build it with the upload-builder skill keeping the integrator's commit behaviour, and prove it against the customer's own spreadsheet on an instance of the customer's release.
Use
/plugin marketplace add enginatics/ai-skills
/plugin install blitz-report-ai-skills@enginaticsThen ask Claude for the task, or run:
/blitz-report-ai-skills:migrate-webadiBlitz Reports used
Skill contents
SKILL.md
## When to use
A customer ran the **Blitz Report Custom Reports Converter** with Include WebADI Integrators and selected integrators of program type WebADI Integrator. The gathered zip (`CONV_<ABBR>_<request id>_<timestamp>/`) holds one folder per integrator. This skill turns each one into a Blitz Upload, a template of a shipped upload, a report, or a documented "nothing to convert".
Read `webadi-migration` skill first: it holds the decisions (category, commit behaviour), the gathered file layout and the WebADI metadata facts this skill relies on. The build itself follows `upload-development` skill and the `upload-builder` skill; this skill supplies their input and the WebADI-specific steps.
## Arguments
- The customer abbreviation (category `Custom Uploads <ABBR>`).
- The gathered zip or its unzipped folder.
- Optionally one integrator code to process only that one.
## Instance
Build and test on an instance of the customer's EBS release where you can compile packages and import content. The integrator's API must exist there: several Oracle integrators exist only on 12.2 (Quoting - Create Quote, Field Service Debrief), and a customer integrator needs its gathered objects compiled first. Check before building: `select ao.object_name, ao.status from all_objects ao where ao.object_name='<API PACKAGE>' and ao.object_type='PACKAGE BODY'`.
## Workflow
### 1. Inventory
1. Unzip into a working folder; read `manifest.txt` (abbreviation, custom object prefix) and `gather_summary.txt`.
2. Run `python ${CLAUDE_PLUGIN_ROOT}/skills/migrate-webadi/webadi_fad.py <gathered folder> [--content <folder of shipped upload XMLs>]`. It writes `webadi_inventory.md` into the folder, `FAD_<INTEGRATOR>.md` into each integrator folder that uploads data, and `<INTEGRATOR>_download_sql.md` for each download-only integrator. `--content` (or the environment variable `BLITZ_CONTENT_DIR`) points at exported shipped uploads (`content/r12/uploads` in the Blitz Report repository), so that the document names shipped uploads that already cover the integrator.
3. Route each integrator by its kind in the inventory:
| Kind | Meaning | Route |
|---|---|---|
| Upload | an interface of type Table, API or Java upload class | steps 2 to 6 |
| Download only | every interface is download only: WebADI fills a sheet from SQL and uploads nothing | a report: convert `<INTEGRATOR>_download_sql.md` with the `migrate-custom-reports` skill |
| No interface | no interface and no SQL download: a shell, or an integrator driven entirely by Java | nothing to convert; ask the customer what they upload with it |
4. Ask the customer, in one message, for what the definition cannot give:
- per integrator, the layout they use and one spreadsheet they uploaded, with its row count: the acceptance reference of step 5, and the only evidence of which columns they really fill;
- for every importer program the document flags as customer-built: a converter run that selects that program as a concurrent program, so that its definition and executable are gathered;
- the source of every customer Java class the document names (validators, defaults, download classes).
### 2. Review the form analysis document
The document is generated: verify each section against the API source before building on it.
- Read the package the interface calls (section 6) and the importer (section 7): a customer package from the gathered `.sql` files, an Oracle one from the instance. Establish what the row procedure does (stages into a table or calls a public API), how it reports an error (raises, returns a status, writes an error table), what groups rows into one importer call (the GROUP columns, often a record id from a sequence), and what the cleanup procedure deletes.
- Section 2 is the sheet the user sees: the column order, read-only columns and defaults of the layout the customer uses.
- Every Java validator or default in section 12 is logic WebADI ran outside the database. Find what it checks in the API source first (the API often repeats the check); otherwise read the class, and reproduce it in the upload package as a row error.
- Section 5 drafts each list of values from the WebADI table validation. Replace a draft with a shared Blitz Report list wherever one covers the same table, and copy the shipped list's where clause for a security-scoped table (`upload-development` skill, Reuse Existing LOVs).
- Tokens without a known equivalent are listed under Open questions; resolve every one.
### 3. Classify
- A shipped upload that covers the columns of section 2: deliver a template of it, in its own category (decision in `webadi-migration` skill). A few missing columns are an enhancement of that upload: raise it as a product change, never as a customer-specific copy.
- Otherwise a new upload in category `Custom Uploads <ABBR>`.
### 4. Build
Build with the `upload-builder` skill from the reviewed document, and keep the processing WebADI used: call the integrator's own API or interface, never a reimplementation of it. The mapping:
| WebADI | Blitz Upload |
|---|---|
| Line block columns, in layout order | Upload columns, headed by the WebADI prompt |
| Context and header block columns | An upload parameter when the value scopes the whole file (ledger, budget, organization), otherwise a column repeated on every row; the Database context value is dropped |
| Required interface column outside the layout | A hidden column with its default, or set in the procedure |
| Alias column (interface column type 2) | A column the user sees that feeds a flexfield or list; not passed to the API |
| Document parameter (`$PARAM$`) | An upload parameter; defaults and lists read it as `:$flex$.<parameter name>` |
| Table validation | A list of values: the shared list, else the section 5 draft |
| Key flexfield (and its segments) | Concatenated segments with segment lists (`xxen_util.kff_segment_lov`); a combination id resolved in the procedure |
| Descriptive flexfield (and its segments) | Attribute columns, built with the `add-dff-to-upload` skill |
| Expanded SQL column set (period amounts) | One row per expanded value (a period and its amount), or fixed columns when the set is fixed |
| Java validator or default | A check or derivation in the procedure, from what the class does |
| SQL, constant, profile, parameter or environment default | `DEFAULT_VALUE` from section 4 |
| Sequence default per row | Assigned in the procedure |
| API interface (types 2 and 5) | The upload procedure calls the API once per row with the section 6 parameters, and turns its error into the row message |
| Table interface (type 1) | The procedure inserts the interface row, keeping its rowid in a hidden column (`upload-builder`, Error and Success SQL design), and the Error SQL reads the interface error status |
| Importer with GROUP columns | The post procedure, with the group columns as its IN parameters |
| PL/SQL importer step | Called by the post procedure, once per group |
| Concurrent request importer step | Submitted by the post procedure per group with the mapped arguments, waited for, its result read back by the Error and Success SQL |
| Cleanup PL/SQL | Called at the end of the post procedure |
| Commit All Rows | Single-call mode with one savepoint for the whole file: one rejected row errors every row and the importer does not run |
| Commit Each Row | Row by row |
| Download content SQL | The main SQL, for Create and Update |
| FORM functions of the responsibilities file | Function assignments; with none, the update function of the form that maintains the entity, or no assignment |
Put each upload procedure into the module package `XXEN_<MODULE>_UPLOAD` in `sql/extensions` (decision in `webadi-migration` skill): the module is the product code of the API or table the integrator writes (`ASO_WEBADI_PVT` gives `XXEN_ASO_UPLOAD`, `GL_BUDGET_INTERFACE` gives `XXEN_GL_UPLOAD`), section 11 of the document names the package and whether it exists. Add the procedure to the existing package, else create the package. The package ships to every customer, so:
- Call a customer's own package, and an API that not every release the package ships to has (the 12.2-only `ASO_WEBADI_PVT` in a package that also runs on 12.1.3), through `execute immediate` only: a static reference makes the whole module package invalid wherever the object is missing.
- Compile the package on every release it ships to before delivery, with `${CLAUDE_PLUGIN_ROOT}/scripts/lint_plsql.py` clean.
### 5. Test
Run the test matrix of `upload-development` skill (section J), and add:
- The customer's spreadsheet, row for row through the Blitz upload, and the same rows through WebADI on the build instance where the integrator runs there; compare the records each creates.
- For All Rows: a file with one bad row creates nothing and reports every row.
- For a group importer: two groups in one file, each processed once.
### 6. Deliver
- New uploads: one XML per category with every upload of it, plus the package files and their install order.
- A template of a shipped upload: as an Outcome 1 template of `custom-reports-migration` skill, the shipped upload's XML with the template and without an `ASSIGNMENTS` block.
- Per integrator, a line in the delivery mail with its route (upload, template, report, nothing to convert) and the input still open from step 1.webadi_fad.py
Show webadi_fad.py
"""Turn gathered WebADI integrator definitions into Form Analysis Documents for the upload-builder skill.
usage: python webadi_fad.py <gathered folder | integrator folder | <INTEGRATOR>_integrator.ldt> [--content <folder of shipped upload XMLs>] [--extensions <folder of upload packages>]
Writes FAD_<INTEGRATOR>.md next to each integrator definition that uploads data, and webadi_inventory.md
(one row per integrator: what it is, what it calls, what is missing) into the folder given.
"""
import argparse
import os
import pathlib
import re
import sys
DATA_TYPES = {'1': 'Number', '2': 'Char', '3': 'Date', '5': 'DateTime', '6': 'DateTime'}
UPLOAD_TYPES = {'0': 'Java upload class', '1': 'Table', '2': 'API', '4': 'Download only', '5': 'API'}
ENV_DEFAULTS = {'SYSDATE': 'sysdate', 'OAUSER.ID': 'fnd_global.user_id', 'DATABASE': None}
ENV_TOKENS = {'userid': 'fnd_global.user_id', 'respid': 'fnd_global.resp_id', 'appid': 'fnd_global.resp_appl_id', 'language': "userenv('lang')"}
TOKEN = re.compile(r'"((?:[^"\\]|\\[\s\S])*)"|(\n)|([^\s"=]+)|(=)')
SEED_OWNER = re.compile(r'^(ORACLE|SEED|ANONYMOUS)', re.I)
def unescape(s):
s = re.sub(r'\\n\\\r?\n', '\n', s)
s = re.sub(r'\\\r?\n', '', s)
return re.sub(r'\\(.)', lambda m: {'n': '\n', 't': '\t'}.get(m.group(1), m.group(1)), s)
def parse(path):
"""FNDLOAD ldt -> tree of {entity, keys, attrs: {name: [values]}, children}"""
text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace')
start = re.search(r'^\s*BEGIN ', text, re.M)
text = text[start.start():] if start else ''
toks = []
for m in TOKEN.finditer(text):
if m.group(1) is not None:
toks.append(('S', unescape(m.group(1))))
elif m.group(2):
toks.append(('NL', None))
elif m.group(3):
toks.append(('W', m.group(3)))
else:
toks.append(('EQ', None))
root = {'entity': 'ROOT', 'keys': [], 'attrs': {}, 'children': []}
stack = [root]
i, n = 0, len(toks)
while i < n:
t, v = toks[i]
if t == 'W' and v == 'BEGIN' and i + 1 < n:
ent, i = toks[i + 1][1], i + 2
keys = []
while i < n and toks[i][0] == 'S':
keys.append(toks[i][1])
i += 1
node = {'entity': ent, 'keys': keys, 'attrs': {}, 'children': []}
stack[-1]['children'].append(node)
stack.append(node)
elif t == 'W' and v == 'END':
if len(stack) > 1:
stack.pop()
i += 2
elif t == 'W' and i + 1 < n and toks[i + 1][0] == 'EQ':
i += 2
while i < n and toks[i][0] == 'NL':
i += 1
vals = []
while i < n and toks[i][0] == 'S':
vals.append(toks[i][1])
i += 1
stack[-1]['attrs'][v] = vals
else:
i += 1
return root
def walk(node, ent):
for c in node['children']:
if c['entity'] == ent:
yield c
yield from walk(c, ent)
def a(node, k):
return ' '.join(x for x in node['attrs'].get(k, []) if x).strip() if node else ''
def ref(node, k):
"""A reference attribute ("APP" "CODE") as its code, '' when empty."""
vals = [x for x in node['attrs'].get(k, []) if x] if node else []
return vals[-1] if vals else ''
def translate(text, issues, where):
"""WebADI tokens to Blitz Report binds; anything unknown is reported, never guessed."""
if not text:
return text
t = re.sub(r'\$PROFILES\$\.([A-Za-z0-9_]+)', lambda m: f"fnd_profile.value('{m.group(1).upper()}')", text, flags=re.I)
t = re.sub(r'\$PARAM\$\.([A-Za-z0-9_:]+)', lambda m: f':$flex$.{m.group(1)}', t, flags=re.I)
t = re.sub(r'\$(TABLE|INTERFACE)\$\.(?:[A-Za-z0-9_]+\.)?([A-Za-z0-9_]+)', lambda m: f':$column$.{m.group(2).lower()}', t, flags=re.I)
def env(m):
rep = ENV_TOKENS.get(m.group(1).lower())
if not rep:
issues.append(f'{where}: no equivalent known for `{m.group(0)}`')
return rep or m.group(0)
t = re.sub(r'\$ENV\$\.([A-Za-z_]+)', env, t, flags=re.I)
for left in sorted(set(re.findall(r'(?<!:)\$[A-Za-z_]+\$(?:\.[A-Za-z0-9_.:]+)?', t))):
issues.append(f'{where}: no equivalent known for `{left}`')
return t
def split_columns(text):
"""Split a select list on the commas outside brackets and quotes."""
parts, depth, quote, cur = [], 0, False, ''
for ch in text:
if ch == "'":
quote = not quote
elif not quote and ch == '(':
depth += 1
elif not quote and ch == ')':
depth -= 1
if ch == ',' and not quote and depth == 0:
parts.append(cur.strip())
cur = ''
else:
cur += ch
return [p for p in parts + [cur.strip()] if p]
def strip_alias(expr):
m = re.match(r'^(.*[\w)\'"])\s+"?[A-Za-z_][A-Za-z0-9_$#]*"?$', expr, re.S)
return m.group(1).strip() if m and not re.fullmatch(r'distinct', m.group(1).strip(), re.I) else expr.strip()
def md(s, width=None):
s = re.sub(r'\s+', ' ', s or '').strip().replace('|', '\\|')
return s if not width or len(s) <= width else s[:width - 3] + '...'
class Integrator:
def __init__(self, ldt, prefixes):
self.ldt = pathlib.Path(ldt)
self.folder = self.ldt.parent
self.prefixes = prefixes
self.issues = []
self.java = []
root = parse(ldt)
self.root = root
integ = next(walk(root, 'BNE_INTEGRATORS'), None)
self.node = integ
self.asn, self.code = (integ['keys'] + ['', ''])[:2] if integ else ('', self.ldt.stem.replace('_integrator', ''))
self.name = a(integ, 'USER_NAME') or self.code
self.owner = a(integ, 'OWNER')
self.attributes = {n['keys'][-1]: n for n in walk(root, 'BNE_ATTRIBUTES') if n['keys']}
self.lists = {n['keys'][-1]: n for n in walk(root, 'BNE_PARAM_LISTS') if n['keys']}
self.defns = {n['keys'][-1]: n for n in walk(root, 'BNE_PARAM_DEFNS') if n['keys']}
self.components = {n['keys'][-1]: n for n in walk(root, 'BNE_COMPONENTS') if n['keys']}
self.interfaces = list(walk(root, 'BNE_INTERFACES'))
self.layouts = list(walk(root, 'BNE_LAYOUTS'))
self.contents = list(walk(root, 'BNE_CONTENTS'))
self.mappings = list(walk(root, 'BNE_MAPPINGS'))
self.rules = [a(r, 'SECURITY_VALUE') for r in walk(root, 'BNE_SECURITY_RULES')]
upload = [i for i in self.interfaces if a(i, 'UPLOAD_TYPE') not in ('4', '')]
if not self.interfaces and not self.contents:
self.kind = 'No interface'
elif upload:
self.kind = 'Upload'
elif self.interfaces:
self.kind = 'Download only'
else:
self.kind = 'No interface'
# parameter lists
def items(self, code):
lst = self.lists.get(code)
if not lst:
return []
return sorted((c for c in lst['children'] if c['entity'] == 'BNE_PARAM_LIST_ITEMS'), key=lambda c: int(c['keys'][-1]) if c['keys'] and c['keys'][-1].isdigit() else 0)
def item_attr(self, item):
return self.attributes.get(ref(item, 'LIST_ITEM'))
def item_value(self, item):
v = a(item, 'STRING_VALUE') or a(item, 'NUMBER_VALUE') or a(item, 'DATE_VALUE')
if not v:
d = self.defns.get(ref(item, 'DEFN'))
v = a(d, 'DEFAULT_STRING') or a(d, 'DEFAULT_STRING_TRANS') or a(d, 'DEFAULT_NUMBER') or a(d, 'DEFAULT_DATE')
return v
def list_target(self, code):
"""The procedure or function a parameter list resolves to, from the attribute row its LIST names."""
att = self.attributes.get(ref(self.lists.get(code), 'LIST'))
return (a(att, 'ATTRIBUTE1'), a(att, 'ATTRIBUTE2')) if att else ('', '')
def is_custom(self, name):
return bool(name) and any(re.match(rf'^{re.escape(p)}', name, re.I) for p in self.prefixes)
# columns
def columns(self, intf):
cols = []
for c in intf['children']:
if c['entity'] != 'BNE_INTERFACE_COLS':
continue
g = lambda k: a(c, k)
cols.append({
'seq': int(c['keys'][-1]) if c['keys'] and c['keys'][-1].isdigit() else 0,
'name': g('INTERFACE_COL_NAME'), 'alias': g('INTERFACE_COL_TYPE') == '2',
'prompt': g('PROMPT_LEFT') or g('PROMPT_ABOVE') or g('INTERFACE_COL_NAME'),
'type': DATA_TYPES.get(g('DATA_TYPE'), g('DATA_TYPE') or '-'), 'size': g('FIELD_SIZE'),
'enabled': g('ENABLED_FLAG') == 'Y', 'required': g('REQUIRED_FLAG') == 'Y', 'display': g('DISPLAY_FLAG') == 'Y',
'read_only': g('READ_ONLY_FLAG') == 'Y', 'dtype': g('DEFAULT_TYPE'), 'dvalue': g('DEFAULT_VALUE'),
'vtype': g('VAL_TYPE'), 'vobj': g('VAL_OBJ_NAME'), 'vid': g('VAL_ID_COL'), 'vmean': g('VAL_MEAN_COL'),
'vdesc': g('VAL_DESC_COL'), 'vwhere': g('VAL_ADDL_W_C'), 'vcomp': ref(c, 'VAL_COMPONENT'), 'lov': g('LOV_TYPE'),
'group': g('GROUP_NAME'), 'segment': g('SEGMENT_NUMBER'), 'flex': g('OA_FLEX_CODE'), 'flex_app': g('OA_FLEX_APPLICATION_ID'),
'flex_num': g('OA_FLEX_NUM'), 'param_item': g('UPLOAD_PARAM_LIST_ITEM_NUM'), 'expanded': g('EXPANDED_SQL_QUERY'), 'hint': g('USER_HINT'),
})
return sorted(cols, key=lambda x: x['seq'])
def layout_rows(self, layout, intf_code):
"""(block name, block element, layout column, interface column seq) in sheet order for one interface."""
rows = []
blocks = [b for b in layout['children'] if b['entity'] == 'BNE_LAYOUT_BLOCKS']
for b in sorted(blocks, key=lambda b: int(a(b, 'SEQUENCE_NUM') or 0)):
lcols = [c for c in b['children'] if c['entity'] == 'BNE_LAYOUT_COLS' and a(c, 'INTERFACE_CODE') == intf_code]
for lc in sorted(lcols, key=lambda c: int(c['keys'][-1]) if c['keys'][-1].isdigit() else 0):
rows.append((a(b, 'USER_NAME') or a(b, 'LAYOUT_ELEMENT'), a(b, 'LAYOUT_ELEMENT'), lc, int(a(lc, 'INTERFACE_SEQ_NUM') or 0)))
return rows
def default_text(self, dtype, dvalue, where):
if not dtype:
return ''
t = dtype.upper()
if t == 'CONSTANT':
return f"'{dvalue}'"
if t == 'SQL':
return translate(dvalue, self.issues, where)
if t == 'ENVIRONMENT':
if dvalue.upper() in ENV_DEFAULTS:
return ENV_DEFAULTS[dvalue.upper()] or '(document context only, no column)'
self.issues.append(f'{where}: environment default `{dvalue}` has no known equivalent')
return f'ENVIRONMENT {dvalue}'
if t == 'PARAMETER':
return f':$flex$.{dvalue}'
if t == 'PROFILE':
return f"fnd_profile.value('{dvalue.upper()}')"
if t == 'TABLELOOKUP':
return translate(dvalue, self.issues, where)
if t == 'SEQUENCE_EACH_ROW':
return f'{dvalue.lower()}.nextval in the procedure, per row'
if t.startswith('JAVA'):
self.java.append((where.split('`')[1] if '`' in where else where, dvalue, f'default ({dtype})'))
return f'{dtype} {dvalue}'
if t == 'NULL':
return 'null'
self.issues.append(f'{where}: default type {dtype} `{dvalue}` not mapped')
return f'{dtype} {dvalue}'
def validation(self, c, where):
vt = c['vtype'].upper()
if vt == 'TABLE':
distinct = any(re.match(r'\s*distinct\s', x, re.I) for x in (c['vid'], c['vmean']))
col = lambda x: re.sub(r'^\s*distinct\s+', '', x, flags=re.I).strip()
vid, vmean = col(c['vid']), col(c['vmean']) or col(c['vid'])
sel = [f'{vid} id'] if vid and vid != vmean else []
sel.append(f'{vmean} value')
descs = [strip_alias(x) for x in split_columns(c['vdesc'])]
sel.append(("||', '||".join(descs) if descs else 'null') + ' description')
sql = f"select {'distinct ' if distinct else ''}{', '.join(sel)} from {c['vobj']}"
if c['vwhere']:
where_clause = translate(c['vwhere'], self.issues, where).strip()
sql += ' ' + (where_clause if re.match(r'(where|order\s+by)\b', where_clause, re.I) else 'where ' + where_clause)
return 'Table', sql
if vt in ('KEYFLEX', 'KEYFLEXID', 'ACCTFLEX'):
return 'Key flexfield', f"{c['flex']} (application {c['flex_app']}, structure {translate(c['flex_num'], self.issues, where) or '-'})" + (' returns the combination id' if vt == 'KEYFLEXID' else '')
if vt in ('KEYFLEXSEG', 'KEYFLEXIDSEG'):
return 'Key flexfield segment', f"segment {c['segment']} of group {c['group']}"
if vt in ('DESCFLEX', 'DESCFLEXCONTEXT'):
return 'Descriptive flexfield' + (' context' if vt.endswith('CONTEXT') else ''), f"{c['flex']} (application {c['flex_app']})"
if vt == 'DESCFLEXSEG':
return 'Descriptive flexfield segment', f"segment {c['segment']} of group {c['group']}"
if vt == 'EXPANDEDSQLSEG':
return 'Expanded SQL segment', f"segment {c['segment']} of group {c['group']}"
if vt == 'EXPANDEDSQL':
self.issues.append(f"{where}: expanded SQL column set of group `{c['group']}` (a variable number of columns built when the document is created); decide the Blitz row design, usually one row per expanded value")
return 'Expanded SQL', md(translate(c['expanded'], self.issues, where), 300)
if vt in ('JAVA', 'GROUP'):
self.java.append((c['name'], c['vobj'], 'validator' if vt == 'JAVA' else 'cross-column validator'))
return 'Java' if vt == 'JAVA' else 'Group', c['vobj']
if vt in ('', 'CALENDAR'):
return '', ''
self.issues.append(f'{where}: validation type {vt} not mapped')
return vt, c['vobj']
def api(self, intf):
"""(kind, target, [(item no, parameter, type, direction, literal)]) of an interface."""
ut = a(intf, 'UPLOAD_TYPE')
if ut == '1':
return 'Table', a(intf, 'INTERFACE_NAME'), []
if ut == '0':
return 'Java upload class', a(intf, 'UPLOAD_OBJ_NAME'), []
code = ref(intf, 'UPLOAD_PARAMS')
kind, target = self.list_target(code)
args = []
for it in self.items(code):
att = self.item_attr(it)
args.append((it['keys'][-1], a(att, 'ATTRIBUTE1') or a(it, 'PARAM_NAME'), a(att, 'ATTRIBUTE2'), a(att, 'ATTRIBUTE3'), a(att, 'ATTRIBUTE5')))
return (kind.title() if kind else 'API'), target or a(intf, 'INTERFACE_NAME'), args
def importer(self):
"""Importer steps: [(step, list code, description, [(name, value, attribute summary)])]"""
steps = []
for it in self.items(ref(self.node, 'IMPORT')):
sub = a(it, 'STRING_VALUE').split(':')[-1]
rows = []
for s in self.items(sub):
att = self.item_attr(s)
attrs = ' '.join(f'{k[9:]}={a(att, k)}' for k in sorted(att['attrs'], key=lambda k: int(k[9:]) if k[9:].isdigit() else 99) if k.startswith('ATTRIBUTE') and a(att, k)) if att else ''
rows.append((a(s, 'PARAM_NAME'), self.item_value(s), attrs))
desc = a(it, 'DESC_VALUE') or a(self.lists.get(sub), 'USER_NAME') if sub in self.lists else self.item_value(it)
steps.append((a(it, 'PARAM_NAME'), sub if sub in self.lists else '', desc, rows))
return steps
def programs(self):
"""Concurrent programs the importer submits: [(application, program)]"""
out = []
for step, _, _, rows in self.importer():
if step.upper() != 'CONCURRENT_REQUEST':
continue
vals = {}
for name, value, attrs in rows:
m = re.search(r'(?:^| )1=(APPLICATION|PROGRAM)\b.*?(?:^| )5=(\S+)', attrs)
if m:
vals[m.group(1)] = m.group(2).strip("'")
if vals.get('PROGRAM'):
out.append((vals.get('APPLICATION', ''), vals['PROGRAM']))
return out
def uploader(self):
"""Upload page parameters: [(name, value, visible, user can change)]"""
out = []
for it in self.items(ref(self.node, 'UPL')):
d = self.defns.get(ref(it, 'DEFN'))
out.append((a(it, 'PARAM_NAME') or a(d, 'PARAM_NAME'), self.item_value(it), a(d, 'DEFAULT_VISIBLE_FLAG'), a(d, 'DEFAULT_USER_MODIFYABLE_FLAG')))
return out
def commit_rows(self):
for name, value, visible, modifiable in self.uploader():
if name.lower() == 'bne:commitrows':
v = (value or '').upper().replace(' ', '_')
eff = 'Each Row' if v == 'EACH_ROW' else 'All Rows'
return eff, f"bne:commitrows={value or '(not set)'}, shown {visible or '-'}, user can change {modifiable or '-'}"
return 'All Rows', 'bne:commitrows not in the upload parameter list (default)'
def responsibilities(self):
f = self.folder / f'{self.code}_responsibilities.txt'
if not f.exists():
return None
rows = [l.rstrip('\r\n').split('|') for l in f.read_text(encoding='utf-8', errors='replace').splitlines()[1:] if l.strip()]
funcs = {}
for r in rows:
if len(r) >= 6:
funcs.setdefault((r[0], r[1], r[2]), set()).add(r[5])
return funcs
def document_parameters(self):
"""Parameters the user enters when creating the WebADI document; they become upload parameters."""
cols = [c for i in self.interfaces for c in self.columns(i) if c['enabled']]
text = ' '.join(c[k] for c in cols for k in ('dvalue', 'vwhere', 'flex_num', 'expanded'))
text += ' '.join(a(s, 'QUERY') for ct in self.contents for s in walk(ct, 'BNE_STORED_SQL'))
names = {m.group(1) for m in re.finditer(r'\$PARAM\$\.([A-Za-z0-9_:]+)', text, re.I)}
names |= {c['dvalue'] for c in cols if c['dtype'].upper() == 'PARAMETER' and c['dvalue']}
return sorted(names, key=str.lower)
def gathered_objects(self):
return sorted(p.name for p in self.folder.glob('*.sql'))
def referenced_custom(self):
text = self.ldt.read_text(encoding='utf-8', errors='replace')
names = set()
for p in self.prefixes:
for m in re.finditer(rf'(?<![A-Za-z0-9_$#]){re.escape(p)}[A-Za-z0-9_$#]*(?:\.[A-Za-z0-9_$#]+)?', text, re.I):
names.add(m.group(0).upper())
return sorted(names)
def shipped_candidates(integ, content_dir):
if not content_dir or not content_dir.is_dir():
return []
needles = set()
for intf in integ.interfaces:
kind, target, _ = integ.api(intf)
if target:
needles.add(target.split('.')[0].lower())
needles |= {p.lower() for _, p in integ.programs()}
prefixes = {t.split('_')[0].upper() for t in needles if '_' in t} | {integ.asn.upper()}
strong, weak = [], []
for f in sorted(content_dir.glob('*.xml')):
text = f.read_text(encoding='utf-8', errors='replace')
m = re.search(r'<REPORT_NAME>([^<]+)</REPORT_NAME>', text)
if not m:
continue
name = m.group(1)
low = text.lower()
if any(re.search(rf'\b{re.escape(n)}\b', low) for n in needles):
strong.append(name)
elif name.split(' ')[0].upper() in prefixes:
weak.append(name)
return [f'{n} (names the same interface, package or program)' for n in strong] + weak[:8]
def module_package(integ, extensions_dir):
"""The XXEN_<MODULE>_UPLOAD package the upload procedure goes into: the product code of the API or table written."""
for intf in integ.interfaces:
if a(intf, 'UPLOAD_TYPE') in ('1', '2', '5'):
obj = integ.api(intf)[1].split('.')[0].upper()
if obj and '_' in obj and not integ.is_custom(obj):
package = f"XXEN_{obj.split('_')[0]}_UPLOAD"
if not extensions_dir:
return f'`{package}` (check whether `sql/extensions` has it)'
return f"`{package}` ({'exists: add the procedure' if (extensions_dir / f'{package}.pks').exists() else 'new: create it'})"
integ.issues.append('the integrator writes through customer objects only: take the package module from the product code of the tables they write')
return 'the module of the tables the customer objects write (section 12)'
def write_fad(integ, content_dir, abbrev, extensions_dir=None):
L = []
w = L.append
custom = not SEED_OWNER.match(integ.owner or 'ORACLE')
w(f'# Form Analysis Document: {integ.name}\n')
w(f'Source: WebADI integrator `{integ.asn}:{integ.code}`, definition `{integ.ldt.name}`. Generated by `webadi_fad.py`; review every section before building.\n')
w('## 1. Summary\n')
w('| Item | Value |\n|---|---|')
w(f'| Integrator | {md(integ.name)} (`{integ.code}`, application `{integ.asn}`) |')
w(f"| Built by | {'the customer (row owner ' + integ.owner + ')' if custom else 'Oracle (row owner ' + (integ.owner or '-') + ')'} |")
for intf in integ.interfaces:
kind, target, args = integ.api(intf)
w(f"| Interface `{intf['keys'][-1]}` | {UPLOAD_TYPES.get(a(intf, 'UPLOAD_TYPE'), a(intf, 'UPLOAD_TYPE'))}: `{target}`{' (customer object)' if integ.is_custom(target) else ''} |")
eff, detail = integ.commit_rows()
w(f'| Commit | {eff}: {md(detail)} |')
progs = integ.programs()
steps = integ.importer()
w(f"| Importer | {', '.join(s[0] for s in steps) or 'none'}{'; submits ' + ', '.join(f'`{p}` ({ap})' for ap, p in progs) if progs else ''} |")
params = integ.document_parameters()
w(f"| Document parameters | {', '.join(f'`{p}`' for p in params) + ' (each becomes an upload parameter: rename `:$flex$.<name>` in the drafts below to its Blitz parameter name)' if params else 'none'} |")
cands = shipped_candidates(integ, content_dir)
w(f"| Shipped upload candidates | {'; '.join(cands) if cands else 'none found' if content_dir else 'not checked (no content folder)'} |")
w(f"| Category if built new | `Custom Uploads {abbrev or '<ABBR>'}` |")
package = module_package(integ, extensions_dir)
w(f'| Upload package | {package} |\n')
for intf in integ.interfaces:
if a(intf, 'UPLOAD_TYPE') == '4':
continue
icode = intf['keys'][-1]
cols = integ.columns(intf)
byseq = {c['seq']: c for c in cols}
kind, target, args = integ.api(intf)
w(f'## 2. Visual field order: interface `{icode}`\n')
if not integ.layouts:
w('No layout in the definition: the column order below is the interface display order.\n')
for lay in integ.layouts:
rows = integ.layout_rows(lay, icode)
if not rows:
continue
w(f"Layout `{lay['keys'][-1]}` {md(a(lay, 'USER_NAME'))}. Context and header block values apply to the whole sheet in WebADI: each becomes an upload parameter or a column repeated on every row; the Database context value has no equivalent.\n")
w('| # | Block | Prompt | Interface column | Type | Req | Read only | List | Default |\n|---|---|---|---|---|---|---|---|---|')
for i, (bname, elem, lc, seq) in enumerate(rows, 1):
c = byseq.get(seq)
if not c:
integ.issues.append(f'layout `{lay["keys"][-1]}` names interface column {seq}, which the interface does not have')
continue
where = f"column `{c['name']}`"
ro = a(lc, 'READ_ONLY_FLAG') == 'Y' or c['read_only']
dflt = integ.default_text(a(lc, 'DEFAULT_TYPE') or c['dtype'], a(lc, 'DEFAULT_VALUE') or c['dvalue'], where)
vkind, _ = integ.validation(c, where)
w(f"| {i} | {md(bname)} | {md(c['prompt'])} | {c['name']} | {c['type']} | {'Y' if c['required'] else ''} | {'Y' if ro else ''} | {vkind} | {md(dflt, 120)} |")
w('')
w(f'## 3. Field inventory: interface `{icode}`\n')
w('Enabled columns only. `Alias` columns are not passed to the API or table; they feed lists, flexfields and other columns.\n')
w('| Seq | Column | Prompt | Type | Size | Req | Shown | Alias | API parameter |\n|---|---|---|---|---|---|---|---|---|')
argmap = {n: (p, t, d) for n, p, t, d, _ in args}
for c in cols:
if not c['enabled']:
continue
p = argmap.get(c['param_item'])
w(f"| {c['seq']} | {c['name']} | {md(c['prompt'])} | {c['type']} | {c['size']} | {'Y' if c['required'] else ''} | {'Y' if c['display'] else ''} | {'Y' if c['alias'] else ''} | {f'{p[0]} {p[1]} {p[2]}' if p else ''} |")
w('')
w('## 4. Defaulting map\n')
w('| Column | WebADI default | Blitz equivalent |\n|---|---|---|')
for c in cols:
if c['enabled'] and c['dtype']:
w(f"| {c['name']} | {c['dtype']}: {md(c['dvalue'], 120)} | {md(integ.default_text(c['dtype'], c['dvalue'], 'column `' + c['name'] + '`'), 160)} |")
w('')
w('## 5. Validation and list of values map\n')
w('| Column | Validation | List SQL or object | List type |\n|---|---|---|---|')
for c in cols:
if not c['enabled'] or not c['vtype']:
continue
vkind, detail = integ.validation(c, f"column `{c['name']}`")
comp = integ.components.get(c['vcomp'])
comp_txt = f" component {a(comp, 'COMPONENT_JAVA_CLASS')}" if comp else ''
w(f"| {c['name']} | {vkind} | `{md(detail)}` | {c['lov'] or '-'}{comp_txt} |")
flexes = sorted({(c['vtype'], c['flex'], c['flex_app'], c['group']) for c in cols if c['enabled'] and c['flex']})
if flexes:
w('\nFlexfields: ' + '; '.join(f'{f} ({vt}, application {ap}, group {g})' for vt, f, ap, g in flexes) + '. Build key flexfield columns per the KFF rules of upload-development and descriptive flexfields with the add-dff-to-upload skill.')
w('')
w('## 6. API / interface details\n')
if kind == 'Table':
w(f'Rows are inserted into `{target}`; the importer below processes them. Interface columns of type Alias are not table columns.\n')
else:
w(f'`{target}` is called once per row ({kind.lower()}). Parameters in call order:\n')
w('| # | Parameter | Type | Direction | Fixed value | Interface column |\n|---|---|---|---|---|---|')
colmap = {c['param_item']: c['name'] for c in cols if c['param_item']}
for n, p, t, d, lit in args:
w(f'| {n} | {p} | {t} | {d} | {md(lit)} | {colmap.get(n, "")} |')
w('')
if integ.is_custom(target.split('.')[0]):
obj = target.split('.')[0].upper()
if not any(obj in f.upper() for f in integ.gathered_objects()):
integ.issues.append(f'customer package `{obj}` is not among the gathered .sql files')
w('## 7. Importer\n')
if not steps:
w('None: the upload calls end the processing.\n')
for step, code, desc, rows in steps:
w(f"**{step}**{' (`' + code + '`)' if code else ''} {md(desc)}\n")
if rows:
w('| Name | Value | Attributes |\n|---|---|---|')
for name, value, attrs in rows:
v = md(translate(value, integ.issues, 'importer ' + step), 200)
w(f"| {md(name)} | {'`' + v + '`' if v else ''} | {md(attrs, 160)} |")
w('')
for ap, p in progs:
if integ.is_custom(p):
integ.issues.append(f'importer program `{p}` ({ap}) is customer-built: gather it as a concurrent program in a converter run')
w('## 8. Download content\n')
if not integ.contents:
w('None: the integrator creates new rows only.\n')
for ct in integ.contents:
cls = a(ct, 'CONTENT_CLASS')
sqls = [a(s, 'QUERY') for s in walk(ct, 'BNE_STORED_SQL')]
w(f"**{md(a(ct, 'USER_NAME'))}** (`{ct['keys'][-1]}`){' class ' + cls if cls else ''}\n")
for q in sqls:
w('```sql\n' + translate(q, integ.issues, f"content `{ct['keys'][-1]}`").strip() + '\n```\n')
if cls and not sqls and not cls.startswith('oracle.apps.bne'):
integ.issues.append(f'download content `{ct["keys"][-1]}` is produced by Java class `{cls}`')
w('## 9. Access\n')
w(f"Security rule functions: {', '.join(f'`{r}`' for r in integ.rules) or 'none'}.\n")
funcs = integ.responsibilities()
if funcs is None:
w('No responsibilities file.\n')
else:
w('| Function | Type | Name | Responsibilities |\n|---|---|---|---|')
for (fn, ft, un), resps in sorted(funcs.items(), key=lambda x: -len(x[1])):
w(f'| {fn} | {ft} | {md(un)} | {len(resps)} |')
forms = [fn for (fn, ft, _) in funcs if ft == 'FORM']
w('\n' + (f"Function assignment candidates (FORM): {', '.join(forms)}." if forms else 'No FORM function: assign the upload to the update function of the form that maintains the entity, or leave it without assignment.') + '\n')
w('## 10. Customer objects\n')
objs = integ.gathered_objects()
refs = integ.referenced_custom()
w(f"Gathered: {', '.join(objs) or 'none'}.\n")
w(f"Customer-prefixed names in the definition: {', '.join(refs) or 'none'}.\n")
w('## 11. Upload design recommendations\n')
w(f"- Outcome: {'template of ' + cands[0].split(' (')[0] + ' if it covers the columns of section 2, otherwise ' if cands else ''}a new upload in `Custom Uploads {abbrev or '<ABBR>'}`.")
w(f'- Package: {package}. Call customer objects, and APIs that not every release of the package has, through `execute immediate` only.')
w(f"- Commit: {eff}." + (' Single-call mode with one savepoint for the whole file: any rejected row errors every row.' if eff == 'All Rows' else ' Row by row.'))
if any(s[0].upper() == 'GROUP' for s in steps):
grp = next(s for s in steps if s[0].upper() == 'GROUP')
w(f"- The importer runs once per distinct {', '.join(r[0] for r in grp[3])}: a post procedure with those columns as its IN parameters.")
w('- Columns: the layout order of section 2; context and header values become upload parameters or columns repeated on every row; required interface columns outside the layout come from their defaults or the procedure.')
sql_download = any(next(walk(ct, 'BNE_STORED_SQL'), None) for ct in integ.contents)
java_download = any(a(ct, 'CONTENT_CLASS') and not a(ct, 'CONTENT_CLASS').startswith('oracle.apps.bne.') and not next(walk(ct, 'BNE_STORED_SQL'), None) for ct in integ.contents)
w('- Upload mode: ' + ('Create and Update, the main SQL from the download content of section 8.' if sql_download else 'Create and Update, the main SQL written from what the Java download class of section 8 returns.' if java_download else 'Create only: the integrator downloads nothing.'))
w('')
w('## 12. Open questions\n')
seen = list(dict.fromkeys(integ.issues))
java = list(dict.fromkeys(integ.java))
w('\n'.join(f'- {i}' for i in seen) if seen or java else '- none found by the extraction')
if java:
w('- Java logic runs in the WebADI middle tier and the upload must reproduce it in PL/SQL; read what each class checks or derives from the API source, an Oracle class from the instance, a customer class from the source the customer supplies:')
w('\n'.join(f" - `{col}` {kind} " + (f"`{cls}`" + ('' if cls.startswith('oracle.apps.') else ' (customer class)') if cls else '(no class named)') for col, cls, kind in java))
out = integ.folder / f'FAD_{integ.code}.md'
out.write_text('\n'.join(L) + '\n', encoding='utf-8')
return out, len(seen) + len(java)
def write_download(integ):
"""A download-only integrator is a report: its download SQL, one block per layout's content."""
L = [f'# Download SQL: {integ.name}\n', f'WebADI integrator `{integ.asn}:{integ.code}` uploads nothing; convert it as a report. `:$flex$.<name>` marks a document parameter, which becomes a report parameter.\n']
for ct in integ.contents:
sqls = [a(s, 'QUERY') for s in walk(ct, 'BNE_STORED_SQL')]
if sqls:
L.append(f"## {md(a(ct, 'USER_NAME'))} (`{ct['keys'][-1]}`)\n")
L += ['```sql\n' + translate(q, integ.issues, ct['keys'][-1]).strip() + '\n```\n' for q in sqls]
if integ.issues:
L.append('## Open questions\n')
L += [f'- {i}' for i in dict.fromkeys(integ.issues)]
out = integ.folder / f'{integ.code}_download_sql.md'
out.write_text('\n'.join(L) + '\n', encoding='utf-8')
return out
def manifest(folder):
f = folder / 'manifest.txt'
vals = {}
if f.exists():
for line in f.read_text(encoding='utf-8', errors='replace').splitlines():
if ':' in line and not line.startswith(' '):
k, v = line.split(':', 1)
vals[k.strip()] = v.strip()
return vals
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('path')
ap.add_argument('--content', help='folder of shipped upload XMLs (default: BLITZ_CONTENT_DIR, else content/r12/uploads of the repository)')
ap.add_argument('--extensions', help='folder of the upload packages (default: sql/extensions of the repository)')
args = ap.parse_args()
path = pathlib.Path(args.path)
if path.is_file():
ldts, root = [path], path.parent
else:
ldts, root = sorted(path.glob('*_integrator.ldt')) or sorted(path.glob('*/*_integrator.ldt')), path
if not ldts:
sys.exit(f'no *_integrator.ldt under {path}')
meta = manifest(root if (root / 'manifest.txt').exists() else root.parent)
prefixes = [p.strip() for p in re.split(r'[,;\s]+', meta.get('custom_object_prefix', '')) if p.strip()]
abbrev = meta.get('customer_abbreviation', '')
repo = pathlib.Path(__file__).resolve().parents[3]
content = pathlib.Path(args.content or os.environ.get('BLITZ_CONTENT_DIR') or repo / 'content' / 'r12' / 'uploads')
extensions = pathlib.Path(args.extensions) if args.extensions else repo / 'sql' / 'extensions'
inv = ['# WebADI integrator inventory\n', '| Integrator | Name | Kind | Interfaces | Importer | Commit | Layout columns | Open questions | Next step |', '|---|---|---|---|---|---|---|---|---|']
for ldt in ldts:
integ = Integrator(ldt, prefixes)
targets = '; '.join(f"{UPLOAD_TYPES.get(a(i, 'UPLOAD_TYPE'), a(i, 'UPLOAD_TYPE'))} `{integ.api(i)[1]}`" for i in integ.interfaces) or '-'
nlay = sum(len(integ.layout_rows(l, i['keys'][-1])) for l in integ.layouts for i in integ.interfaces if a(i, 'UPLOAD_TYPE') != '4')
if integ.kind == 'Upload':
fad, nq = write_fad(integ, content if content.is_dir() else None, abbrev, extensions if extensions.is_dir() else None)
nxt = f'build from `{fad.name}`'
elif integ.kind == 'Download only':
nq, nxt = 0, f'a report: convert `{write_download(integ).name}` with the custom reports path'
else:
nq, nxt = 0, 'nothing to convert: ask what the customer uploads with it'
prog = ', '.join(p for _, p in integ.programs()) or ', '.join(s[0] for s in integ.importer()) or '-'
inv.append(f"| `{integ.code}` | {md(integ.name)} | {integ.kind} | {md(targets, 140)} | {md(prog, 60)} | {integ.commit_rows()[0] if integ.kind == 'Upload' else '-'} | {nlay or '-'} | {nq or '-'} | {nxt} |")
print(f'{integ.code}: {integ.kind}' + (f', {nq} open question(s)' if integ.kind == 'Upload' else ''))
(root / 'webadi_inventory.md').write_text('\n'.join(inv) + '\n', encoding='utf-8')
print(f"wrote {root / 'webadi_inventory.md'}")
if __name__ == '__main__':
main()