chore: site certificates, site spend and cedar-gate model updates

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-27 12:23:34 +02:00
parent 1c8bf45664
commit 76d450aa1d
17 changed files with 692 additions and 169 deletions

View file

@ -5,15 +5,15 @@
385.411
],
"totals": {
"cut": 2073.784,
"fill": 16.436
"cut": 2119.452,
"fill": 16.919
},
"validation": {
"samples": 360,
"meanAbs": 0.01,
"median": -0.004,
"medianAbs": 0.004,
"p90Abs": 0.0276,
"median": -0.0043,
"medianAbs": 0.0043,
"p90Abs": 0.0271,
"note": "Drawn finished road profile versus independently extracted FRL_CONTOURS; tested both offset directions."
},
"offset_side": 1,
@ -52,23 +52,23 @@
],
[
80.0,
9.55
9.6
],
[
90.0,
9.55
9.6
],
[
100.0,
9.55
9.6
],
[
110.0,
9.55
9.6
],
[
120.0,
9.55
9.6
],
[
130.0,
@ -80,39 +80,39 @@
],
[
150.0,
9.55
10.1
],
[
160.0,
9.55
10.2
],
[
170.0,
9.55
11.45
],
[
180.0,
9.55
10.45
],
[
190.0,
9.55
10.9
],
[
200.0,
9.55
11.45
],
[
210.0,
9.55
12.45
],
[
220.0,
9.55
11.8
],
[
230.0,
9.55
9.6
],
[
240.0,
@ -120,7 +120,7 @@
],
[
250.0,
9.55
9.6
],
[
260.0,
@ -128,23 +128,23 @@
],
[
270.0,
10.2
10.25
],
[
280.0,
10.05
10.1
],
[
290.0,
10.05
10.1
],
[
300.0,
10.05
10.1
],
[
310.0,
10.05
10.1
],
[
320.0,
@ -152,7 +152,7 @@
],
[
330.0,
10.45
10.95
],
[
340.0,
@ -179,7 +179,47 @@
9.6
]
],
"excluded_independent_service_paths": 419,
"excluded_independent_service_paths": 138,
"scope": "Cut/fill covers Cedar Gate Road only, from C3277-912-001 Rev A sections 0\u2013385.411 m. Road formation earthworks exclude service trenches and external roads; fill is design compacted fill, not pipe-trench backfill.",
"earthworks_sha256": "cf5b7501e7153f89f1cf819cb93cbb312ef2885c662ac314dcd4bd186af7ca04"
"longSection": {
"check": {
"ground": {
"samples": 39,
"medianAbs": 0.0025,
"maxAbs": 0.0049
},
"design": {
"samples": 117,
"medianAbs": 0.003,
"maxAbs": 0.0407
}
},
"roads": {
"Access road": {
"length": 36.75,
"levels": 11,
"maxCentreCut": 0.32,
"maxCentreFill": -0.01
},
"Furrow Road": {
"length": 237.62,
"levels": 28,
"maxCentreCut": 0.05,
"maxCentreFill": 0.17
},
"Libertas Avenue": {
"length": 173.06,
"levels": 26,
"maxCentreCut": 0.25,
"maxCentreFill": 0.11
},
"Cedar Gate Road": {
"length": 385.41,
"levels": 52,
"maxCentreCut": 0.94,
"maxCentreFill": -0.02
}
}
},
"earthworks_sha256": "0dfce0ae278ffa02d567f75e884eef7d635d1fdbcf9ce6cceb62162fde3b681f"
}

File diff suppressed because one or more lines are too long

View file

@ -15,8 +15,17 @@
{
"file": "C3277-913-001 REVA - INT TYP RD CS - 2026-08-11.pdf",
"sha256": "c62d6eca7d5f525b926239a5c3ea7102b093e3c19cc9944464e399a4c503a0a8"
},
{
"file": "C3277-912-001 REVA - INT RD CS - 2026-08-11 - bound.dwg",
"sha256": "350adc69b3736ad77a4a94c8fe63a1dd1d505903ea34ce2f405c9ca5fe6d9038"
},
{
"file": "C3277-911-001 REVA - INT RD LS - 2026-08-11 - bound.dwg",
"sha256": "09ae9d9cf3833bda2692fe4178e4cb7282c119d88589541a56875929ac841b27"
}
],
"excluded": "Nellmapius Ext. 25 drawings: different project; not used.",
"conversion": "LibreDWG 0.13.3; source units metres. Survey contour XY are positive Lo Y/X and are negated to match road CAD."
"conversion": "LibreDWG 0.13.3; source units metres. Survey contour XY are positive Lo Y/X and are negated to match road CAD.",
"earthworks": "Cut/fill read from the C3277-912-001 cross-section CAD drawing and checked against the C3277-911-001 road long section (extract_cedar_gate_xs.py)."
}

View file

@ -49,7 +49,7 @@
"sha256": {
"bushveld.json": "21bf7211dc6d3e3ceabf97bda34d26aee47a1ef674fbbc7bcdf2f9b7df90fce3",
"services.json": "afecbee518f269c5376db7a57a4fbd204f80a595196a348e4d481ca61726523c",
"earthworks.json": "cf5b7501e7153f89f1cf819cb93cbb312ef2885c662ac314dcd4bd186af7ca04",
"earthworks.json": "0dfce0ae278ffa02d567f75e884eef7d635d1fdbcf9ce6cceb62162fde3b681f",
"schedules.json": "1fb1d2ad7eeaf40282634bcf4fb0b4ab1f708ef1f595f237f31bca879f460d6d",
"surface.json": "10e6c3ebbb6621888d93ff257b6251e04e6b061ed8341af69c510a8f2c0ee80a"
},
@ -60,15 +60,15 @@
385.411
],
"totals": {
"cut": 2073.784,
"fill": 16.436
"cut": 2119.452,
"fill": 16.919
},
"validation": {
"samples": 360,
"meanAbs": 0.01,
"median": -0.004,
"medianAbs": 0.004,
"p90Abs": 0.0276,
"median": -0.0043,
"medianAbs": 0.0043,
"p90Abs": 0.0271,
"note": "Drawn finished road profile versus independently extracted FRL_CONTOURS; tested both offset directions."
},
"offset_side": 1,
@ -107,23 +107,23 @@
],
[
80.0,
9.55
9.6
],
[
90.0,
9.55
9.6
],
[
100.0,
9.55
9.6
],
[
110.0,
9.55
9.6
],
[
120.0,
9.55
9.6
],
[
130.0,
@ -135,39 +135,39 @@
],
[
150.0,
9.55
10.1
],
[
160.0,
9.55
10.2
],
[
170.0,
9.55
11.45
],
[
180.0,
9.55
10.45
],
[
190.0,
9.55
10.9
],
[
200.0,
9.55
11.45
],
[
210.0,
9.55
12.45
],
[
220.0,
9.55
11.8
],
[
230.0,
9.55
9.6
],
[
240.0,
@ -175,7 +175,7 @@
],
[
250.0,
9.55
9.6
],
[
260.0,
@ -183,23 +183,23 @@
],
[
270.0,
10.2
10.25
],
[
280.0,
10.05
10.1
],
[
290.0,
10.05
10.1
],
[
300.0,
10.05
10.1
],
[
310.0,
10.05
10.1
],
[
320.0,
@ -207,7 +207,7 @@
],
[
330.0,
10.45
10.95
],
[
340.0,
@ -234,8 +234,48 @@
9.6
]
],
"excluded_independent_service_paths": 419,
"excluded_independent_service_paths": 138,
"scope": "Cut/fill covers Cedar Gate Road only, from C3277-912-001 Rev A sections 0\u2013385.411 m. Road formation earthworks exclude service trenches and external roads; fill is design compacted fill, not pipe-trench backfill.",
"earthworks_sha256": "cf5b7501e7153f89f1cf819cb93cbb312ef2885c662ac314dcd4bd186af7ca04"
"longSection": {
"check": {
"ground": {
"samples": 39,
"medianAbs": 0.0025,
"maxAbs": 0.0049
},
"design": {
"samples": 117,
"medianAbs": 0.003,
"maxAbs": 0.0407
}
},
"roads": {
"Access road": {
"length": 36.75,
"levels": 11,
"maxCentreCut": 0.32,
"maxCentreFill": -0.01
},
"Furrow Road": {
"length": 237.62,
"levels": 28,
"maxCentreCut": 0.05,
"maxCentreFill": 0.17
},
"Libertas Avenue": {
"length": 173.06,
"levels": 26,
"maxCentreCut": 0.25,
"maxCentreFill": 0.11
},
"Cedar Gate Road": {
"length": 385.41,
"levels": 52,
"maxCentreCut": 0.94,
"maxCentreFill": -0.02
}
}
},
"earthworks_sha256": "0dfce0ae278ffa02d567f75e884eef7d635d1fdbcf9ce6cceb62162fde3b681f"
}
}

