fix: history mock
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m32s
Build / Build-and-test-development (pull_request) Failing after 33m38s

This commit is contained in:
hermes
2026-08-27 22:41:47 +01:00
parent 3595bb9499
commit 0082828543
5 changed files with 947 additions and 209 deletions
@@ -4,33 +4,97 @@ let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
eval(fs.readFileSync(nodePath.resolve(driveRoot, "files", appLoc, "services", "dcMockUtils.js"), "utf8"))
eval(
fs.readFileSync(
nodePath.resolve(
driveRoot,
'files',
appLoc,
'services',
'dcMockUtils.js'
),
'utf8'
)
)
const dcLibref = 'DC_JSLIB'
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
// Load MPE_SUBMIT - return all items (history shows everything)
let fromsas = []
try {
const file = nodePath.resolve(dataDir, 'mpe_submit.json')
const raw = fs.readFileSync(file, {encoding:'utf8'}).toString()
const submitData = JSON.parse(raw)
fromsas = submitData.rows.map(r => ({
TABLE_ID: r.TABLE_ID,
REVIEW_STATUS_ID: r.SUBMIT_STATUS_CD,
SUBMITTED_BY_NM: r.SUBMITTED_BY_NM,
BASE_TABLE: r.BASE_LIB + '.' + r.BASE_DS,
SUBMITTED_ON_DTTM: new Date(r.SUBMITTED_ON_DTTM * 1000).toISOString().replace('T',' ').replace(/\.\d+Z$/, ''),
SUBMITTED_ON_DTTM2: r.SUBMITTED_ON_DTTM,
SUBMITTED_REASON_TXT: r.SUBMITTED_REASON_TXT || '',
NUM_OF_APPROVALS_REQUIRED: r.NUM_OF_APPROVALS_REQUIRED,
NUM_OF_APPROVALS_REMAINING: r.NUM_OF_APPROVALS_REMAINING,
LIBREF: r.BASE_LIB,
DSN: r.BASE_DS,
REVIEWED_BY_NM: r.REVIEWED_BY_NM || '',
REVIEWED_ON_DTTM: r.REVIEWED_ON_DTTM ? new Date(r.REVIEWED_ON_DTTM * 1000).toISOString().replace('T',' ').replace(/\.\d+Z$/, '') : null
}))
} catch(err) {}
const loadTableData = makeTableLoader(dataDir)
// ─── Parse input (BrowserParams: STARTROW / HIST) ───────────────────────────
const browserParams = fetchTable('BrowserParams')[0] || {}
const startRow = browserParams.STARTROW
? parseInt(String(browserParams.STARTROW), 10)
: 1
let hist = 40
if (browserParams.HIST) {
hist = parseInt(String(browserParams.HIST), 10)
} else {
// fall back to MPE_CONFIG HISTORY_ROWS
try {
const cfg = loadTableData('MPE_CONFIG')
const row = cfg.rows.find(
(r) => r.var_scope === 'DC_REVIEW' && r.var_name === 'HISTORY_ROWS'
)
if (row) hist = parseInt(String(row.var_value), 10)
} catch (e) {
/* keep default */
}
}
// ─── Load MPE_SUBMIT history rows (SUBMIT_STATUS_CD != 'SUBMITTED') ────────
const submitData = loadTableData('MPE_SUBMIT') || { rows: [] }
// Filter to approved/rejected (not 'SUBMITTED') and sort by submitted desc
const historyRows = submitData.rows.filter(
(r) => r.SUBMIT_STATUS_CD !== 'SUBMITTED'
)
historyRows.sort((a, b) => b.SUBMITTED_ON_DTTM - a.SUBMITTED_ON_DTTM)
// Slice by startRow / hist
const slicedRows = historyRows.slice(startRow - 1, startRow - 1 + hist)
// ─── Format output for the history grid ───────────────────────────────────────
const fromsas = slicedRows.map((r) => {
const submitted = new Date(r.SUBMITTED_ON_DTTM * 1000)
const reviewed = r.REVIEWED_ON_DTTM
? new Date(r.REVIEWED_ON_DTTM * 1000)
: null
const pad = (n) => String(n).padStart(2, '0')
const fmt = (d) =>
[d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())].join('-') +
' ' +
[pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds())].join(':')
return {
TABLE_ID: r.TABLE_ID,
BASE_TABLE: r.BASE_LIB + '.' + r.BASE_DS,
SUBMITTED: fmt(submitted),
SUBMITTED_REASON_TXT: r.SUBMITTED_REASON_TXT || '',
SUBMITTER: r.SUBMITTED_BY_NM || '',
REVIEWED: reviewed ? fmt(reviewed) : ' ',
STATUS: r.SUBMIT_STATUS_CD,
REVIEWED_ON_DTTM: r.REVIEWED_ON_DTTM,
APPROVER: r.REVIEWED_BY_NM || '',
NUM_OF_APPROVALS_REQUIRED: r.NUM_OF_APPROVALS_REQUIRED || 1
}
})
// ─── Pagination params ────────────────────────────────────────────────────────
const histparams = [
{
HIST: hist,
STARTROW: startRow,
NOBS: historyRows.length
}
]
webOutOpen()
webOutObj(fromsas, 'fromsas')
webOutObj(histparams, 'histparams')
webOutClose()
@@ -7,7 +7,12 @@ const dcLibref = 'DC_JSLIB'
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
const stagingDir = nodePath.resolve(dataDir, 'staging')
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const loadTableData = makeTableLoader(dataDir)
// ─── Parse input ──────────────────────────────────────────────────────────────
@@ -19,33 +24,45 @@ let stagetable = []
let fmt_stagetable = []
if (tableId) {
// staged data lives in a subfolder per submission (DSID) under staging/
const stageFolder = nodePath.resolve(stagingDir, tableId)
const candidates = [
nodePath.resolve(stageFolder, 'staged.json'),
// legacy layout: staging/<table_id>.json
nodePath.resolve(stagingDir, tableId + '.json')
]
const stageFile = candidates.find((f) => fs.existsSync(f))
if (stageFile) {
try {
const stageFile = nodePath.resolve(stagingDir, tableId + '.json')
const stageData = JSON.parse(fs.readFileSync(stageFile, {encoding:'utf8'}).toString())
const stageData = JSON.parse(
fs.readFileSync(stageFile, { encoding: 'utf8' }).toString()
)
stagetable = stageData.rows
stagetable = stageData.rows
// Build fmt_stagetable by applying SAS formats to numeric temporal columns
const dsn = stageData.dsn || ''
const schema = loadTableSchema(dataDir, dsn)
const colDdtypes = {}
for (const col of schema) colDdtypes[col.name.toUpperCase()] = getDdType(col)
// Build fmt_stagetable by applying SAS formats to temporal columns
const dsn = stageData.dsn || ''
const schema = loadTableSchema(dataDir, dsn)
const colDdtypes = {}
for (const col of schema)
colDdtypes[col.name.toUpperCase()] = getDdType(col)
fmt_stagetable = stageData.rows.map(r => {
const fmt = {}
for (const key of Object.keys(r)) {
const ddtype = colDdtypes[key.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
fmt[key] = formatSasValue(r[key], ddtype)
} else {
fmt[key] = String(r[key])
}
}
return fmt
})
} catch(err) {
console.log('Error loading staged data: ' + err.message)
fmt_stagetable = stageData.rows.map((r) => {
const fmt = {}
for (const key of Object.keys(r)) {
const ddtype = colDdtypes[key.toUpperCase()]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
fmt[key] = formatSasValue(r[key], ddtype)
} else {
fmt[key] = String(r[key])
}
}
return fmt
})
} catch (err) {
console.log('Error loading staged data: ' + err.message)
}
}
}
webOutOpen()
+688 -109
View File
@@ -1,110 +1,689 @@
_webout = `{"SYSDATE" : "07OCT22"
,"SYSTIME" : "12:25"
, "params":
[
{"TABLE_ID":"DC20221007T122326121_612316_7259" ,"SUBMIT_STATUS_CD":"SUBMITTED" ,"BASE_LIB":"DC_JSLIB" ,"BASE_DS":"MPE_X_TEST" ,"SUBMITTED_BY_NM":"mihajlo" ,"SUBMITTED_ON_DTTM":1980764606.22 ,"SUBMITTED_REASON_TXT":"" ,"INPUT_OBS":15 ,"INPUT_VARS":10 ,"NUM_OF_APPROVALS_REQUIRED":1 ,"NUM_OF_APPROVALS_REMAINING":1 ,"REVIEWED_BY_NM":"" ,"REVIEWED_ON_DTTM":null ,"LIBDS":"DC_JSLIB.MPE_X_TEST" ,"REVIEWED_ON":"." ,"DIFFTIME":"07OCT22:12:25:57.40" ,"DIFFS_CSV":"tempDiffs_20221007_122559.csv" ,"FILESIZE":"684.0KB" ,"FILESIZE_RAW":700416 ,"TRUNCATED":"NO" ,"NUM_ADDED":6 ,"NUM_DELETED":4 ,"NUM_UPDATED":5 ,"SUBMITTED_ON":"07OCT2022:12:23:26" ,"ISAPPROVER":"YES" }
]
, "cols":
[
{"NAME":"PRIMARY_KEY_FIELD" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"" }
,{"NAME":"SOME_CHAR" ,"TYPE":2 ,"LENGTH":32767 ,"FORMAT":"" }
,{"NAME":"SOME_DROPDOWN" ,"TYPE":2 ,"LENGTH":128 ,"FORMAT":"" }
,{"NAME":"SOME_NUM" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"" }
,{"NAME":"SOME_DATE" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"DATE" }
,{"NAME":"SOME_DATETIME" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"DATETIME" }
,{"NAME":"SOME_TIME" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"TIME" }
,{"NAME":"SOME_SHORTNUM" ,"TYPE":1 ,"LENGTH":4 ,"FORMAT":"" }
,{"NAME":"SOME_BESTNUM" ,"TYPE":1 ,"LENGTH":8 ,"FORMAT":"BEST" }
]
, "submits":
[
{"TABLE_ID":"DC20221007T122119628_344949_7254" ,"SUBMIT_STATUS_CD":"SUBMITTED" ,"BASE_LIB":"DC_JSLIB" ,"BASE_DS":"MPE_X_TEST" ,"SUBMITTED_BY_NM":"mihajlo" ,"SUBMITTED_ON_DTTM":1980764479.74 ,"SUBMITTED_REASON_TXT":"" ,"INPUT_OBS":15 ,"INPUT_VARS":10 ,"NUM_OF_APPROVALS_REQUIRED":1 ,"NUM_OF_APPROVALS_REMAINING":1 ,"REVIEWED_BY_NM":"" ,"REVIEWED_ON_DTTM":null }
,{"TABLE_ID":"DC20221006T142649516_059582_7169" ,"SUBMIT_STATUS_CD":"SUBMITTED" ,"BASE_LIB":"DC_JSLIB" ,"BASE_DS":"MPE_X_TEST" ,"SUBMITTED_BY_NM":"mihajlo" ,"SUBMITTED_ON_DTTM":1980685609.6 ,"SUBMITTED_REASON_TXT":"" ,"INPUT_OBS":1 ,"INPUT_VARS":10 ,"NUM_OF_APPROVALS_REQUIRED":1 ,"NUM_OF_APPROVALS_REMAINING":1 ,"REVIEWED_BY_NM":"" ,"REVIEWED_ON_DTTM":null }
]
, "deleted":
[
{"PRIMARY_KEY_FIELD":1016 ,"SOME_CHAR":"16 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.6963824517 ,"SOME_DATE":790 ,"SOME_DATETIME":null ,"SOME_TIME":60 ,"SOME_SHORTNUM":27 ,"SOME_BESTNUM":84 }
,{"PRIMARY_KEY_FIELD":1017 ,"SOME_CHAR":"17 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.476432963 ,"SOME_DATE":432 ,"SOME_DATETIME":null ,"SOME_TIME":60 ,"SOME_SHORTNUM":6 ,"SOME_BESTNUM":93 }
,{"PRIMARY_KEY_FIELD":1018 ,"SOME_CHAR":"18 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.498669565 ,"SOME_DATE":754 ,"SOME_DATETIME":null ,"SOME_TIME":0 ,"SOME_SHORTNUM":87 ,"SOME_BESTNUM":29 }
,{"PRIMARY_KEY_FIELD":1019 ,"SOME_CHAR":"19 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.7999903256 ,"SOME_DATE":492 ,"SOME_DATETIME":null ,"SOME_TIME":60 ,"SOME_SHORTNUM":4 ,"SOME_BESTNUM":15 }
]
, "new":
[
{"PRIMARY_KEY_FIELD":10101234 ,"SOME_CHAR":"10 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.3297298762 ,"SOME_DATE":728 ,"SOME_DATETIME":null ,"SOME_TIME":60 ,"SOME_SHORTNUM":52 ,"SOME_BESTNUM":23 }
,{"PRIMARY_KEY_FIELD":10101235 ,"SOME_CHAR":"11 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.4249602912 ,"SOME_DATE":456 ,"SOME_DATETIME":null ,"SOME_TIME":60 ,"SOME_SHORTNUM":37 ,"SOME_BESTNUM":91 }
,{"PRIMARY_KEY_FIELD":10101236 ,"SOME_CHAR":"12 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.5175091785 ,"SOME_DATE":383 ,"SOME_DATETIME":null ,"SOME_TIME":0 ,"SOME_SHORTNUM":53 ,"SOME_BESTNUM":5 }
,{"PRIMARY_KEY_FIELD":10101237 ,"SOME_CHAR":"13 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.1282490171 ,"SOME_DATE":650 ,"SOME_DATETIME":20160 ,"SOME_TIME":0 ,"SOME_SHORTNUM":74 ,"SOME_BESTNUM":88 }
,{"PRIMARY_KEY_FIELD":10101238 ,"SOME_CHAR":"14 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.9579004319 ,"SOME_DATE":203 ,"SOME_DATETIME":20160 ,"SOME_TIME":0 ,"SOME_SHORTNUM":51 ,"SOME_BESTNUM":65 }
,{"PRIMARY_KEY_FIELD":10101239 ,"SOME_CHAR":"15 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":0.4804529285 ,"SOME_DATE":155 ,"SOME_DATETIME":23820 ,"SOME_TIME":0 ,"SOME_SHORTNUM":28 ,"SOME_BESTNUM":88 }
]
, "updates":
[
{"PRIMARY_KEY_FIELD":0 ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":null ,"SOME_TIME":0 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":1 ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":null ,"SOME_TIME":420 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":2 ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 3" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":null ,"SOME_TIME":120 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":3 ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":1613.001 ,"SOME_DATE":423 ,"SOME_DATETIME":null ,"SOME_TIME":0 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":4 ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":1613.0011235 ,"SOME_DATE":4231 ,"SOME_DATETIME":null ,"SOME_TIME":360 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
]
, "originals":
[
{"PRIMARY_KEY_FIELD":0 ,"SOME_CHAR":"this is dummy data" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_TIME":42 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":1 ,"SOME_CHAR":"more dummy data" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_TIME":422 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":2 ,"SOME_CHAR":"even more dummy data" ,"SOME_DROPDOWN":"Option 3" ,"SOME_NUM":42 ,"SOME_DATE":42 ,"SOME_DATETIME":42 ,"SOME_TIME":142 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":3 ,"SOME_CHAR":"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":1613.001 ,"SOME_DATE":423 ,"SOME_DATETIME":423 ,"SOME_TIME":44 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
,{"PRIMARY_KEY_FIELD":4 ,"SOME_CHAR":"if you can fill the unforgiving minute" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":1613.001123456 ,"SOME_DATE":4231 ,"SOME_DATETIME":423123123 ,"SOME_TIME":412 ,"SOME_SHORTNUM":3 ,"SOME_BESTNUM":44 }
]
, "fmt_deleted":
[
{"PRIMARY_KEY_FIELD":"1016" ,"SOME_CHAR":"16 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.6963824517" ,"SOME_DATE":"01MAR1962" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:01:00" ,"SOME_SHORTNUM":"27" ,"SOME_BESTNUM":"84" }
,{"PRIMARY_KEY_FIELD":"1017" ,"SOME_CHAR":"17 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.476432963" ,"SOME_DATE":"08MAR1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:01:00" ,"SOME_SHORTNUM":"6" ,"SOME_BESTNUM":"93" }
,{"PRIMARY_KEY_FIELD":"1018" ,"SOME_CHAR":"18 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.498669565" ,"SOME_DATE":"24JAN1962" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"87" ,"SOME_BESTNUM":"29" }
,{"PRIMARY_KEY_FIELD":"1019" ,"SOME_CHAR":"19 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.7999903256" ,"SOME_DATE":"07MAY1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:01:00" ,"SOME_SHORTNUM":"4" ,"SOME_BESTNUM":"15" }
]
, "fmt_new":
[
{"PRIMARY_KEY_FIELD":"10101234" ,"SOME_CHAR":"10 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.3297298762" ,"SOME_DATE":"29DEC1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:01:00" ,"SOME_SHORTNUM":"52" ,"SOME_BESTNUM":"23" }
,{"PRIMARY_KEY_FIELD":"10101235" ,"SOME_CHAR":"11 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.4249602912" ,"SOME_DATE":"01APR1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:01:00" ,"SOME_SHORTNUM":"37" ,"SOME_BESTNUM":"91" }
,{"PRIMARY_KEY_FIELD":"10101236" ,"SOME_CHAR":"12 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.5175091785" ,"SOME_DATE":"18JAN1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"53" ,"SOME_BESTNUM":"5" }
,{"PRIMARY_KEY_FIELD":"10101237" ,"SOME_CHAR":"13 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.1282490171" ,"SOME_DATE":"12OCT1961" ,"SOME_DATETIME":"01JAN1960:05:36:00" ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"74" ,"SOME_BESTNUM":"88" }
,{"PRIMARY_KEY_FIELD":"10101238" ,"SOME_CHAR":"14 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.9579004319" ,"SOME_DATE":"22JUL1960" ,"SOME_DATETIME":"01JAN1960:05:36:00" ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"51" ,"SOME_BESTNUM":"65" }
,{"PRIMARY_KEY_FIELD":"10101239" ,"SOME_CHAR":"15 bottles of beer on the wall" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"0.4804529285" ,"SOME_DATE":"04JUN1960" ,"SOME_DATETIME":"01JAN1960:06:37:00" ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"28" ,"SOME_BESTNUM":"88" }
]
, "fmt_updates":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"1" ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:07:00" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"2" ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 3" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:02:00" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"3" ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":"1613.001" ,"SOME_DATE":"27FEB1961" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:00:00" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"4" ,"SOME_CHAR":"updated row" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"1613.0011235" ,"SOME_DATE":"02AUG1971" ,"SOME_DATETIME":"." ,"SOME_TIME":"0:06:00" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
]
, "fmt_originals":
[
{"PRIMARY_KEY_FIELD":"0" ,"SOME_CHAR":"this is dummy data" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_TIME":"0:00:42" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"1" ,"SOME_CHAR":"more dummy data" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_TIME":"0:07:02" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"2" ,"SOME_CHAR":"even more dummy data" ,"SOME_DROPDOWN":"Option 3" ,"SOME_NUM":"42" ,"SOME_DATE":"12FEB1960" ,"SOME_DATETIME":"01JAN1960:00:00:42" ,"SOME_TIME":"0:02:22" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"3" ,"SOME_CHAR":"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:" ,"SOME_DROPDOWN":"Option 2" ,"SOME_NUM":"1613.001" ,"SOME_DATE":"27FEB1961" ,"SOME_DATETIME":"01JAN1960:00:07:03" ,"SOME_TIME":"0:00:44" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
,{"PRIMARY_KEY_FIELD":"4" ,"SOME_CHAR":"if you can fill the unforgiving minute" ,"SOME_DROPDOWN":"Option 1" ,"SOME_NUM":"1613.0011235" ,"SOME_DATE":"02AUG1971" ,"SOME_DATETIME":"29MAY1973:06:12:03" ,"SOME_TIME":"0:06:52" ,"SOME_SHORTNUM":"3" ,"SOME_BESTNUM":"44" }
]
,"_DEBUG" : ""
,"_PROGRAM" : "/30.SASApps/app/mihajlo/services/auditors/postdata"
,"AUTOEXEC" : "%2Fhome%2Fmihajlo%2Fsasjs_root%2Fsessions%2F20221007122541-37053-1665145541605%2Fautoexec.sas"
,"MF_GETUSER" : "mihajlo"
,"SYSCC" : "0"
,"SYSENCODING" : "utf-8"
,"SYSERRORTEXT" : ""
,"SYSHOSTINFOLONG" : ""
,"SYSHOSTNAME" : "sas.4gl.io"
,"SYSPROCESSID" : "41DD84049178EB530000000000000000"
,"SYSPROCESSMODE" : "Stored Program"
,"SYSPROCESSNAME" : ""
,"SYSJOBID" : "726676"
,"SYSSCPL" : "LINUX"
,"syssite" : "123"
,"SYSTCPIPHOSTNAME" : "https://sas.4gl.io:5002"
,"SYSUSERID" : "mihajlo"
,"SYSVLONG" : "05.00.00.02.001146"
,"SYSWARNINGTEXT" : ""
,"END_DTTM" : "2022-10-07T12:26:00.209420"
,"MEMSIZE" : "0KB"
const nodePath = require('path')
let appLoc = nodePath.join(..._program.split('services')[0].split('/'))
const sasjsRoot = nodePath.resolve(weboutPath, '..', '..', '..')
const driveRoot = nodePath.resolve(sasjsRoot, 'drive')
const dcLibref = 'DC_JSLIB'
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
const stagingDir = nodePath.resolve(dataDir, 'staging')
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const loadTableData = makeTableLoader(dataDir)
function saveTableData(tableName, tableData) {
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
fs.writeFileSync(file, JSON.stringify(tableData, null, 2))
}
/**
* Resolves the staging subfolder for a given table_id, creating it if needed.
* Each submission gets its own subfolder under staging/ so that staged data
* can sit alongside audit artifacts (submit/approval logs, temp diffs, etc).
*/
function getStageFolder(tableId) {
const folder = nodePath.resolve(stagingDir, tableId)
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true })
return folder
}
/**
* Loads staged data for a table_id from the subfolder layout, with a fallback
* to the legacy flat-file layout (staging/<table_id>.json).
*/
function loadStagedData(tableId) {
const candidates = [
nodePath.resolve(stagingDir, tableId, 'staged.json'),
nodePath.resolve(stagingDir, tableId + '.json')
]
for (const f of candidates) {
if (fs.existsSync(f)) {
return JSON.parse(fs.readFileSync(f, { encoding: 'utf8' }).toString())
}
}
return null
}
/**
* Copies audit items from the Stored Program's session directory (its
* current working directory at runtime) into the staging subfolder.
* - log.log -> <prefix>.log
* - code.js -> <prefix>_code.js
* - reqHeaders.txt -> <prefix>_reqHeaders.txt
*/
function copySessionAuditItems(stageFolder, prefix) {
try {
const sessionDir = nodePath.dirname(weboutPath)
const copies = [
['log.log', prefix + '.log'],
['code.js', prefix + '_code.js'],
['reqHeaders.txt', prefix + '_reqHeaders.txt']
]
for (const [src, dest] of copies) {
const srcPath = nodePath.resolve(sessionDir, src)
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, nodePath.resolve(stageFolder, dest))
}
}
} catch (err) {
console.log('Error copying session audit items: ' + err.message)
}
}
// ─── Parse input (SASCONTROLTABLE: ACTION / TABLE / DIFFTIME) ────────────────
const _sctRow = fetchTable('SASControlTable')[0] || {}
const action = _sctRow.ACTION || ''
const loadRef = _sctRow.TABLE || _sctRow.table || ''
// ─── Load submission from MPE_SUBMIT ─────────────────────────────────────────
const submitData = loadTableData('MPE_SUBMIT') || { rows: [] }
const submitRow = submitData.rows.find((r) => r.TABLE_ID === loadRef) || {}
const libref = submitRow.BASE_LIB || dcLibref
const dsn = submitRow.BASE_DS || ''
// ─── Load table registration from MPE_TABLES (BUSKEY, LOADTYPE, temporal vars)
const mpeTablesData = loadTableData('MPE_TABLES') || { rows: [] }
const tableReg =
mpeTablesData.rows.find(
(r) => (r.libref || r.LIBREF) === libref && (r.dsn || r.DSN) === dsn
) || {}
const buskey = String(tableReg.buskey || tableReg.BUSKEY || '')
.trim()
.split(/\s+/)
.filter(Boolean)
const loadtype = tableReg.loadtype || tableReg.LOADTYPE || 'UPDATE'
const varTxfrom = tableReg.var_txfrom || tableReg.VAR_TXFROM || ''
const varTxto = tableReg.var_txto || tableReg.VAR_TXTO || ''
const varBusfrom = tableReg.var_busfrom || tableReg.VAR_BUSFROM || ''
const varBusto = tableReg.var_busto || tableReg.VAR_BUSTO || ''
const numApprovalsRequired =
submitRow.NUM_OF_APPROVALS_REQUIRED || tableReg.num_of_approvals_required || 1
const numApprovalsRemaining =
submitRow.NUM_OF_APPROVALS_REMAINING || numApprovalsRequired
// ─── Load base table and staged data ─────────────────────────────────────────
const baseTableData = loadTableData(dsn) || { columns: [], rows: [] }
const baseCols = (baseTableData.columns || []).map((c) => ({
...c,
name: String(c.name).toUpperCase()
}))
const baseRows = baseTableData.rows || []
let stagedRows = []
const stageData = loadStagedData(loadRef)
if (stageData) {
stagedRows = stageData.rows || []
}
// ─── Column setup ─────────────────────────────────────────────────────────────
// Diff is displayed on non-temporal columns only (tech + business dates are
// managed by the loader, as in %quickmacro in postdata.sas)
const excludeCols = new Set(
[varTxfrom, varTxto, varBusfrom, varBusto]
.filter(Boolean)
.map((v) => String(v).toUpperCase())
)
const diffCols = baseCols.filter((c) => !excludeCols.has(c.name))
const colDdtypes = {}
for (const col of diffCols) colDdtypes[col.name] = getDdType(col)
// ─── Value normalisation ──────────────────────────────────────────────────────
// Staged values arrive from CSV as strings ("42"), base values are native JSON
// (42). Compare numerics by value and strings case-sensitively.
function normVal(value, colName) {
const col = diffCols.find((c) => c.name === colName)
if (col && col.type === 'N') {
if (value === null || value === undefined || value === '') return null
const num = Number(value)
return isNaN(num) ? null : num
}
if (value === null || value === undefined) return null
return String(value)
}
function rowKeyOf(row) {
return buskey
.map((k) => {
const actual = Object.keys(row).find(
(kk) => kk.toUpperCase() === k.toUpperCase()
)
return normVal(row[actual], k)
})
.join('|')
}
function stripRow(row) {
// rebuild in base table column order, dropping the delete flag and any
// staged column not present in the base table (mirrors the SAS data step
// "if 0 then set &emptybasetable; set &inds;" in %quickmacro)
const out = {}
for (const col of diffCols) {
const actual = Object.keys(row).find((k) => k.toUpperCase() === col.name)
if (actual === undefined) {
out[col.name] = null
} else if (col.type === 'N') {
out[col.name] = normVal(row[actual], col.name)
} else {
out[col.name] = row[actual] === undefined ? null : String(row[actual])
}
}
return out
}
// ─── Compute the diff (mirrors %mpe_targetloader -> %bitemporal_dataloader) ──
const DELETE_FLAG = '_____DELETE__THIS__RECORD_____'
const deleted = []
const newRows = []
const updatedRows = []
const originals = []
if (loadtype === 'REPLACE') {
// full replace: every staged record is NEW and every previous base
// record is DELETED (mirrors the LOADTARGET=NO REPLACE branch of
// %mpe_targetloader)
for (const row of stagedRows) newRows.push(stripRow(row))
for (const row of baseRows) deleted.push(stripRow(row))
} else {
// index base rows by buskey
const baseByBuskey = {}
for (const row of baseRows) {
baseByBuskey[rowKeyOf(row)] = row
}
for (const row of stagedRows) {
const stripped = stripRow(row)
const key = rowKeyOf(row)
const deleteFlagRaw = Object.keys(row).find(
(k) => k.toUpperCase() === DELETE_FLAG
)
const isDelete =
deleteFlagRaw && String(row[deleteFlagRaw]).trim().toUpperCase() === 'YES'
if (isDelete) {
const baseRow = baseByBuskey[key]
if (baseRow) deleted.push(stripRow(baseRow))
continue
}
const baseRow = baseByBuskey[key]
if (!baseRow) {
newRows.push(stripped)
continue
}
// compare non-key values
const strippedBase = stripRow(baseRow)
let changed = false
for (const col of diffCols) {
const name = col.name
if (buskey.includes(name)) continue
if (String(stripped[name]) !== String(strippedBase[name])) {
changed = true
break
}
}
if (changed) {
updatedRows.push(stripped)
originals.push(strippedBase)
}
}
}
// ─── Sort by buskey (SAS orders diffs by business key) ────────────────────────
function sortRows(rows) {
return rows.slice().sort((a, b) => {
for (const k of buskey) {
const av = a[k],
bv = b[k]
if (av === bv) continue
if (typeof av === 'number' && typeof bv === 'number') return av - bv
return String(av) < String(bv) ? -1 : 1
}
return 0
})
}
const sortedDeleted = sortRows(deleted)
const sortedNew = sortRows(newRows)
const sortedUpdates = sortRows(updatedRows)
const sortedOriginals = sortRows(originals)
// ─── maxdiff truncation (same as &maxdiff=100 in postdata.sas) ───────────────
const maxdiff = 100
// ─── cols table (proc contents of work.updates) ───────────────────────────────
const cols = diffCols.map((col) => {
const fmtname = (col.format || '')
.replace(/[\d.]+$/, '')
.replace('datetime', 'DATETIME')
.replace('date', 'DATE')
.replace('time', 'TIME')
.replace('best', 'BEST')
return {
NAME: col.name,
TYPE: col.type === 'N' ? 1 : 2,
LENGTH: col.length,
FORMAT: col.type === 'N' ? fmtname.toUpperCase() : ''
}
})
// ─── formatted variants (fmt_* tables in %webout) ─────────────────────────────
function fmtRow(row) {
const out = {}
for (const key of Object.keys(row)) {
const ddtype = colDdtypes[key]
if (ddtype === 'DATE' || ddtype === 'DATETIME' || ddtype === 'TIME') {
out[key] = formatSasValue(row[key], ddtype)
} else if (row[key] === null || row[key] === undefined) {
out[key] = ''
} else {
out[key] = String(row[key])
}
}
return out
}
// ─── other submits against the same base table (max 10) ──────────────────────
const submits = submitData.rows
.filter(
(r) =>
r.SUBMIT_STATUS_CD === 'SUBMITTED' &&
r.BASE_LIB === libref &&
r.BASE_DS === dsn &&
r.TABLE_ID !== loadRef
)
.sort((a, b) => b.SUBMITTED_ON_DTTM - a.SUBMITTED_ON_DTTM)
.slice(0, 10)
// ─── TEMPDIFFS csv (kept in the staging subfolder for audit) ──────────────────
function sasDateTimeNow() {
const d = new Date()
const pad = (n, w) => String(n).padStart(w || 2, '0')
return (
d.getFullYear() +
pad(d.getMonth() + 1) +
pad(d.getDate()) +
'_' +
pad(d.getHours()) +
pad(d.getMinutes()) +
pad(d.getSeconds())
)
}
// ─── helpers for datetime formatting ─────────────────────────────────────────
function sasDt(d) {
if (!d) return '.'
const pad = (n) => String(n).padStart(2, '0')
const MONTHS = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
return (
pad(d.getDate()) +
MONTHS[d.getMonth()] +
d.getFullYear() +
':' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
)
}
const now = new Date()
const sastime = now.getTime() / 1000
const userNm =
typeof _METAUSER !== 'undefined' && _METAUSER ? _METAUSER : 'sasdemo'
// ─── APPLY THE LOAD (used by both SHOW_DIFFS temp-table creation and
// APPROVE_TABLE actual load) ──────────────────────────────────────────────
// For UPDATE / TXTEMPORAL / BITEMPORAL: upsert staged rows into the base table
// (new rows appended, updated rows replaced, deleted rows removed).
// For REPLACE: wipe the base table and append all staged rows.
// For FORMAT_CAT: not supported in the mock (no format catalog engine).
function applyLoadToBase() {
const updatedBaseRows = baseRows.map((r) => ({ ...r }))
if (loadtype === 'REPLACE') {
// replace all base rows with staged rows (minus delete flag / temporal cols)
return stagedRows.map(stripRow)
}
// index base rows by buskey for quick lookup
const baseByBuskey = {}
for (let i = 0; i < updatedBaseRows.length; i++) {
baseByBuskey[rowKeyOf(updatedBaseRows[i])] = i
}
for (const row of stagedRows) {
const stripped = stripRow(row)
const key = rowKeyOf(row)
const deleteFlagRaw = Object.keys(row).find(
(k) => k.toUpperCase() === DELETE_FLAG
)
const isDelete =
deleteFlagRaw && String(row[deleteFlagRaw]).trim().toUpperCase() === 'YES'
if (isDelete) {
const idx = baseByBuskey[key]
if (idx !== undefined) {
updatedBaseRows.splice(idx, 1)
// rebuild index after splice
for (let i = 0; i < updatedBaseRows.length; i++) {
baseByBuskey[rowKeyOf(updatedBaseRows[i])] = i
}
}
continue
}
const idx = baseByBuskey[key]
if (idx !== undefined) {
updatedBaseRows[idx] = stripped
} else {
updatedBaseRows.push(stripped)
baseByBuskey[key] = updatedBaseRows.length - 1
}
}
return updatedBaseRows
}
// ─── Route by action ─────────────────────────────────────────────────────────
if (action === 'SHOW_DIFFS') {
// ── SHOW_DIFFS: build diff tables for the approval screen ──────────────────
const stageFolder = getStageFolder(loadRef)
const tempDiffsCsv = 'tempDiffs_' + sasDateTimeNow() + '.csv'
try {
const csvLines = ['_____status,' + diffCols.map((c) => c.name).join(',')]
const csvEscape = (v) =>
v === null || v === undefined
? ''
: '"' + String(v).replace(/"/g, '""') + '"'
const pushRows = (rows, status) => {
for (const row of rows) {
csvLines.push(
csvEscape(status) +
',' +
diffCols.map((c) => csvEscape(row[c.name])).join(',')
)
}
}
pushRows(sortedDeleted, 'DELETED ')
pushRows(sortedNew, 'NEW')
pushRows(sortedUpdates, 'UPDATED')
pushRows(sortedOriginals, 'ORIGINAL')
fs.writeFileSync(
nodePath.resolve(stageFolder, tempDiffsCsv),
csvLines.join('\n')
)
} catch (err) {
console.log('Error writing TEMPDIFFS csv: ' + err.message)
}
let filesizeRaw = 0
try {
filesizeRaw = fs.statSync(nodePath.resolve(stageFolder, tempDiffsCsv)).size
} catch (err) {
/* csv not written */
}
const submittedOn = submitRow.SUBMITTED_ON_DTTM
? new Date(submitRow.SUBMITTED_ON_DTTM * 1000)
: null
const truncated =
sortedOriginals.length > maxdiff ||
sortedNew.length > maxdiff ||
sortedDeleted.length > maxdiff ||
sortedUpdates.length > maxdiff
const params = [
{
TABLE_ID: submitRow.TABLE_ID,
SUBMIT_STATUS_CD: submitRow.SUBMIT_STATUS_CD,
BASE_LIB: libref,
BASE_DS: dsn,
SUBMITTED_BY_NM: submitRow.SUBMITTED_BY_NM || '',
SUBMITTED_ON_DTTM: submitRow.SUBMITTED_ON_DTTM,
SUBMITTED_REASON_TXT: submitRow.SUBMITTED_REASON_TXT || '',
INPUT_OBS: submitRow.INPUT_OBS,
INPUT_VARS: submitRow.INPUT_VARS,
NUM_OF_APPROVALS_REQUIRED: numApprovalsRequired,
NUM_OF_APPROVALS_REMAINING: numApprovalsRemaining,
REVIEWED_BY_NM: submitRow.REVIEWED_BY_NM || '',
REVIEWED_ON_DTTM: submitRow.REVIEWED_ON_DTTM || null,
LIBDS: libref + '.' + dsn,
REVIEWED_ON: submitRow.REVIEWED_ON_DTTM
? sasDt(new Date(submitRow.REVIEWED_ON_DTTM * 1000))
: '.',
DIFFTIME: sasDt(now),
DIFFS_CSV: tempDiffsCsv,
FILESIZE: (filesizeRaw / 1024).toFixed(1) + 'KB',
FILESIZE_RAW: filesizeRaw,
TRUNCATED: truncated ? 'YES' : 'NO',
NUM_ADDED: sortedNew.length,
NUM_DELETED: sortedDeleted.length,
NUM_UPDATED: sortedUpdates.length,
SUBMITTED_ON: sasDt(submittedOn),
ISAPPROVER: 'YES'
}
]
// already REVIEWED / APPROVED submissions just return params (mirrors postdata.sas)
if (
submitRow.SUBMIT_STATUS_CD === 'REJECTED' ||
submitRow.SUBMIT_STATUS_CD === 'APPROVED'
) {
webOutOpen()
webOutObj([submitRow], 'params')
webOutClose()
} else {
webOutOpen()
webOutObj(params, 'params')
webOutObj(cols, 'cols')
webOutObj(submits, 'submits')
webOutObj(sortedDeleted.slice(0, maxdiff), 'deleted')
webOutObj(sortedNew.slice(0, maxdiff), 'new')
webOutObj(sortedUpdates.slice(0, maxdiff), 'updates')
webOutObj(sortedOriginals, 'originals')
webOutObj(sortedDeleted.slice(0, maxdiff).map(fmtRow), 'fmt_deleted')
webOutObj(sortedNew.slice(0, maxdiff).map(fmtRow), 'fmt_new')
webOutObj(sortedUpdates.slice(0, maxdiff).map(fmtRow), 'fmt_updates')
webOutObj(sortedOriginals.map(fmtRow), 'fmt_originals')
webOutClose()
}
} else if (action === 'APPROVE_TABLE') {
// ── APPROVE_TABLE: apply the load to the base table and record the approval
const stageFolder = getStageFolder(loadRef)
// check: has this user already approved? (mirrors prev_upload_check in postdata.sas)
const reviewData = loadTableData('MPE_REVIEW') || { rows: [] }
const alreadyApproved = reviewData.rows.some(
(r) =>
r.TABLE_ID === loadRef &&
r.REVIEWED_BY_NM === userNm &&
r.REVIEW_STATUS_ID !== 'SUBMITTED'
)
if (alreadyApproved) {
webOutOpen()
webOutObj([{ AUTHORISED: 1, PREV_UPLOAD_CHECK: 1 }], 'apPARAMS')
webOutClose()
} else if (numApprovalsRemaining > 1) {
// multi-approval table: record this approval but don't load yet
const apprNo = numApprovalsRequired - numApprovalsRemaining + 1
reviewData.rows.push({
TABLE_ID: loadRef,
BASE_TABLE: libref + '.' + dsn,
REVIEW_STATUS_ID: 'APPROVED',
REVIEWED_BY_NM: userNm,
REVIEWED_ON_DTTM: sastime,
REVIEW_REASON_TXT: 'APPROVAL ' + apprNo + ' of ' + numApprovalsRequired
})
saveTableData('MPE_REVIEW', reviewData)
// decrement remaining approvals in MPE_SUBMIT
const subRow = submitData.rows.find((r) => r.TABLE_ID === loadRef)
if (subRow) {
subRow.NUM_OF_APPROVALS_REMAINING = numApprovalsRemaining - 1
subRow.REVIEWED_BY_NM = userNm
subRow.REVIEWED_ON_DTTM = sastime
}
saveTableData('MPE_SUBMIT', submitData)
// copy audit items from this session into the staging subfolder
copySessionAuditItems(stageFolder, 'approve_' + apprNo)
const apReqd = [
{
AUTHORISED: 1,
ALREADY_UPDATED: 0,
ALREADY_UPDATED_DTTM: null,
...submitRow
}
]
webOutOpen()
webOutObj(apReqd, 'apReqd')
webOutClose()
} else {
// ── final approval: apply the load to the base table ─────────────────────
// write the ActualDiffs csv (mirrors approve.ActualDiffs in postdata.sas)
try {
const csvLines = [
'_____STATUS_____,' + diffCols.map((c) => c.name).join(',')
]
const csvEscape = (v) =>
v === null || v === undefined
? ''
: '"' + String(v).replace(/"/g, '""') + '"'
const pushRows = (rows, status) => {
for (const row of rows) {
csvLines.push(
csvEscape(status) +
',' +
diffCols.map((c) => csvEscape(row[c.name])).join(',')
)
}
}
pushRows(sortedDeleted, 'DELETED')
pushRows(sortedNew, 'NEW')
pushRows(sortedUpdates, 'UPDATED')
fs.writeFileSync(
nodePath.resolve(stageFolder, 'ActualDiffs.csv'),
csvLines.join('\n')
)
} catch (err) {
console.log('Error writing ActualDiffs csv: ' + err.message)
}
// apply the load to the base table
const newBaseRows = applyLoadToBase()
const updatedTableData = {
name: baseTableData.name || dsn,
columns: baseTableData.columns || baseCols,
rows: newBaseRows
}
saveTableData(dsn, updatedTableData)
// record the approval in MPE_REVIEW
const apprNo = numApprovalsRequired
reviewData.rows.push({
TABLE_ID: loadRef,
BASE_TABLE: libref + '.' + dsn,
REVIEW_STATUS_ID: 'APPROVED',
REVIEWED_BY_NM: userNm,
REVIEWED_ON_DTTM: sastime,
REVIEW_REASON_TXT: 'APPROVAL ' + apprNo + ' of ' + numApprovalsRequired
})
saveTableData('MPE_REVIEW', reviewData)
// update MPE_SUBMIT to APPROVED
const subRow = submitData.rows.find((r) => r.TABLE_ID === loadRef)
if (subRow) {
subRow.SUBMIT_STATUS_CD = 'APPROVED'
subRow.NUM_OF_APPROVALS_REMAINING = 0
subRow.REVIEWED_BY_NM = userNm
subRow.REVIEWED_ON_DTTM = sastime
}
saveTableData('MPE_SUBMIT', submitData)
// record in MPE_DATALOADS (mirrors the dataloads entry in postdata.sas)
const dataloadsData = loadTableData('MPE_DATALOADS') || { rows: [] }
dataloadsData.rows.push({
LIBREF: libref,
DSN: dsn,
ETLSOURCE: loadRef,
LOADTYPE: loadtype,
CHANGED_RECORDS: sortedUpdates.length,
NEW_RECORDS: sortedNew.length,
DELETED_RECORDS: sortedDeleted.length,
DURATION: 0,
USER_NM: userNm,
PROCESSED_DTTM: sastime,
MAC_VER: 'MOCK'
})
saveTableData('MPE_DATALOADS', dataloadsData)
// copy audit items from this session into the staging subfolder
copySessionAuditItems(stageFolder, 'approve')
const apPARAMS = [
{
AUTHORISED: 1,
ALREADY_UPDATED: 0,
ALREADY_UPDATED_DTTM: null,
DIFFTIME: _sctRow.DIFFTIME || sasDt(now),
RESPONSE: 'SUCCESS!'
}
]
webOutOpen()
webOutObj(apPARAMS, 'apPARAMS')
webOutClose()
}
} else {
// unsupported action
webOutOpen()
webOutObj([], 'params')
webOutClose()
}
`
+2 -2
View File
@@ -158,11 +158,11 @@ function formatSasValue(value, ddtype) {
if (ddtype === 'DATE') {
const d = new Date(SAS_EPOCH_MS + value * 86400000)
return `${d.getUTCDate()}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}`
return `${String(d.getUTCDate()).padStart(2,'0')}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}`
}
if (ddtype === 'DATETIME') {
const d = new Date(SAS_EPOCH_MS + value * 1000)
return `${d.getUTCDate()}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}:${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}:${String(d.getUTCSeconds()).padStart(2,'0')}`
return `${String(d.getUTCDate()).padStart(2,'0')}${MONTHS_ABBR[d.getUTCMonth()]}${d.getUTCFullYear()}:${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}:${String(d.getUTCSeconds()).padStart(2,'0')}`
}
if (ddtype === 'TIME') {
const h = Math.floor(value / 3600)
+130 -52
View File
@@ -7,15 +7,44 @@ const dcLibref = 'DC_JSLIB'
const dataDir = nodePath.resolve(driveRoot, 'files', appLoc, 'data', dcLibref)
const stagingDir = nodePath.resolve(dataDir, 'staging')
if (!fs.existsSync(stagingDir)) fs.mkdirSync(stagingDir, { recursive: true })
eval(fs.readFileSync(nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'), 'utf8'))
eval(
fs.readFileSync(
nodePath.resolve(driveRoot, 'files', appLoc, 'services', 'dcMockUtils.js'),
'utf8'
)
)
const loadTableData = makeTableLoader(dataDir)
function saveTableData(tableName, tableData) {
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
fs.writeFileSync(file, JSON.stringify(tableData, null, 2))
const file = nodePath.resolve(dataDir, tableName.toLowerCase() + '.json')
fs.writeFileSync(file, JSON.stringify(tableData, null, 2))
}
/**
* Copies audit items from the Stored Program's session directory (its
* current working directory at runtime) into the staging subfolder:
* - log.log -> submit.log (runtime log of the submission)
* - code.js -> submit_code.js (program source as executed)
* - reqHeaders.txt -> submit_reqHeaders.txt
*/
function copySessionAuditItems(stageFolder, prefix) {
try {
const sessionDir = nodePath.dirname(weboutPath)
const copies = [
['log.log', prefix + '.log'],
['code.js', prefix + '_code.js'],
['reqHeaders.txt', prefix + '_reqHeaders.txt']
]
for (const [src, dest] of copies) {
const srcPath = nodePath.resolve(sessionDir, src)
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, nodePath.resolve(stageFolder, dest))
}
}
} catch (err) {
console.log('Error copying session audit items: ' + err.message)
}
}
// ─── Parse input ──────────────────────────────────────────────────────────────
@@ -32,74 +61,123 @@ const jsDataRaw = fetchRaw('jsdata')
let jsDataHeaders = []
let jsDataRows = []
if (jsDataRaw) {
try {
const lines = jsDataRaw.split(/\r?\n/)
if (lines.length >= 1) {
jsDataHeaders = lines[0].trim().split(/\s+/).map(h => h.split(':')[0])
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const values = []
let cur = '', inQuote = false
for (let j = 0; j < line.length; j++) {
const ch = line[j]
if (ch === '"') { if (inQuote && line[j+1] === '"') { cur += '"'; j++ } else inQuote = !inQuote }
else if (ch === ',' && !inQuote) { values.push(cur); cur = '' }
else cur += ch
}
values.push(cur)
const row = {}
for (let k = 0; k < jsDataHeaders.length && k < values.length; k++) row[jsDataHeaders[k]] = values[k]
jsDataRows.push(row)
}
try {
const lines = jsDataRaw.split(/\r?\n/)
if (lines.length >= 1) {
jsDataHeaders = lines[0]
.trim()
.split(/\s+/)
.map((h) => h.split(':')[0])
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const values = []
let cur = '',
inQuote = false
for (let j = 0; j < line.length; j++) {
const ch = line[j]
if (ch === '"') {
if (inQuote && line[j + 1] === '"') {
cur += '"'
j++
} else inQuote = !inQuote
} else if (ch === ',' && !inQuote) {
values.push(cur)
cur = ''
} else cur += ch
}
} catch(err) { console.log('Error parsing jsdata: ' + err.message) }
values.push(cur)
const row = {}
for (let k = 0; k < jsDataHeaders.length && k < values.length; k++)
row[jsDataHeaders[k]] = values[k]
jsDataRows.push(row)
}
}
} catch (err) {
console.log('Error parsing jsdata: ' + err.message)
}
}
// Convert formatted date/datetime/time strings back to SAS numeric values
const schema = loadTableSchema(dataDir, dsn)
if (schema.length > 0) {
const colDdtypes = {}
for (const col of schema) colDdtypes[col.name.toUpperCase()] = getDdType(col)
jsDataRows = jsDataRows.map(row => {
const converted = {}
for (const key of Object.keys(row)) {
const ddtype = colDdtypes[key.toUpperCase()]
converted[key] = ddtype ? parseFormattedToSas(row[key], ddtype) : row[key]
}
return converted
})
const colDdtypes = {}
for (const col of schema) colDdtypes[col.name.toUpperCase()] = getDdType(col)
jsDataRows = jsDataRows.map((row) => {
const converted = {}
for (const key of Object.keys(row)) {
const ddtype = colDdtypes[key.toUpperCase()]
converted[key] = ddtype ? parseFormattedToSas(row[key], ddtype) : row[key]
}
return converted
})
}
// ─── Generate table_id and stage the data ────────────────────────────────────
// One subfolder per submission (DSID) under the staging directory, so that the
// staged data can sit alongside related artifacts (submit/approval logs,
// temp diffs etc) for the audit trail.
const now = new Date()
const pad = (n, w) => String(n).padStart(w, '0')
const dtStr = now.getFullYear() + pad(now.getMonth()+1,2) + pad(now.getDate(),2) + '-' +
pad(now.getHours(),2) + pad(now.getMinutes(),2) + pad(now.getSeconds(),2) + '.' +
pad(now.getMilliseconds(),3)
const dtStr =
now.getFullYear() +
pad(now.getMonth() + 1, 2) +
pad(now.getDate(), 2) +
'-' +
pad(now.getHours(), 2) +
pad(now.getMinutes(), 2) +
pad(now.getSeconds(), 2) +
'.' +
pad(now.getMilliseconds(), 3)
const rand6 = pad(Math.floor(Math.random() * 1000000), 6)
const pid4 = pad(process.pid % 10000, 4)
const tableId = `DC${dtStr}_${rand6}_${pid4}`
const stageFile = nodePath.resolve(stagingDir, tableId + '.json')
const stageFolder = nodePath.resolve(stagingDir, tableId)
fs.mkdirSync(stageFolder, { recursive: true })
const stageData = {
table_id: tableId, libds, libref, dsn,
headers: jsDataHeaders, rows: jsDataRows
table_id: tableId,
libds,
libref,
dsn,
submitted_by_nm:
typeof _METAUSER !== 'undefined' && _METAUSER ? _METAUSER : 'sasdemo',
submitted_on_dttm: now.getTime() / 1000,
headers: jsDataHeaders,
rows: jsDataRows
}
fs.writeFileSync(stageFile, JSON.stringify(stageData, null, 2))
fs.writeFileSync(
nodePath.resolve(stageFolder, 'staged.json'),
JSON.stringify(stageData, null, 2)
)
// keep audit items from this Stored Program run alongside the staged data
copySessionAuditItems(stageFolder, 'submit')
// Insert a record into MPE_SUBMIT
const submitTable = loadTableData('MPE_SUBMIT') || { name: 'MPE_SUBMIT', columns: [], rows: [] }
const submitTable = loadTableData('MPE_SUBMIT') || {
name: 'MPE_SUBMIT',
columns: [],
rows: []
}
submitTable.rows.push({
TABLE_ID: tableId, SUBMIT_STATUS_CD: 'SUBMITTED',
BASE_LIB: libref, BASE_DS: dsn,
SUBMITTED_BY_NM: 'sasdemo', SUBMITTED_ON_DTTM: now.getTime() / 1000,
SUBMITTED_REASON_TXT: message,
INPUT_OBS: jsDataRows.length,
INPUT_VARS: jsDataHeaders.filter(h => h !== '_____DELETE__THIS__RECORD_____').length,
NUM_OF_APPROVALS_REQUIRED: 1, NUM_OF_APPROVALS_REMAINING: 1,
REVIEWED_BY_NM: '', REVIEWED_ON_DTTM: null
TABLE_ID: tableId,
SUBMIT_STATUS_CD: 'SUBMITTED',
BASE_LIB: libref,
BASE_DS: dsn,
SUBMITTED_BY_NM: stageData.submitted_by_nm,
SUBMITTED_ON_DTTM: stageData.submitted_on_dttm,
SUBMITTED_REASON_TXT: message,
INPUT_OBS: jsDataRows.length,
INPUT_VARS: jsDataHeaders.filter(
(h) => h !== '_____DELETE__THIS__RECORD_____'
).length,
NUM_OF_APPROVALS_REQUIRED: 1,
NUM_OF_APPROVALS_REMAINING: 1,
REVIEWED_BY_NM: '',
REVIEWED_ON_DTTM: null
})
saveTableData('MPE_SUBMIT', submitTable)