"""Render SIMULATION_REPORT.md for 03_Simulated from outputs + stats (numbers are never typed by hand).""" import json, os import numpy as np import pandas as pd from sim_common import OUT, DATA, CLEAN, FX_USD_SAR, load_stats from parameters import tables D = DATA V1 = {'recoverable_sar': 657107, 'recoverable_p10': 512994, 'recoverable_p90': 832086, 'recoverable_h': 27018, 'fte_rec': 11.0, 'paid_h': 189879, 'fte': 117, 'unobs': 0.469, 'sessions': 24052, 'labour_annual': 7026980} # version 1 (21 Oct 2026), for the change log V2 = {'labour_annual': 6945349, 'fte': 115, 'unobs': 0.4596, 'ebit_opt': 0.108, 'rec_pct': 0.092, 'case_rate': 1.64, 'case_labour': 0.77, 'receipt_rate': 25.87} # version 2 (22 Oct 2026, mappings; before head count) def n0(x): return f'{x:,.0f}' def n2(x): return f'{x:,.2f}' def pc(x, d=1): return f'{100 * x:.{d}f}%' def md_table(df, cols=None, fmt=None): cols = cols or list(df.columns) fmt = fmt or {} out = ['| ' + ' | '.join(cols) + ' |', '|' + '---|' * len(cols)] for _, r in df.iterrows(): cells = [] for c in cols: v = r[c] f = fmt.get(c) cells.append(f(v) if f and pd.notna(v) else ('' if (isinstance(v, float) and np.isnan(v)) else str(v))) out.append('| ' + ' | '.join(str(x).replace('|', '/') for x in cells) + ' |') return '\n'.join(out) def main(): src, par = tables() L, P, I, C = load_stats('labour'), load_stats('pnl'), load_stats('inferred'), load_stats('commercial_sim') chk = json.load(open(os.path.join(OUT, 'simulation_checks.json'))) S = pd.read_parquet(f'{D}/sim_session_attendance.parquet') SC = pd.read_parquet(f'{D}/sim_scenarios.parquet') MS = pd.read_parquet(f'{D}/sim_monte_carlo_summary.parquet').set_index('metric') RC = pd.read_parquet(f'{D}/sim_rate_card.parquet') PM = pd.read_parquet(f'{D}/sim_jeddah_pnl_monthly.parquet') ST = pd.read_parquet(f'{D}/sim_standard_times.parquet') LC = pd.read_parquet(f'{D}/sim_labour_cost_model.parquet') CP = pd.read_parquet(f'{D}/inf_client_principal_map.parquet') WP = pd.read_parquet(f'{D}/inf_warehouse_profile.parquet') CH = pd.read_parquet(f'{D}/inf_customer_channel_map.parquet') OI = pd.read_parquet(f'{D}/sim_receivables_open_items.parquet') V = pd.read_parquet(f'{D}/sim_activity_volumes.parquet') CPU = pd.read_parquet(f'{D}/sim_cost_per_unit_wh_month.parquet') LV = pd.read_parquet(f'{D}/sim_opportunity_levers.parquet') MP = json.load(open(os.path.join(os.path.dirname(CLEAN), 'audit', 'stats_mappings.json'))) J = json.load(open(os.path.join(os.path.dirname(CLEAN), 'audit', 'stats_jeddah.json'))) base = SC.set_index('scenario').loc['Base'] pnl8 = P['pnl_8m_base'] H = L['headcount']; HA = H['avg_per_month'] HCt = pd.read_parquet(f'{D}/inf_jeddah_headcount.parquet') unobs_scan = L['unobserved_paid_hours_total'] / L['paid_hours_total'] unobs = H['unobserved_share']['headcount_base'] WM = pd.read_parquet(f'{D}/sim_wh_month_labour.parquet') whu = WM.groupby('wh_id')[['paid_hours_headcount_base', 'unobserved_paid_h_headcount_base']].sum() whu = (whu.unobserved_paid_h_headcount_base / whu.paid_hours_headcount_base).sort_values() cpu8 = P['cost_per_unit_8m'] pv = par.set_index('parameter') lo_c, hi_c = float(pv.loc['client_case_rate_example', 'low']), float(pv.loc['client_case_rate_example', 'high']) # ---- tables comp = LC[LC.role.isin(['operative (expat)', 'saudi operative'])].pivot_table(index=['role', 'component'], columns='scenario', values='sar_per_month', sort=False).reset_index()[['role', 'component', 'low', 'base', 'high']] tiers = LC[LC.role.str.endswith('(blended)')].pivot_table(index=['role', 'component'], columns='scenario', values='sar_per_month', sort=False).reset_index()[['role', 'component', 'low', 'base', 'high']] key_acts = ['Picking (Pick)', 'Loading (Pick)', 'Consolidation (Pick/Put)', 'Receipt of Shipment (Rcpt)', 'Move (Put)', 'Directed Pickup (Pick)'] s30 = ST[(ST.cap_minutes == 30) & ST.tran_desc.isin(key_acts)] bands = ['<=1', '2-4', '5-10', '11-25', '26-60', '>60', 'unknown'] stdp = s30.pivot_table(index='tran_desc', columns='size_band', values='std_s_median_p50').reindex(columns=[b for b in bands if b in s30.size_band.unique()]).reindex(key_acts).reset_index() stdp_fmt = {b: (lambda v: f'{v:.0f}') for b in bands} e = S[S.eligible_for_benchmark] role = e.groupby('role').agg(eligible_shifts=('session_id', 'size'), median_productivity=('span_productivity', 'median'), target_p75=('target_span_productivity', 'first'), observed_h=('observed_h', 'sum'), recoverable_h=('recoverable_h_base', 'sum')).reset_index().sort_values('eligible_shifts', ascending=False) role['recoverable_pct_of_observed'] = role.recoverable_h / role.observed_h wh = S.groupby('main_wh').agg(sessions=('session_id', 'size'), paid_h=('paid_hours', 'sum'), unobserved_h=('unobserved_paid_h', 'sum'), recoverable_h=('recoverable_h_base', 'sum'), labour_cost=('labour_cost_base_sar', 'sum'), recoverable_sar=('recoverable_cost_base_sar', 'sum'), badge_sharing_splits=('badge_sharing_split', 'sum')).reset_index() wh['unobserved_pct'] = wh.unobserved_h / wh.paid_h; wh['recoverable_pct'] = wh.recoverable_h / wh.paid_h wh = wh.sort_values('paid_h', ascending=False).rename(columns={'main_wh': 'wh_id'}) wh = wh.merge(WP[['wh_id', 'temperature_regime_inferred']], on='wh_id', how='left') wr = S.groupby('wms_role').agg(sessions=('session_id', 'size'), paid_h=('paid_hours', 'sum'), labour_cost=('labour_cost_base_sar', 'sum'), recoverable_h=('recoverable_h_base', 'sum')).reset_index().sort_values('paid_h', ascending=False) mv = V.groupby('movement_type').agg(events=('events', 'sum'), labour_cost=('posted_labour_cost_sar', 'sum')).reset_index() mv['share_of_labour_cost'] = mv.labour_cost / mv.labour_cost.sum() mv = mv.sort_values('labour_cost', ascending=False) acts = V.groupby(['tran_desc', 'movement_type', 'cost_or_revenue']).agg(events=('events', 'sum'), cases=('cases', 'sum'), labour_cost=('posted_labour_cost_sar', 'sum')).reset_index() acts['share'] = acts.labour_cost / acts.labour_cost.sum() acts['cost_per_event'] = acts.labour_cost / acts.events acts = acts.sort_values('labour_cost', ascending=False).head(15) inbound_admin = V[V.tran_desc.isin(['RoS Shipment Audit', 'Shipment Reconciliation', 'Close PO', 'PO Modification', 'RoS Close'])].posted_labour_cost_sar.sum() / V.posted_labour_cost_sar.sum() in_house = float(mv.loc[mv.movement_type == 'In House', 'share_of_labour_cost'].sum()) cw = CPU.groupby('wh_id')[['labour_cost_sar', 'outbound_cost_incl_overhead_sar', 'case_pick_cost_sar', 'pallet_pick_cost_sar', 'receipt_cost_sar', 'outbound_worked_time_cost_sar', 'cases_shipped', 'loose_cases_picked', 'pallets_picked', 'pallets_received', 'case_pick_lines']].sum().reset_index() rt = lambda a, b: np.where(b > 0, a / b.where(b > 0, 1), np.nan) cw['per_case_shipped'] = rt(cw.outbound_cost_incl_overhead_sar, cw.cases_shipped) cw['per_loose_case'] = rt(cw.case_pick_cost_sar, cw.loose_cases_picked) cw['per_loose_case_worked_time'] = rt(cw.outbound_worked_time_cost_sar * rt(cw.case_pick_lines, cw.case_pick_lines + cw.pallets_picked), cw.loose_cases_picked) cw['per_pallet_picked'] = rt(cw.pallet_pick_cost_sar, cw.pallets_picked) cw['per_pallet_received'] = rt(cw.receipt_cost_sar, cw.pallets_received) sp = P['outbound_split_check'] cw = cw.sort_values('labour_cost_sar', ascending=False) sc_tab = SC[['scenario', 'cost_parameters', 'paid_time_basis', 'fte_paid', 'standard', 'target_percentile', 'capture_rate', 'labour_cost_annual_sar', 'recoverable_labour_annual_sar', 'recoverable_pct_of_labour', 'fte_recoverable', 'ebit_margin', 'ebit_margin_optimised']] ms_tab = MS.loc[['labour_cost_annual_sar', 'recoverable_labour_annual_sar', 'recoverable_pct_of_labour', 'recoverable_hours_annual', 'fte_recoverable', 'ebit_margin_optimised']].reset_index() def mfmt(m, v): return pc(v) if ('pct' in m or 'margin' in m) else (f'{v:.1f}' if 'fte' in m else n0(v)) ms_fmt = ms_tab.copy() for c in ('p10', 'p50', 'p90'): ms_fmt[c] = [mfmt(m, v) for m, v in zip(ms_tab.metric, ms_tab[c])] sens = pd.DataFrame(list(P['mc_sensitivity_spearman'].items()), columns=['input', 'spearman_rank_corr_with_recoverable_SAR']) pm = PM[['month', 'handling_revenue_sar', 'labour_cost_sar', 'labour_cost_paid_days_without_scan_sar', 'other_opex_sar', 'ebit_sar', 'recoverable_labour_sar', 'ebit_margin', 'ebit_margin_optimised', 'employee_ids_scanning', 'fte_equivalent', 'fte_scan_days', 'fte_recoverable']] hct = HCt[['month', 'registered_active_people_log_sites', 'employee_ids_scanning', 'payroll_people_scanning', 'other_logins_scanning', 'fte_scan_days', 'fte_headcount_base', 'fte_all_ids', 'paid_days_without_scan_base', 'labour_cost_scan_days_sar', 'labour_cost_headcount_base_sar', 'labour_cost_all_ids_sar', 'unobserved_share_headcount_base']] buckets = OI.groupby('ageing_bucket').open_amount_sar.sum().reindex(['not_due', '1-30', '31-60', '61-90', '90+', 'unknown_terms']).dropna().reset_index() ch2 = CH[CH.level == 'cust_hier2'].sort_values('sales_sar_mar_oct_2026', ascending=False).head(10) cp = CP.head(12) hv = C['holiday_validation'] hv_tab = pd.DataFrame([{'event': k, 'wms_activity_index_excl_fridays': v['wms_index_mean_excl_fridays'], 'observed_warehouse_closure_days': ', '.join(v['observed_warehouse_closure_days'])} for k, v in hv.items()]) srcs = src.copy(); srcs['link'] = srcs.apply(lambda r: f'[{r.source_id}]({r.url})' if r.url else r.source_id, axis=1) rec = C['receivables'] lv = LV.pivot_table(index=['lever', 'module', 'confidence'], columns='case', values='profit_annual_sar').reindex(columns=['low', 'base', 'high']).reset_index().sort_values('base', ascending=False) lv_tot = lv[['low', 'base', 'high']].sum() ch_log = pd.DataFrame([ ['Recoverable labour, SAR / year (base)', n0(V1['recoverable_sar']), '639,485', n0(base.recoverable_labour_annual_sar), 'v2: people counted once (payroll), standards by line size, role wages, batch posting not benchmarked; v3: unchanged (savings come from worked shifts)'], ['Likely range (Monte Carlo P10–P90)', f"{n0(V1['recoverable_p10'])} – {n0(V1['recoverable_p90'])}", '491,647 – 805,806', f"{n0(MS.loc['recoverable_labour_annual_sar','p10'])} – {n0(MS.loc['recoverable_labour_annual_sar','p90'])}", ''], ['Recoverable hours / year (FTE)', f"{n0(V1['recoverable_h'])} ({V1['fte_rec']:.1f})", '26,082 (10.7)', f"{n0(base.recoverable_hours_annual)} ({base.fte_recoverable:.1f})", ''], ['People paid (FTE)', f"{V1['fte']}", f"{V2['fte']}", f"{HA['fte_headcount_base']:.0f}", 'v3: head count from employee ids - payroll-confirmed people paid on every working day from first to last scan'], ['Simulated labour cost, SAR / year', n0(V1['labour_annual']), n0(V2['labour_annual']), n0(base.labour_cost_annual_sar), 'v3: + paid days without a single scan'], ['Recoverable as % of labour cost', '9.4%', pc(V2['rec_pct']), pc(base.recoverable_pct_of_labour), ''], ['Paid time with no scan', pc(V1['unobs']), pc(V2['unobs']), pc(unobs), 'v3: unscanned part of worked days + whole paid days with no scan'], ['Simulated EBIT after savings', '10.9%', pc(V2['ebit_opt']), pc(pnl8['ebit_margin_optimised']), 'baseline 6.5% by construction in every version'], ['Labour per loose case picked, SAR', '-', f"{V2['case_labour']:.2f}", f"{cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f} worked days / {cpu8['case_pick_labour_per_loose_case_sar']:.2f} all paid days", ''], ['Cost-recovery rate: case pick / pallet received', '-', f"{V2['case_rate']:.2f} / {V2['receipt_rate']:.2f}", f"{P['case_pick_rate_base']:.2f} / {RC[RC.service=='Inbound receipt'].rate_base_sar.iloc[0]:.2f}", 'rates recover the larger labour cost'], ['Billed activities', '8 services incl. loading, LP audit, QC', '9 Revenue Generation activities', 'same', 'loading, audit and QC are cost per Binzagr'], ], columns=['Figure', 'v1 (21 Oct)', 'v2 (22 Oct, mappings)', 'v3 (22 Oct, head count)', 'Why it moved']) md = f"""# Binzagr PoV — Simulated & inferred data (03_Simulated) **Version 3 — 22 Oct 2026.** Version 2 rebuilt the model on Binzagr's own mappings (received from Badr on 22 Oct): which activities are billed, who is behind each badge (payroll number and role, month by month), and units of sale per case / pallet. Version 3 adds the **head count from employee ids** (Mehul's suggestion): people are paid on every working day they are employed, not only on days they scan. §0 shows what moved. **What this folder is:** the cleaned data (`02_Cleaned`) plus every gap filled by **research or a stated model**, so the PoV can be built and demonstrated now. Every table has a `basis` column and every number traces back to either the client's data or a cited source: | Label | Meaning | |---|---| | **Actual** | Straight from `02_Cleaned` (client data, including Binzagr's mappings) | | **Inferred** | Derived from client data by a stated rule / model — no outside numbers | | **Researched** | A published fact or figure, cited in `sources.csv` ({len(src)} sources) | | **Client** | A figure Binzagr stated in a meeting (e.g. example case rates), used only as a check | | **Simulated** | Computed from Researched / assumed parameters, always with low / base / high | **Validation:** {chk['summary'].get('PASS', 0)} of {len(chk['results'])} checks pass, {chk['summary'].get('WARN', 0)} information warnings (`SIMULATION_CHECKS.md`; §5.7 explains the two warnings). --- ## 0. What changed (version 1 → 2 → 3) {md_table(ch_log)} The savings figure holds: it comes from how Binzagr's own best shifts perform, and the head count does not change that. What the head count changes is the size of the bill those savings come out of - and the share of paid time nobody can see. --- ## 1. Headline results (Jeddah warehouse labour PoV) | | Base | Monte Carlo P10 – P90 | |---|---|---| | Head count (average month) | {HA['employee_ids_scanning']:.0f} employee ids scan; {HA['payroll_people_scanning']:.0f} of them are payroll-confirmed people; Binzagr's user list shows {HA['registered_active_people_log_sites']:.0f} active people at these sites | | | People paid (FTE, head-count basis) | **{HA['fte_headcount_base']:.0f}** (scan days only: {HA['fte_scan_days']:.0f}; every id salaried: {HA['fte_all_ids']:.0f}) | | | Simulated labour cost (annualised, head-count basis) | SAR {n0(base.labour_cost_annual_sar)} | SAR {n0(MS.loc['labour_cost_annual_sar','p10'])} – {n0(MS.loc['labour_cost_annual_sar','p90'])} | | **Recoverable labour cost per year** | **SAR {n0(base.recoverable_labour_annual_sar)}** ({pc(base.recoverable_pct_of_labour)}) | SAR {n0(MS.loc['recoverable_labour_annual_sar','p10'])} – {n0(MS.loc['recoverable_labour_annual_sar','p90'])} ({pc(MS.loc['recoverable_pct_of_labour','p10'])} – {pc(MS.loc['recoverable_pct_of_labour','p90'])}) | | Recoverable hours per year | {n0(base.recoverable_hours_annual)} h (≈ {base.fte_recoverable:.1f} FTE) | {n0(MS.loc['recoverable_hours_annual','p10'])} – {n0(MS.loc['recoverable_hours_annual','p90'])} h | | Labour per loose case picked / per pallet picked / per pallet received — days people worked | SAR {cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f} / {cpu8['pallet_pick_worked_time_labour_per_pallet_sar']:.2f} / {cpu8['receipt_worked_time_labour_per_pallet_received_sar']:.2f} | (compare with client prices, V12) | | Same, all paid days (head-count basis) | SAR {cpu8['case_pick_labour_per_loose_case_sar']:.2f} / {cpu8['pallet_pick_labour_per_pallet_sar']:.2f} / {cpu8['receipt_labour_per_pallet_received_sar']:.2f} | (compare with Binzagr's facility cost per case / pallet, V11) | | Outbound labour per case shipped (loose + in full pallets) | SAR {cpu8['outbound_worked_time_labour_per_case_shipped_sar']:.2f} worked days / {cpu8['outbound_labour_per_case_shipped_sar']:.2f} all paid days | ({pc(1 - cpu8['loose_share_of_cases_shipped'], 0)} of cases leave in full pallets) | **How to say it to Binzagr:** *"If every full shift performed like the top quartile of your own shifts in the same role already does, Jeddah would need ~{base.fte_recoverable:.0f} fewer FTE — about SAR {base.recoverable_labour_annual_sar/1e3:,.0f}k a year (range SAR {MS.loc['recoverable_labour_annual_sar','p10']/1e3:,.0f}k–{MS.loc['recoverable_labour_annual_sar','p90']/1e3:,.0f}k)."* Deliberately conservative (§5.5); below the 15–35% gains vendors report from engineered standards ([S23]). **The bigger finding — paid time nobody can see:** of {n0(H['paid_hours_8m']['headcount_base'])} paid hours on the head-count basis (Feb–Sep), **{pc(unobs)} have no scan** ({pc(whu.min(), 0)} at {whu.index[0]}, {pc(whu.iloc[1], 0)}–{pc(whu.max(), 0)} at the other sites). Two parts: the unscanned part of the days people did scan ({pc(unobs_scan)} of those days' paid hours), and **{n0(H['paid_days_without_scan_8m']['headcount_base'])} paid working days on which a payroll-confirmed person did not scan at all** (they scanned on {pc(H['payroll_people_scan_share_of_expected_days'], 0)} of the days they were expected to work, after leave and holidays). No savings are claimed on any of it: it can be leave, sickness, unscanned work, a colleague's badge, or idle time. Attendance by payroll number tells them apart. **Where labour goes (task-based posting, §5.6):** {pc(in_house)} of labour cost is *In House* work no client is billed for (moves, replenishment, cycle counts); **{pc(inbound_admin)} goes on inbound paperwork** (shipment audit, reconciliation, closing POs). **Prices vs cost:** on the days people worked, case picking costs **SAR {cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f} of simulated labour per loose case** (outbound labour incl. loading and its share of overhead, split between case and pallet picks by billed lines — the split matches actual pick time, {pc(sp['case_share_by_billed_lines'])} vs {pc(sp['case_share_by_pick_time'])}). That is at the top of the SAR {lo_c:.2f}–{hi_c:.2f} per case Badr quoted for the same activity at different clients. Adding the paid days without a scan, it is **SAR {cpu8['case_pick_labour_per_loose_case_sar']:.2f}** — above every price he quoted. Either part of that head count is not paid by these sites (or not full time), or case picking is priced below its labour and storage / other fees carry the margin. Binzagr's facility cost per case settles it (04_Gaps V11, V12). A full cost-recovery rate would be SAR {P['case_pick_rate_base']:.2f} per case, so other charges (storage, shipped-HU and VAS fees) must carry much of Binzagr's revenue — only the real rate card can show it. **Caution on the P&L:** the baseline P&L earns the benchmark 6.5% margin **by construction** (cost-recovery rate card, §5.7–5.8). Only the *change* is a result. Labour-to-revenue in this simulation ({pc(pnl8['labour_pct_of_revenue'])} → {pc(pnl8['labour_pct_of_revenue_optimised'])}) is an artefact of the labour-share parameter, not evidence for or against the "25% → 18%" example — present it only once the actual P&L is in. --- ## 2. Folder contents ``` 03_Simulated/ data/ + csv/ (same tables; CSV gzipped when large) sources {len(src)} sources (Researched + Client statements) sim_parameters {len(par)} parameters: low / base / high, basis, sources, notes sim_labour_cost_model loaded monthly cost per role tier (operative, storekeeper, supervisory), shift & hour rates sim_standard_times standard seconds per WMS activity x line-size band (3 variants x 3 idle caps) inf_jeddah_headcount per month: active people in Binzagr's user list, employee ids scanning, FTE / paid hours / labour cost on 3 bases sim_person_month_labour one row per person (or unconfirmed login) x month: employed days, scan days, paid days without a scan, cost sim_session_attendance {n0(L['sessions'])} PERSON work sessions: paid shifts / hours, observed vs unobserved time, earned hours, productivity, role, WMS role, cost tier, target, recoverable hours & SAR sim_wh_month_labour warehouse x month roll-up sim_activity_volumes wh x month x activity x client type: events, cases, pallets, hours, posted labour cost (Binzagr classes) sim_cost_per_unit_wh_month labour cost per loose case, case shipped, pallet picked and pallet received by site and month sim_rate_card cost-recovery rate per billed activity (Binzagr revenue codes), low/base/high + external checks sim_jeddah_pnl_monthly monthly simulated P&L: revenue, labour, other opex, EBIT, optimised EBIT, FTE sim_jeddah_pnl_by_wh_month same by warehouse sim_scenarios Conservative / Base / Stretch sim_monte_carlo_draws / _summary 5,000 draws over all uncertain inputs; P10/P50/P90 sim_opportunity_levers five levers (Jeddah labour + four from Riyadh sales), low / base / high inf_client_principal_map client code -> principals behind it; Binzagr BU vs external 3PL inf_warehouse_profile site timelines, structural changes, client mix, cold-chain indicator inf_badge_session_risk badge-sharing risk per person session (rules + IsolationForest) inf_customer_channel_map customer hierarchy codes -> inferred channel (EN + AR name keywords) inf_outlet_activity recency, visit cadence, churn proxy per customer inf_salesman_route_profile drops/day, weekday pattern, revenue per drop per van rep sim_receivables_open_items outstanding balances allocated to invoices (FIFO), due dates, days past due sim_receivables_customer_ageing ageing buckets per credit customer ref_calendar_events 2026 Hijri calendar + cited events/holidays + observed activity index scripts/ SIMULATION_CHECKS.md simulation_checks.json MANIFEST.csv ``` --- ## 3. Research — sources {md_table(srcs, ['link', 'title', 'publisher', 'date'])} --- ## 4. Parameters (low / base / high) "Low" and "high" bracket the plausible range. For the labour-model parameters (M-codes), *low = conservative savings*, *high = stretch*. `Assumption` = no authoritative public figure; set from the nearest evidence and varied in the Monte Carlo. `Client` = stated by Binzagr; used only for checks. {md_table(par, ['param_id', 'parameter', 'unit', 'low', 'base', 'high', 'basis', 'sources', 'note'])} --- ## 5. Methods and results ### 5.1 Loaded labour cost by role (Simulated) Binzagr's user list gives each badge a WMS role: **tally clerk** ({n0(L['paid_hours_by_wms_role'].get('Tally clerk', 0))} paid hours), **storekeeper** ({n0(L['paid_hours_by_wms_role'].get('Storekeeper', 0))} h), **warehouse manager** ({n0(L['paid_hours_by_wms_role'].get('Warehouse manager', 0))} h), and logins not in the list ({n0(L['paid_hours_by_wms_role'].get('Not in user list', 0))} h). Labour is costed per role, as Binzagr posts it (wage × task × duration, [S39]): - **Operative** (tally clerk, and logins not in the list): cash pay (basic, housing, transport, food) + GOSI ([S04]), expat levy and iqama ([S06]), medical insurance, end-of-service accrual (Art. 84, [S09]), air ticket, recruitment / visa; blended with an assumed Saudi share ([S12]). - **Storekeeper:** the same build-up on a storekeeper basic salary (expat postings SAR 1,500–2,500, [S34], [S35]) — research shows no real premium over operatives. - **Supervisory** (warehouse manager): cash wage SAR 5,000–12,000 ([S36]) plus on-costs; never benchmarked for savings. Cost per worked shift spreads paid leave (21 days, [S09]) and public holidays over {LC[(LC.role == 'all') & (LC.component == 'worked_days_per_year') & (LC.scenario == 'base')].sar_per_month.iloc[0]:.0f} worked days a year. Overtime = hourly wage + 50% of basic (Art. 107, [S08]). Expat operative and Saudi operative build-up (SAR / month): {md_table(comp, fmt={'low': n0, 'base': n0, 'high': n0})} Per role tier: {md_table(tiers, fmt={'low': n2, 'base': n2, 'high': n2})} ### 5.2 Work content per scan (Inferred) For each badge's timeline: **cycle time = idle time since the previous scan + own duration**; idle gaps longer than 30 minutes count as breaks and tasks left open are capped at 1 h. Records written in the same second (e.g. *Case Pick* next to *Picking (Pick)*, billing echo rows) get zero, so time is not double-counted — Binzagr confirmed that zero-second tasks are an operator practice ([S39]), so the recorded duration alone is not used. Total scan-evidenced work: {n0(L['scan_work_hours'])} h over 8 months. Variants with 15 / 45-minute caps go into the Monte Carlo. ### 5.3 Standard times by activity and line size (Inferred) New in version 2: Binzagr's conversion factors give every scan its quantity in **cases**, so the standard is set per activity **and line-size band** (cases per line). It matters for physical moves: a *Picking (Pick)* line of one case takes about a minute, a line of 11–25 cases more than two. With a single per-line standard, shifts that pick big lines would look slow. For each activity × band: the mean cycle time of every badge-month with ≥ 30 such scans; **standard = median of those means** (P50; lenient P60 and tight P40 variants). Sparse bands use the pooled band mean (scaled to the variant), and lines without a factor use the activity-level standard. Standards are Jeddah-wide so sites can be compared. Median standard (seconds per line), idle cap 30 min: {md_table(stdp, ['tran_desc'] + [b for b in bands if b in stdp.columns], stdp_fmt)} ### 5.4 Simulated attendance (paid time) per person under Saudi Labour Law No roster exists, so each **person work session** is one paid shift. New in version 2: badges of the same payroll number are merged (a session ends after > 6 h without scans across all of the person's badges), so a person using two badges is paid once. Unmapped logins stay one person per badge. - Legal hours 8/day, 6/day in Ramadan (18 Feb – 19 Mar) (Art. 98, [S07], [S33]); 30-min break (Art. 101). - Overtime = span beyond legal hours + break, up to the 12 h presence limit (Art. 101), paid per Art. 107. - **Sessions longer than 12 h** are legally impossible for one person → counted as **two** shifts ({L['badge_sharing_splits']} sessions; [S24] for context). - {n0(L['multi_badge_sessions'])} person sessions used two or more badges; in some, two badges of one person were active **at the same time** ({L['concurrent_badge_hours']:,.0f} h) — flagged for the badge-sharing review. - System logins (SAPImport, Admin, ShortPick) excluded. Result: {n0(L['sessions'])} person sessions → {n0(L['paid_hours_total'])} paid hours on the days people scanned (incl. {n0(L['overtime_hours_total'])} overtime) ≈ **{L['fte_equivalent_avg']:.0f} FTE** — a *lower bound*: a salaried person is also paid on the days they do not scan (§5.4b). {md_table(wr, ['wms_role', 'sessions', 'paid_h', 'labour_cost', 'recoverable_h'], {'sessions': n0, 'paid_h': n0, 'labour_cost': n0, 'recoverable_h': n0})} ### 5.4b Head count from employee ids (new in version 3) Mehul's point: the employee ids give a head count, which beats a head count built from shifts. Three sources, from the hardest to the softest: - **Binzagr's user list** (`02_Cleaned/ref_wms_user_headcount_month`, Actual): {HA['registered_active_people_log_sites']:.0f} people a month on average hold an active login at the 9 sites in the log ({HA['registered_active_people_all_jeddah']:.0f} across all Jeddah sites). Only about half of them scan in a given month, so the list alone overstates who works here. - **Employee ids in the scans** (Actual): {HA['employee_ids_scanning']:.0f} ids scan in an average month — {HA['payroll_people_scanning']:.0f} payroll-confirmed people and {HA['other_logins_scanning']:.0f} logins the list does not know (the {H['desktop_logins_merged_with_twin']} desktop `WA.AD.nnn` logins are merged with their plain-number twin; system logins excluded). Over Feb–Sep: {H['payroll_people_total']} people and {H['other_logins_total']} other logins. - **Employment spells** (Inferred): each id counts from its first to its last scan day (from 1 Feb / to 30 Sep when seen in the first / last week of the extract). A salaried person is paid on every working day (Sat–Thu) of the spell; {pc(H['worked_share_of_working_days'], 0)} of those days are expected to be worked after annual leave and public holidays. Payroll-confirmed people scanned on {pc(H['payroll_people_scan_share_of_expected_days'], 0)} of the days they were expected to work. Three paid-time bases (all with base cost parameters): | Basis | Who is paid when | FTE | Labour cost, Feb–Sep | Paid time with no scan | |---|---|---|---|---| | Scan days (v2, low) | everyone only on days they scan | {HA['fte_scan_days']:.0f} | SAR {n0(H['labour_cost_8m']['scan_days'])} | {pc(H['unobserved_share']['scan_days'])} | | **Payroll head count (v3 base)** | payroll-confirmed people on every working day of their spell; other logins on days they scan | **{HA['fte_headcount_base']:.0f}** | **SAR {n0(H['labour_cost_8m']['headcount_base'])}** | **{pc(H['unobserved_share']['headcount_base'])}** | | Every employee id salaried (high) | every login, incl. the {HA['other_logins_scanning']:.0f} a month the list does not know | {HA['fte_all_ids']:.0f} | SAR {n0(H['labour_cost_8m']['all_ids'])} | {pc(H['unobserved_share']['all_ids'])} | By month: {md_table(hct, fmt={'fte_scan_days': lambda v: f'{v:.0f}', 'fte_headcount_base': lambda v: f'{v:.0f}', 'fte_all_ids': lambda v: f'{v:.0f}', 'paid_days_without_scan_base': n0, 'labour_cost_scan_days_sar': n0, 'labour_cost_headcount_base_sar': n0, 'labour_cost_all_ids_sar': n0, 'unobserved_share_headcount_base': pc})} The head count falls from February to September (fewer ids scan each month), and the gap between the user list and the people who scan is large: those accounts may belong to people on leave, people working at other sites, staff whose work is not scanned, or stale logins. **Attendance by payroll number (G2) and one month of actual labour cost (G3) decide which basis is right.** Recoverable hours do not depend on the basis: they come from the shifts people actually worked. ### 5.5 Benchmarking shifts and recoverable hours (Inferred → Simulated) **Why not compare against absolute standards?** Scans cover only part of the work; comparing earned standard hours with full paid shifts would claim ~75% savings — the kind of number that would be "dismissed out of hand". **Method (like-for-like, own-data benchmark):** 1. Each session gets an **activity role** = its dominant activity family (Picking, Loading / shipping, Internal movement, …). 2. **Observed time** = the scanned span (capped by paid hours); **unobserved** = paid time outside it (reported, never claimed). 3. **Span productivity** = earned standard hours ÷ observed hours, for *full* shifts (≥ 30 scans, ≥ 4 h span): {n0(L['eligible_sessions'])} shifts. 4. Not benchmarked: supervisory staff, and **batch-posting sessions** — more than {pc(L['bulk_posting_threshold'], 0)} of scans in the same second as the previous scan ({n0(L['bulk_posting_sessions'])} sessions, {n0(L['bulk_posting_paid_hours'])} paid hours; mostly the `WA.AD.nnn`, HM1008 and AMR2100 logins). Their paid hours still count. Badge BHAS210004 (a storekeeper login, [S40]) batch-posts too but most of its sessions stay under the threshold; excluding it entirely would lower recoverable hours by about 1%. 5. **Target** = the top-quartile (P75) productivity of shifts **in the same role** (roles with < 30 full shifts get no target). 6. **Recoverable hours** = observed hours − earned ÷ target (if positive) × **capture rate** 70%; valued at the person's role cost. {md_table(role, ['role', 'eligible_shifts', 'median_productivity', 'target_p75', 'observed_h', 'recoverable_h', 'recoverable_pct_of_observed'], {'median_productivity': n2, 'target_p75': n2, 'observed_h': n0, 'recoverable_h': n0, 'recoverable_pct_of_observed': pc})} By warehouse (base, Feb–Sep): {md_table(wh, ['wh_id', 'sessions', 'paid_h', 'unobserved_pct', 'recoverable_h', 'recoverable_pct', 'labour_cost', 'recoverable_sar', 'badge_sharing_splits', 'temperature_regime_inferred'], {'sessions': n0, 'paid_h': n0, 'unobserved_pct': pc, 'recoverable_h': n0, 'recoverable_pct': pc, 'labour_cost': n0, 'recoverable_sar': n0})} ### 5.6 Task-based cost posting, cost per case and per pallet (Simulated) — new Binzagr posts labour cost per task (wage × task × duration, per user) and costs the facility per case and per pallet ([S39]). Here each person session's paid cost is spread over the tasks in it by cycle time (time the scans cannot see is spread pro rata), so the full labour cost lands on activities — reconciled to the riyal (check S15). Binzagr's classification of each activity ([S40]) then shows where labour goes: {md_table(mv, ['movement_type', 'events', 'labour_cost', 'share_of_labour_cost'], {'events': n0, 'labour_cost': n0, 'share_of_labour_cost': pc})} *Duplicate* in Binzagr's mapping means "billed under another code" (e.g. the *Picking (Pick)* scan, whose billing sits on *Case Pick* / *Pallet Pick*), not wasted work. *In House* is work no client pays for. Largest activities: {md_table(acts, ['tran_desc', 'movement_type', 'cost_or_revenue', 'events', 'labour_cost', 'share', 'cost_per_event'], {'events': n0, 'labour_cost': n0, 'share': pc, 'cost_per_event': n2})} Labour cost per unit, Feb–Sep by site, on the head-count basis (each site-month's overhead and paid days without a scan spread over its families; `per_loose_case_worked_time` = days with scans only). Outbound family (picking, loading, LP audit, QC) incl. its share of overhead is split between case picks and pallet picks by billed lines; that split matches actual pick time ({pc(sp['case_share_by_billed_lines'])} vs {pc(sp['case_share_by_pick_time'])} for case picks: {pc(sp['linked_share'])} of {n0(sp['picking_scans'])} *Picking (Pick)* scans were linked to the *Case Pick* / *Pallet Pick* row stamped when they end; check S19). Inbound family is split the same way between receipts, damaged-goods receipts and unloads. {md_table(cw, ['wh_id', 'labour_cost_sar', 'loose_cases_picked', 'per_loose_case_worked_time', 'per_loose_case', 'cases_shipped', 'per_case_shipped', 'pallets_picked', 'per_pallet_picked', 'pallets_received', 'per_pallet_received'], {'labour_cost_sar': n0, 'loose_cases_picked': n0, 'per_loose_case_worked_time': n2, 'per_loose_case': n2, 'cases_shipped': n0, 'per_case_shipped': n2, 'pallets_picked': n0, 'per_pallet_picked': n2, 'pallets_received': n0, 'per_pallet_received': n2})} Jeddah overall, days people worked → all paid days: **SAR {cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f} → {cpu8['case_pick_labour_per_loose_case_sar']:.2f} per loose case picked**, SAR {cpu8['pallet_pick_worked_time_labour_per_pallet_sar']:.2f} → {cpu8['pallet_pick_labour_per_pallet_sar']:.2f} per pallet picked and **SAR {cpu8['receipt_worked_time_labour_per_pallet_received_sar']:.2f} → {cpu8['receipt_labour_per_pallet_received_sar']:.2f} per pallet received**; per case shipped (loose + full pallets) SAR {cpu8['outbound_worked_time_labour_per_case_shipped_sar']:.2f} → {cpu8['outbound_labour_per_case_shipped_sar']:.2f}, or SAR {cpu8['all_labour_per_case_shipped_sar']:.2f} with all labour. The Jeddah figures use the rate-card allocation (overhead spread over all families together), so they equal the rate card's labour per unit; the site table spreads each site-month's overhead over its own families. Binzagr's facility cost per case / per pallet is the natural validation (04_Gaps V11). ### 5.7 Rate card (Simulated, cost-recovery) on Binzagr's revenue codes New in version 2: only the activities Binzagr marks **Revenue Generation** are billed — inbound receipt, damaged-goods receipt, unload / unpick, **case pick (per case)**, pallet pick, consolidation, re-palletisation, pallet split, returns put-away. Loading, LP audit and QC are *cost* in Binzagr's mapping, so they are no longer billed; the three "Per Pallet (Billing)" rows are *Duplicate*. Code 100 is excluded (mapping says "Picked Volume", the log says "Item Import" — to confirm). Allocation: posted labour is grouped into five service families (the billed activity + the cost activities that support it); warehouse overhead (in-house moves, counts, QC, adjustments: {pc(P['overhead_share_of_labour'])} of labour) and the paid days without a scan ({pc(P['paid_days_without_scan_share_of_labour'])} of labour on the head-count basis) are spread pro rata; each family is split over its billed activities by billed lines; then grossed up for non-labour opex (labour ≈ 50% of warehouse opex, [S15], [S14]) and a 6.5% EBIT margin ([S16]). {md_table(RC, ['service', 'unit', 'units_8_months', 'share_of_labour_cost', 'worked_time_labour_per_unit_sar', 'labour_cost_per_unit_sar', 'rate_low_sar', 'rate_base_sar', 'rate_high_sar', 'external_check_sar'], {'units_8_months': n0, 'share_of_labour_cost': pc, 'worked_time_labour_per_unit_sar': n2, 'labour_cost_per_unit_sar': n2, 'rate_low_sar': n2, 'rate_base_sar': n2, 'rate_high_sar': n2, 'external_check_sar': n2})} **Checks against the outside world** (S08b–d): - **Case pick vs Binzagr's own prices:** labour per loose case on the days people worked (SAR {cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f}) is not above the highest of the SAR {lo_c:.2f}–{hi_c:.2f} per case Badr quoted (PASS: otherwise every client would be priced below labour), but it is above the lowest — a question for Binzagr, not a model error (V12). With the paid days without a scan it is SAR {cpu8['case_pick_labour_per_loose_case_sar']:.2f}: Binzagr's facility cost per case (V11) shows which is closer. The *full cost-recovery* case rate (SAR {P['case_pick_rate_base']:.2f}) is 2–3× his figures: because storage revenue is not modelled, this rate card has to carry all non-labour cost and margin on handling. Binzagr's real price list therefore matters more than any external benchmark (information warning). - **Receiving vs a US survey** (USD 10.52 per pallet ÷ USD 39.88/h = {(10.52/39.88):.3f} billing-hours per pallet at SAR {P['ksa_billing_labour_rate_base']:.1f}/h = SAR {RC[RC.service=='Inbound receipt'].external_check_sar.iloc[0]:.2f}) vs SAR {RC[RC.service=='Inbound receipt'].rate_base_sar.iloc[0]:.2f} here: ours carries the inbound paperwork ({pc(inbound_admin)} of labour) and the opex gross-up (information warning; in version 1 this was a pass/fail test at ±50% — with Binzagr's real price signal available, a US handling fee is no longer a fair pass/fail benchmark). - Storage revenue is **not** modelled (pallet positions unknown; market SAR 80–250 / pallet / month, [S19]). ### 5.8 Simulated monthly P&L (base) Revenue = Actual billed units × base rates. Labour = head-count basis (paid shifts on scan days × role cost + paid days without a scan). Other opex = labour × (1/0.5 − 1). Optimised = labour − recoverable cost. **Baseline margin = 6.5% by construction.** `fte_equivalent` = people paid on the head-count basis; `fte_scan_days` = the version-2 figure. {md_table(pm, fmt={'handling_revenue_sar': n0, 'labour_cost_sar': n0, 'labour_cost_paid_days_without_scan_sar': n0, 'other_opex_sar': n0, 'ebit_sar': n0, 'recoverable_labour_sar': n0, 'ebit_margin': pc, 'ebit_margin_optimised': pc, 'employee_ids_scanning': n0, 'fte_equivalent': lambda v: f'{v:.0f}', 'fte_scan_days': lambda v: f'{v:.0f}', 'fte_recoverable': lambda v: f'{v:.1f}'})} ### 5.9 Scenarios and Monte Carlo Deterministic scenarios: {md_table(sc_tab, fmt={'fte_paid': lambda v: f'{v:.0f}', 'labour_cost_annual_sar': n0, 'recoverable_labour_annual_sar': n0, 'recoverable_pct_of_labour': pc, 'fte_recoverable': lambda v: f'{v:.1f}', 'ebit_margin': pc, 'ebit_margin_optimised': pc})} Monte Carlo: 5,000 draws; every labour-cost (all role tiers), cost-structure and model parameter drawn from a triangular(low, base, high) distribution; standard variant (P60/P50/P40), idle cap (15/30/45 min) and paid-time basis (scan days / payroll head count / every id) drawn 25/50/25. The basis moves the labour cost (median SAR {n0(P['mc_labour_cost_median_by_basis']['scan_days'])} / {n0(P['mc_labour_cost_median_by_basis']['headcount_base'])} / {n0(P['mc_labour_cost_median_by_basis']['all_ids'])} a year), not the recoverable labour. {md_table(ms_fmt, ['metric', 'p10', 'p50', 'p90'])} **What drives the answer** (Spearman rank correlation with recoverable SAR): {md_table(sens, fmt={'spearman_rank_corr_with_recoverable_SAR': n2})} The result is driven by **management choices** (how ambitious the target is, how much of the gap is captured), far more than by wage assumptions. ### 5.10 Badge-sharing risk per person session (Inferred) Rules (span > 12 h / > 16 h, warehouse switch < 2 min, extreme scan rate, **two badges of one person active together > 30 min**) + an **IsolationForest** anomaly model on session behaviour (span, scans, scans/hour, sites, fast switches, badges used, concurrent hours, same-second share; 300 trees, 3% contamination). Tiers: {', '.join(f'{k} {v:,}' for k, v in I['risk_tiers'].items())}. Sessions flagged for concurrent badges: {I.get('risk_concurrent_badge_sessions', 0)}. Badges with most high/medium sessions: {', '.join(f'{k} ({v})' for k, v in list(I['risk_by_badge_top'].items())[:6])}. These are **candidates to check against attendance**, not findings. ### 5.11 Clients and warehouses (Inferred) Client code → principal via item code → product master ({I['client_types'].get('binzagr_business_unit',0)} Binzagr business units, {I['client_types'].get('external_3pl_client',0)} external 3PL clients): {md_table(cp, ['client_code', 'sku_events', 'client_type_inferred', 'principals_top3', 'confidence'], {'sku_events': n0})} Warehouse timelines (structural changes = month-on-month change ≥ 50%): {md_table(WP, ['wh_id', 'rows_total', 'first_month', 'last_month', 'structural_changes', 'external_3pl_share', 'temperature_regime_inferred'], {'rows_total': n0, 'external_3pl_share': pc})} The data confirms the **2100-01 → 2100-07 move in August** and **2100-12 going silent after April**. 2100-14/15 handle mostly frozen/chilled lines (McCain, dairy) — benchmark them separately once confirmed. Bonded-warehouse services sit at King Abdullah Port, not in Jeddah ([S39]). ### 5.12 Customer channels (Inferred) Each customer-hierarchy code is labelled from the share of its customer names containing channel words in English and Arabic (baqala/بقالة, tamweenat/تموينات, aswaq/markets, bufia/matam/restaurant, pharmacy/صيدلية, trading, fuel stations…). Market context: traditional trade 23% of Saudi FMCG sales in 3Q25 ([S31]); baqalahs ~57% of grocery in 2018 ([S32]). {md_table(ch2, ['code', 'customers', 'sales_sar_mar_oct_2026', 'share_names_with_any_keyword', 'inferred_channel', 'confidence'], {'sales_sar_mar_oct_2026': n0, 'share_names_with_any_keyword': pc})} ### 5.13 Receivables ageing (Simulated) Customer balances are a single snapshot (20 Oct 2026). Assumption: payments clear the oldest invoices first (FIFO), so each credit customer's balance sits on its **most recent** van-sales invoices. Due date from the payment term. - {rec['credit_customers_with_balance']:,} credit customers with a balance: SAR {n0(rec['outstanding_total_sar'])}; allocated to {rec['open_items']:,} invoices SAR {n0(rec['allocated_sar'])}; **SAR {n0(rec['unexplained_sar'])} cannot be matched** to van-sales invoices in the window — flagged per customer. These are van-sales credit customers only; wholesale receivables (the PoV focus) are not in the data. {md_table(buckets, ['ageing_bucket', 'open_amount_sar'], {'open_amount_sar': n0})} ### 5.14 Outlets and van routes (Inferred) - Activity status at 20 Oct 2026: {', '.join(f'{k} {v:,}' for k, v in C['outlets'].items())}. Churn proxy = no purchase for max(60 days, 3 × the outlet's usual gap). - Van reps: median **{C['reps']['median_drops_per_day']:.0f} drops/day**, median SAR {C['reps']['median_sales_per_drop_sar']:,.0f} per drop. ### 5.15 Calendar (Researched + validated against Actual) Events: Ramadan 18 Feb – 19 Mar ([S30]); Founding Day 22 Feb ([S29]); Eid al-Fitr holiday from close of 18 Mar for 4 days, Eid = Fri 20 Mar ([S25], [S26]); Arafat Tue 26 May + Eid al-Adha Wed 27 May ([S27], [S28]); National Day 23 Sep ([S29]). The warehouse data confirms them: {md_table(hv_tab, fmt={'wms_activity_index_excl_fridays': n2})} During Ramadan the warehouse ran **~{C['ramadan_vs_normal_wms_excl_fridays']*100-100:.0f}% above** its normal weekday level. ### 5.16 Opportunity levers (Simulated) Annual profit impact. Jeddah labour from §1; the four Riyadh levers turn extra sales into profit at a 16% distributor gross margin ([S38]). {md_table(lv, ['lever', 'module', 'confidence', 'low', 'base', 'high'], {'low': n0, 'base': n0, 'high': n0})} Total: SAR {n0(lv_tot['base'])} a year (SAR {n0(lv_tot['low'])} – {n0(lv_tot['high'])}), USD {lv_tot['base']/FX_USD_SAR/1e3:,.0f}k. --- ## 6. What this means for the deck - **Safe to present (labelled "Simulated")**: recoverable SAR {base.recoverable_labour_annual_sar/1e3:,.0f}k/yr (P10–P90 SAR {MS.loc['recoverable_labour_annual_sar','p10']/1e3:,.0f}k–{MS.loc['recoverable_labour_annual_sar','p90']/1e3:,.0f}k), ~{base.fte_recoverable:.0f} FTE of ~{HA['fte_headcount_base']:.0f} people paid, method = "top quartile of your own shifts, same role"; labour per loose case picked SAR {cpu8['case_pick_worked_time_labour_per_loose_case_sar']:.2f}–{cpu8['case_pick_labour_per_loose_case_sar']:.2f} vs client prices SAR {lo_c:.2f}–{hi_c:.2f} (a question, not a verdict). - **Head count**: {HA['employee_ids_scanning']:.0f} employee ids scan each month ({HA['payroll_people_scanning']:.0f} payroll-confirmed); Binzagr's user list shows {HA['registered_active_people_log_sites']:.0f} active people. Present the range, not one number. - **Headline ask**: {pc(unobs, 0)} of paid time has no scan → attendance by payroll number unlocks the larger opportunity. - **Don't present yet**: blue-collar % of revenue, EBIT level, cost-recovery rates as prices. ## 7. Limitations - Paid time: the head count rests on employment spells from the scans (first to last scan day); logins Binzagr's list does not know are paid only on days they scan; nobody who never scans is counted. Sessions < 4 h are not benchmarked. - Standards come from the same population they benchmark (relative, not engineered); the time-and-motion study Binzagr mentioned would anchor them. - WMS role is a menu level (tally clerk / storekeeper / manager), not a job title (picker, forklift driver…); 6% of scans come from logins not in the user list. - Rate card is cost-recovery, not market price; storage revenue excluded; the per-case example rates are a single statement, not a price list. - Receivables ageing rests on FIFO; channel labels on name keywords. ## 8. Reproduce Run from `03_Simulated/scripts`, in this order: `parameters.py` → `sim_labour.py` → `sim_pnl.py` → `sim_inferred.py` → `sim_commercial.py` → `sim_opportunity_levers.py` → `sim_checks.py` → `build_sim_report.py`. Input is `../../02_Cleaned/data` (run 02 first, including `clean_mappings.py`) and output is `03_Simulated` (override with `BZ_CLEAN` / `BZ_OUT`; `BZ_RAW` for the name check). Monte Carlo seed 20261021; IsolationForest seed 7. Requirements: pandas ≥ 2, numpy, pyarrow, scikit-learn, openpyxl. """ open(os.path.join(OUT, 'SIMULATION_REPORT.md'), 'w', encoding='utf-8').write(md) print('report', len(md), 'chars') if __name__ == '__main__': main()