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:
parent
1c8bf45664
commit
76d450aa1d
17 changed files with 692 additions and 169 deletions
|
|
@ -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
|
|
@ -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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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)
|
||||
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.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__':
|
||||
|
|
|
|||
59
scripts/site-model/test_cedar_gate_xs.py
Normal file
59
scripts/site-model/test_cedar_gate_xs.py
Normal 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()
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
38
src/app/dashboard/sites/[id]/site-detail-view.test.tsx
Normal file
38
src/app/dashboard/sites/[id]/site-detail-view.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<OutflowRow
|
||||
icon={Fuel}
|
||||
label={`Fuel (${formatNumber(data.fuelLitres, { maximumFractionDigits: 1 })} L · ${data.fuelLogs} txns)`}
|
||||
amount={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>
|
||||
<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)`}
|
||||
month={data.month.fuelBurn}
|
||||
total={data.fuelBurn}
|
||||
/>
|
||||
<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&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">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
<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 shrink-0 text-primary" />
|
||||
{label}
|
||||
</span>
|
||||
</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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue