"""Build the input data set for the Xero cleanup + asset-tracking sample (FY26: 1 Jul 2025 - 30 Jun 2026).
Scenario: a Melbourne trade business hiring temporary fencing and scaffolding and doing make-safe and roof repair work.
Outputs data/*.csv exactly as they would arrive: Xero exports, the transcribed paper asset log, the yard stocktake.
truth/*.csv holds what the generator knows (used only by the regression test, never by the engine)."""
import random, csv, os, datetime as dt, re, json
from collections import defaultdict
HERE = os.path.dirname(os.path.abspath(__file__)); R = random.Random(2607)
os.makedirs(os.path.join(HERE, "data"), exist_ok=True); os.makedirs(os.path.join(HERE, "truth"), exist_ok=True)
FY0, FY1 = dt.date(2025, 7, 1), dt.date(2026, 6, 30)
def W(name, rows, head=None, d="data"):
    with open(os.path.join(HERE, d, name), "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=head or list(rows[0].keys())); w.writeheader(); w.writerows(rows)
RATE = dict(FP=4.50, BR=3.20, ST=95.00, TK=35.00)  # per unit per week, ex GST
CLASSES = {"FP": "Fence panel 2.4 m", "FB": "Fence base", "BR": "Pedestrian barrier", "ST": "Scaffold tower set", "TK": "Tarp kit"}
FLEET0 = dict(FP=1180, FB=1420, BR=300, ST=24, TK=56)
SUB = ["Preston", "Coburg", "Brunswick", "Footscray", "Sunshine", "Essendon", "Reservoir", "Thornbury", "Northcote", "Heidelberg", "Box Hill", "Doncaster", "Ringwood", "Dandenong",
       "Clayton", "Oakleigh", "Moorabbin", "Cheltenham", "Frankston", "Werribee", "Point Cook", "Craigieburn", "Epping", "South Morang", "Richmond", "Collingwood", "Carlton", "Kew", "Hawthorn", "St Kilda"]
A1 = ["Northgate", "Bluegum", "Ironbark", "Redline", "Southbank", "Kestrel", "Harbour", "Granite", "Summit", "Westside", "Riverbend", "Corner", "Parkview", "Highpoint", "Lakeside",
      "Stonebridge", "Banksia", "Wattle", "Coastline", "Eastlink", "Bayside", "Metro", "Fairway", "Oakridge", "Silverleaf", "Cobalt", "Ember", "Juniper", "Norfolk", "Ashford", "Clifton", "Kingsway"]
T = {"builder": ["Builders", "Constructions", "Building Group", "Developments", "Homes", "Projects"], "strata": ["Strata Management", "Owners Corporation Services", "Body Corporate"],
     "insurer": ["Loss Assessing", "Claims Services", "Insurance Repairs"], "council": ["City Council"], "events": ["Events", "Productions", "Festivals"]}
FIRST = ["James", "Olivia", "Liam", "Mia", "Noah", "Ava", "Lucas", "Chloe", "Ethan", "Grace", "Mason", "Ruby", "Leo", "Zoe", "Jack", "Ella", "Oscar", "Isla", "Henry", "Amelia", "Arjun", "Mei", "Tariq", "Sofia", "Daniel", "Priya", "Sam", "Hannah"]
LAST = ["Nguyen", "Smith", "Patel", "Jones", "Williams", "Brown", "Wilson", "Taylor", "Kelly", "Martin", "Singh", "Chen", "Walker", "Ryan", "Murphy", "Rossi", "Papadopoulos", "Ali", "Hughes", "Doyle"]
def tax_no():
    while True:
        d = [9] + [R.randint(0, 9) for _ in range(10)]; w = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
        if sum((x - (1 if i == 0 else 0)) * w[i] for i, x in enumerate(d)) % 89:  # deliberately NOT a valid ABN checksum
            s = "".join(map(str, d)); return f"{s[:2]} {s[2:5]} {s[5:8]} {s[8:]}"
# ---------------------------------------------------------------- entities and Xero contact records
ents = []; used = set()
for kind, n in [("builder", 44), ("strata", 14), ("insurer", 10), ("council", 4), ("events", 8), ("home", 38)]:
    for _ in range(n):
        while True:
            if kind == "home": nm = f"{R.choice(FIRST)} {R.choice(LAST)}"
            elif kind == "council": nm = f"{R.choice(['Merri-bek', 'Darebin', 'Banyule', 'Whitehorse', 'Maribyrnong', 'Kingston', 'Monash', 'Brimbank'])} City Council"
            else: nm = f"{R.choice(A1)} {R.choice(T[kind])}" + (" Pty Ltd" if R.random() < .7 else "")
            if nm.replace(" Pty Ltd", "") not in used: used.add(nm.replace(" Pty Ltd", "")); break
        slug = re.sub(r"[^a-z]", "", nm.lower().replace("pty ltd", ""))[:18]
        ents.append(dict(eid=f"E{len(ents)+1:03d}", kind=kind, name=nm, abn=tax_no() if kind != "home" and R.random() < .85 else "",
                         email=(f"{R.choice(FIRST).lower()}@{slug}.example" if kind == "home" else f"accounts@{slug}.example"), weight=R.choice([1, 1, 2, 3, 5, 8]) if kind == "builder" else R.choice([1, 2, 3])))
# trap pairs: distinct businesses with near-identical names (must NOT be merged)
for a, b in [("Northgate Builders Pty Ltd", "Northgate Building Group"), ("Bluegum Constructions", "Bluegum Construction Pty Ltd"), ("Harbour Strata Management", "Harbor Strata Management")]:
    for nm in (a, b):
        if nm.replace(" Pty Ltd", "") not in used:
            used.add(nm.replace(" Pty Ltd", "")); slug = re.sub(r"[^a-z]", "", nm.lower().replace("pty ltd", ""))[:18]
            ents.append(dict(eid=f"E{len(ents)+1:03d}", kind="strata" if "Strata" in nm else "builder", name=nm, abn=tax_no(), email=f"accounts@{slug}.example", weight=2))
contacts = []; ent_recs = defaultdict(list)
def addc(e, name, abn, email, created):
    cid = f"C{len(contacts)+1:04d}"; contacts.append(dict(contact_id=cid, name=name, tax_number=abn, email=email, is_customer="true", created=created.isoformat(), _eid=e["eid"]))
    ent_recs[e["eid"]].append((cid, created)); return cid
for e in ents: addc(e, e["name"], e["abn"], e["email"], dt.date(2023, 1, 1) + dt.timedelta(R.randint(0, 800)))
def variant(nm):
    v = R.choice(["nosfx", "upper", "and", "accts", "typo", "space", "dot"])
    if v == "nosfx" and "Pty Ltd" in nm: return nm.replace(" Pty Ltd", "")
    if v == "upper": return nm.upper()
    if v == "and": return nm.replace(" Pty Ltd", " P/L") if "Pty" in nm else nm + " (Accounts)"
    if v == "accts": return nm + " - Accounts"
    if v == "typo":
        i = R.randint(1, len(nm) - 3); return nm[:i] + nm[i + 1] + nm[i] + nm[i + 2:]
    if v == "space": return nm.replace(" ", "  ", 1) + " "
    return nm.replace(" Pty Ltd", " Pty. Ltd.") if "Pty" in nm else nm + "."
dup_truth = []
for e in R.sample([e for e in ents if e["kind"] != "council"], 26):
    for _ in range(1 if R.random() < .8 else 2):
        nm = variant(e["name"]); abn = e["abn"] if R.random() < .55 else ""
        em = e["email"] if R.random() < .75 else e["email"].replace("accounts@", R.choice(["admin@", "office@", "ap@"]))
        cid = addc(e, nm, abn, em, FY0 + dt.timedelta(R.randint(-200, 200))); dup_truth.append(dict(contact_id=cid, entity=e["eid"], master=ent_recs[e["eid"]][0][0]))
# one record with no activity and no details (left as-is, archive candidate)
def pick_contact(e, d):
    recs = [c for c, cr in ent_recs[e["eid"]] if cr <= d]
    return recs[0] if len(recs) == 1 or R.random() < .55 else R.choice(recs[1:]) if len(recs) > 1 else ent_recs[e["eid"]][0][0]
# ---------------------------------------------------------------- jobs, movements, invoices
sites = []; mv = []; lines = []; inv_seq = [4100]; truth_lines = []; cycles_truth = []
def inv(contact, date, ref, items, status=None):
    inv_seq[0] += 1; no = f"INV-{inv_seq[0]}"; st = status or ("PAID" if date < dt.date(2026, 5, 15) or R.random() < .5 else "AUTHORISED")
    if R.random() < .008: st = "VOIDED"
    for i, (desc, qty, price, cat) in enumerate(items, 1):
        acct = "200" if R.random() > .06 else "260"
        tax = "GST on Income"; r = R.random()
        if cat == "DEP": tax = "GST on Income" if r < .6 else "BAS Excluded"
        elif r < .021: tax = "GST Free Income"
        elif r < .026: tax = "BAS Excluded"
        lid = f"{no}-{i}"
        lines.append(dict(line_id=lid, invoice_no=no, type="ACCREC", date=date.isoformat(), due_date=(date + dt.timedelta(days=14 if R.random() < .7 else 30)).isoformat(), contact_id=contact,
                          contact_name=next(c["name"] for c in contacts if c["contact_id"] == contact), reference=ref, description=desc, quantity=qty, unit_amount=f"{price:.2f}",
                          line_amount=f"{qty * price:.2f}", account_code=acct, tax_type=tax, status=st))
        truth_lines.append(dict(line_id=lid, category=cat, tax_ok="1" if (tax == "GST on Income") == (cat != "DEP") or (cat == "DEP" and tax == "BAS Excluded") else "0"))
    return no
def credit(contact, date, ref, desc, amt, cat):
    inv_seq[0] += 1; no = f"CN-{inv_seq[0]}"; lid = f"{no}-1"
    lines.append(dict(line_id=lid, invoice_no=no, type="ACCRECCREDIT", date=date.isoformat(), due_date=date.isoformat(), contact_id=contact, contact_name=next(c["name"] for c in contacts if c["contact_id"] == contact),
                      reference=ref, description=desc, quantity=1, unit_amount=f"{-amt:.2f}", line_amount=f"{-amt:.2f}", account_code="200", tax_type="GST on Income", status="PAID"))
    truth_lines.append(dict(line_id=lid, category=cat, tax_ok="0" if cat == "DEP" else "1"))
dock = [70000]
def move(d, site, cls, qty, direction, note=""):
    dock[0] += R.randint(1, 3); mv.append(dict(docket=f"D{dock[0]}", date=d, site=site, cls=cls, qty=qty, dir=direction, note=note))
def site_new(e, kind, start):
    sid = f"S{len(sites)+1:04d}"; sites.append(dict(site_id=sid, customer_entity=e["eid"], suburb=R.choice(SUB), job_type=kind, start=start.isoformat(), address=f"Site {sid[1:]}, {R.choice(SUB)} VIC")); return sid
HIREDESC = {"FP": "Temporary fence hire", "BR": "Pedestrian barrier hire", "ST": "Scaffold tower hire", "TK": "Tarp kit hire"}
UNITW = {"FP": "panels", "BR": "barriers", "ST": "towers", "TK": "kits"}
def hire_job(e, sid, start, plan, end, lost=None, silent_short=None):
    """plan: {cls: [(date, +qty/-qty), ...]} movements; bills 4-weekly in arrears with office errors seeded."""
    lost = lost or {}; silent_short = silent_short or {}
    for cls, evs in plan.items():
        if cls == "FB": continue
        bal_by_day = {}
        evs = sorted(evs)
        # true weekly charge: max units on site during each hire week
        weeks = []; k = 0
        while True:
            ws = start + dt.timedelta(7 * k); we = ws + dt.timedelta(6)
            if ws > end: break
            b = 0; mx = 0
            for d, q in evs:
                if d < ws: b += q
            mx = b
            for d, q in evs:
                if ws <= d <= we: b += q; mx = max(mx, b)
            if mx <= 0 and ws > evs[0][0]: break
            weeks.append((ws, mx)); k += 1
            if end and we >= end: break
        # cycles of 4 weeks, invoice on cycle end or end-of-hire
        office_q = evs[0][1]; known_events = list(evs)
        for c in range(0, len(weeks), 4):
            cyc = weeks[c:c + 4]; cend = min(cyc[-1][0] + dt.timedelta(6), end); idate = cend + dt.timedelta(1)
            true_q = sum(q for _, q in cyc)
            if idate > FY1 or idate < FY0:
                cycles_truth.append(dict(site=sid, cls=cls, cycle_start=cyc[0][0].isoformat(), invoice_date=idate.isoformat(), weeks=len(cyc), true_units=true_q, billed_units=0, in_fy=0)); continue
            # office keeps billing the quantity it last knew about; updates late after partial returns or extra deliveries
            cur_true = [q for _, q in cyc][-1]
            if office_q != cur_true and R.random() < .55: office_q = cur_true
            billed = office_q * len(cyc)
            if R.random() < .035: billed = 0   # cycle missed entirely
            if billed:
                inv(pick_contact(e, idate), idate, sid, [(f"{HIREDESC[cls]} {len(cyc)} wks ({cyc[0][0]:%d/%m/%y}-{cend:%d/%m/%y}) - {office_q} {UNITW[cls]}", billed, RATE[cls], f"HIRE-{cls}")])
            cycles_truth.append(dict(site=sid, cls=cls, cycle_start=cyc[0][0].isoformat(), invoice_date=idate.isoformat(), weeks=len(cyc), true_units=true_q, billed_units=billed, in_fy=1))
            office_q = cur_true if R.random() < .5 else office_q
# fencing jobs
open_at_ye = 0
for j in range(232):
    e = R.choices(ents, weights=[x["weight"] * (3 if x["kind"] in ("builder", "events", "council") else .6 if x["kind"] == "home" else .2) for x in ents])[0]
    start = FY0 + dt.timedelta(R.randint(-70, 355))
    ev = e["kind"] == "events"; n = R.randint(30, 120) if ev else R.randint(12, 160)
    dur = R.randint(1, 3) * 7 if ev else int(R.choice([1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 26, 34]) * 7 + R.randint(-3, 3))
    end = start + dt.timedelta(dur); kind = "Fencing (event)" if ev else "Fencing"
    sid = site_new(e, kind, start)
    fp = [(start, n)]; fb = [(start, n + max(1, n // 10))]; br = []
    if R.random() < .3: k = R.randint(4, 24); br = [(start, k)]
    if dur > 70 and R.random() < .3: d = start + dt.timedelta(R.randint(20, dur - 14)); x = R.randint(8, 40); fp.append((d, x)); fb.append((d, x))
    if dur > 70 and R.random() < .35: d = start + dt.timedelta(R.randint(28, dur - 7)); x = -int(sum(q for _, q in fp) * R.uniform(.3, .6)); fp.append((d, x)); fb.append((d, x))
    still = end > FY1
    lostq = 0; short = 0
    if not still:
        rem = sum(q for _, q in fp)
        if R.random() < .12: lostq = R.randint(1, 6)
        if R.random() < .07: short = R.randint(2, 9)   # returned short, nobody noticed (no charge, no LOST docket)
        fp.append((end, -(rem - lostq - short))); fb.append((end, -sum(q for _, q in fb)))
        if br: br.append((end, -br[0][1]))
    else: open_at_ye += 1
    plan = {"FP": fp, "FB": fb}
    if br: plan["BR"] = br
    for cls, evs in plan.items():
        for d, q in evs: move(d, sid, cls, abs(q), "OUT" if q > 0 else "IN")
    if lostq: move(end, sid, "FP", lostq, "LOST", "billed as lost")
    if short: cycles_truth.append(dict(site=sid, cls="FP", cycle_start="", invoice_date=end.isoformat(), weeks=0, true_units=-short, billed_units=0, in_fy=1 if FY0 <= end <= FY1 else 0))
    hire_job(e, sid, start, {k: v for k, v in plan.items()}, min(end, dt.date(2026, 12, 31)))
    if FY0 <= start <= FY1:
        inv(pick_contact(e, start), start, sid, [("Delivery and installation - temporary fencing", 1, 165.0, "DEL-FP"), (f"Installation labour - {n} panels @ 2.20", n, 2.20, "DEL-FP")]
            + ([(f"Security deposit - fence panels (refundable)", 1, round(n * 8, -1), "DEP")] if e["kind"] == "home" or R.random() < .08 else []))
    if not still and FY0 <= end <= FY1:
        items = [("Collection and dismantle - temporary fencing", 1, 165.0, "DEL-FP")]
        if lostq: items.append((f"Lost/damaged panels charged - {lostq} @ 145.00", lostq, 145.0, "LOSS"))
        inv(pick_contact(e, end + dt.timedelta(1)), end + dt.timedelta(1), sid, items)
# scaffolding jobs
for j in range(64):
    e = R.choices([x for x in ents if x["kind"] in ("builder", "strata", "home")], k=1)[0]
    start = FY0 + dt.timedelta(R.randint(-40, 350)); t = R.choice([1, 1, 2, 2, 3, 4, 6]); dur = int(R.choice([1, 2, 3, 4, 6, 8, 12, 16]) * 7)
    end = start + dt.timedelta(dur); sid = site_new(e, "Scaffolding", start); still = end > FY1
    plan = {"ST": [(start, t)] + ([] if still else [(end, -t)])}
    for d, q in plan["ST"]: move(d, sid, "ST", abs(q), "OUT" if q > 0 else "IN")
    hire_job(e, sid, start, plan, min(end, dt.date(2026, 12, 31)))
    if FY0 <= start <= FY1: inv(pick_contact(e, start), start, sid, [(f"Scaffold erect - {t} tower(s)", t, 420.0, "LAB-ST")])
    if not still and FY0 <= end <= FY1: inv(pick_contact(e, end + dt.timedelta(1)), end + dt.timedelta(1), sid, [(f"Scaffold dismantle - {t} tower(s)", t, 320.0, "LAB-ST")])
# make-safe and roof repair jobs
for j in range(250):
    e = R.choices([x for x in ents if x["kind"] in ("insurer", "strata", "home", "builder")], weights=[6 if x["kind"] == "insurer" else 2 if x["kind"] == "strata" else 1 for x in ents if x["kind"] in ("insurer", "strata", "home", "builder")])[0]
    d = FY0 + dt.timedelta(R.randint(0, 364)); roof_only = R.random() < .3
    sid = site_new(e, "Roof repair" if roof_only else "Make-safe", d)
    if not roof_only:
        ah = R.random() < .35; items = [("Emergency make safe call-out - after hours" if ah else "Make safe call-out", 1, 560.0 if ah else 385.0, "MS")]
        if R.random() < .6:
            k = R.randint(1, 3); wk = R.randint(1, 6); items.append((f"Tarp supply and install - {k} tarp(s)", k, 240.0, "MS"))
            move(d, sid, "TK", k, "OUT"); te = d + dt.timedelta(7 * wk)
            if te <= FY1: move(te, sid, "TK", k, "IN")
            hire_job(e, sid, d, {"TK": [(d, k)] + ([(te, -k)] if te <= FY1 else [])}, min(te, dt.date(2026, 12, 31)))
        if d <= FY1: inv(pick_contact(e, d), d, sid, items)
    if roof_only or R.random() < .45:
        d2 = d + dt.timedelta(R.randint(3, 40))
        if d2 <= FY1:
            h = R.choice([3, 4, 6, 8, 12, 16, 24]); items = [(f"Roof repair labour - {h} hrs", h, 98.0, "LAB-RF")]
            for m in R.sample(["Materials - Colorbond sheet", "Materials - ridge capping", "Materials - flashing", "Materials - gutter and downpipe", "Materials - roof tiles (matched)", "Materials - sealant and fixings"], R.randint(1, 3)):
                items.append((m, 1, round(R.uniform(45, 1400), 2), "MAT-RF"))
            if R.random() < .4: items.append(("Waste removal and tip fees", 1, 120.0, "MAT-RF"))
            inv(pick_contact(e, d2), d2, sid, items)
# unclear / mixed lines (as typed by the office)
mixed = ["As per quote Q-{}", "Works completed as discussed", "Variation {} - additional works", "Misc charges", "Fence and make safe package - Job {}", "Job {} - progress claim", "Site works"]
for j in range(46):
    s = R.choice(sites); e = next(x for x in ents if x["eid"] == s["customer_entity"]); d = FY0 + dt.timedelta(R.randint(0, 364))
    inv(pick_contact(e, d), d, s["site_id"] if R.random() < .5 else "", [(R.choice(mixed).format(R.randint(1100, 4800)), 1, round(R.uniform(180, 3800), 2), "UNCLEAR")])
# credit notes (hire overcharges and deposit refunds)
for j in range(14):
    s = R.choice([s for s in sites if s["job_type"].startswith("Fencing")]); e = next(x for x in ents if x["eid"] == s["customer_entity"]); d = FY0 + dt.timedelta(R.randint(20, 364))
    if R.random() < .5: credit(pick_contact(e, d), d, s["site_id"], "Credit - temporary fence hire overcharge", round(R.uniform(60, 900), 2), "HIRE-FP")
    else: credit(pick_contact(e, d), d, s["site_id"], "Refund of security deposit - fence panels", round(R.uniform(10, 120)) * 10, "DEP")
lines.sort(key=lambda r: (r["date"], r["invoice_no"], r["line_id"]))
W("xero_invoice_lines_fy26.csv", lines); W("truth_lines.csv", truth_lines, d="truth")
W("xero_contacts.csv", [{k: v for k, v in c.items() if not k.startswith("_")} for c in contacts])
W("dup_truth.csv", dup_truth, d="truth"); W("cycles_truth.csv", cycles_truth, d="truth")
W("entities.csv", [{k: v for k, v in e.items()} for e in ents], d="truth")
W("sites.csv", [{"site_id": s["site_id"], "address": s["address"], "job_type": s["job_type"], "customer_name": next(x["name"] for x in ents if x["eid"] == s["customer_entity"]), "start": s["start"]} for s in sites])
# ---------------------------------------------------------------- paper asset log as transcribed (with the usual transcription faults)
opening = defaultdict(int); log = []; truth_log = []
for m in mv:
    if m["date"] > FY1: continue   # log transcribed from 1 Apr 2025 (covers every job still on hire at 1 Jul 2025)
    site_txt = m["site"]; r = R.random()
    if r < .10: site_txt = m["site"].lower().replace("s0", "s")
    elif r < .16: site_txt = m["site"][1:].lstrip("0")
    elif r < .20: site_txt = f"{m['site']} {next(s['address'].split(', ')[1].replace(' VIC', '') for s in sites if s['site_id'] == m['site'])}"
    cls_txt = {"FP": R.choice(["FP", "Fence panel", "panels", "FP"]), "FB": R.choice(["FB", "Base", "fence bases"]), "BR": R.choice(["BR", "Barrier"]), "ST": R.choice(["ST", "Scaffold tower"]), "TK": R.choice(["TK", "Tarp kit"])}[m["cls"]]
    issue = ""
    if m["dir"] == "IN" and m["cls"] == "FP" and R.random() < .045:
        # return written against the wrong site (digits transposed)
        wrong = next((s["site_id"] for s in sites if s["site_id"] != m["site"] and s["site_id"][1:] == m["site"][1:3] + m["site"][4] + m["site"][3]), None)
        if wrong: site_txt = wrong; issue = "WRONGSITE"
    row = dict(docket=m["docket"], date=m["date"].isoformat(), site=site_txt, asset=cls_txt, qty=m["qty"], direction=m["dir"], entered_by=R.choice(["Office", "Office", "Yard", "Driver"]), note=m["note"])
    log.append(row); truth_log.append(dict(docket=m["docket"], site=m["site"], cls=m["cls"], issue=issue))
    if R.random() < .016: log.append(dict(row, entered_by="Yard")); truth_log.append(dict(docket=m["docket"], site=m["site"], cls=m["cls"], issue="DUP"))
log.sort(key=lambda r: (r["date"], r["docket"]))
W("asset_movement_log_fy26.csv", log); W("truth_log.csv", truth_log, d="truth")
# purchases during the year and the stocktake
purch = [dict(date="2025-10-14", asset="FP", qty=120, supplier="Fence supplier (placeholder)"), dict(date="2026-02-09", asset="FB", qty=150, supplier="Fence supplier (placeholder)"), dict(date="2026-03-02", asset="ST", qty=4, supplier="Scaffold supplier (placeholder)")]
W("asset_purchases_fy26.csv", purch)
on_true = defaultdict(int); lost_true = defaultdict(int)
for m in mv:
    if m["date"] > FY1: continue
    if m["dir"] == "OUT": on_true[m["cls"]] += m["qty"]
    elif m["dir"] == "IN": on_true[m["cls"]] -= m["qty"]
    else: on_true[m["cls"]] -= m["qty"]; lost_true[m["cls"]] += m["qty"]
# fleet sized from peak use: owned at 30 Jun 2025 = peak units out on any day + a yard buffer, less what was bought during the year
peak = defaultdict(int); bal = defaultdict(int)
for m in sorted(mv, key=lambda m: (m["date"], m["dir"] != "IN")):
    if m["date"] > FY1: continue
    bal[m["cls"]] += m["qty"] if m["dir"] == "OUT" else -m["qty"]; peak[m["cls"]] = max(peak[m["cls"]], bal[m["cls"]])
for k in CLASSES: FLEET0[k] = int(round(peak[k] * 1.12 + 5, -1 if k in ("FP", "FB", "BR") else 0)) - sum(p["qty"] for p in purch if p["asset"] == k)
owned = {k: FLEET0[k] + sum(p["qty"] for p in purch if p["asset"] == k) for k in CLASSES}
lost_pre = defaultdict(int)
for m in mv:
    if m["dir"] == "LOST" and m["date"] < FY0: lost_pre[m["cls"]] += m["qty"]
count_err = {"FP": -4, "FB": 3}
stock = [dict(asset=k, counted_in_yard=owned[k] - lost_true[k] - on_true[k] + count_err.get(k, 0), counted_by="Yard team", count_date="2026-06-30") for k in CLASSES]
W("yard_stocktake_2026-06-30.csv", stock); W("fleet_register_2025-06-30.csv", [dict(asset=k, description=v, owned=FLEET0[k]) for k, v in CLASSES.items()])
FLEET_PRE_LOST = dict(lost_pre)
json.dump(dict(fleet_owned=owned, lost_before_fy=FLEET_PRE_LOST, count_err=count_err), open(os.path.join(HERE, "truth/fleet.json"), "w"))
# chart of accounts and trial balance (current state)
coa = [("090", "Business Bank Account", "BANK", "", "ACTIVE"), ("200", "Sales", "REVENUE", "GST Free Income", "ACTIVE"), ("260", "Other Revenue", "REVENUE", "GST on Income", "ACTIVE"),
       ("270", "Interest Income", "REVENUE", "GST Free Income", "ACTIVE"), ("310", "Cost of Goods Sold", "DIRECTCOSTS", "GST on Expenses", "ACTIVE"), ("311", "Subcontractors", "DIRECTCOSTS", "GST on Expenses", "ACTIVE"),
       ("312", "Sub-Contractors", "EXPENSE", "GST on Expenses", "ACTIVE"), ("313", "Materials", "DIRECTCOSTS", "GST on Expenses", "ACTIVE"), ("314", "Roofing materials", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("315", "Tip fees", "DIRECTCOSTS", "GST on Expenses", "ACTIVE"), ("400", "Advertising", "EXPENSE", "GST on Expenses", "ACTIVE"), ("404", "Bank Fees", "EXPENSE", "GST Free Expenses", "ACTIVE"),
       ("408", "Cleaning", "EXPENSE", "GST on Expenses", "ACTIVE"), ("412", "Consulting & Accounting", "EXPENSE", "GST on Expenses", "ACTIVE"), ("416", "Depreciation", "EXPENSE", "BAS Excluded", "ACTIVE"),
       ("420", "Entertainment", "EXPENSE", "GST on Expenses", "ACTIVE"), ("425", "Freight & Courier", "EXPENSE", "GST on Expenses", "ACTIVE"), ("429", "General Expenses", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("433", "Insurance", "EXPENSE", "GST on Expenses", "ACTIVE"), ("445", "Light, Power, Heating", "EXPENSE", "GST on Expenses", "ACTIVE"), ("449", "Motor Vehicle Expenses", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("450", "Fuel", "EXPENSE", "GST on Expenses", "ACTIVE"), ("451", "Truck repairs", "EXPENSE", "GST on Expenses", "ACTIVE"), ("461", "Printing & Stationery", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("469", "Rent", "EXPENSE", "GST on Expenses", "ACTIVE"), ("473", "Repairs and Maintenance", "EXPENSE", "GST on Expenses", "ACTIVE"), ("474", "Equipment repairs", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("477", "Wages and Salaries", "EXPENSE", "BAS Excluded", "ACTIVE"), ("478", "Superannuation", "EXPENSE", "BAS Excluded", "ACTIVE"), ("485", "Subscriptions", "EXPENSE", "GST on Expenses", "ACTIVE"),
       ("489", "Telephone & Internet", "EXPENSE", "GST on Expenses", "ACTIVE"), ("493", "Travel - National", "EXPENSE", "GST on Expenses", "ACTIVE"), ("494", "Travel - International", "EXPENSE", "GST Free Expenses", "ACTIVE"),
       ("497", "Bank Revaluations", "EXPENSE", "BAS Excluded", "ACTIVE"), ("610", "Accounts Receivable", "CURRENT", "", "ACTIVE"), ("620", "Prepayments", "CURRENT", "BAS Excluded", "ACTIVE"),
       ("710", "Office Equipment", "FIXED", "GST on Capital", "ACTIVE"), ("720", "Hire fleet - fencing and scaffold", "FIXED", "GST on Capital", "ACTIVE"), ("721", "Less Accumulated Depreciation on Hire fleet", "FIXED", "BAS Excluded", "ACTIVE"),
       ("730", "Motor Vehicles", "FIXED", "GST on Capital", "ACTIVE"), ("800", "Accounts Payable", "CURRLIAB", "", "ACTIVE"), ("820", "GST", "CURRLIAB", "", "ACTIVE"),
       ("825", "PAYG Withholdings Payable", "CURRLIAB", "", "ACTIVE"), ("826", "Superannuation Payable", "CURRLIAB", "", "ACTIVE"), ("877", "Tracking Transfers", "CURRLIAB", "", "ACTIVE"),
       ("880", "Owner A Drawings", "EQUITY", "", "ACTIVE"), ("881", "Owner A Funds Introduced", "EQUITY", "", "ACTIVE"), ("960", "Retained Earnings", "EQUITY", "", "ACTIVE"),
       ("999", "Suspense - ask accountant", "CURRLIAB", "", "ACTIVE")]
W("xero_chart_of_accounts.csv", [dict(code=a, name=b, type=c, default_tax=d, status=s) for a, b, c, d, s in coa])
bal = {"311": 182340.55, "312": 96410.20, "313": 142880.10, "314": 61220.45, "315": 18930.00, "449": 23310.90, "450": 41870.35, "451": 12440.60, "473": 9870.25, "474": 27650.80,
       "420": 1980.00, "494": 0.0, "497": 0.0, "408": 0.0, "999": 6342.18, "400": 8420.00, "404": 1260.40}
W("xero_tb_fy26_expenses.csv", [dict(code=k, balance=f"{v:.2f}") for k, v in sorted(bal.items())])
print("contacts", len(contacts), "entities", len(ents), "sites", len(sites), "invoice lines", len(lines), "invoices", len({l["invoice_no"] for l in lines}), "movements", len(log), "on hire at YE (fencing sites)", open_at_ye)
