generate_planning_report.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. <?php
  2. /**
  3. * Planning Report Generator (MVP)
  4. * Input: JSON payload (see schema below)
  5. * Output: { ok: true, markdown: "...", html: "...", meta: {...} }
  6. *
  7. * Place at: /internal/classes/generate_planning_report.php
  8. * Test: curl -s -X POST -H "Content-Type: application/json" \
  9. * --data @sample.json http://localhost/internal/classes/generate_planning_report.php | jq
  10. */
  11. declare(strict_types=1);
  12. require_once __DIR__ . '/_bootstrap.php';
  13. ini_set('display_errors', '0');
  14. ini_set('log_errors', '1');
  15. error_reporting(E_ALL);
  16. $corsEnv = getenv('CORS_ORIGINS') ?: 'https://tasplanning.report';
  17. $allowedOrigins = array_filter(array_map('trim', explode(',', $corsEnv)));
  18. $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
  19. if ($origin && in_array($origin, $allowedOrigins, true)) {
  20. header("Access-Control-Allow-Origin: $origin");
  21. header("Vary: Origin"); // prevent cache mixups
  22. }
  23. // If you need credentials/cookies later, also set:
  24. // header('Access-Control-Allow-Credentials: true');
  25. header('Access-Control-Allow-Methods: POST, OPTIONS');
  26. header('Access-Control-Allow-Headers: Content-Type, Accept, X-Requested-With');
  27. // Preflight short-circuit
  28. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
  29. http_response_code(204);
  30. exit; // stop here, no body required
  31. }
  32. header('Content-Type: application/json; charset=UTF-8');
  33. try {
  34. if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  35. http_response_code(405);
  36. echo json_encode(['ok' => false, 'error' => 'Use POST with application/json']);
  37. exit;
  38. }
  39. // Reject non-JSON content types early — prevents json_decode silently
  40. // returning null on form-encoded or multipart bodies.
  41. $ct = $_SERVER['CONTENT_TYPE'] ?? '';
  42. if (strpos($ct, 'application/json') === false) {
  43. http_response_code(415);
  44. echo json_encode(['ok' => false, 'error' => 'Content-Type must be application/json']);
  45. exit;
  46. }
  47. $raw = file_get_contents('php://input') ?: '';
  48. $in = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
  49. // --------- Schema (minimum viable) ----------
  50. $d = [
  51. // site
  52. 'address' => trim((string)($in['address'] ?? '')),
  53. 'lat' => floatval($in['lat'] ?? 0),
  54. 'lng' => floatval($in['lng'] ?? 0),
  55. 'pid' => trim((string)($in['pid'] ?? '')),
  56. 'title_id' => trim((string)($in['title_id'] ?? '')),
  57. 'total_area' => $in['total_area'] ?? null, // e.g. {sqm_label:"1,652 m²", ha_label:"0.1652 ha"} or string
  58. 'area_sqm' => $in['area_sqm'] ?? '',
  59. 'area_ha' => $in['area_ha'] ?? '',
  60. 'tenure' => trim((string)($in['tenure'] ?? '')),
  61. 'lpi' => trim((string)($in['lpi'] ?? '')),
  62. 'list_guid' => trim((string)($in['list_guid'] ?? '')),
  63. 'locality' => trim((string)($in['locality'] ?? '')),
  64. 'council' => trim((string)($in['council'] ?? '')),
  65. 'planning_scheme' => trim((string)($in['planning_scheme'] ?? 'Tasmanian Planning Scheme')),
  66. 'planning_zones' => array_values(array_filter((array)($in['planning_zones'] ?? []))),
  67. 'planning_codes' => array_values(array_filter((array)($in['planning_codes'] ?? []))),
  68. // proposal
  69. 'use_class' => trim((string)($in['use_class'] ?? 'Educational and Occasional Care')),
  70. 'proposal_summary' => trim((string)($in['proposal_summary'] ?? '')), // 1–3 paras freeform
  71. 'operations' => (array)($in['operations'] ?? []), // ['hours','staff','children']
  72. 'parking' => (array)($in['parking'] ?? []), // ['cars','bikes','accessible','motorcycle']
  73. 'signage' => (array)($in['signage'] ?? []), // [{type,desc}, ...]
  74. 'consultants' => (array)($in['consultants'] ?? []), // ['TIA'=>'…','Acoustic'=>'…','Bushfire'=>'…']
  75. // overlays
  76. 'overlays' => (array)($in['overlays'] ?? []), // ['bushfire'=>bool, 'airport_noise'=>bool, ...]
  77. // assessments matrix (zone + codes)
  78. 'standards' => (array)($in['standards'] ?? []), // [{clause, standard, acceptable, relies_on_pc:[], notes}]
  79. // assets (optional)
  80. 'map_png' => (string)($in['map_png'] ?? ''), // data:image/png;base64,...
  81. 'appendices' => array_values(array_filter((array)($in['appendices'] ?? []))),
  82. // meta
  83. 'prepared_for' => trim((string)($in['prepared_for'] ?? '')),
  84. 'prepared_by' => trim((string)($in['prepared_by'] ?? 'Modulos Design')),
  85. 'author' => trim((string)($in['author'] ?? '')),
  86. 'job_number' => trim((string)($in['job_number'] ?? '')),
  87. 'version' => trim((string)($in['version'] ?? 'Draft')),
  88. 'prepared_date' => trim((string)($in['prepared_date'] ?? date('j F Y'))),
  89. ];
  90. // Minimal required fields
  91. foreach (['address','planning_scheme'] as $k) {
  92. if ($d[$k] === '' || $d[$k] === null) {
  93. throw new RuntimeException("Missing required field: {$k}");
  94. }
  95. }
  96. // Helpers -----------------------------------------------------------
  97. $join = fn(array $arr, string $sep=', ') => implode($sep, array_values(array_filter($arr, fn($x)=>$x!=='' && $x!==null)));
  98. $fmt_area = function($d) {
  99. if (is_array($d['total_area'] ?? null)) {
  100. return $d['total_area']['ha_label'] ?? $d['total_area']['sqm_label'] ?? '';
  101. }
  102. return $d['area_ha'] ?: $d['area_sqm'];
  103. };
  104. $md_table_row = fn(array $cols) => '| ' . implode(' | ', array_map(fn($c)=>trim((string)$c) ?: '–', $cols)) . ' |';
  105. $render_standards = function(array $rows) use ($md_table_row) {
  106. if (!$rows) return "_No specific standards provided in this draft._\n";
  107. $out = [];
  108. $out[] = $md_table_row(['Clause','Standard','AS','PC','Notes']);
  109. $out[] = $md_table_row(['---','---','---','---','---']);
  110. foreach ($rows as $r) {
  111. $out[] = $md_table_row([
  112. $r['clause'] ?? '',
  113. $r['standard'] ?? '',
  114. $r['acceptable'] ?? '',
  115. $r['relies_on_pc'] ? implode(', ', (array)$r['relies_on_pc']) : '',
  116. $r['notes'] ?? ''
  117. ]);
  118. }
  119. return implode("\n", $out)."\n";
  120. };
  121. // Markdown builder --------------------------------------------------
  122. $md = [];
  123. $md[] = '# Supporting Planning Report';
  124. $md[] = '';
  125. $md[] = "Prepared for: **" . ($d['prepared_for'] ?: '—') . "**";
  126. $md[] = "Prepared by: **" . ($d['prepared_by'] ?: '—') . "**" . ($d['author'] ? " ({$d['author']})" : "");
  127. $md[] = "Job No.: **" . ($d['job_number'] ?: '—') . "**";
  128. $md[] = "Version: **" . ($d['version'] ?: '—') . "**";
  129. $md[] = "Date: **" . ($d['prepared_date'] ?: '—') . "**";
  130. $md[] = '';
  131. $md[] = '> ' . $d['acknowledgement'];
  132. $md[] = '';
  133. // Permit overview
  134. $md[] = '## Permit overview';
  135. $md[] = '### Permit application details';
  136. $md[] = $md_table_row(['Applicant','Owner','Address','Title']);
  137. $md[] = $md_table_row(['---','---','---','---']);
  138. $md[] = $md_table_row([
  139. $d['prepared_for'] ?: '—',
  140. '—',
  141. $d['address'],
  142. ($d['title_id'] ?: '—')
  143. ]);
  144. $md[] = '';
  145. $md[] = '### Relevant Planning Provisions';
  146. $md[] = "- Applicable planning scheme: **{$d['planning_scheme']}**";
  147. if ($d['planning_zones']) $md[] = '- Zone(s): **' . $join($d['planning_zones']) . '**';
  148. if ($d['planning_codes']) $md[] = '- Codes: **' . $join($d['planning_codes']) . '**';
  149. $md[] = '';
  150. // Proposal
  151. $md[] = '## 1. Introduction';
  152. $md[] = '**Purpose of the report.** This report seeks planning approval for the proposed use and development described below and assesses the application against the relevant provisions of the Tasmanian Planning Scheme.';
  153. $md[] = '';
  154. $md[] = '## 2. Proposal';
  155. if ($d['proposal_summary']) $md[] = $d['proposal_summary'];
  156. $ops = [];
  157. if (!empty($d['operations']['hours'])) $ops[] = "**Hours:** {$d['operations']['hours']}";
  158. if (!empty($d['operations']['staff'])) $ops[] = "**Staff:** {$d['operations']['staff']}";
  159. if (!empty($d['operations']['children'])) $ops[] = "**Children capacity:** {$d['operations']['children']}";
  160. if ($ops) $md[] = implode(' \n', $ops);
  161. if ($d['parking']) {
  162. $pbits = [];
  163. if (isset($d['parking']['cars'])) $pbits[] = "{$d['parking']['cars']} car spaces";
  164. if (isset($d['parking']['accessible'])) $pbits[] = "{$d['parking']['accessible']} accessible";
  165. if (isset($d['parking']['bikes'])) $pbits[] = "{$d['parking']['bikes']} bicycle spaces";
  166. if (isset($d['parking']['motorcycle'])) $pbits[] = "{$d['parking']['motorcycle']} motorcycle spaces";
  167. if ($pbits) $md[] = '**Parking:** ' . implode(', ', $pbits) . '.';
  168. }
  169. if ($d['signage']) {
  170. $md[] = '**Signage:**';
  171. foreach ($d['signage'] as $s) {
  172. $md[] = "- " . trim(is_array($s) ? (($s['type'] ?? 'Sign') . ': ' . ($s['desc'] ?? '')) : (string)$s);
  173. }
  174. }
  175. if (!empty($d['map_png'])) {
  176. $md[] = '';
  177. $md[] = '![Site and surrounds map]('.$d['map_png'].')';
  178. $md[] = '_Figure: Site context (auto-captured from map)._';
  179. }
  180. $md[] = '';
  181. // Site
  182. $md[] = '## 3. Site description';
  183. $md[] = $md_table_row(['Property ID','Title ID','Locality','Area','Tenure','LIST GUID']);
  184. $md[] = $md_table_row(['---','---','---','---','---','---']);
  185. $md[] = $md_table_row([
  186. $d['pid'] ?: '—',
  187. $d['title_id'] ?: '—',
  188. $d['locality'] ?: '—',
  189. $fmt_area($d) ?: '—',
  190. $d['tenure'] ?: '—',
  191. $d['list_guid'] ?: '—'
  192. ]);
  193. $md[] = '';
  194. // Zoning
  195. $md[] = '## 4. Zoning assessment';
  196. $md[] = "**Zone:** " . ($d['planning_zones'] ? $join($d['planning_zones']) : '—');
  197. $md[] = "**Use class:** {$d['use_class']} (assessment against applicable use and development standards).";
  198. $md[] = '';
  199. // Standards matrix
  200. $md[] = '### 4.x Applicable standards (zone + codes overview)';
  201. $md[] = $render_standards($d['standards']);
  202. // Codes — notes (driven by the standards rows)
  203. $md[] = '## 5. Code assessment';
  204. if (in_array('Signs', $d['planning_codes'])) $md[] = '- **Signs Code** – see relevant rows above.';
  205. if (in_array('Parking and Sustainable Transport', $d['planning_codes'])) $md[] = '- **Parking and Sustainable Transport Code** – see relevant rows above.';
  206. if (in_array('Road and Railway Assets', $d['planning_codes'])) $md[] = '- **Road and Railway Assets Code** – see relevant rows above.';
  207. if (!empty($d['overlays']['bushfire'])) $md[] = '- **Bushfire-Prone Areas Code** – see relevant rows above.';
  208. if (!empty($d['overlays']['airport_noise'])) $md[] = '- **Safeguarding of Airports Code** – see relevant rows above.';
  209. $md[] = '';
  210. // Consultants
  211. if ($d['consultants']) {
  212. $md[] = '### Supporting technical reports';
  213. foreach ($d['consultants'] as $k => $v) {
  214. $md[] = "- **{$k}:** {$v}";
  215. }
  216. $md[] = '';
  217. }
  218. // Conclusion (quick tally)
  219. $t_as = 0; $t_pc = 0;
  220. foreach ($d['standards'] as $r) {
  221. if (!empty($r['acceptable'])) $t_as++;
  222. if (!empty($r['relies_on_pc'])) $t_pc++;
  223. }
  224. $md[] = '## 6. Conclusion';
  225. $md[] = "This application has been assessed against the Tasmanian Planning Scheme provisions relevant to the site and proposal. Based on the information provided and the supporting technical assessments, the proposal **complies with acceptable solutions for ~{$t_as} standards** and **relies on performance criteria for ~{$t_pc} standards**. Approval is therefore requested subject to any reasonable conditions.";
  226. $md[] = '';
  227. // Appendices (labels only)
  228. if ($d['appendices']) {
  229. $md[] = '## Appendices';
  230. foreach ($d['appendices'] as $i => $label) {
  231. $md[] = ($i+1) . ". " . trim((string)$label);
  232. }
  233. $md[] = '';
  234. }
  235. $markdown = implode("\n", $md);
  236. // Minimal Markdown → HTML
  237. $html = md_to_html_min($markdown);
  238. // Optional: save a .md copy (ensure directory exists & is writable)
  239. $SAVE_DIR = __DIR__ . '/../../reports';
  240. if (is_dir($SAVE_DIR) && is_writable($SAVE_DIR)) {
  241. $fname = sanitize_filename("Planning Report - {$d['address']} - " . date('Ymd_His')) . ".md";
  242. @file_put_contents($SAVE_DIR . '/' . $fname, $markdown);
  243. }
  244. echo json_encode([
  245. 'ok' => true,
  246. 'markdown' => $markdown,
  247. 'html' => $html,
  248. 'meta' => [
  249. 'address' => $d['address'],
  250. 'scheme' => $d['planning_scheme'],
  251. 'zones' => $d['planning_zones'],
  252. 'codes' => $d['planning_codes'],
  253. ],
  254. ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  255. exit;
  256. } catch (Throwable $e) {
  257. http_response_code(400);
  258. echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
  259. exit;
  260. }
  261. /** -------- Helpers -------- */
  262. function sanitize_filename(string $s): string {
  263. $s = preg_replace('~[^\w\-. ]+~u', '', $s);
  264. $s = preg_replace('~\s+~', ' ', $s);
  265. return trim($s) ?: 'report';
  266. }
  267. function esc_html(string $s): string {
  268. return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  269. }
  270. function md_to_html_min(string $md): string {
  271. // Extremely small converter: headers, bold/italics, lists, images, tables, paragraphs.
  272. $h = esc_html($md);
  273. // Images ![alt](src)
  274. $h = preg_replace('~!\[([^\]]*)\]\(([^)]+)\)~', '<img alt="$1" src="$2" style="max-width:100%;height:auto;border-radius:8px;" />', $h);
  275. // Bold / italic
  276. $h = preg_replace('~\*\*([^*]+)\*\*~', '<strong>$1</strong>', $h);
  277. $h = preg_replace('~\*([^*]+)\*~', '<em>$1</em>', $h);
  278. // Headers
  279. foreach ([6,5,4,3,2,1] as $n) {
  280. $re = '~^' . str_repeat('#', $n) . '\s*(.+)$~m';
  281. $h = preg_replace($re, "<h{$n}>$1</h{$n}>", $h);
  282. }
  283. // Tables (GitHub-style)
  284. if (preg_match('~^\|.+\|$~m', $h)) {
  285. $lines = explode("\n", $h);
  286. $out = [];
  287. $inTable = false;
  288. foreach ($lines as $line) {
  289. if (preg_match('~^\|(.+)\|$~', $line)) {
  290. if (!$inTable) { $out[] = '<table class="table table-sm"><tbody>'; $inTable = true; }
  291. if (preg_match('~^\|\s*-+(\s*\|\s*-+)+\s*\|$~', $line)) continue; // separator
  292. $cells = array_map('trim', explode('|', trim($line, '|')));
  293. $out[] = '<tr>' . implode('', array_map(fn($c)=>'<td>'.esc_html($c ?: '–').'</td>', $cells)) . '</tr>';
  294. } else {
  295. if ($inTable) { $out[] = '</tbody></table>'; $inTable = false; }
  296. $out[] = $line;
  297. }
  298. }
  299. if ($inTable) $out[] = '</tbody></table>';
  300. $h = implode("\n", $out);
  301. }
  302. // Lists
  303. $h = preg_replace_callback('~(?:^|\n)([-*]\s.+(?:\n[-*]\s.+)*)~m', function($m){
  304. $items = preg_split('~\n~', trim($m[1]));
  305. $lis = '';
  306. foreach ($items as $li) {
  307. $txt = preg_replace('~^[-*]\s~', '', $li);
  308. $lis .= '<li>'. $txt .'</li>';
  309. }
  310. return "\n<ul>{$lis}</ul>";
  311. }, $h);
  312. // Line breaks " \n" → <br>
  313. $h = str_replace(" \n", "<br>\n", $h);
  314. // Paragraphs
  315. $parts = preg_split('~\n{2,}~', $h);
  316. foreach ($parts as &$p) {
  317. if (!preg_match('~^\s*<(h\d|ul|ol|table|img|blockquote)~', $p)) {
  318. $p = '<p>'.$p.'</p>';
  319. }
  320. }
  321. $h = implode("\n", $parts);
  322. return '<article class="report-body" style="max-width:900px;margin:0 auto;">'.$h.'</article>';
  323. }