View file

@ -47,14 +47,27 @@ The 2026-10-20 delivery contains two projects. Use the C3277 archive and
3. Check `validation.json`, the source hashes in `sources.json`, surface coverage,
and the rendered model. The initial reviewed output is in
`reports/site-models/cedar-gate/2026-10-20`.
4. Upload with the normal uploader using Cedar Gate site ID `u6n5fye4czinw6o`.
4. Cut/fill for Cedar Gate Road: convert C3277-912-001 Rev A (cross-sections) and
C3277-911-001 Rev A (road long section) to DXF, then run
```sh
python3 scripts/site-model/extract_cedar_gate_xs.py "C3277-912-001 ... - bound.dxf" raw.json OUTPUT_FOLDER \
--long-section "C3277-911-001 ... - bound.dxf"
```
`raw.json` is the CAD geometry cache from step 2 (`--raw-cache`). The 40 sections are read from
their named CAD layers (XS_GROUND, XS_ROAD_SURFACE, layerworks, kerbs, shoulders and the revised
side slopes to be shaped); scales come from the grid labels. Every section's centreline ground
and design levels must agree with the long section (2 cm median, 10 cm max) or the build stops.
The plotted 912 PDF is still accepted in place of the DXF (needs poppler).
5. Upload with the normal uploader using Cedar Gate site ID `u6n5fye4czinw6o`.
The extractor checks all four alignment lengths against drawing labels, conserves
hatch areas in disjoint 20 m planning sections, measures single kerb lines once,
and keeps surveyed ground separate from the finished-level surface. Service
routes are plan overlays without inferred inverts, pipe sizes or trench volumes.
External lower pavement layers, earthwork volumes and setting-out pegs are
deliberately unquantified pending reconciliation with the relevant source data.
External lower pavement layers and setting-out pegs are deliberately unquantified
pending the relevant source data. Earthworks cover Cedar Gate Road only: the long
section also covers Furrow Road, Libertas Avenue and the access road, but without
their cross-sections and pavement layers their formation volumes are not calculated.
### Cedar Gate pipes and manholes (added 2026-10-21)

View file

@ -21,10 +21,9 @@ from shapely.geometry import LineString, MultiPoint, Point, Polygon, box
from shapely.ops import unary_union
def read_dxf(path):
"""Repair LibreDWG's multiline text and unsupported annotation payloads, not geometry."""
def load_dxf(path):
"""Open a LibreDWG DXF after repairing its multiline text and unsupported annotation payloads, not geometry."""
import ezdxf
from ezdxf.path import make_path, from_hatch
lines = Path(path).read_text(errors="replace").splitlines()
pairs = []
i = 0
@ -46,7 +45,12 @@ def read_dxf(path):
skip = True
if not skip:
clean.extend((str(code), value))
doc = ezdxf.read(io.StringIO("\n".join(clean) + "\n"))
return ezdxf.read(io.StringIO("\n".join(clean) + "\n"))
def read_dxf(path):
from ezdxf.path import make_path, from_hatch
doc = load_dxf(path)
omitted = []
def flatten(entities):
for entity in entities:

View file

