@tasksai/install 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/bootstrap/Install RealtorTasksAI.command +37 -0
- package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
- package/package.json +14 -3
- package/runtime/document_renderer.py +882 -0
- package/runtime/server.py +55 -581
- package/runtime/software_use.py +39 -0
- package/runtime/workflow-requirements.txt +5 -0
- package/runtime/workflows/__init__.py +0 -0
- package/runtime/workflows/account_snapshot.py +33 -0
- package/runtime/workflows/attachments.py +45 -0
- package/runtime/workflows/authority_guides.py +81 -0
- package/runtime/workflows/catalog.py +51 -0
- package/runtime/workflows/catalog_app.py +174 -0
- package/runtime/workflows/catalog_generation.py +186 -0
- package/runtime/workflows/catalog_output.py +312 -0
- package/runtime/workflows/catalog_suggestions.py +51 -0
- package/runtime/workflows/catalog_workspace.py +152 -0
- package/runtime/workflows/cli.py +66 -0
- package/runtime/workflows/document_answers.py +96 -0
- package/runtime/workflows/document_selection.py +50 -0
- package/runtime/workflows/documents.py +243 -0
- package/runtime/workflows/embedded/catalog.html +83 -0
- package/runtime/workflows/embedded/offer-review.html +516 -0
- package/runtime/workflows/embedded/preview.html +36 -0
- package/runtime/workflows/embedded_actions.py +96 -0
- package/runtime/workflows/embedded_demo.py +424 -0
- package/runtime/workflows/folders.py +120 -0
- package/runtime/workflows/gateway.py +157 -0
- package/runtime/workflows/generation_lock.py +26 -0
- package/runtime/workflows/launcher.py +61 -0
- package/runtime/workflows/licensed_delivery.py +46 -0
- package/runtime/workflows/mcp_dev.py +52 -0
- package/runtime/workflows/meeting_plan.py +92 -0
- package/runtime/workflows/model_client.py +26 -0
- package/runtime/workflows/numeric_consistency.py +72 -0
- package/runtime/workflows/public_source_fetch.py +66 -0
- package/runtime/workflows/realtor/__init__.py +0 -0
- package/runtime/workflows/realtor/adapter.py +179 -0
- package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
- package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
- package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
- package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
- package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
- package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
- package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
- package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
- package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
- package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
- package/runtime/workflows/realtor-release-registry.json +705 -0
- package/runtime/workflows/released_catalog.py +91 -0
- package/runtime/workflows/source_capture.py +82 -0
- package/runtime/workflows/store.py +492 -0
- package/runtime/workflows/table_calculations.py +132 -0
- package/runtime/workflows/template.py +65 -0
- package/runtime/workflows/workspace_launch.py +76 -0
- package/src/index.js +217 -118
- package/src/managed-python.js +63 -0
- package/src/operation-lock.js +22 -0
- package/src/prepared-update.js +86 -0
- package/src/private-workflow.js +116 -0
- package/src/python-runtime.js +50 -0
- package/src/recover-installation.js +30 -0
- package/src/recovery-lock.js +30 -0
- package/src/software-hash.js +24 -0
- package/src/software-use.js +32 -0
- package/src/update-journal.js +24 -0
- package/src/update-recovery.js +72 -0
- package/src/workspace-runtime.js +45 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Optional host adapter. Requires @oai/artifact-tool; the core engine does not.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Workbook, SpreadsheetFile } from '@oai/artifact-tool';
|
|
5
|
+
|
|
6
|
+
const [input, output] = process.argv.slice(2);
|
|
7
|
+
if (!input || !output) throw new Error('Usage: build_workbook.mjs result.json NEW_OUTPUT_DIR');
|
|
8
|
+
const data = JSON.parse(await fs.readFile(input, 'utf8'));
|
|
9
|
+
const wb = Workbook.create();
|
|
10
|
+
const summary = wb.worksheets.add('Comparison');
|
|
11
|
+
const inputs = wb.worksheets.add('Offer inputs');
|
|
12
|
+
const costs = wb.worksheets.add('Costs');
|
|
13
|
+
const evidence = wb.worksheets.add('Evidence');
|
|
14
|
+
const queue = wb.worksheets.add('Verification');
|
|
15
|
+
const navy = '#233E53', pale = '#F0F4F8', ink = '#1B2935';
|
|
16
|
+
const currency = '"$"#,##0.00;("$"#,##0.00);"$"0.00';
|
|
17
|
+
const literal = x => typeof x === 'string' && /^[=+@-]/.test(x) ? "'" + x : x;
|
|
18
|
+
const q = s => '"' + String(s).replaceAll('"', '""') + '"';
|
|
19
|
+
const at = (s, address, val) => { s.getRange(address).values = [[literal(val ?? 'Not supplied')]]; };
|
|
20
|
+
const f = (s, address, formula) => { s.getRange(address).formulas = [[formula]]; };
|
|
21
|
+
const stamp = v => v ? new Date(`${v}T00:00:00Z`) : 'Source date not supplied';
|
|
22
|
+
function base(s, lastCol, rows, widths) {
|
|
23
|
+
const range = s.getRange(`A1:${lastCol}${Math.max(6, rows)}`);
|
|
24
|
+
range.format.font = {name:'Arial', size:11, color:ink};
|
|
25
|
+
range.format.rowHeight = 25;
|
|
26
|
+
range.format.verticalAlignment = 'center';
|
|
27
|
+
s.showGridLines = false;
|
|
28
|
+
widths.forEach((w,i) => { s.getRangeByIndexes(0,i,Math.max(6, rows),1).format.columnWidth = w; });
|
|
29
|
+
}
|
|
30
|
+
function heading(s, row, names) {
|
|
31
|
+
s.getRangeByIndexes(row-1,0,1,names.length).values = [names];
|
|
32
|
+
s.getRangeByIndexes(row-1,0,1,names.length).format = {fill:navy, font:{name:'Arial', size:11, bold:true, color:'#FFFFFF'}, wrapText:true, rowHeight:36};
|
|
33
|
+
}
|
|
34
|
+
function band(s, row, cols) {
|
|
35
|
+
if (row % 2 === 0) s.getRangeByIndexes(row-1,0,1,cols).format.fill = pale;
|
|
36
|
+
}
|
|
37
|
+
base(inputs,'G',100,[15,23,36,36,18,28,33]);
|
|
38
|
+
at(inputs,'A2','Offer inputs');
|
|
39
|
+
inputs.getRange('A2').format.font = {name:'Arial',size:16,bold:true};
|
|
40
|
+
at(inputs,'A3','Edit column C for scenarios; column D preserves the generated input. Regenerate all files after edits.');
|
|
41
|
+
heading(inputs,5,['Offer','Field','Editable value','Original value','Source','Location','Recorded confirmation']);
|
|
42
|
+
const priceRows = {}, termRows = {}, originalComparisons = {};
|
|
43
|
+
let ir = 6;
|
|
44
|
+
for (const offer of data.offers) {
|
|
45
|
+
originalComparisons[offer.id] = [];
|
|
46
|
+
termRows[offer.id] = {};
|
|
47
|
+
for (const [key,val] of [['price',offer.price],['earnest_money',offer.earnest_money],...Object.entries(offer.terms)]) {
|
|
48
|
+
const ev = data.evidence.find(x => x.target === `${offer.id}.${key}`);
|
|
49
|
+
let typed = val ?? 'Not supplied';
|
|
50
|
+
if (val !== null && ['price','earnest_money'].includes(key)) typed = Number(val);
|
|
51
|
+
if (val && key === 'closing') typed = stamp(val);
|
|
52
|
+
inputs.getRange(`A${ir}:G${ir}`).values = [[offer.id,key.replaceAll('_',' '),literal(typed),literal(typed),ev?.source_id ?? 'Missing',literal(ev?.locator ?? 'Missing'),literal(ev?.verified_by ? `${ev.verified_by} on ${ev.verified_on}` : 'Not confirmed')]];
|
|
53
|
+
inputs.getRange(`C${ir}`).format.font.color = '#0000FF';
|
|
54
|
+
inputs.getRange(`A${ir}:G${ir}`).format.wrapText = true;
|
|
55
|
+
inputs.getRange(`E${ir}`).format.horizontalAlignment = 'center';
|
|
56
|
+
if (['price','earnest_money'].includes(key)) inputs.getRange(`C${ir}:D${ir}`).setNumberFormat(currency);
|
|
57
|
+
if (key === 'closing' && val) inputs.getRange(`C${ir}:D${ir}`).setNumberFormat('yyyy-mm-dd');
|
|
58
|
+
if (key === 'price') priceRows[offer.id] = ir;
|
|
59
|
+
termRows[offer.id][key] = ir;
|
|
60
|
+
originalComparisons[offer.id].push(`'Offer inputs'!C${ir}='Offer inputs'!D${ir}`);
|
|
61
|
+
inputs.getRange(`A${ir}:G${ir}`).format.rowHeight = 66;
|
|
62
|
+
band(inputs,ir,7);
|
|
63
|
+
ir++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
inputs.freezePanes.freezeRows(5);
|
|
67
|
+
base(costs,'L',400,[13,30,15,17,10,14,13,15,18,18,18,18]);
|
|
68
|
+
at(costs,'A2','Seller costs');
|
|
69
|
+
costs.getRange('A2').format.font = {name:'Arial',size:16,bold:true};
|
|
70
|
+
at(costs,'A3','D uses dollars for fixed and daily amounts, percent points for percent items. Blue text is editable.');
|
|
71
|
+
at(costs,'A4','J is the signed base effect. K and L are conditional adjustments, not complete proceeds. Source details are in Evidence.');
|
|
72
|
+
heading(costs,5,['Offer','Item','Calculation','Amount or rate','Days','Direction','Payer','Treatment','Computed amount','Base effect','Low adjustment','High adjustment']);
|
|
73
|
+
const costSpans = {};
|
|
74
|
+
let cr = 6;
|
|
75
|
+
for (const offer of data.offers) {
|
|
76
|
+
const start = cr;
|
|
77
|
+
for (const line of offer.costs) {
|
|
78
|
+
const a = line.amount;
|
|
79
|
+
const raw = a.kind === 'percent' ? a.rate : a.kind === 'per_diem' ? a.daily_rate : a.value;
|
|
80
|
+
const v = raw == null ? 'Not supplied' : Number(raw);
|
|
81
|
+
const days = a.kind === 'per_diem' ? (a.days ?? 'Not supplied') : '';
|
|
82
|
+
const vals = [offer.id,`${line.id}: ${line.label}`,a.kind,v,days,line.direction,line.payer,line.treatment];
|
|
83
|
+
costs.getRange(`A${cr}:H${cr}`).values = [vals.map(literal)];
|
|
84
|
+
costs.getRange(`C${cr}:H${cr}`).format.font.color = '#0000FF';
|
|
85
|
+
costs.getRange(`B${cr}`).format.wrapText = true;
|
|
86
|
+
costs.getRange(`A${cr}:L${cr}`).format.rowHeight = 45;
|
|
87
|
+
const p = `'Offer inputs'!C${priceRows[offer.id]}`;
|
|
88
|
+
f(costs,`I${cr}`,`=IF(AND(ISNUMBER(D${cr}),D${cr}>=0),IF(C${cr}="fixed",IF(ROUND(D${cr},2)=D${cr},D${cr},"Check cents"),IF(C${cr}="percent",IF(AND(D${cr}<=100,ISNUMBER(${p}),${p}>0),ROUND(${p}*D${cr}/100,2),"Check price or rate"),IF(C${cr}="per_diem",IF(AND(ISNUMBER(E${cr}),E${cr}>=0,E${cr}<=3660,MOD(E${cr},1)=0,ROUND(D${cr},2)=D${cr}),ROUND(D${cr}*E${cr},2),"Check days or cents"),"Check calculation"))),"Amount not supplied")`);
|
|
89
|
+
const allocation = `AND(OR(F${cr}="deduction",F${cr}="credit"),OR(G${cr}="seller",G${cr}="buyer",G${cr}="unknown"),OR(H${cr}="base",H${cr}="conditional",H${cr}="excluded"))`;
|
|
90
|
+
f(costs,`J${cr}`,`=IF(${allocation},IF(OR(G${cr}="buyer",H${cr}="excluded"),0,IF(ISNUMBER(I${cr}),IF(AND(G${cr}="seller",H${cr}="base"),IF(F${cr}="deduction",-I${cr},I${cr}),0),"Amount not supplied")),"Check allocation")`);
|
|
91
|
+
for (const [col,direction] of [['K','deduction'],['L','credit']]) {
|
|
92
|
+
f(costs,`${col}${cr}`,`=IF(${allocation},IF(OR(G${cr}="buyer",H${cr}="excluded"),0,IF(ISNUMBER(I${cr}),IF(AND(OR(G${cr}="unknown",H${cr}="conditional"),F${cr}=${q(direction)}),${direction === 'deduction' ? '-' : ''}I${cr},0),"Amount not supplied")),"Check allocation")`);
|
|
93
|
+
}
|
|
94
|
+
for (const [index,val] of vals.entries()) {
|
|
95
|
+
if (index < 2) continue;
|
|
96
|
+
const col = String.fromCharCode(65+index);
|
|
97
|
+
originalComparisons[offer.id].push(`'Costs'!${col}${cr}=${typeof val === 'number' ? val : q(val)}`);
|
|
98
|
+
}
|
|
99
|
+
band(costs,cr,12);
|
|
100
|
+
cr++;
|
|
101
|
+
}
|
|
102
|
+
costSpans[offer.id] = [start,cr-1];
|
|
103
|
+
}
|
|
104
|
+
costs.getRange(`D6:D${cr-1}`).setNumberFormat('0.00####');
|
|
105
|
+
costs.getRange(`I6:L${cr-1}`).setNumberFormat(currency);
|
|
106
|
+
costs.getRange(`C6:C${cr-1}`).dataValidation = {rule:{type:'list',values:['fixed','percent','per_diem']}};
|
|
107
|
+
costs.getRange(`F6:F${cr-1}`).dataValidation = {rule:{type:'list',values:['deduction','credit']}};
|
|
108
|
+
costs.getRange(`G6:G${cr-1}`).dataValidation = {rule:{type:'list',values:['seller','buyer','unknown']}};
|
|
109
|
+
costs.getRange(`H6:H${cr-1}`).dataValidation = {rule:{type:'list',values:['base','conditional','excluded']}};
|
|
110
|
+
costs.freezePanes.freezeRows(5);
|
|
111
|
+
const endCol = String.fromCharCode(65+data.offers.length);
|
|
112
|
+
base(summary,endCol,27,[29,...data.offers.map(()=>34)]);
|
|
113
|
+
at(summary,'A2',data.route === 'single_offer_estimate' ? 'Seller offer estimate' : 'Seller offer comparison');
|
|
114
|
+
summary.getRange('A2').format.font = {name:'Arial',size:17,bold:true};
|
|
115
|
+
at(summary,'A3',`As of ${data.as_of}. USD. Professional review required.`);
|
|
116
|
+
at(summary,'A4',data.example ? 'Demonstration using fictional records. Not an actual transaction.' : 'Preliminary estimates. No offer selected or communicated.');
|
|
117
|
+
heading(summary,6,['Term',...data.offers.map(x=>x.label)]);
|
|
118
|
+
const labels = ['Offer price','Base deductions','Base credits','Known subtotal','Conditional low','Conditional high','Calculation scope','Earnest money','Financing','Financing evidence','Contingencies','Closing','Possession','Expiration'];
|
|
119
|
+
labels.forEach((x,i)=>at(summary,`A${7+i}`,x));
|
|
120
|
+
for (const [i,offer] of data.offers.entries()) {
|
|
121
|
+
const col = String.fromCharCode(66+i);
|
|
122
|
+
const [start,end] = costSpans[offer.id];
|
|
123
|
+
const j = `'Costs'!J${start}:J${end}`, k = `'Costs'!K${start}:K${end}`, l = `'Costs'!L${start}:L${end}`;
|
|
124
|
+
const n = end-start+1;
|
|
125
|
+
f(summary,`${col}7`,`='Offer inputs'!C${priceRows[offer.id]}`);
|
|
126
|
+
f(summary,`${col}8`,`=IF(COUNT(${j})=${n},-SUMIF(${j},"<0",${j}),"Input needed")`);
|
|
127
|
+
f(summary,`${col}9`,`=IF(COUNT(${j})=${n},SUMIF(${j},">0",${j}),"Input needed")`);
|
|
128
|
+
f(summary,`${col}10`,`=IF(AND(ISNUMBER(${col}7),${col}7>0,ROUND(${col}7,2)=${col}7,COUNT(${j})=${n}),${col}7+SUM(${j}),"Input needed")`);
|
|
129
|
+
for (const [r,range,bound] of [[11,k,'net_low'],[12,l,'net_high']]) {
|
|
130
|
+
if (offer[bound] === null) at(summary,`${col}${r}`,'Not established - resolve inputs');
|
|
131
|
+
else f(summary,`${col}${r}`,`=IF(AND(ISNUMBER(${col}10),COUNT(${range})=${n}),${col}10+SUM(${range}),"Input needed")`);
|
|
132
|
+
}
|
|
133
|
+
f(summary,`${col}13`,`=IF(AND(${originalComparisons[offer.id].join(',')}),${q(offer.estimate_complete ? 'Financial inputs accounted for; professional review required' : 'Unresolved inputs remain; see Verification')},"Inputs changed; review and regenerate all files")`);
|
|
134
|
+
['earnest_money','financing','financing_evidence','contingencies','closing','possession','expiration'].forEach((key,jj)=>f(summary,`${col}${14+jj}`,`='Offer inputs'!C${termRows[offer.id][key]}`));
|
|
135
|
+
summary.getRange(`${col}7:${col}12`).setNumberFormat(currency);
|
|
136
|
+
summary.getRange(`${col}14`).setNumberFormat(currency);
|
|
137
|
+
summary.getRange(`${col}18`).setNumberFormat('yyyy-mm-dd');
|
|
138
|
+
summary.getRange(`${col}7:${col}20`).format.font.color = '#008000';
|
|
139
|
+
}
|
|
140
|
+
summary.getRange(`A7:${endCol}20`).format.wrapText = true;
|
|
141
|
+
summary.getRange(`A7:${endCol}20`).format.rowHeight = 38;
|
|
142
|
+
summary.getRange(`A13:${endCol}13`).format.rowHeight = 63;
|
|
143
|
+
summary.getRange(`A16:${endCol}17`).format.rowHeight = 72;
|
|
144
|
+
summary.getRange(`A10:${endCol}10`).format.borders = {top:{style:'thin',color:navy}};
|
|
145
|
+
summary.getRange(`A10:${endCol}10`).format.font.bold = true;
|
|
146
|
+
at(summary,'A22','Earnest money is informational only. It is not another proceeds deduction or credit.');
|
|
147
|
+
at(summary,'A23','Word and HTML are snapshots. Regenerate them after changing the inputs.');
|
|
148
|
+
at(summary,'A24','Conditional bounds cover listed items only. Missing cost categories prevent a complete estimate.');
|
|
149
|
+
at(summary,'A25','Sources, confirmations, exclusions and missing items are recorded on the other tabs.');
|
|
150
|
+
base(evidence,'F',data.evidence.length+5,[29,40,18,30,34,18]);
|
|
151
|
+
at(evidence,'A2','Evidence references');
|
|
152
|
+
at(evidence,'A3','Recorded confirmations are supplied data, not independent professional validation.');
|
|
153
|
+
heading(evidence,5,['Field','Source document','Source date','Location','Recorded verifier','Verification date']);
|
|
154
|
+
data.evidence.forEach((e,i)=>{
|
|
155
|
+
const r=i+6;
|
|
156
|
+
evidence.getRange(`A${r}:F${r}`).values = [[e.target,`${e.source_id ?? 'Missing'}: ${e.source_title ?? 'Missing source'}`,stamp(e.source_date),literal(e.locator ?? 'Missing'),literal(e.verified_by ?? 'Not confirmed'),e.verified_on ? stamp(e.verified_on) : 'Not supplied']];
|
|
157
|
+
evidence.getRange(`C${r}`).setNumberFormat('yyyy-mm-dd');
|
|
158
|
+
evidence.getRange(`F${r}`).setNumberFormat('yyyy-mm-dd');
|
|
159
|
+
evidence.getRange(`C${r}`).format.horizontalAlignment='center';
|
|
160
|
+
evidence.getRange(`F${r}`).format.horizontalAlignment='center';
|
|
161
|
+
evidence.getRange(`A${r}:F${r}`).format.wrapText=true;
|
|
162
|
+
evidence.getRange(`A${r}:F${r}`).format.rowHeight=55;
|
|
163
|
+
band(evidence,r,6);
|
|
164
|
+
});
|
|
165
|
+
evidence.freezePanes.freezeRows(5);
|
|
166
|
+
const questionRows = data.presentation
|
|
167
|
+
? data.presentation.questions.map(x=>[x.label,x.messages.join(' '),x.owner])
|
|
168
|
+
: data.verification_queue.map(x=>[x.target,x.message,x.owner]);
|
|
169
|
+
const reviewRows = data.presentation
|
|
170
|
+
? data.presentation.review_checks.map(x=>[x.source,`Review extracted fields against this source: ${x.fields.join(', ')}.`,x.owner])
|
|
171
|
+
: [];
|
|
172
|
+
const qrows = [...questionRows,...reviewRows,...data.offers.flatMap(o=>o.costs.filter(c=>c.note).map(c=>[`${o.id}.${c.id}`,c.note,'Supplied allocation or scenario basis'])),...data.limits.map(x=>['Scope',x,'Listing professional'])];
|
|
173
|
+
base(queue,'C',qrows.length+5,[27,82,33]);
|
|
174
|
+
at(queue,'A2','Verification queue');
|
|
175
|
+
at(queue,'A3','Resolve these items against authorized records. This sheet does not approve presentation.');
|
|
176
|
+
heading(queue,5,['Field or scope','What to confirm','Responsible person or basis']);
|
|
177
|
+
qrows.forEach((row,i)=>{
|
|
178
|
+
const r=i+6;
|
|
179
|
+
queue.getRange(`A${r}:C${r}`).values=[row.map(literal)];
|
|
180
|
+
queue.getRange(`A${r}:C${r}`).format.wrapText=true;
|
|
181
|
+
queue.getRange(`A${r}:C${r}`).format.rowHeight=Math.max(60,Math.ceil(row[1].length/82)*17,Math.ceil(row[0].length/27)*17);
|
|
182
|
+
band(queue,r,3);
|
|
183
|
+
});
|
|
184
|
+
queue.freezePanes.freezeRows(5);
|
|
185
|
+
|
|
186
|
+
// Changes are a snapshot of this saved job revision, never a live scenario claim.
|
|
187
|
+
if (data.job_context) {
|
|
188
|
+
const context = data.job_context;
|
|
189
|
+
const changes = wb.worksheets.add('Package changes');
|
|
190
|
+
base(changes, 'A', context.changes.length + 7, [125]);
|
|
191
|
+
at(changes, 'A2', 'Package changes');
|
|
192
|
+
changes.getRange('A2').format.font = {name:'Arial',size:16,bold:true};
|
|
193
|
+
at(changes, 'A3', `Revision ${context.revision_id.slice(0,8)}. Previous revision ${(context.parent_revision || 'None').slice(0,8)}.`);
|
|
194
|
+
at(changes, 'A4', 'Saved package history. Scenario edits do not change this history or the Word briefing. Revise the job to regenerate both files.');
|
|
195
|
+
changes.getRange('A4').format = {wrapText:true,rowHeight:45};
|
|
196
|
+
heading(changes, 6, ['What changed']);
|
|
197
|
+
context.changes.forEach((change, i) => {
|
|
198
|
+
at(changes, `A${i+7}`, change);
|
|
199
|
+
changes.getRange(`A${i+7}`).format = {wrapText:true,rowHeight:Math.max(45, Math.ceil(change.length / 115) * 18)};
|
|
200
|
+
band(changes, i+7, 1);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check the actual spreadsheet's calculated cells against the Decimal engine.
|
|
205
|
+
const checks = [];
|
|
206
|
+
for (const [i,offer] of data.offers.entries()) {
|
|
207
|
+
const c = String.fromCharCode(66+i);
|
|
208
|
+
const actual = summary.getRange(`${c}10`).values[0][0];
|
|
209
|
+
const allBaseKnown = offer.costs.every(x => x.computed_amount !== null || x.payer === 'buyer' || x.treatment === 'excluded');
|
|
210
|
+
if (offer.known_subtotal !== null && allBaseKnown) {
|
|
211
|
+
if (typeof actual !== 'number' || Math.abs(actual-Number(offer.known_subtotal)) > .001) throw new Error(`Workbook and engine disagree for ${offer.id}`);
|
|
212
|
+
checks.push({offer:offer.id,field:'known_subtotal',expected:offer.known_subtotal,actual});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const inspected = await wb.inspect({kind:'table',range:`Comparison!A6:${endCol}20`,include:'values,formulas',tableMaxRows:15,tableMaxCols:7,maxChars:12000});
|
|
216
|
+
const errors = await wb.inspect({kind:'match',searchTerm:'#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A|#NUM!|#NULL!|#SPILL!|#CALC!',options:{useRegex:true,maxResults:30},summary:'Formula error scan'});
|
|
217
|
+
await fs.writeFile(path.join(output,'workbook-checks.json'),JSON.stringify({checks,inspection:inspected.ndjson,error_scan:errors.ndjson},null,2));
|
|
218
|
+
if (/"(?:value|text|match)"\s*:\s*"#(?:REF|DIV|VALUE|NAME|N\/A|NUM|NULL|SPILL|CALC)/.test(errors.ndjson)) throw new Error('Workbook contains formula errors');
|
|
219
|
+
const xlsx = await SpreadsheetFile.exportXlsx(wb);
|
|
220
|
+
await xlsx.save(path.join(output,'seller-comparison.xlsx'));
|
|
221
|
+
if (process.env.TASKSAI_RENDER_PREVIEWS === '1') {
|
|
222
|
+
const previewDir = path.join(output,'qa-previews');
|
|
223
|
+
await fs.mkdir(previewDir,{recursive:true});
|
|
224
|
+
for (const [sheetName,range,name] of [['Comparison',`A1:${endCol}25`,'comparison'],['Offer inputs',`A1:G${ir-1}`,'inputs'],['Costs',`A1:L${cr-1}`,'costs'],['Evidence',`A1:F${Math.min(18,data.evidence.length+5)}`,'evidence'],['Verification',`A1:C${qrows.length+5}`,'verification']]) {
|
|
225
|
+
const blob = await wb.render({sheetName,range,scale:1,format:'png'});
|
|
226
|
+
await fs.writeFile(path.join(previewDir,`${name}.png`),new Uint8Array(await blob.arrayBuffer()));
|
|
227
|
+
}
|
|
228
|
+
if (data.job_context) {
|
|
229
|
+
const blob = await wb.render({sheetName:'Package changes',range:`A1:A${data.job_context.changes.length+6}`,scale:1,format:'png'});
|
|
230
|
+
await fs.writeFile(path.join(previewDir,'changes.png'),new Uint8Array(await blob.arrayBuffer()));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log(JSON.stringify({workbook:'seller-comparison.xlsx',reconciled:checks.length}));
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Distributable Excel exporter. Decimal-engine values are cached; Excel recalculates scenarios.
|
|
2
|
+
|
|
3
|
+
XlsxWriter does not evaluate formulas. Qualification separately opens/recalculates exported
|
|
4
|
+
files; cached values alone must never be described as independent formula verification.
|
|
5
|
+
"""
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import xlsxwriter
|
|
11
|
+
from xlsxwriter.utility import xl_col_to_name
|
|
12
|
+
|
|
13
|
+
CURRENCY = '"$"#,##0.00;("$"#,##0.00);"$"0.00'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build(data, output):
|
|
17
|
+
wb = xlsxwriter.Workbook(str(output / 'seller-comparison.xlsx'),
|
|
18
|
+
{'strings_to_formulas': False, 'strings_to_urls': False})
|
|
19
|
+
wb.set_properties({'title': 'Seller offer comparison', 'author': 'TasksAI'})
|
|
20
|
+
wb.set_calc_mode('auto')
|
|
21
|
+
formats = {}
|
|
22
|
+
def fmt(kind='body', money=False):
|
|
23
|
+
key = kind, money
|
|
24
|
+
if key not in formats:
|
|
25
|
+
style = {'font_name':'Arial', 'font_size':11, 'font_color':'#1B2935', 'valign':'vcenter', 'text_wrap':True}
|
|
26
|
+
if kind == 'heading': style.update(bg_color='#233E53', font_color='white', bold=True)
|
|
27
|
+
if kind == 'title': style.update(font_size=17, bold=True)
|
|
28
|
+
if kind == 'edit': style.update(font_color='blue', bg_color='#F0F4F8')
|
|
29
|
+
if kind == 'formula': style.update(font_color='#008000')
|
|
30
|
+
if money: style['num_format'] = CURRENCY
|
|
31
|
+
formats[key] = wb.add_format(style)
|
|
32
|
+
return formats[key]
|
|
33
|
+
def sheet(name, widths, title, note, headers, header_row=5):
|
|
34
|
+
s = wb.add_worksheet(name); s.hide_gridlines(2)
|
|
35
|
+
s.set_default_row(30); s.freeze_panes(header_row, 0)
|
|
36
|
+
for i,w in enumerate(widths): s.set_column(i,i,w,fmt())
|
|
37
|
+
s.merge_range(1,0,1,max(1,len(widths)-1),title,fmt('title'))
|
|
38
|
+
s.merge_range(2,0,2,max(1,len(widths)-1),note,fmt())
|
|
39
|
+
s.set_row(2,42); s.set_row(header_row-1,36)
|
|
40
|
+
for i,h in enumerate(headers): s.write(header_row-1,i,h,fmt('heading'))
|
|
41
|
+
s.set_landscape(); s.set_paper(1); s.fit_to_pages(1,0)
|
|
42
|
+
s.repeat_rows(0,header_row-1)
|
|
43
|
+
return s
|
|
44
|
+
def write(s,r,c,v,kind='body',money=False):
|
|
45
|
+
s.write(r-1,c, 'Not supplied' if v is None else v, fmt(kind,money))
|
|
46
|
+
def formula(s,r,c,f,v,money=False):
|
|
47
|
+
s.write_formula(r-1,c,f,fmt('formula',money),'Input needed' if v is None else v)
|
|
48
|
+
def num(v): return float(Decimal(v)) if v is not None else None
|
|
49
|
+
def quote(v): return '"'+str(v).replace('"','""')+'"'
|
|
50
|
+
offers = data['offers']
|
|
51
|
+
summary = sheet('Comparison',[29]+[34]*len(offers),'Seller offer comparison',
|
|
52
|
+
f"As of {data['as_of']}. USD. Fictional records. Professional review required.",['Term']+[o['label'] for o in offers],6)
|
|
53
|
+
inputs = sheet('Offer inputs',[14,23,36,36,40,24,32],'Offer inputs',
|
|
54
|
+
'Blue cells support scenarios. Original values remain in column D. Regenerate both files after edits.',
|
|
55
|
+
['Offer','Field','Editable value','Original value','Source','Location','Recorded confirmation'])
|
|
56
|
+
costs = sheet('Costs',[13,30,15,17,10,14,13,15,19,19,19,19],'Seller costs',
|
|
57
|
+
'D uses dollars or percent points. J is base effect; K/L are conditional adjustments. Blue cells support scenarios.',
|
|
58
|
+
['Offer','Item','Calculation','Amount or rate','Days','Direction','Payer','Treatment','Computed amount','Base effect','Low adjustment','High adjustment'])
|
|
59
|
+
evidence = sheet('Evidence',[29,49,18,25,30,18,65],'Evidence references',
|
|
60
|
+
'Check exact excerpts and locations against captured original documents. Recorded confirmations are not independent validation.',
|
|
61
|
+
['Field','Source document','Source date','Location','Recorded verifier','Verification date','Exact excerpt'])
|
|
62
|
+
queue = sheet('Verification',[27,82,33],'Verification queue','Resolve missing inputs and check sources. This sheet does not approve presentation.',
|
|
63
|
+
['Field or scope','What to confirm','Responsible person or basis'])
|
|
64
|
+
rows, originals, spans, terms = {}, {}, {}, {}
|
|
65
|
+
ir, cr = 6, 6
|
|
66
|
+
for offer in offers:
|
|
67
|
+
oid=offer['id']; rows[oid]={}; originals[oid]=[]; terms[oid]={}
|
|
68
|
+
for key,val in [('price',offer['price']),('earnest_money',offer['earnest_money']),*offer['terms'].items()]:
|
|
69
|
+
ev=next((x for x in data['evidence'] if x['target']==f'{oid}.{key}'),{})
|
|
70
|
+
typed=num(val) if key in ('price','earnest_money') else val
|
|
71
|
+
if key=='closing' and val:
|
|
72
|
+
typed=(datetime.fromisoformat(val)-datetime(1899,12,30)).days
|
|
73
|
+
vals=[oid,key.replace('_',' '),typed,typed,ev.get('source_id'),ev.get('locator'),f"{ev['verified_by']} on {ev['verified_on']}" if ev.get('verified_by') else 'Not confirmed']
|
|
74
|
+
for c,v in enumerate(vals): write(inputs,ir,c,v,'edit' if c==2 else 'body', key in ('price','earnest_money') and c in (2,3))
|
|
75
|
+
if key=='closing' and val:
|
|
76
|
+
datefmt=wb.add_format({'font_name':'Arial','num_format':'yyyy-mm-dd','font_color':'blue'})
|
|
77
|
+
inputs.write_number(ir-1,2,typed,datefmt);inputs.write_number(ir-1,3,typed,datefmt)
|
|
78
|
+
inputs.set_row(ir-1,66)
|
|
79
|
+
rows[oid][key]=ir; terms[oid][key]=typed
|
|
80
|
+
originals[oid].append(f"'Offer inputs'!C{ir}='Offer inputs'!D{ir}")
|
|
81
|
+
ir+=1
|
|
82
|
+
start=cr
|
|
83
|
+
for line in offer['costs']:
|
|
84
|
+
a=line['amount'];raw=a.get('rate') if a['kind']=='percent' else a.get('daily_rate') if a['kind']=='per_diem' else a.get('value')
|
|
85
|
+
vals=[oid,f"{line['id']}: {line['label']}",a['kind'],num(raw) if raw is not None else 'Not supplied',a.get('days','') if a['kind']=='per_diem' else '',line['direction'],line['payer'],line['treatment']]
|
|
86
|
+
for c,v in enumerate(vals): write(costs,cr,c,v,'edit' if c>=2 else 'body')
|
|
87
|
+
costs.set_row(cr-1,48)
|
|
88
|
+
p=f"'Offer inputs'!C{rows[oid]['price']}"
|
|
89
|
+
f=f'=IF(AND(ISNUMBER(D{cr}),D{cr}>=0),IF(C{cr}="fixed",IF(ROUND(D{cr},2)=D{cr},D{cr},"Check cents"),IF(C{cr}="percent",IF(AND(D{cr}<=100,ISNUMBER({p}),{p}>0),ROUND({p}*D{cr}/100,2),"Check price or rate"),IF(C{cr}="per_diem",IF(AND(ISNUMBER(E{cr}),E{cr}>=0,E{cr}<=3660,MOD(E{cr},1)=0,ROUND(D{cr},2)=D{cr}),ROUND(D{cr}*E{cr},2),"Check days or cents"),"Check calculation"))),"Amount not supplied")'
|
|
90
|
+
amount=num(line['computed_amount'])
|
|
91
|
+
formula(costs,cr,8,f,amount if amount is not None else 'Amount not supplied',True)
|
|
92
|
+
allocation=f'AND(OR(F{cr}="deduction",F{cr}="credit"),OR(G{cr}="seller",G{cr}="buyer",G{cr}="unknown"),OR(H{cr}="base",H{cr}="conditional",H{cr}="excluded"))'
|
|
93
|
+
excluded=line['payer']=='buyer' or line['treatment']=='excluded'
|
|
94
|
+
base=0 if excluded else num(line['base_effect']) if amount is not None else 'Amount not supplied'
|
|
95
|
+
formula(costs,cr,9,f'=IF({allocation},IF(OR(G{cr}="buyer",H{cr}="excluded"),0,IF(ISNUMBER(I{cr}),IF(AND(G{cr}="seller",H{cr}="base"),IF(F{cr}="deduction",-I{cr},I{cr}),0),"Amount not supplied")),"Check allocation")',base,True)
|
|
96
|
+
for c,direction in [(10,'deduction'),(11,'credit')]:
|
|
97
|
+
sign='-' if direction=='deduction' else ''
|
|
98
|
+
effect=0 if excluded else 'Amount not supplied' if amount is None else ((-amount if sign else amount) if (line['payer']=='unknown' or line['treatment']=='conditional') and line['direction']==direction else 0)
|
|
99
|
+
formula(costs,cr,c,f'=IF({allocation},IF(OR(G{cr}="buyer",H{cr}="excluded"),0,IF(ISNUMBER(I{cr}),IF(AND(OR(G{cr}="unknown",H{cr}="conditional"),F{cr}={quote(direction)}),{sign}I{cr},0),"Amount not supplied")),"Check allocation")',effect,True)
|
|
100
|
+
for c,v in enumerate(vals[2:],2): originals[oid].append(f"'Costs'!{xl_col_to_name(c)}{cr}={v if isinstance(v,(int,float)) else quote(v)}")
|
|
101
|
+
cr+=1
|
|
102
|
+
spans[oid]=(start,cr-1)
|
|
103
|
+
if cr>6:
|
|
104
|
+
for col,values in [(2,['fixed','percent','per_diem']),(5,['deduction','credit']),(6,['seller','buyer','unknown']),(7,['base','conditional','excluded'])]:
|
|
105
|
+
costs.data_validation(5,col,cr-2,col,{'validate':'list','source':values,'error_type':'stop'})
|
|
106
|
+
labels=['Offer price','Base deductions','Base credits','Known subtotal','Conditional low','Conditional high','Calculation scope','Earnest money','Financing','Financing evidence','Contingencies','Closing','Possession','Expiration']
|
|
107
|
+
for r,label in enumerate(labels,7): write(summary,r,0,label);summary.set_row(r-1,72 if r in (13,16,17) else 38)
|
|
108
|
+
checks=[]
|
|
109
|
+
for col,offer in enumerate(offers,1):
|
|
110
|
+
oid=offer['id'];letter=xl_col_to_name(col);start,end=spans[oid];n=end-start+1
|
|
111
|
+
j=f"'Costs'!J{start}:J{end}";k=f"'Costs'!K{start}:K{end}";l=f"'Costs'!L{start}:L{end}"
|
|
112
|
+
count_j=f'COUNT({j})' if n else '0'
|
|
113
|
+
sum_j=f'SUM({j})' if n else '0'
|
|
114
|
+
deductions=f'-SUMIF({j},"<0",{j})' if n else '0'
|
|
115
|
+
credits=f'SUMIF({j},">0",{j})' if n else '0'
|
|
116
|
+
known=all(x['computed_amount'] is not None or x['payer']=='buyer' or x['treatment']=='excluded' for x in offer['costs'])
|
|
117
|
+
subtotal=num(offer['known_subtotal']) if known else None
|
|
118
|
+
base=[num(x['base_effect']) for x in offer['costs']]
|
|
119
|
+
formula(summary,7,col,f"='Offer inputs'!C{rows[oid]['price']}",num(offer['price']),True)
|
|
120
|
+
formula(summary,8,col,f'=IF({count_j}={n},{deductions},"Input needed")',-sum(x for x in base if x<0) if known else None,True)
|
|
121
|
+
formula(summary,9,col,f'=IF({count_j}={n},{credits},"Input needed")',sum(x for x in base if x>0) if known else None,True)
|
|
122
|
+
formula(summary,10,col,f'=IF(AND(ISNUMBER({letter}7),{letter}7>0,ROUND({letter}7,2)={letter}7,{count_j}={n}),{letter}7+{sum_j},"Input needed")',subtotal,True)
|
|
123
|
+
for r,span,bound in [(11,k,'net_low'),(12,l,'net_high')]:
|
|
124
|
+
if offer[bound] is None: write(summary,r,col,'Not established - resolve inputs')
|
|
125
|
+
else:
|
|
126
|
+
count_span=f'COUNT({span})' if n else '0'
|
|
127
|
+
sum_span=f'SUM({span})' if n else '0'
|
|
128
|
+
formula(summary,r,col,f'=IF(AND(ISNUMBER({letter}10),{count_span}={n}),{letter}10+{sum_span},"Input needed")',num(offer[bound]),True)
|
|
129
|
+
scope='Financial inputs accounted for; professional review required' if offer['estimate_complete'] else 'Unresolved inputs remain; see Verification'
|
|
130
|
+
formula(summary,13,col,f'=IF(AND({",".join(originals[oid])}),{quote(scope)},"Inputs changed; review and regenerate all files")',scope)
|
|
131
|
+
for r,key in enumerate(['earnest_money','financing','financing_evidence','contingencies','closing','possession','expiration'],14):
|
|
132
|
+
formula(summary,r,col,f"='Offer inputs'!C{rows[oid][key]}",terms[oid][key] if terms[oid][key] is not None else 'Not supplied',r==14)
|
|
133
|
+
if key=='closing' and terms[oid][key] is not None:
|
|
134
|
+
summary.write_formula(r-1,col,f"='Offer inputs'!C{rows[oid][key]}",wb.add_format({'font_name':'Arial','num_format':'yyyy-mm-dd','font_color':'#008000'}),terms[oid][key])
|
|
135
|
+
checks.append({'offer':oid,'cached_subtotal':subtotal,'basis':'Decimal engine; formula recalculation requires separate qualification'})
|
|
136
|
+
for r,note in enumerate(['Earnest money is informational only; it is not another proceeds adjustment.','Word and HTML are snapshots. Regenerate all files after changing inputs.','Conditional bounds cover listed items only; missing categories prevent a complete estimate.','See Evidence, Verification and Saved concerns before relying on this package.'],22):
|
|
137
|
+
summary.merge_range(r-1,0,r-1,len(offers),note,fmt());summary.set_row(r-1,32)
|
|
138
|
+
for r,e in enumerate(data['evidence'],6):
|
|
139
|
+
vals=[e['target'],f"{e.get('source_id') or 'Missing'}: {e.get('source_title') or 'Missing source'}",e.get('source_date'),e.get('locator'),e.get('verified_by') or 'Not confirmed',e.get('verified_on'),e.get('quote')]
|
|
140
|
+
for c,v in enumerate(vals): write(evidence,r,c,v)
|
|
141
|
+
evidence.set_row(r-1,max(66,min(300,math.ceil(len(str(vals[-1]))/60)*16)))
|
|
142
|
+
view=data.get('presentation',{})
|
|
143
|
+
qrows=[[q['label'],' '.join(q['messages']),q['owner']] for q in view.get('questions',[])]
|
|
144
|
+
qrows += [[q['source'],'Review extracted fields against this source: '+', '.join(q['fields']),q['owner']] for q in view.get('review_checks',[])]
|
|
145
|
+
if not view: qrows += [[q['target'],q['message'],q['owner']] for q in data['verification_queue']]
|
|
146
|
+
qrows += [[f"{o['id']}.{c['id']}",c['note'],'Supplied allocation or scenario basis'] for o in offers for c in o['costs'] if c.get('note')]
|
|
147
|
+
qrows += [['Scope',x,'Listing professional'] for x in data['limits']]
|
|
148
|
+
for r,row in enumerate(qrows,6):
|
|
149
|
+
for c,v in enumerate(row): write(queue,r,c,v)
|
|
150
|
+
queue.set_row(r-1,max(66,math.ceil(len(row[1])/75)*16))
|
|
151
|
+
context=data.get('job_context')
|
|
152
|
+
if context:
|
|
153
|
+
changes=sheet('Package changes',[125],'Package changes',f"Revision {context['revision_id'][:8]}. Previous {(context.get('parent_revision') or 'None')[:8]}. Saved history; scenario edits require regeneration.",['What changed'],6)
|
|
154
|
+
for r,line in enumerate(context['changes'],7): write(changes,r,0,line);changes.set_row(r-1,max(45,math.ceil(len(line)/110)*17))
|
|
155
|
+
concerns=sheet('Saved concerns',[32,23,55,30,60,55],'Saved review concerns','Snapshot with this package. Recorded explanations are not professional review. Later updates require a refresh.', ['Concern','Status','Detail','Responsible role','Latest explanation','Source references'])
|
|
156
|
+
for r,c in enumerate(context.get('concerns',[]),6):
|
|
157
|
+
event=c['events'][-1]
|
|
158
|
+
vals=[c['title'],c['status'].replace('_',' '),c['detail'],c['responsible_role'],event['explanation'],'; '.join(f"{ref['source_id']}, {ref['locator']}" for ref in event['references'])]
|
|
159
|
+
for col,v in enumerate(vals): write(concerns,r,col,v)
|
|
160
|
+
concerns.set_row(r-1,max(96,math.ceil(len(vals[-1])/50)*16))
|
|
161
|
+
if not context.get('concerns'): write(concerns,6,0,'No saved concerns at preparation. This is not an independent review.')
|
|
162
|
+
wb.close()
|
|
163
|
+
(output/'workbook-checks.json').write_text(json.dumps({'exporter':'XlsxWriter','cached_values':checks,'formula_validation':'Requires independent recalculation; not performed by this exporter'},indent=2)+'\n')
|