4H Phase 1 — approver briefing: all six PRs, every change explained
Prepared 2026-08-03 for reviewer/approver discussion. Covers pakas #394, siunta #90, go-import #49, veniship #743, venipak-helper #43, and pakas #516 (parked) — what each diff does, why, and what actually happens when it merges.
1. The one-paragraph story
Phase 1 turns on the 4-Hands (4H) service for Lithuania: heavy parcels (per-package weight, LT ≥26 kg, LV/EE ≥31 kg — the communicated 25/30 kg floors plus the approved 1 kg tolerance) get flagged siunt_4rp=1 and billed the 4H tariff, doubling to 2×4H above 50 kg (LT) / 60 kg (LV·EE), with a hard eligibility ceiling at 75/90 kg. The single authoritative auto-writer is the pakas pack_4rp cron, and it only auto-flags shipments whose payer opted in (tbl_klientai.kl_4rp_auto=1, a Sales-only switch on the PAKAS client card; default is MANUAL). The three creation channels (siunta UI, go-import XML/API, veniship) are aligned to the same rule: client-declared 4H is always honored, creation-time auto-4H happens only where the payer flag is readable (siunta, go-import) and never client-blind. Nothing bills until the LT posti_service1 data flip — postcodes are the master switch, and they stay off until every gate below is green.
2. Why merging is safe now (the dormancy model)
| Layer | Master switch | State after merge |
|---|---|---|
| Postcode allowlist | tbl_post_info.posti_service1='1' |
LT mass zones off → cron + creation-time auto paths find no eligible postcodes |
| Payer opt-in | tbl_klientai.kl_4rp_auto (VDT-3384.sql) |
Column defaults 0 (MANUAL) → auto-4H fires for nobody until Sales flips a client; if the SQL hasn't been applied on an env yet, every reader fails closed (no auto-4H, no error) |
| Client-declared 4H | import XML four_hands=1, siunta checkbox, veniship service |
Unchanged semantics — works today, keeps working |
| Pricing (2×4H doubling) | vendored venipak-helper release 0.8.3 + pakas pin |
Config maps ship in #394; the two-file library patch is PR #43 (§8) and the 0.8.3/0.9.1 tags are a separate gated step |
So the merge order is flexible and low-risk: the PRs are the plumbing; the flip is the tap. The exception is veniship #743 — its merge is itself a prod deploy (see §6) and it fixes a live billing bug, so it has day-one behavior changes (all of them corrections).
3. Current path to LT enable (where we are)
- ✅ All five active PRs pushed and gated (suites green vs baselines, veni-guard 42/0 on each app PR, adversarial review rounds on #394/#90/#49/#743; helper #43 suite 170 green). The sixth, pakas #516, is parked off the 4H path (§9).
- ⏳ 2 approvals per PR ← you are here.
- ⏳ venipak-helper PR #43 (§8) merge → tag 0.8.3 (cut from the 0.8.2 tag, patches
Price.php+DirectionProvider.phpscalar→country maps) → cherry-pick → 0.9.1 → pakas pin^0.8.3. - ⏳ Promotion dev → uat → prod with
VDT-3384.sqlapplied before/with code on every env (incl. dev + demo). - ⏳ Pre-flip gates: VDT-3395 (terminal re-weigh re-eval — Product ruled it Phase-1 scope 2026-08-03) · tariff reconciliation (Evelina states 14 €/28 €; programme record 6/12 €;
kain_4rpschema default 50.00 — Finance must pick) · AUTO seed list (legacy four vs Confluence six — Sales) · prodkain_4rp/posti_service1probes · client comms. - ⏳ Wave-by-zone LT
posti_service1flip (zones 2→3→4→11) with per-wave monitors and a pre-staged reverse UPDATE.
Recommended review order: #43 first. It is the smallest diff (two library files + tests), and everything downstream waits on the tags it unblocks — the pakas pin is a promotion gate and siunta #90 cannot merge before the 0.9.1 tag exists. Then #394 → #90 → #49 (they share the band table and payer-gate concepts, in that order of depth), then #743 on its own release decision (merge = prod deploy), and #516 not at all until its refresh (§9).
4. pakas PR #394 (VDT-3203 + VDT-3384 fold) → dev
Branch VDT-3203 → dev · head 425bf63c pushed · 15 files, +676/−28 (4 prod PHP areas, 4 lang files, 1 SQL migration, 4 test files, test bootstrap).
4.1 What it is
The heart of Phase 1: the pack_4rp cron rewrite + the client AUTO/MANUAL switch (VDT-3384 fold) + the 2×4H pricing config tiers.
4.2 File-by-file
config/config.php (+8/−4) — 2×4H pricing tiers. Legacy scalars $config_2x4rp_low_limit=80 / high=100 become per-country maps with LT-derived scalar aliases:
$config_2x4rp_low_limit_by_country = array('LT' => 50, 'LV' => 60, 'EE' => 60);
$config_2x4rp_high_limit_by_country = array('LT' => 75, 'LV' => 90, 'EE' => 90);
$config_2x4rp_low_limit = $config_2x4rp_low_limit_by_country['LT']; // = 50
⚠️ This is the one change that is NOT dormant at deploy (see 4.3).
inc/inc.function.php (+81/−1) — the 4H helper trio. getFourHandsPackageWeightThresholds() (single source of truth: LT 26–75, LV/EE 31–90, inclusive; max is the hard stop, not the doubling point) · isFourHandsRequiredByPackageWeight[s]() (per-package check — any single pack qualifies, never summed; unknown countries and bad input → false) · getFourHandsPackageWeightSqlCondition() (emits the identical rule as SQL so the cron cannot drift from the PHP path; values interpolated from the hardcoded table, no injection surface). Only the cron and the tests call these — no existing entry/pricing path was rewired.
mod/auto_script/pack/pack_4rp.php (+31/−17) — the cron. Old rule (removed): pack ≥26.5 kg AND volume >0.179 m³, postcode posti_zone='1', payer in hardcoded IN('89398','157673','158280','3464'). New single UPDATE:
UPDATE tbl_siuntos
INNER JOIN tbl_pack ON (pack_siunt_id = siunt_id AND {band-per-country} AND pack_paletes_tipas = '0')
INNER JOIN tbl_post_info ON (siunt_g_country = posti_salis AND siunt_g_post = posti_kodas AND posti_service1 = '1')
INNER JOIN tbl_klientai ON (kl_id = siunt_moketojo_id AND kl_4rp_auto = 1)
SET siunt_4rp = '1'
WHERE siunt_priemimo_data BETWEEN yesterday AND today
AND UPPER(siunt_g_name) NOT LIKE '%VENIPAK PICKUP%' AND UPPER(siunt_g_name) NOT LIKE '%VENIPAK LOCKER%'
Eight deltas: per-country weight bands (volume criterion removed), pallet exclusion, posti_service1 gate (the dormancy switch), payer whitelist → kl_4rp_auto JOIN, sender-country gate (both ends Baltic, inside the band fragment), OOH receiver-name exclusion, unchanged daily window, and real error handling (logs UPDATE failed: <mysqli_error> + rows:N instead of always echoing ok). The statement stays monotonic — it only ever sets 1, never clears (that's what VDT-3395 will add).
inc/sql/VDT-3384.sql (new). ALTER TABLE tbl_klientai ADD kl_4rp_auto TINYINT(1) NOT NULL DEFAULT 0 + index + seed UPDATE … WHERE kl_id IN (89398,157673,158280,3464) → the legacy whitelist four become AUTO, everyone else MANUAL. Manual deploy artifact (house style), must run before/with the code.
mod/client/kl_add.php (+15/−4), kl_edit.php (+13/−2), kl_view.php (+6), 4 lang files (+2 each). Client-card UI for the switch. Save AND render are gated behind the kl_edit_plius permission (Sales-only — the 425bf63c amend per Product 2026-08-03); non-Sales users can't set the flag even by hand-posting the field (kl_add forces 0). kl_view shows a read-only "4H AUTO: yes/no" row to all viewers (deliberate: operators can see the client's mode; flag for approvers as an intentional asymmetry).
Tests. FourHandsWeightTest (29-case boundary matrix incl. 25.99/26.01, 30.99/31.0, 75.01-no, 90.01-no, comma decimals, case/whitespace, per-package never-sum; plus a literal assertion on the SQL fragment so SQL and PHP paths can't diverge). FourHandsPricingConfigTest (pins 50/75 + LT/LV/EE maps + cross-checks eligibility ceiling == high-limit map; docblock records that published helper 0.8.2/0.9.0 read only the scalar). Pack4rpCronTest (runs the cron's real UPDATE against CREATE TEMPORARY TABLE shadows with a column-count tripwire proving shadowing before any write; 18-row eligibility matrix asserting exactly 6 flagged; preservation test proving pre-existing siunt_4rp=1 outside criteria survives; idempotency test). tests/bootstrap.php defines placeholder DB constants only when config.local.php is absent — real creds are never overridden.
4.3 What happens if merged today
Immediate (NOT dormant):
1. The 2×4H threshold drops 80→50 for everything that reads the scalar: the vendored helper Price.php doubling and DirectionProvider label direction, plus three internal display readers (inc.siuntos.nurodymai.php:89, two courier load prints). This affects only shipments that already carry siunt_4rp=1 (manual/declared/legacy) — but for those it applies at deploy, not at the flip. LT >50 doubling is the ratified figure; LV/EE will double at >50 instead of >60 until the patched helper (0.8.3) is tagged and pinned — that is exactly why the helper release is a promotion gate.
2. The four legacy whitelist payers stop being auto-flagged the moment this merges (old cron rule gone; new rule finds no open postcodes). Deliberate, same-day effect.
3. Client-card UI appears; cron log format changes (rows:N / possible error line) — tell whoever monitors cron output.
Dormant until the LT posti_service1 flip: the entire flag-setting side — the JOIN matches zero postcodes.
Dormant until VDT-3384.sql: the cron UPDATE errors on the unknown column → logged, echoes error, flags nothing (fail-closed). The client-card save would also fail — sequence the SQL first.
4.4 Reviewer flags / deliberately untouched
CONFIG_2X4RP_HIGH_LIMIT+ high-map have zero consumers in this repo — the 75/90 ceiling is enforced only on the cron path; manual 4H entry is not ceiling-checked anywhere (known accepted risk: manual = deliberate operator action).- Unchecked-checkbox saves produce a PHP notice on
$_POST['kl_4rp_auto'](7.4 log noise, harmless). - OOH exclusion is a name-substring heuristic (
%VENIPAK PICKUP/LOCKER%) — misses non-Venipak OOH, can't use an index. Known programme item (P11, with Product). - AUTO→MANUAL never un-flags already-set shipments (monotonic by design, test-locked) — the re-eval mechanism is VDT-3395.
- The e2e cron test skips without
VENIPAK_DB_*env — green CI without creds proves only the unit files; evidence of a creds-set run is in the Jira comments (PHP 7.4.33 vs MySQL 5.6, 3/3, 35 asserts). - ~20 other
siunt_4rpreaders (labels, costing, recount) untouched by design.
5. siunta PR #90 (VDT-3204) → dev
Branch VDT-3204 → dev · head aa31edc3 · 27 files, +1521/−30. The client-facing creation channel: seven auto-4H write points collapsed onto one decision chokepoint, gated on the payer's client-card mode.
5.1 The core: inc/inc.function.php (+120/−1)
Seven new global helpers:
- getFourHandsPackageWeightThresholds() — the band table (LT 26–75, LV/EE 31–90, inclusive, per package). Doubling thresholds deliberately live elsewhere (config_2x4rp_*).
- isFourHandsEligibleCountry() / isFourHandsRequiredByPackageWeight[s]() — country gate, comma-decimal normalization, non-numeric fail-closed, hard ceiling (75.01 LT is NOT auto-4H), per-package OR (never summed, order-independent).
- isFourHandsAutoClient($payerId) — the client-card gate: memoized SELECT kl_4rp_auto FROM tbl_klientai per payer per request; (int)-cast (injection-safe); fail-closed on invalid payer AND on query failure (missing column → everyone MANUAL, no error — verified from code: sql() is a thin mysqli_query wrapper, no mysqli_report() anywhere, PHP 7.4 default = REPORT_OFF).
- getFourHandsDoublingLowLimit() — per-country 2× threshold for label wording, scalar fallback.
- isFourHandsOohReceiver() — cron-parity OOH match (VENIPAK PICKUP/LOCKER, comma-free; Pastomatas deliberately NOT matched on the 4H path, mirroring the cron).
- shouldApplyFourHandsByPackageWeights(sender, receiver, postcode, name, weights, payerId) — the single chokepoint, check order load-bearing: sender-country → OOH → weight band → payer AUTO → postcode 4rp. Payer before postcode = MANUAL clients cost zero postcode reads.
5.2 The seven write points (all route through the chokepoint)
shipment_register_resolver.php(new shipment): operates on a copy of$weights(the originals feed the pack writer), removes pallet-flagged packs first, and guards with!isset($_POST['rp4'])— if the form posted the checkbox at all, the client decided and auto never overrides.$rp4is only ever raised to'1'.shipment_updater.php(edit): byte-identical block.jquery.get_local_price.php(price preview): same precedence — the quoted price now includes 4H exactly when it will actually apply, so preview and saved shipment agree.lib/shipment/shipment.php::add()(international/API): previously hardcoded$rp4=0— this path never consulted 4H at all. Now picks the true consignee in both branches (export used to see the LT depot address — a real bug fixed here: an FI export via LT depot would have been flagged off the depot) and evaluates the persisted sender (= cron's view; an FI-origin import with LT depot as persisted sender + eligible LT consignee flags'1'— deliberate cron parity, test expectation flipped to pin it).App/Service/ShipmentImportManager.php(SIM): auto-set before the pre-existing postcode gate; per-pack weights from the model. 6–7. Both XLS import paths: inline logic collapsed onto the chokepoint with the blank-vs-explicit-0 distinction (blank cell = "no opinion" → may auto-fill; literal0= client declaration → respected), plus the notice-freeempty($postcodeInfo['4rp'])rewrite (same truth table).
5.3 Labels, UI, config
config/config.php: same per-country 2× maps as pakas, scalars re-derived from LT (80→50, 100→75) — live at merge.siunta.php+print_manif.pdf.php: "4 hands"/"2x4 hands" note now country-aware viagetFourHandsDoublingLowLimit()(+siunt_g_countryadded to the manifest query). Live at merge: LT 51–75 kg prints "2x4 hands" (was "4 hands"); LV/EE 51–60 kg prints "4 hands" (was "2x4 hands").view.php+js/shipment_add.js: by-country maps exported to JS; validators (CheckP_svoris,Check4rp_virsvoris) become country-aware (75/90 instead of flat 80) — and a previously dead else-branch in the average-weight validator becomes reachable (old code made the high-limit tier unreachable).- 12 lang strings across 6 languages rewritten static per-country (no
%dtemplates left).
5.4 Tests
FourHandsWeightTest(143 lines, no DB): 29-case band matrix on both edges per country, per-package OR semantics, OOH matcher incl. the deliberatePastomatas → false, fail-closed payer inputs, doubling-limit helper.FourHandsSenderCountryWiringTest(source-text assertions — the only way to pin procedural entry points): exact argument lists at all seven call sites, blank-only XLS condition,!isset($_POST['rp4'])guards, payer-before-postcode ordering via regex.FourHandsShipmentPersistTest(DB, temp-shadow): compute→persist contract incl. the quoted-ENUM subtlety, 24 cases, narrow-shadow safety proof.FourHandsShipmentAddWiringTest+FourHandsPackageOrderTest(DB): extract the REAL source spans ofshipment.php::add()/ the resolver between unique anchors andeval()them (guard-waivered, test-only) against temp shadows — pinning the depot-vs-consignee regression, MANUAL-payer skip with manual checkbox still working, pallet skip, later-package activation, order independence, explicitrp4=1surviving an FI sender.
5.5 What happens if merged today
Immediate: config scalars 80→50 / 100→75; label wording shift (see 5.3); country-aware JS validation (blocks above 75/90 instead of 80); new lang strings; shipment.php stops hardcoding 0 (but can't produce a 1 without the payer flag + postcode).
Dormant: all seven auto-write points need payer AUTO + posti_service1 postcode; with the column missing, everyone is MANUAL, silently (one failed memoized query per payer per request). Client-declared 4H unchanged throughout.
5.6 Reviewer flags / untouched quirks
- SIM asymmetry:
ShipmentImportManagertestsfourHands != 1(no blank-vs-0 distinction) — an explicit0there can be auto-upgraded for an AUTO payer, unlike the XLS paths. - Pallet filter coverage: resolver/updater/get_local_price filter pallets; SIM,
shipment.phpand XLS paths have no pallet concept (unrepresentable in those formats) — the pakas cron'spack_paletes_tipas='0'is the compensating control, same as go-import. - Depot-as-sender on the import branch is a conscious cron-parity choice (test-pinned).
$rp4only ever raised — the auto path cannot remove an inappropriate flag (VDT-3395 territory).- Pre-existing notices (
$_POST['rp4']unticked),getPostcodeServices()raw interpolation (no new surface — payer id int-cast), lv lang double-semicolon — all left alone by design. - DB-backed tests skip without
VENIPAK_DB_*— gate evidence: 36/36 unit + 44/44 integration (294 asserts) vs MySQL 5.6, on Jira c.111510.
6. veniship PR #743 (VDT-3205) → master ⚠️ merge = prod deploy
Branch VDT-3205 → master · head 99f5a8b (7 commits on master 5caf391) · 14 files, +1001/−7 (8 app files, 6 new test files).
6.1 Read this first: the pipeline
bitbucket-pipelines.yml: the *test step is commented out at every trigger (pull-requests, dev, uat, master) — CI runs no tests for this PR, ever. A merge to master runs *build then four parallel prod deploys (eff_master1/2, self_master1/2) with no manual gate. The PR adds no migrations, so the migrate --force calls are no-ops; the container swap is real and immediate. There is no uat hop — destination is master. The manual suite run recorded on the PR is the only test gate.
Commit shape note for reviewers: commits 1–3 built a larger design (weight auto-detect), commits 4–7 cut it back and hardened it. Review the net diff (origin/master...HEAD), not commit 1 in isolation.
6.2 The live bug this fixes
Master Rates.php hardcodes '4rp' => false in every quote request while booking sends four_hands=1 when the merchant ticks the service. Merchants are quoted a non-4H price and shipped/invoiced a 4H shipment — today, on every explicit 4H order.
6.3 File-by-file
RateResponseLayer.php(+3): one newpublic bool $fourHandsIncluded = false— provenance: "this API price already contains the 4H charge". Other couriers leave it false → byte-identical behavior.Venipak/FourHands.php(new, 60 lines): band constants (LT 26 / EE 31 / LV 31 — floors only) + three static predicates. OnlyisRequestedInRateServices()has a production caller. The weight helpers are documented dormant — the band reference for a future payer-gated auto-detect (veniship has no legacy-DB connection and cannot readkl_4rp_auto, so it cannot legally decide auto-4H; the pakas cron stays the sole auto-writer).Venipak/Rates.php(+19/−3): (a)'4rp' => false→shouldApplyFourHands($layer, $shippingService); (b) the gate — PARCEL_TERMINAL_DELIVERY → false first (mirrors booking; the quote path substitutes a canned locker address for terminal rows, and asking that synthetic locker for a 4H carry is what the courier rejects), else true iff FOUR_HANDS explicitly requested; (c)$rate->fourHandsIncluded = $request['4rp']— stamps the actual sent value, no recomputation drift; (d)getGlobalPrices()now feeds the nested global-company calculator a services-less layer clone — necessary because of this PR's own wiring: without it the merchant's COD/insurance/4H would hit a different company's pricelists and either kill every global rate row ("Service 'x' not allowed" per nested pricelist) or double-charge. Shallow clone is sufficient (only the array is reassigned).Venipak/Integration.php(+15/−1): booking gate —pickup_point_code→four_hands=0first, elseserviceEnabled(FOUR_HANDS)(identical to master for every non-locker order). Locker+4H orders currently hard-fail import with error 136; after this they book cleanly withfour_hands=0.CalculateFinalPriceTrait.php(+5/−1):calculateFinalPrice(..., array $skipServices = [])— optional trailing param, fully backward compatible. The skip sits AFTER the "Service not allowed" throw, deliberately: a merchant whose pricelist has nofour_handscharge row still cannot sell 4H (master policy, preserved verbatim); the skip only prevents the second charge when the API base already carried the first. Swapping the order would silently open 4H to unpriced merchants — a commercial policy change smuggled in; the PR explicitly does not do that.ShipmentPriceCalculator.php(+15/−1):$skipServicesreset per pricelist iteration (no leakage), set only in theuse_api_ratesbranch via the newapiPriceSkipServices()seam (flag →['four_hands']), passed through tocalculateFinalPrice;calculateBasePriceFromAPI()now forwards the merchant's services onto the outbound rate layer (without this, the quote gate never sees the 4H tick on the API-pricelist path).ShipmentPriceCalculatorSettersGetters.php::fillFromOrder()(+8/−1): skips FOUR_HANDS when the order haspickup_point_code— money matches the wire: a locker order that booksfour_hands=0is no longer priced with 4H. Third corner of the quote/book/price triangle.RatesController.php(+13/−1): the services loop replicated intoaddApiRates()(the second quote entry point — its fresh layer previously always went out service-less), with anis_array($service) || empty($service['active'])guard (near-neutral hardening; both loop copies identical).
6.4 Tests (6 files, 866 lines — revert-detection for humans; CI won't run them)
Gate test (fake wire client records every call; every assertion checks the boolean AND zero client calls — catches restoring 4rp=false, reordering the gate, reintroducing auto-detect, or dropping the provenance stamp) · booking-gate test (catches reverting to unconditional serviceEnabled) · threshold matrix (catches EE 31→26 revert; documents the gram-rounding quirk) · skip-seam test (catches removing the skip, moving the skip above the throw, or breaking the flag→skip mapping) · fillFromOrder strip test.
6.5 Day-one production behavior (merge = deploy)
Fixes of live wrongness: (1) quoted prices for explicit-4H orders go UP to the correct 4H price — the correction, but the change merchants will notice; expect support contacts; (2) locker+4H orders stop hard-failing import (136) and book cleanly without 4H; (3) no double charge on use_api_rates pricelists; (4) locker orders stop being charged 4H at order time.
Behavior changes that aren't bug fixes: terminal rows are never quoted 4H even when requested (matches booking); merchant services now reach the Venipak rates API on both quote entry points (only 4rp is consumed by veniship, but the payload changes — if the courier's engine reacts to other fields, that arrives with this deploy); global rows priced from a services-less layer (master parity — and the very thing that keeps them appearing).
Unchanged: non-Venipak couriers, table-pricelist charge arithmetic, and the "no charge row → cannot sell 4H" policy. veniship still performs ZERO automatic 4H detection.
6.6 Risks / residuals (stated openly in the PR body)
- R1 — 4H inside BASE_PRICE on API pricelists: no
FOUR_HANDSline inprice_split/service_prices(total right, itemisation absent — downstream breakdown readers won't find a row), and percentage charges/discounts now apply to the 4H-inclusive base. Needs conscious business acceptance. - R2 — preview≠charge on terminal rows (table pricelists): the /rates preview still lists the merchant's 4H charge on terminal rows (master display parity) while the order books and prices without it — preview over-states; a follow-up-ticket candidate.
- R3 — two changes are review/mutation-verified only: the
getGlobalPricesclone call site (highest-consequence change) and thecalculateList → skipServiceswire have no test pins (private methods block an in-memory harness). Manual verification of the global-rates path before merge is that path's only protection. - R4 — no rollout control: the price correction lands for all merchants on all four prod nodes at once; rollback = revert-and-merge (another full deploy).
- R5/R6: broader service payload to the courier API (untested side effects if the engine reacts to non-4rp fields); dormant weight helpers ship to prod (documented in-file; future readers must not wire them up without the payer-gate precondition).
7. go-import PR #49 (VDT-3202) → dev
Branch VDT-3202 → dev · head 7fc0030 (13 linear commits on dev tip cbc30fd; composer untouched per review 2026-08-03) · 5 files, +622/-1 — only ONE production file changes (lib/import_helper.php, +83); everything else is test harness or hygiene.
7.1 The single production change: lib/import_helper.php
New private field $fourHandsAutoPermission — per-request memoization slot for the payer lookup (untyped, PHP 7.4-safe).
Weight bands (lines 118–123):
private const FOUR_HANDS_PACKAGE_WEIGHT_THRESHOLDS = array(
'LT' => array('min' => 26.0, 'max' => 75.0),
'EE' => array('min' => 31.0, 'max' => 90.0),
'LV' => array('min' => 31.0, 'max' => 90.0),
);
Per-package inclusive windows; the map doubles as the "is this a 4H country" test.
The gated call site (validateXmlData, ~:1160):
if ($this->getFourHandsAutoPermission()) {
$validShipmentData = $this->applyFourHandsPackageWeightThreshold($validShipmentData, $isPickupReceiver);
}
Ordering inside the method (all pre-existing except the last): client four_hands validated (~:905) → validateByPostcode() silent strip (:938) → locker/pickup error-136 check (:1045-1058) → new gated auto-apply (:1160). When the payer is not AUTO, not one line of new logic executes — output byte-identical to today's dev.
applyFourHandsPackageWeightThreshold() — four sequential gates, then an additive set: early-out on pickup receiver OR already-declared 4H (additive-only: can only turn 4H on, never off, never overrides the client); sender must be LT/LV/EE; receiver postcode must offer 4rp (posti_service1=1, via the shared warm cache); then per-package band check, first match wins, sets string '1'.
getFourHandsAutoPermission() — the payer gate: memoized SELECT kl_4rp_auto FROM tbl_klientai WHERE kl_id='{(int)$payerId}' (int-cast → injection-safe, guard-annotated). Fail-closed in every branch: no payer → false; query failure — including "unknown column" on an env that hasn't run VDT-3384.sql yet — → false. The import proceeds normally, just without auto-4H, no error surfaces.
validateByPostcode() condition rewrite (:2086): !($postcodeInfo && $postcodeInfo['4rp']) → empty($postcodeInfo['4rp']) — behaviour-identical in every reachable state, removes a PHP 7.4 array-offset-on-bool notice. The strip stays silent (VDT-2773 contract, now characterization-tested).
7.2 Hygiene + tests
- composer.json / composer.lock: untouched (review 2026-08-03 — lock diff was too noisy). Suite runs via the standalone
phpunit-9.6PHAR + the existing prod autoloader; phpunit.xml schema ref points at schema.phpunit.de. Known pre-existing issue observed (follow-up ticket, NOT in this PR): the dev-tracked lock pinsgiggsey/locale 2.9.0(requires PHP ^8.1) —composer installfrom the lock is broken on the 7.4.33 runtime today; deploys work because pipelines runcomposer update. phpunit.xml+tests/bootstrap.php: the harness this repo never had; nothing in the runtime path loads them.FourHandsImportTest(28 rows, no DB): floors 25.99/26.01 · EE-is-31-not-26 regression row · 2×4H seam (50.001/60.001 stay eligible) · ceilings 75.001/90.001 → no · comma decimals, case/whitespace, non-numeric fail-closed.FourHandsImportFlowTest(DB-backed, auto-skips without creds): drives the REAL private methods + REAL postcode SQL over session-localCREATE TEMPORARY TABLEshadows (narrow schema = write-safety proof; guard-annotated). Pins: postcode gate flip, pickup early-out, explicit-4H survives above band, non-Baltic consignee AND sender blocks (PL + FI, isolated), per-package not summed, ceilings end-to-end, the silent-strip characterization, warm-cache retry idempotency, and the payer matrix (AUTO→true, MANUAL→false, unknown payer→false, missing payer→false) over atbl_klientaitemp shadow.
7.3 What happens if merged today
Immediate: one extra memoized SELECT per import request; the notice-free strip rewrite. Dormant: on envs without the VDT-3384 column the whole feature is silently OFF (fail-closed — merging out of order is safe, not broken); with the column, everyone defaults MANUAL; even AUTO payers need posti_service1=1 postcodes. Unchanged in all cases: client-declared 4H (honored / silently stripped / error 136 to lockers) — no error code, message, or response shape changes.
7.4 Reviewer flags / deliberately untouched
- Pre-existing ordering quirk: declared 4H to a locker at a non-4rp postcode is stripped at :938 before the 136 check → never errors, ships without 4H. Not created or fixed here.
isLocker()/isPickup()are name-string heuristics that diverge from the pakas cron's matcher — cross-channel OOH classification can differ (flagged, not changed; ties to ask P11).- Pallets deliberately absent: creation-time pallet state is unrepresentable in go-import (no XML field;
createPackages()never writespack_paletes_tipas) — the pakas cron is the compensating control. - Raw-SQL idiom retained (consistent with the file; injection closed by the int cast).
- Integration suite skips silently without
VENIPAK_DB_*— the DB-backed run evidence is on Jira (57/64 green, PHP 7.4.33 vs MySQL 5.6).
8. venipak-helper PR #43 (VDT-3203) → master — review this one first
Branch VDT-3203 → master · head e868978, cut from the 0.8.2 tag · 3 files, +60/−4 — src diff vs 0.8.2 is exactly two files (src/SS1/Pricing/Price.php +14/−2, src/Microservices/Label/DirectionProvider.php +14/−2) plus 7 new DirectionProviderTest cases. Suite: 170 tests green on PHP 7.4.33 (the one Redis cache test is environment-dependent and excluded — it needs the repo's compose Redis).
8.1 What it is
The published helper — 0.8.2 and 0.9.0 — reads only the legacy scalar $config_2x4rp_low_limit (historically 80) when deciding 2×4H, in two places: the price doubling in Price.php:699 and the parcel-label "2x4 hands"/"4 hands" direction in DirectionProvider.php:53. The ratified rule is per-country: double above LT 50 / LV·EE 60. #394 and #90 ship the country maps in app config, but until the library reads them, LV/EE shipments in the 51–60 kg window would be doubled (price) and mislabeled. This PR is the library half of that fix.
8.2 The change (identical pattern in both files)
Each site now also imports $config_2x4rp_low_limit_by_country, uppercases/trims the shipment's siunt_g_country, and uses the country's low limit iff the map exists, is an array, and has that key — otherwise it falls back to the legacy scalar. The doubling/label comparison itself (> $lowLimit) is untouched. Consequences of the guarded fallback:
- An app that deploys helper 0.8.3 without the #394/#90 config maps behaves byte-identically to today (scalar). No consumer can be broken by the tag alone.
- An app that has the maps but a row without a country (or an unknown country) also falls back to the scalar — fail-safe to current behavior, never to a wrong country's limit.
8.3 Tests
7 new data-provider cases on the label direction: LT 55 kg → "2x4 hands"; LV 55 → "4 hands"; EE 60 → "4 hands" / 61 → "2x4 hands" (boundary); lowercase lt normalized; and two no-country rows proving the scalar fallback (55 → "4 hands", 85 → "2x4 hands" against the stub scalar 80).
8.4 What happens if merged today
Nothing, anywhere — consumers pin tags, and merging to master cuts no tag. What it unblocks is the release chain: tag 0.8.3 off this branch (release gate: git diff 0.8.2..0.8.3 = exactly the two src files) → cherry-pick the patch onto the 0.9.x line → tag 0.9.1 (0.9.0 = 0.8.2 + SmsService, which siunta uses, so siunta can never re-pin down to 0.8.x) → pakas pins ^0.8.3 (a small #394 follow-up commit). The pakas pin is a promotion gate; the 0.9.1 tag is a merge gate on siunta #90.
8.5 Reviewer flags
- Config arrives via
global— the repo's house style for this file pair, not new debt. - The by-country map itself deliberately lives in app config (#394/#90), not in the helper — the helper stays data-driven and the countries/limits stay app-owned.
- Other consumers are unaffected by construction: go-ws tracks a lock at
^0.8.2(composer install— moves only on a lock bump), gocourier locks helper 0.3.16 (out of tag reach), vpcourier-api^0.8.0with no lock auto-upgrades on its next pipeline run → smoke its charge-works path after 0.8.3 exists.
9. pakas PR #516 (VDT-3343) → dev — parked, not on the 4H path
Branch VDT-3343 → dev · head 8bcfde19 · 13 commits, 14 new test files, +2668/−0 — zero production files. Do not merge it in this wave; it needs a refresh first (below). It is in this briefing so approvers know what it is and why it is deliberately not moving.
9.1 What it is
The HF/LW test groundwork from the research phase (Waves 1–3): characterization tests that pin today's behavior (charge_works write/read semantics, entity-clone (array) cast, split/late-package derivation, pricing-component structure, recount-vs-engine divergence) plus pre-ratification executable specs for the future HF/LW build (HF applicability oracle, provenance state machine, the T-NUR guard that HF must never render under Nurodymai, DPDT forward guards, return-exclusion fence, HF×LW combinatorial legality, LW ordered-eligibility). Tests only — it changes no runtime behavior and shares no files with #394 (whose test additions are all new files too), so merge order between them can never conflict textually.
9.2 Why it is parked
- It is not needed for 4H. Nothing in Phase 1 depends on it; merging it buys nothing before the HF/LW build resumes.
- It has gone stale under itself (now ~48 commits behind dev) and needs a known refresh (tournament review R6, champ-B step 29) before merge:
SplitLatePackageDerivationCharacterizationTest.php:191invokes a private method whose signature grew to 7 parameters on dev (VDT-3360) — a silent TypeError: no textual conflict, and pakas CI runs no phpunit, so an unrefreshed merge lands green and the suite breaks at the next local gate run.- Two spec files still encode the superseded 350 kg LW boundary (eight call sites) — the programme has since unified HF/LW on 250 kg; as-is the oracle certifies a 100 kg band of wrongly-billable LW.
- The LW-ordered spec docblocks still describe the pre-ADR "extend charge_works" design; the binding ADR is
siunt_lw_order+ a new InnoDB attempt table. - Smaller items: pricing-structure test must accept the 2×4H rows that enter its window post-flip; PR body still says ">350 kg" and lists OD-4/8/27 as open (all answered since).
- Sequencing: refresh + merge belongs to the HF/LW resume gate (after LT 4H is stable), and it is explicitly never a flip gate.
9.3 If an approver wants to act on it now
The only sensible action is the refresh commit (merge dev in, fix the 7-param call, 350→250, relabel docblocks, re-run the suite on PHP 7.4.33 + veni-guard, rewrite the PR body). Approving/merging it unrefreshed is the one wrong move — it would break the local suite silently.
10. Cross-PR consistency (what reviewers should check against each other)
| Rule | pakas #394 (cron) | siunta #90 | go-import #49 | veniship #743 | helper #43 | pakas #516 |
|---|---|---|---|---|---|---|
| Bands (per package, never summed) | LT 26–75 / LV·EE 31–90, SQL fragment generated from the same table the PHP path uses | identical table in inc.function.php, hard ceiling enforced |
identical const in import_helper.php |
floors only (26/31/31) in dormant helpers — runtime is explicit-request-only | n/a — doubling only, no eligibility logic | n/a — HF/LW tests only |
Payer gate (kl_4rp_auto) |
INNER JOIN … kl_4rp_auto = 1 |
memoized isFourHandsAutoClient(), payer-before-postcode |
memoized getFourHandsAutoPermission(), fail-closed |
not readable → auto-detect removed; cron is the writer for veniship traffic | n/a | n/a |
| Missing-column behavior | UPDATE errors → logged, flags nothing (fail-closed) | everyone MANUAL, silent, memoized | everyone MANUAL, silent, memoized | n/a | n/a | n/a |
| Client-declared 4H | untouched (cron is additive) | never overridden (!isset($_POST['rp4']); XLS blank-vs-0) |
additive-only (!empty(four_hands) early-out) |
explicit request honored (the whole point) | n/a | n/a |
| OOH exclusion | %VENIPAK PICKUP/LOCKER% name match in SQL |
isFourHandsOohReceiver() — same two names, comma-free, no pastomatas aliases (cron parity) |
pre-existing isLocker()/isPickup() heuristics (divergent — flagged, ask P11) |
terminal-service / pickup_point gates, first in both trees | n/a | n/a |
| Pallets | pack_paletes_tipas='0' on the JOIN — the compensating control |
filtered where representable (resolver/updater/price); SIM/XLS/API can't represent pallets | unrepresentable at creation — no code by construction | pallet passes through to the courier probe only | n/a | n/a |
| Monotonicity | set-only, test-locked; clearing = VDT-3395 | $rp4 only raised |
additive-only | n/a | n/a | n/a |
| 2×4H config | per-country maps + scalar 80→50 (live at merge) | same maps, same scalar shift, labels + JS country-aware | n/a | n/a (pricing upstream) | the library half: per-country low limit in Price.php + DirectionProvider.php, guarded scalar fallback |
n/a |
Known, accepted divergences to mention proactively: go-import's OOH matcher vs the cron's (P11, with Product); siunta SIM's != 1 vs XLS blank-distinction; the LV/EE >50 doubling window between merge and the helper 0.8.3 pin (why #43 and its tags are a promotion gate).
11. Anticipated approver questions
"If I approve and merge, does anyone get billed 4H?" Not from the auto paths — postcodes are closed and everyone defaults MANUAL. Two real day-one effects to own: the 2×4H threshold shift for already-flagged shipments (pakas/siunta scalar 80→50; correct for LT, temporarily wrong direction for LV/EE until helper 0.8.3 — LV/EE aren't Phase-1-enabled, and the helper pin is a promotion gate), and veniship's price-quote correction (merge=prod there; the higher quotes are the fix of a live under-quote).
"Why does veniship not have the payer gate the other two channels have?" It physically can't — no legacy-DB connection, no API that carries kl_4rp_auto. Options were: ship auto-detect ungated (a client-blind writer that silently self-activates at the LT flip — the worst sequencing possible), invent a veniship-side duplicate flag (second source of truth, product decision pending), or ship explicit-only and let the cron cover AUTO payers. We shipped explicit-only. Cost: AUTO payers quoting through veniship see creation-time prices without 4H until a payer-flag mechanism exists (recorded product ask); the cron flags and bills them correctly next run.
"Who can turn a client AUTO?" Only users with kl_edit_plius (Sales) — Product ruled it a commercial field on 2026-08-03; the ACL gate is the latest #394 commit. Everyone can see the mode on the client card (deliberate).
"What clears the flag if a package is re-weighed lighter?" Nothing, today — all four PRs are set-only/additive by design. Product ruled (2026-08-03) that re-weigh re-evaluation is Phase-1 scope, so VDT-3395 now carries that mechanism and joins the flip gate. It does not block these merges; it blocks the flip.
"What if the VDT-3384.sql column isn't on an environment yet?" Everything fails closed, verified by execution: pakas cron errors-and-logs, flags nothing; siunta and go-import silently treat every payer as MANUAL. Deploy ordering (SQL before/with code) matters for correctness of the feature, not for safety.
"Can we merge these in any order?" Helper #43 first is strictly best — it is inert on merge (consumers pin tags) and everything else waits on its tags. The dev-targeted PRs (#394/#90/#49): yes, any order — each is independently dormant (except #90 must wait for the 0.9.1 tag). #743: independent of the others but is a prod deploy in its own right; it needs its own release decision and the manual-run evidence stands in for CI (which runs no tests in that repo). #516: do not merge until refreshed (§9).
"What's still open before the LT flip?" Helper 0.8.3 tag + pakas pin → promotion (SQL-before-code per env) → VDT-3395 → tariff reconciliation (Evelina: 14/28 €; programme record: 6/12 €; kain_4rp default: 50 — Finance must pick one) → AUTO seed list (legacy four vs Confluence six — Sales) → prod probes + comms → wave-by-zone flip with per-wave monitors and a pre-staged reverse UPDATE.