@ -1,11 +1,17 @@
"""Extract Cedar Gate Road's 40 drawn cross-sections from C3277-912-001 Rev A.
"""Extract Cedar Gate Road's 40 drawn cross-sections from C3277-912-001 Rev A and check them against
the C3277-911-001 Rev A road long section.
Uses the drawing's H 1:250 / V 1:100 scales and snaps axes to the plotted grid.
Connected road design paths are separated from the independent service symbols.
Quantities are drawing-derived end-area estimates, not measured backfill or as-built volumes.
The cross-sections are read from the CAD drawing converted to DXF (preferred: named layers, exact lines)
or, as before, from the plotted PDF. Scales are measured from the grid labels. Connected road design
paths are separated from the independent service symbols. The long section's centreline ground and design
levels must agree with every matching cross-section. Quantities are drawing-derived end-area estimates,
not measured backfill or as-built volumes.
python3 extract_cedar_gate_xs.py "C3277-912-001 ... - bound.dxf" raw.json MODEL_FOLDER --long-section "C3277-911-001 ... - bound.dxf"
"""
import argparse
import base64
from collections import defaultdict
import hashlib
import json
import math
@ -17,7 +23,7 @@ import tempfile
import numpy as np
from shapely.geometry import LineString, Point
from extract_cedar_gate import alignment
from extract_cedar_gate import alignment, load_dxf
from extract_xs import load_svg_paths, load_words
H_SCALE = 1000/250/25.4*72
@ -25,6 +31,46 @@ V_SCALE = -1000/100/25.4*72
STEP = .05
def profile(chainage, ground, design_segments, excluded):
"""Sample one section's natural ground and road design every STEP m and measure cut/fill to formation.
ground and design_segments are ((offset, level), (offset, level)) pairs sorted by offset.
"""
xs = np.arange(-15, 15+STEP/2, STEP)
def sample(segments, aggregate):
out = np.full(len(xs), np.nan)
for (ax, ay), (bx, by) in segments:
mask = (xs >= ax-1e-8) & (xs <= bx+1e-8)
value = ay+(xs[mask]-ax)*(by-ay)/(bx-ax)
out[mask] = np.where(np.isnan(out[mask]), value, aggregate(out[mask], value))
return out
if not ground:
raise ValueError(f'{chainage}: no natural ground line')
ngl = sample(ground, np.maximum)
good = np.isfinite(ngl)
if good.sum() < 20:
raise ValueError(f'{chainage}: insufficient natural ground')
ngl = np.interp(xs, xs[good], ngl[good]) # bridge plotted dashes
top = sample(design_segments, np.maximum)
bottom = sample(design_segments, np.minimum)
cut = np.maximum(ngl-bottom, 0)
fill = np.maximum(bottom-ngl, 0)
# Integrate only contiguous runs of drawn formation; do not bridge blank spans.
def integrate(values):
mask = np.isfinite(values[:-1]) & np.isfinite(values[1:])
return float(np.sum((values[:-1][mask]+values[1:][mask])*.5*STEP))
return {'ch': chainage, 'x': xs, 'ngl': ngl, 'top': top, 'bot': bottom,
'cutArea': integrate(cut), 'fillArea': integrate(fill),
'roadWidth': float(np.ptp(xs[np.isfinite(bottom)])), 'excludedIndependentPaths': excluded}
def check_chainages(result):
result.sort(key=lambda s: s['ch'])
if len(result) != 40 or [s['ch'] for s in result] != [*range(0, 381, 10), 385.411]:
raise ValueError('Expected all 40 drawn sections from 0 to 385.411 m')
return result
def read_sections(svg_path, words_path):
paths = load_svg_paths(Path(svg_path).read_text())
words = load_words(Path(words_path).read_text())
@ -90,39 +136,141 @@ def read_sections(svg_path, words_path):
for a, b in zip(p['pts'], p['pts'][1:]):
if abs(a[0]-b[0]) > .02:
ground.append(tuple(sorted((a, b))))
if not ground:
raise ValueError(f'{chainage}: no natural ground line')
xs = np.arange(-15, 15+STEP/2, STEP)
def sample(segments, aggregate):
out = np.full(len(xs), np.nan)
for (ax, ay), (bx, by) in segments:
mask = (xs >= ax-1e-8) & (xs <= bx+1e-8)
value = ay+(xs[mask]-ax)*(by-ay)/(bx-ax)
out[mask] = np.where(np.isnan(out[mask]), value, aggregate(out[mask], value))
return out
ngl = sample(ground, np.maximum)
good = np.isfinite(ngl)
if good.sum() < 20:
raise ValueError(f'{chainage}: insufficient natural ground')
ngl = np.interp(xs, xs[good], ngl[good]) # bridge plotted dashes
design_segments = [tuple(sorted((a, b))) for p in connected for a, b in zip(p['pts'], p['pts'][1:]) if abs(a[0]-b[0]) > .001]
top = sample(design_segments, np.maximum)
bottom = sample(design_segments, np.minimum)
cut = np.maximum(ngl-bottom, 0)
fill = np.maximum(bottom-ngl, 0)
# Integrate only contiguous runs of drawn formation; do not bridge blank spans.
def integrate(values):
mask = np.isfinite(values[:-1]) & np.isfinite(values[1:])
return float(np.sum((values[:-1][mask]+values[1:][mask])*.5*STEP))
result.append({'ch': chainage, 'x': xs, 'ngl': ngl, 'top': top, 'bot': bottom,
'cutArea': integrate(cut), 'fillArea': integrate(fill),
'roadWidth': float(np.ptp(xs[np.isfinite(bottom)])), 'excludedIndependentPaths': len(remaining)})
result.sort(key=lambda s: s['ch'])
if len(result) != 40 or [s['ch'] for s in result] != [*range(0, 381, 10), 385.411]:
raise ValueError('Expected all 40 drawn sections from 0 to 385.411 m')
result.append(profile(chainage, ground, design_segments, len(remaining)))
return check_chainages(result)
XS_DESIGN_LAYERS = ('XS_ROAD_SURFACE', 'XS_LAYERWORKS', 'XS_KERBS', 'XS_ROAD_SHOULDERS', 'XS_SLOPE_REV')
def read_sections_cad(dxf_path):
"""Read the 40 sections from the C3277-912-001 CAD drawing (converted to DXF).
Each section is a plot frame above a caption strip "Peg Distance (m) N m". Offsets are drawn at
true horizontal scale and levels stretched (2.5x, from H 1:250 / V 1:100); both scales are measured
from the grid labels. Natural ground is the XS_GROUND polyline; the design is the road surface plus
the layerworks, kerbs, shoulders and batters connected to it. Unconnected service markers are excluded.
"""
doc = load_dxf(dxf_path)
msp = doc.modelspace()
layer = lambda e: e.dxf.layer.split('$')[-1]
lines = defaultdict(list)
for e in msp:
if e.dxftype() == 'LINE':
lines[layer(e)].append(((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)))
grounds = [[tuple(p[:2]) for p in e.get_points()] for e in msp if e.dxftype() == 'LWPOLYLINE' and layer(e) == 'XS_GROUND']
texts = [(layer(e), e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip()) for e in msp if e.dxftype() == 'TEXT']
horizontal = [(min(a[0], b[0]), max(a[0], b[0]), a[1]) for a, b in lines['XS_FRAME_A'] if abs(a[1]-b[1]) < 1e-6]
result = []
for kind, cx, cy, text in texts:
m = re.fullmatch(r'Peg Distance \(m\) (\d+(?:\.\d+)?) m', text) if kind == 'XS_TEXT_FRAME' else None
if not m:
continue
chainage = float(m.group(1))
above = sorted({round(y, 6) for x0, x1, y in horizontal if x0 <= cx <= x1 and y > cy})
if len(above) < 2:
raise ValueError(f'{chainage}: plot frame not found')
bottom, top = above[0], above[1]
x0, x1 = next((a, b) for a, b, y in horizontal if round(y, 6) == bottom and a <= cx <= b)
inside = lambda x, y, pad=.01: x0-pad <= x <= x1+pad and bottom-pad <= y <= top+pad
gx = sorted({a[0] for a, b in lines['XS_GRID'] if abs(a[0]-b[0]) < 1e-6 and inside(*a)})
gy = sorted({a[1] for a, b in lines['XS_GRID'] if abs(a[1]-b[1]) < 1e-6 and inside(*a)})
offsets = [(float(t), min(gx, key=lambda g: abs(g-x))) for k, x, y, t in texts
if k == 'XS_TEXT_GRID' and re.fullmatch(r'-?\d{1,2}', t) and x0 <= x <= x1 and bottom-4 <= y < bottom]
levels = [(float(t), max([g for g in gy if g <= y+1e-6], default=None)) for k, x, y, t in texts
if k == 'XS_TEXT_GRID' and re.fullmatch(r'1[34]\d{2}', t) and inside(x, y, .5)]
levels = [(z, g) for z, g in levels if g is not None]
if len(offsets) < 5 or len(levels) < 2:
raise ValueError(f'{chainage}: grid labels unreadable')
h_scale = np.polyfit([o for o, _ in offsets], [x for _, x in offsets], 1)[0]
v_scale = np.polyfit([z for z, _ in levels], [y for _, y in levels], 1)[0]
if abs(h_scale-1) > .01 or abs(v_scale-2.5) > .02:
raise ValueError(f'{chainage}: unexpected drawing scales {h_scale:.3f} / {v_scale:.3f}')
xo = float(np.median([x-h_scale*o for o, x in offsets]))
yo = float(np.median([y-v_scale*z for z, y in levels]))
to_m = lambda p: ((p[0]-xo)/h_scale, (p[1]-yo)/v_scale)
ground_lines = [g for g in grounds if all(inside(*p) for p in g)]
if len(ground_lines) != 1:
raise ValueError(f'{chainage}: expected one natural ground line, found {len(ground_lines)}')
ground = [tuple(sorted((to_m(a), to_m(b)))) for a, b in zip(ground_lines[0], ground_lines[0][1:]) if abs(a[0]-b[0]) > 1e-6]
design = [(name, tuple(sorted((to_m(a), to_m(b))))) for name in XS_DESIGN_LAYERS for a, b in lines[name] if inside(*a) and inside(*b)]
connected = [seg for name, seg in design if name == 'XS_ROAD_SURFACE']
if not connected:
raise ValueError(f'{chainage}: no finished road profile')
remaining = [seg for name, seg in design if name != 'XS_ROAD_SURFACE']
while True:
geometry = [LineString(seg) for seg in connected]
joined = [seg for seg in remaining if any(LineString(seg).distance(g) < .005 for g in geometry)]
if not joined:
break
connected.extend(joined)
remaining = [seg for seg in remaining if seg not in joined]
design_segments = [seg for seg in connected if abs(seg[0][0]-seg[1][0]) > .001]
result.append(profile(chainage, ground, design_segments, len(remaining)))
return check_chainages(result)
def read_long_section(dxf_path):
"""Read the level bands under each panel of the C3277-911-001 road long section.
Returns one panel per road: rows of chainage with centreline ground level and the design levels at the
centreline and 2.5 m left and right, wherever the drawing gives them.
"""
doc = load_dxf(dxf_path)
msp = doc.modelspace()
frame = [((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)) for e in msp if e.dxftype() == 'LINE' and e.dxf.layer.endswith('CC_LS_FRAME')]
top = max(max(a[1], b[1]) for a, b in frame)
panels = sorted((min(a[0], b[0]), max(a[0], b[0])) for a, b in frame if abs(a[1]-top) < 1e-6 and abs(b[1]-top) < 1e-6)
texts = [(e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip(), round(e.dxf.get('rotation', 0))) for e in msp
if e.dxftype() == 'TEXT' and e.dxf.layer.endswith('CC_LS_TEXT')]
bands = {'Peg Distance (m)': 'ch', 'Ground Level (m) (@GLO)': 'ground', 'Left': 'left', 'Center': 'centre', 'Right': 'right'}
result = []
for x0, x1 in panels:
labels = [(y, bands[t]) for x, y, t, r in texts if x0 <= x <= x1 and t in bands and r == 0]
if sorted(b for _, b in labels) != sorted(bands.values()):
raise ValueError(f'Long-section panel at x {x0:.1f}: level bands not found')
columns = defaultdict(dict)
for x, y, t, r in texts:
if not (x0 <= x <= x1 and r == 90 and re.fullmatch(r'\d+(\.\d+)?', t)):
continue
# A value sits in the band whose label is level with it or up to 8 units above it.
band = min(((ly-y, b) for ly, b in labels if -1 <= ly-y <= 8), default=None)
if band:
columns[round(x, 1)][band[1]] = float(t)
rows = sorted((c for c in columns.values() if 'ch' in c), key=lambda c: c['ch'])
result.append({'length': rows[-1]['ch'], 'rows': rows})
return result
def long_section_check(sections, panel):
"""Compare each cross-section's centreline ground and design level with the long section at that chainage."""
rows = {round(r['ch'], 3): r for r in panel['rows']}
centre = int(round(15/STEP))
side = int(round(2.5/STEP))
ground, design = [], []
for s in sections:
r = rows.get(round(s['ch'], 3))
if not r:
continue
if 'ground' in r:
ground.append(float(s['ngl'][centre])-r['ground'])
if 'centre' in r and np.isfinite(s['top'][centre]):
design.append(float(s['top'][centre])-r['centre'])
# Left/right are a matter of drawing direction: compare with whichever pairing agrees.
if 'left' in r and 'right' in r and np.isfinite(s['top'][centre-side]) and np.isfinite(s['top'][centre+side]):
a = (s['top'][centre-side]-r['left'], s['top'][centre+side]-r['right'])
b = (s['top'][centre-side]-r['right'], s['top'][centre+side]-r['left'])
design.extend(min((a, b), key=lambda d: abs(d[0])+abs(d[1])))
if len(ground) < 30 or len(design) < 30:
raise ValueError(f'Long section matches too few cross-sections: {len(ground)} ground, {len(design)} design levels')
stats = lambda d: {'samples': len(d), 'medianAbs': round(float(np.median(np.abs(d))), 4), 'maxAbs': round(float(np.max(np.abs(d))), 4)}
report = {'ground': stats(ground), 'design': stats(design)}
if report['ground']['medianAbs'] > .02 or report['design']['medianAbs'] > .02 or report['ground']['maxAbs'] > .1 or report['design']['maxAbs'] > .1:
raise ValueError(f'Cross-sections disagree with the long section: {report}')
return report
def dem(surface):
q = np.frombuffer(base64.b64decode(surface['z']), dtype='<i2')
grid = np.where(q < 0, np.nan, surface['zmin']+q/1000).reshape(surface['ny'], surface['nx'])
@ -140,7 +288,7 @@ def dem(surface):
return at
def build_sections(sections, raw, folder, source):
def build_sections(sections, raw, folder, source, long_section=None):
road = json.loads((folder/'bushveld.json').read_text())
surface = json.loads((folder/'surface.json').read_text())
previous = json.loads((folder/'earthworks.json').read_text())
@ -183,10 +331,22 @@ def build_sections(sections, raw, folder, source):
volumes = [{'from': a['ch'], 'to': b['ch'], 'cut': round((a['cutArea']+b['cutArea'])*.5*(b['ch']-a['ch']), 3),
'fill': round((a['fillArea']+b['fillArea'])*.5*(b['ch']-a['ch']), 3)} for a, b in zip(sections, sections[1:])]
section_keys = [s['key'] for s in road['sections'] if s.get('name') == 'Cedar Gate Road']
lengths = {name: max(s['to'] for s in road['sections'] if s.get('name') == name) for name in {s.get('name') for s in road['sections']}}
long_report = None
if long_section:
named = {min(lengths, key=lambda n: abs(lengths[n]-panel['length'])): panel for panel in long_section}
long_report = {'check': long_section_check(sections, named['Cedar Gate Road']), 'roads': {
name: {'length': panel['length'], 'levels': sum('ground' in r and 'centre' in r for r in panel['rows']),
'maxCentreCut': round(max([r['ground']-r['centre'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3),
'maxCentreFill': round(max([r['centre']-r['ground'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3)}
for name, panel in named.items()}}
for s in road['sections']:
if s['key'] == section_keys[-1]:
s['to'] = 385.411
result = {'source': source, 'method': 'End-area between 10 m drawn sections (last strip 5.411 m); natural ground versus connected road formation. Service trench symbols excluded.',
method = 'End-area between 10 m drawn sections (last strip 5.411 m); natural ground versus connected road formation, including the revised side slopes to be shaped. Service trench symbols excluded.'
if long_report:
method += f" Centreline ground and design levels agree with the C3277-911-001 long section to {long_report['check']['ground']['medianAbs']*1000:.0f} mm / {long_report['check']['design']['medianAbs']*1000:.0f} mm median."
result = {'source': source, 'method': method,
'sectionKeys': section_keys, 'chainageOffset': 0, 'chainageEnd': 385.411, 'sections': out, 'volumes': volumes,
'alignment': [[round(float(ch), 3), *at(ch)[0].round(4).tolist()] for ch in np.arange(0, line.length, 2)],
'totals': {key: round(sum(v[key] for v in volumes), 3) for key in ['cut', 'fill']},
@ -203,6 +363,7 @@ def build_sections(sections, raw, folder, source):
report = {'sections': len(out), 'chainage': [0, 385.411], 'totals': result['totals'], 'validation': result['validation'],
'offset_side': side, 'widths': [[s['ch'], round(s['roadWidth'], 3)] for s in sections],
'excluded_independent_service_paths': sum(s['excludedIndependentPaths'] for s in sections), 'scope': replacement,
'longSection': long_report,
'earthworks_sha256': hashlib.sha256((folder/'earthworks.json').read_bytes()).hexdigest()}
(folder/'earthworks-validation.json').write_text(json.dumps(report, indent=2))
validation_path = folder/'validation.json'
@ -214,17 +375,24 @@ def build_sections(sections, raw, folder, source):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('pdf', type=Path)
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('cross_sections', type=Path, help='C3277-912-001 as DXF (converted from the DWG) or as the plotted PDF')
parser.add_argument('raw_geometry', type=Path)
parser.add_argument('model_folder', type=Path)
parser.add_argument('--long-section', type=Path, help='C3277-911-001 road long section as DXF; checks every cross-section')
parser.add_argument('--poppler-bin', type=Path, default=Path('/usr/local/bin'))
args = parser.parse_args()
if args.cross_sections.suffix.lower() == '.dxf':
sections = read_sections_cad(args.cross_sections)
else:
with tempfile.TemporaryDirectory() as tmp:
svg, words = Path(tmp)/'sections.svg', Path(tmp)/'words.html'
subprocess.run([str(args.poppler_bin/'pdftocairo'), '-svg', str(args.pdf), str(svg)], check=True)
subprocess.run([str(args.poppler_bin/'pdftotext'), '-bbox', str(args.pdf), str(words)], check=True)
build_sections(read_sections(svg, words), json.loads(args.raw_geometry.read_text()), args.model_folder, args.pdf.name)
subprocess.run([str(args.poppler_bin/'pdftocairo'), '-svg', str(args.cross_sections), str(svg)], check=True)
subprocess.run([str(args.poppler_bin/'pdftotext'), '-bbox', str(args.cross_sections), str(words)], check=True)
sections = read_sections(svg, words)
long_section = read_long_section(args.long_section) if args.long_section else None
sources = ' + '.join(p.name.replace('.dxf', '.dwg') if p.suffix.lower() == '.dxf' else p.name for p in [args.cross_sections, args.long_section] if p)
build_sections(sections, json.loads(args.raw_geometry.read_text()), args.model_folder, sources, long_section)
if __name__ == '__main__':

View file

@ -0,0 +1,59 @@
import sys
import unittest
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
from extract_cedar_gate_xs import STEP, long_section_check, profile # noqa: E402
def flat_road(ch, ground_level=101.0, surface=100.5, depth=.5, half_width=5.0):
"""Level ground over a flat road box: cut = (ground - formation) x width."""
ground = [((-15.0, ground_level), (15.0, ground_level))]
design = [((-half_width, surface), (half_width, surface)), ((-half_width, surface-depth), (half_width, surface-depth))]
return profile(ch, ground, design, 0)
class ProfileTest(unittest.TestCase):
def test_cut_is_ground_above_formation_across_the_drawn_width(self):
s = flat_road(0)
self.assertAlmostEqual(s['cutArea'], 1.0*10, places=6)
self.assertEqual(s['fillArea'], 0)
self.assertAlmostEqual(s['roadWidth'], 10)
self.assertTrue(np.isnan(s['bot'][0])) # no formation outside the drawn road
def test_fill_where_formation_is_above_ground(self):
s = flat_road(0, ground_level=99.5)
self.assertEqual(s['cutArea'], 0)
self.assertAlmostEqual(s['fillArea'], .5*10, places=6)
def test_missing_ground_is_an_error(self):
with self.assertRaises(ValueError):
profile(0, [], [((-5.0, 100.0), (5.0, 100.0))], 0)
class LongSectionCheckTest(unittest.TestCase):
def setUp(self):
self.sections = [flat_road(ch) for ch in range(0, 400, 10)]
self.panel = {'length': 390, 'rows': [{'ch': float(ch), 'ground': 101.0, 'centre': 100.5, 'left': 100.5, 'right': 100.5} for ch in range(0, 400, 10)]}
def test_agreeing_levels_pass(self):
report = long_section_check(self.sections, self.panel)
self.assertEqual(report['ground'], {'samples': 40, 'medianAbs': 0.0, 'maxAbs': 0.0})
self.assertEqual(report['design']['samples'], 120)
def test_a_long_section_for_another_road_fails(self):
for r in self.panel['rows']:
r['ground'] += .25
with self.assertRaises(ValueError):
long_section_check(self.sections, self.panel)
def test_too_few_matching_chainages_fail(self):
self.panel['rows'] = self.panel['rows'][:10]
with self.assertRaises(ValueError):
long_section_check(self.sections, self.panel)
if __name__ == '__main__':
unittest.main()

View file

@ -63,12 +63,28 @@ export async function POST(
return NextResponse.json({ error: "site_not_found" }, { status: 404 });
}
// Re-read the PDF so a typed-over certificate number cannot import the
// same valuation twice (a reprint of Cert 1 saved as "Cert 2" did once).
const fromPdf = await readCertificatePdf(new Uint8Array(await file.arrayBuffer()), file.name).catch(() => null);
if (fromPdf?.numberFromPdf && fromPdf.certificateNumber.toLowerCase() !== certificateNumber.toLowerCase()) {
return badRequest(
"certificate_number",
`This PDF is ${fromPdf.certificateNumber}${fromPdf.invoiceNumber ? ` (invoice ${fromPdf.invoiceNumber})` : ""}, not ${certificateNumber}. Check you attached the right certificate.`,
);
}
const invoiceNote = fromPdf?.invoiceNumber ? `Invoice No. ${fromPdf.invoiceNumber}` : "";
const existing = await pb.collection("income_claims").getFullList({
filter: pb.filter("site = {:site} && claim_number = {:num}", { site: id, num: certificateNumber }),
fields: "id",
filter: invoiceNote
? pb.filter("site = {:site} && (claim_number = {:num} || notes ~ {:inv})", { site: id, num: certificateNumber, inv: invoiceNote })
: pb.filter("site = {:site} && claim_number = {:num}", { site: id, num: certificateNumber }),
fields: "id,claim_number",
});
if (existing.length) {
return badRequest("certificate_number", `${certificateNumber} has already been imported for this site.`);
return badRequest(
"certificate_number",
`${existing[0].claim_number}${invoiceNote ? ` (${invoiceNote.toLowerCase()})` : ""} has already been imported for this site.`,
);
}
const record = new FormData();
@ -79,7 +95,7 @@ export async function POST(
gross_amount: String(certified),
retention_amount: String(retention),
status: "certified",
notes: "Imported from payment certificate. Certified invoice incl VAT; retention ex VAT.",
notes: `Imported from payment certificate.${invoiceNote ? ` ${invoiceNote}.` : ""} Certified invoice incl VAT; retention ex VAT.`,
})) record.append(k, v);
record.append("certificate", file, file.name);

View file

@ -6,6 +6,10 @@ import { pbAdmin } from "@/lib/pb";
import { loadFuelPrices } from "@/lib/fuel-prices";
import { loadCashFloatSlips } from "@/lib/agent/load";
import { type PnGItemLike } from "@/lib/partition-fixed-costs";
import { deliveryLineValues, type DeliveryLineValueInput } from "@/lib/metrics/supplier-balance";
import { isRealPurchaseOrder } from "@/lib/purchasing/real-purchase-order";
import type { DocumentKind } from "@/lib/document-kind";
import { workDate } from "@/lib/clock-day";
import {
buildDailyFuelCost,
buildMonthlyIncomeVsExpenses,
@ -71,6 +75,9 @@ export default async function SiteDetailPage({
plant,
income,
fuelPrices,
poItems,
dnItems,
deliveryNotes,
] = await Promise.all([
pb.collection("fixed_costs").getFullList({ filter: `site='${id}'` }),
pb.collection("timesheet_entries").getFullList(),
@ -81,8 +88,37 @@ export default async function SiteDetailPage({
pb.collection("vehicles").getFullList({ filter: `site='${id}'` }),
pb.collection("income_claims").getFullList({ filter: `site='${id}'` }),
loadFuelPrices(),
// Delivery note value inputs, joined exactly as the Financial page does.
pb.collection("purchase_order_items").getFullList({
filter: `purchase_order.site='${id}'`,
fields: "id,purchase_order,qty,rate,disc_percent",
}),
pb.collection("delivery_note_items").getFullList({
filter: `delivery_note.site='${id}'`,
fields: "id,purchase_order_item,booking,qty_received,delivery_note",
}),
pb.collection("delivery_notes").getFullList({
filter: `site='${id}'`,
fields: "id,document_type,date_received",
}),
]);
// Materials = value received on delivery notes (what arrived), not PO
// totals (what was ordered) -- the Financial page's "Received" rule.
const deliveries = deliveryLineValues({
visiblePoIds: new Set(purchaseOrders.filter(isRealPurchaseOrder).map((p) => String(p.id))),
items: poItems as unknown as DeliveryLineValueInput["items"],
deliveryLines: dnItems as unknown as DeliveryLineValueInput["deliveryLines"],
kindByDnId: new Map(deliveryNotes.map((d) => [String(d.id), (d.document_type ?? "") as DocumentKind])),
dateByDnId: new Map(deliveryNotes.map((d) => [String(d.id), String(d.date_received ?? "")])),
});
const thisMonth = workDate(new Date().toISOString()).slice(0, 7);
const round2 = (n: number) => Math.round(n * 100) / 100;
const deliveredTotal = round2(deliveries.reduce((s, d) => s + d.value, 0));
const deliveredMonth = round2(
deliveries.filter((d) => d.dateReceived.slice(0, 7) === thisMonth).reduce((s, d) => s + d.value, 0),
);
const employeeRates: EmployeeRateMap = new Map();
for (const e of employees) {
employeeRates.set(e.id, { rate: e.rate_per_hour ?? 0, site: e.site, name: e.name });
@ -94,7 +130,7 @@ export default async function SiteDetailPage({
(t) => employeeRates.get(t.employee)?.site === id,
);
const spend = computeSiteSpend({
const spendInput = {
siteId: id,
fixedCosts: fixedCosts.map((c) => ({
id: c.id,
@ -107,9 +143,24 @@ export default async function SiteDetailPage({
timesheets: timesheets as unknown as TimesheetLike[],
employeeRates,
purchaseOrders: purchaseOrders as unknown as PurchaseOrderLike[],
deliveredMaterials: deliveredTotal,
fuelLogs: fuelLogs as unknown as FuelLogLike[],
cashFloats: cashFloatSlips as unknown as { amount?: number }[],
cashFloats: cashFloatSlips as unknown as { amount?: number; occurred_on?: string }[],
fuelPrices,
};
const spend = computeSiteSpend(spendInput);
// The same roll-up over this month's rows only. P&G follows
// computeMonthlyFixedCost: every recurring item is due again this month,
// a one-off only if it was booked this month.
const inMonth = (d?: string) => !!d && workDate(d.replace(" ", "T")).slice(0, 7) === thisMonth;
const monthSpend = computeSiteSpend({
...spendInput,
fixedCosts: spendInput.fixedCosts.filter((c) => c.is_recurring !== false || inMonth(c.date_logged)),
timesheets: (timesheets as unknown as (TimesheetLike & { date_logged?: string })[]).filter((t) => inMonth(t.date_logged)),
deliveredMaterials: deliveredMonth,
fuelLogs: spendInput.fuelLogs.filter((f) => inMonth(f.date_logged)),
cashFloats: spendInput.cashFloats.filter((c) => inMonth(c.occurred_on)),
});
const start = resolveSiteStartDate({
@ -236,6 +287,16 @@ export default async function SiteDetailPage({
fixedSum: spend.fixedSum,
payrollBurn: spend.payrollBurn,
materialSum: spend.materialSum,
month: {
label: thisMonth,
fixedSum: monthSpend.fixedSum,
oneOffSum: monthSpend.oneOffSum,
payrollBurn: monthSpend.payrollBurn,
materialSum: monthSpend.materialSum,
fuelBurn: monthSpend.fuelBurn,
cashFloatSum: monthSpend.cashFloatSum,
spent: monthSpend.totalSpent,
},
fuelBurn: spend.fuelBurn,
cashFloatSum: spend.cashFloatSum,
remaining: projection.remaining,

View file

@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { SiteDetailView, type SiteDetailData } from "./site-detail-view";
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/dashboard/sites/s1",
}));
const data: SiteDetailData = {
id: "s1", projectCode: "C26/13", name: "Cedar Gate", duration: "12 months", startDate: "2026-08-01",
contractValue: 1_000_000, spent: 1000, fixedSum: 100, oneOffSum: 50, payrollBurn: 300, materialSum: 400,
month: { label: "2026-10", fixedSum: 100, oneOffSum: 0, payrollBurn: 120, materialSum: 98351.77, fuelBurn: 20, cashFloatSum: 5, spent: 98596.77 },
fuelBurn: 100, fuelLitres: 10, fuelLogs: 2, cashFloatSum: 50, remaining: 0, burnPct: 0, months: 12, elapsedMonths: 3,
elapsedPct: 25, remainingMonths: 9, projectedTotal: 0, projectedProfit: 0, status: "on-track",
monthlyItems: [], oneOffItems: [], poList: [], payrollByEmp: [], weekly: [], monthly: [], dailyFuel: [],
plantUtilization: [], siteWeeklyHours: 50,
incomeList: [
{ id: "c1", claimNumber: "Cert 1", periodStart: "", periodEnd: "2026-09-30", grossAmount: 1000, retentionAmount: 100, certificateUrl: "", certifiedBy: "", paidOn: "", status: "certified", isVerified: false, notes: "" },
{ id: "c2", claimNumber: "Cert 2", periodStart: "", periodEnd: "2026-10-31", grossAmount: 2000, retentionAmount: 200, certificateUrl: "", certifiedBy: "", paidOn: "", status: "certified", isVerified: false, notes: "" },
],
} as SiteDetailData;
describe("site outflows and inflows", () => {
it("shows this month and total columns", () => {
const html = renderToStaticMarkup(<SiteDetailView data={data} />);
expect(html).toContain("Oct 2026");
expect(html).toContain("Delivery notes");
expect(html).not.toContain("Materials (purchase orders)");
// [month, total] cells after a row label; formatCurrency rounds to whole rand.
const cells = (label: string) =>
[...html.split(label)[1].matchAll(/<td[^>]*>([^<]*)<\/td>/g)].slice(0, 2).map((m) => m[1].replace(/\s/g, " "));
expect(cells("Delivery notes</span></td>")).toEqual(["R 98 352", "R 400"]);
expect(cells("Certified invoice (VAT incl)</span></td>")).toEqual(["R 2 000", "R 3 000"]);
expect(cells("Total outflow</td>")).toEqual(["R 98 597", "R 1 000"]);
});
});

View file

@ -127,7 +127,19 @@ export interface SiteDetailData {
fixedSum: number;
oneOffSum: number;
payrollBurn: number;
/** Value received on delivery notes, all time. */
materialSum: number;
/** The same outflow categories for the current month only. */
month: {
label: string;
fixedSum: number;
oneOffSum: number;
payrollBurn: number;
materialSum: number;
fuelBurn: number;
cashFloatSum: number;
spent: number;
};
fuelBurn: number;
/** What the cash-float slips came to. */
cashFloatSum: number;
@ -325,6 +337,8 @@ export function SiteDetailView({ data }: { data: SiteDetailData }) {
const status = STATUS_COPY[data.status];
const [addOpen, setAddOpen] = React.useState(false);
const [certOpen, setCertOpen] = React.useState(false);
// A certificate counts in the month on its certificate date.
const monthCerts = data.incomeList.filter((c) => (c.periodEnd || c.paidOn).slice(0, 7) === data.month.label);
const certificatePoints = React.useMemo(
() =>
buildCertificateSeries(
@ -566,39 +580,39 @@ export function SiteDetailView({ data }: { data: SiteDetailData }) {
Operational cash leaving the site.
</p>
</CardHeader>
<CardContent className="space-y-3">
<OutflowRow
icon={Receipt}
label="P&G (preliminaries & general)"
amount={data.fixedSum}
/>
<OutflowRow
icon={Receipt}
label="One-off overheads"
amount={data.oneOffSum}
/>
<OutflowRow icon={Users} label="Payroll / labour" amount={data.payrollBurn} />
<OutflowRow
icon={Package}
label="Materials (purchase orders)"
amount={data.materialSum}
/>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="h-9 px-4 text-left font-medium">Category</th>
<th className="h-9 px-3 text-right font-medium">{monthLabel(data.month.label)}</th>
<th className="h-9 px-4 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
<OutflowRow icon={Receipt} label="P&G (preliminaries & general)" month={data.month.fixedSum} total={data.fixedSum} />
<OutflowRow icon={Receipt} label="One-off overheads" month={data.month.oneOffSum} total={data.oneOffSum} />
<OutflowRow icon={Users} label="Payroll / labour" month={data.month.payrollBurn} total={data.payrollBurn} />
<OutflowRow icon={Package} label="Delivery notes" month={data.month.materialSum} total={data.materialSum} />
<OutflowRow
icon={Fuel}
label={`Fuel (${formatNumber(data.fuelLitres, { maximumFractionDigits: 1 })} L · ${data.fuelLogs} txns)`}
amount={data.fuelBurn}
month={data.month.fuelBurn}
total={data.fuelBurn}
/>
<OutflowRow
icon={WalletCards}
label="Cash float slips"
amount={data.cashFloatSum}
/>
<div className="flex items-center justify-between border-t border-border pt-3">
<span className="text-sm font-semibold">Total outflow</span>
<span className="text-base font-bold tabular-nums">
{formatCurrency(data.spent)}
</span>
</div>
<OutflowRow icon={WalletCards} label="Cash float slips" month={data.month.cashFloatSum} total={data.cashFloatSum} />
</tbody>
<tfoot className="border-t border-border">
<tr>
<td className="px-4 py-3 font-semibold">Total outflow</td>
<td className="px-3 py-3 text-right font-semibold tabular-nums">{formatCurrency(data.month.spent)}</td>
<td className="px-4 py-3 text-right text-base font-bold tabular-nums">{formatCurrency(data.spent)}</td>
</tr>
</tfoot>
</table>
<p className="px-4 pb-4 text-xs text-muted-foreground">
P&amp;G this month is the recurring run rate. Delivery notes are valued at what was received.
</p>
</CardContent>
</Card>
@ -615,10 +629,21 @@ export function SiteDetailView({ data }: { data: SiteDetailData }) {
Import certificate
</Button>
</CardHeader>
<CardContent className="space-y-3">
<OutflowRow icon={Banknote} label="Certified invoice (VAT incl)" amount={totalIncome(data.incomeList)} />
<OutflowRow icon={PiggyBank} label="Retention (ex VAT)" amount={totalRetention(data.incomeList)} />
<p className="border-t border-border pt-3 text-xs text-muted-foreground">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b border-border text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="h-9 px-4 text-left font-medium">Category</th>
<th className="h-9 px-3 text-right font-medium">{monthLabel(data.month.label)}</th>
<th className="h-9 px-4 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
<OutflowRow icon={Banknote} label="Certified invoice (VAT incl)" month={totalIncome(monthCerts)} total={totalIncome(data.incomeList)} />
<OutflowRow icon={PiggyBank} label="Retention (ex VAT)" month={totalRetention(monthCerts)} total={totalRetention(data.incomeList)} />
</tbody>
</table>
<p className="px-4 py-3 text-xs text-muted-foreground">
{data.incomeList.length} certificate{data.incomeList.length === 1 ? "" : "s"} imported. Retention is ex VAT.
</p>
</CardContent>
@ -1216,7 +1241,7 @@ function BurnComposition({
const items = [
{ label: "P&G", value: fixed, accent: "bg-[var(--brand-soft)]" },
{ label: "Payroll", value: payroll, accent: "bg-[var(--brand-soft)]/70" },
{ label: "Materials", value: material, accent: "bg-[var(--brand-soft)]/50" },
{ label: "Delivery notes", value: material, accent: "bg-[var(--brand-soft)]/50" },
{ label: "Fuel", value: fuel, accent: "bg-[var(--brand-soft)]/30" },
];
const total = items.reduce((s, i) => s + i.value, 0);
@ -1298,23 +1323,32 @@ function PlanRow({
function OutflowRow({
icon: Icon,
label,
amount,
month,
total,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
amount: number;
month: number;
total: number;
}) {
return (
<div className="flex items-center justify-between text-sm">
<tr className="border-b border-border/60 last:border-0">
<td className="px-4 py-2">
<span className="flex items-center gap-2 text-muted-foreground">
<Icon className="h-4 w-4 text-primary" />
<Icon className="h-4 w-4 shrink-0 text-primary" />
{label}
</span>
<span className="font-semibold tabular-nums">{formatCurrency(amount)}</span>
</div>
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{formatCurrency(month)}</td>
<td className="px-4 py-2 text-right font-semibold tabular-nums">{formatCurrency(total)}</td>
</tr>
);
}
function monthLabel(yearMonth: string): string {
return format(parseISO(`${yearMonth}-01`), "MMM yyyy");
}
function SnapshotStat({
label,
value,

View file

@ -29,6 +29,16 @@ describe("computeSiteSpend", () => {
expect(r.oneOffSum).toBe(0);
});
it("uses delivered value for materials when given, not PO totals", () => {
const r = computeSiteSpend({
...base,
purchaseOrders: [{ total_value: 9000 }],
deliveredMaterials: 1200,
});
expect(r.materialSum).toBe(1200);
expect(r.totalSpent).toBe(1200);
});
it("computes payroll as hours x rate", () => {
const r = computeSiteSpend({
...base,

View file

@ -46,6 +46,12 @@ export interface SiteSpendInput {
employeeRates: EmployeeRateMap;
/** Pre-filtered to this site. */
purchaseOrders: PurchaseOrderLike[];
/**
* Value received on this site's delivery notes (deliveryLineValues).
* When given, materials are what arrived rather than what was ordered,
* and purchaseOrders is ignored for the total.
*/
deliveredMaterials?: number;
/** Pre-filtered to this site. */
fuelLogs: FuelLogLike[];
/** The cash-float slips booked to this site. */
@ -100,7 +106,7 @@ export function computeSiteSpend(input: SiteSpendInput): SiteSpend {
byEmp.set(t.employee, cur);
}
const materialSum = input.purchaseOrders.reduce(
const materialSum = input.deliveredMaterials ?? input.purchaseOrders.reduce(
(s, p) => s + (p.total_value ?? 0),
0,
);

View file

@ -25,6 +25,8 @@ describe("parseCertificateText", () => {
it("takes the VAT-inclusive invoice and the ex-VAT retention", () => {
expect(parseCertificateText(sample)).toEqual({
certificateNumber: "Cert 1",
numberFromPdf: true,
invoiceNumber: "",
certificateDate: "2026-10-20",
certifiedAmount: 1293750,
retentionAmount: 125000,
@ -55,6 +57,8 @@ Ref: Std_AllDebtorRecon_DN.rpt (Std/Link) Mod: 25-February-2026 Printed 27-O
it("reads the RIB valuation invoice: invoice total and this month's retention", () => {
expect(parseCertificateText(ribInvoice, "The Hills Ext 12 - Cert 1.pdf")).toEqual({
certificateNumber: "Cert 1",
numberFromPdf: true,
invoiceNumber: "544",
certificateDate: "2026-07-20",
certifiedAmount: 2207679.85,
retentionAmount: 213302.4,
@ -69,9 +73,22 @@ Ref: Std_AllDebtorRecon_DN.rpt (Std/Link) Mod: 25-February-2026 Printed 27-O
expect(parseCertificateText(cert2).certificateNumber).toBe("Cert 2");
});
it("reads Cedar Gate's long month name and flags a reprint named Cert 2", () => {
const cedar = ribInvoice
.replace("Invoice No. 544", "Invoice No. 596")
.replace("Date 20-July-2026", "Date 30-September-2026");
const parsed = parseCertificateText(cedar, "Ceader Gate - Cert 2.pdf");
expect(parsed.certificateDate).toBe("2026-09-30");
// The valuation number on the page wins over the file name.
expect(parsed.certificateNumber).toBe("Cert 1");
expect(parsed.numberFromPdf).toBe(true);
expect(parsed.invoiceNumber).toBe("596");
});
it("falls back to the file name for the certificate number", () => {
const parsed = parseCertificateText("Total invoice amount 50 000.00", "The Hills Ext 12 - Cert 3.pdf");
expect(parsed.certificateNumber).toBe("Cert 3");
expect(parsed.numberFromPdf).toBe(false);
expect(parsed.certifiedAmount).toBe(50000);
expect(parsed.retentionAmount).toBeNull();
});

View file

@ -16,6 +16,10 @@ const run = promisify(execFile);
export interface ParsedCertificate {
certificateNumber: string;
/** True when certificateNumber was read from the PDF itself, not guessed from the file name. */
numberFromPdf: boolean;
/** "Invoice No." on the RIB tax invoice; the same invoice printed twice has the same number. */
invoiceNumber: string;
certificateDate: string;
certifiedAmount: number | null;
retentionAmount: number | null;
@ -79,13 +83,17 @@ export function parseCertificateText(text: string, fileName = ""): ParsedCertifi
amountAfter(lines, /^\s*invoice\s+total\b/i) ??
amountAfter(lines, /total\s+invoice\s+amount[^\d\n]*?(?:vat\s*inc[a-z.]*\)?)?/i);
const retentionAmount = amountAfter(lines, /less\s*:?\s*retention[^\d\n]*?(?:\d{1,2}(?:\.\d+)?\s*%)?/i);
const numberMatch =
const pdfNumber =
/valuation\s+(?:no\.?|number)\s*:?\s*(\d+)/i.exec(text) ??
/cert(?:ificate)?\.?\s*(?:no\.?|number|#)?\s*:?\s*(\d+)/i.exec(text) ??
/cert(?:ificate)?\.?\s*(?:no\.?|number|#)?\s*:?\s*(\d+)/i.exec(fileName);
/cert(?:ificate)?\.?\s*(?:no\.?|number|#)?\s*:?\s*(\d+)/i.exec(text);
const numberMatch = pdfNumber ?? /cert(?:ificate)?\.?\s*(?:no\.?|number|#)?\s*:?\s*(\d+)/i.exec(fileName);
const invoiceMatch = /invoice\s+no\.?\s*:?\s*(\d+)/i.exec(text);
// "Total To Date" has no date on it; the first labelled line that does is the certificate date.
const dateLine = lines.find((l) => /date/i.test(l) && findDate(l)) ?? text;
return {
certificateNumber: numberMatch ? `Cert ${numberMatch[1]}` : "",
numberFromPdf: !!pdfNumber,
invoiceNumber: invoiceMatch ? invoiceMatch[1] : "",
certificateDate: findDate(dateLine),
certifiedAmount,
retentionAmount,

View file

@ -84,7 +84,7 @@ describe("Cedar Gate source model", () => {
expect(ew.volumes.reduce((sum, v) => sum + v[kind], 0)).toBeCloseTo(ew.totals[kind], 6);
expect(totals.find(x => x.id === kind)?.qty).toBeCloseTo(ew.totals[kind], 6);
}
expect(ew.totals.cut).toBeCloseTo(2073.784, 3);
expect(ew.totals.fill).toBeCloseTo(16.436, 3);
expect(ew.totals.cut).toBeCloseTo(2119.452, 3);
expect(ew.totals.fill).toBeCloseTo(16.919, 3);
});
});