Nachtragshaushaltsplan (NHHP): change-set overlay, apply/revert, editor & view - #329
Nachtragshaushaltsplan (NHHP): change-set overlay, apply/revert, editor & view#329lukas-staab wants to merge 37 commits into
Conversation
Prepares for the Nachtragshaushaltsplan feature, where a supplementary plan is "applied" when it transitions into the live-effective state. "Active" names that state more clearly than "Published" going forward. Includes a data migration for existing `state='published'` rows and updates the demo data dump, factory, converter and tests accordingly. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lays the groundwork for supplementary budget plans (amendments): a new budget_item_change table stores per-item deltas (modify/add/delete) against an amendment plan instead of copying items, plus two new budget_plan columns (effective_date, justification). The legacy haushaltsplan/haushaltsgruppen/haushaltstitel views are recreated so an amendment never surfaces as its own plan and its unapplied additions/ deletions don't leak into the parent plan's legacy rows. BudgetPlan gains parentPlan()/amendments()/itemChanges() relations, isAmendment(), an original() scope, and hasAppliedAmendments()/ appliedAmendments(). Existing call sites that enumerate "plans" are audited to exclude amendments: organizationTaken(), newest(), the plan index (amendments now render nested under their parent with a "Nachtrag" badge), the plan-edit mount picker, and plan-create's clone source list. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate machine App\Support\Budget\AmendmentApplier implements the Approved<->Active apply/revert semantics from OP#581: modify writes verify the drafted `from` value against the live item first (Money compared in integer cents), add re-homes the new item onto/off the parent plan, delete parks/un-parks it onto/off the amendment plan — never a hard delete. Any stale item aborts the whole operation via a new AmendmentConflictException, listing every conflicting item/field. Two custom spatie/laravel-model-states transitions (ApplyAmendmentTransition, RevertAmendmentTransition) wrap the Approved->Active and Active->Approved arcs of BudgetPlanState in a DB transaction that runs apply()/revert() before the state write itself, so a conflict leaves the plan's state untouched. Both are no-ops for a non-amendment plan. plan-view's state-change modal now surfaces AmendmentConflictException as an error toast instead of crashing. Also: BudgetPlan defaults an amendment's effective_date to its approval_date the first time it reaches Approved (still editable). Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New `stufis:apply-due-amendments` command finds Approved amendments whose effective_date has arrived (and whose parent plan is Active) and transitions each to Active via the state machine, so a Nachtrag goes live on its own on the intended day rather than needing someone to click "aktivieren". Amendments are processed independently — one conflict doesn't block the rest — and the command exits non-zero (with a log entry) when any amendment failed, so a failed run stays visible and is safely re-triggerable. Scheduled daily at 04:00 in routes/console.php. plan-view now shows a warning callout on an amendment stuck in Approved past its effective_date (e.g. because its parent plan wasn't Active at the scheduled time). Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d editor
plan-view gets a "Nachtrag erstellen" button (Flux, disabled + tooltip
when the plan isn't Active or the user lacks budget-officer rights)
that drafts a new amendment (Draft, same organization/fiscal_year_id,
parent_plan_id set) and jumps into its editor; parallel drafts are
allowed, and any open (not yet/no longer applied) amendments are
listed with links.
New ⚡amendment-edit Livewire MFC (route
plan/{plan_id}/amendment/{amendment_id}/edit) renders the parent
plan's item tree merged with this amendment's overlay: modified base
items show the drafted value (highlighted) while the live row stays
untouched, additions are highlighted, deletions are struck through
with an undo action. Every edit against a base item is written as a
`modify` BudgetItemChange row (dropped again once undone back to the
base value); only items the amendment itself created are written to
directly. Add/delete/reorder all follow the same rule. A second
"Begründungen" tab lists the drafted changes with a reason field each,
plus one justification field for the whole amendment. The editor is
only reachable while the amendment is in Draft; plan-edit now redirects
away from an amendment (it only edits original plans).
is_group/mount conversion stays disabled with a tooltip in the
amendment editor (out of scope for v1, per the architecture doc); copy/
copy-inverse are omitted there entirely rather than duplicated.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… as a TODO
An amendment's own plan-view now shows its justification, approval/
effective date and a diff list (changed items only, per field from ->
to, with reasons) instead of the plain item tree, which stays the
editor's job. Unaffected items are omitted per the WP.
History ("Stand zum Datum X" — reconstructing a parent plan's tree as
of before a given applied amendment) is deliberately NOT implemented
in this pass: correctly reconstructing it requires pulling in items
parked outside the live tree by later deletions and reversing a chain
of amendments in the right order, which risked more subtle bugs than
the remaining budget could safely absorb. Left as an explicit TODO —
see the final report.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he amendment editor
Livewire's updated{Property} hook passes only the path AFTER the first dot, so for the
top-level array property "reasonInputs.{change_id}" the $property argument received by
updatedReasonInputs() is already just "{change_id}" — mirroring how updatedItems() handles
"items.{id}.field". The code wrongly assumed the "reasonInputs." prefix was still present and
tried to explode() a string with no dot in it, throwing "Undefined array key 1" whenever a
per-change reason was edited. Found while writing the amendment-edit test suite (OP#581).
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment model basics ActiveStateTest locks in the BudgetPlanState linear workflow (Draft<->Resolved<->Approved <->Active<->Completed) after the Published->Active rename, the 'active' column round-trip, the 2026_08_01_..._rename_budget_plan_published_state_to_active data migration, and the legacy haushaltsplan view's final/draft mapping. AmendmentModelTest covers BudgetPlan's Nachtragshaushaltsplan relation basics: isAmendment()/ parentPlan()/amendments(), the original() scope keeping amendments out of organizationTaken(), the mount picker, BudgetPlan::newest() and the plan index. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AmendmentEditTest exercises the ⚡amendment-edit component: editing a base item's value/name records/updates a modify budget_item_change row without touching the live item, reverting a field to its base value prunes it (and the whole row once empty), multiple fields on one item accumulate into a single row, adding a budget line creates a real item under the amendment plus an add row, deleting a base item without bookings parks a delete row (refused once booked), deleting an amendment-added item removes it outright, reordering records position in `changes` only, reasons/justification persist, the editor is Draft-only and 404s on a plan/amendment mismatch, and "Nachtrag erstellen" is disabled (not hidden) off an Active plan. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AmendmentApplyTest is the core suite for AmendmentApplier::apply() (Approved->Active): a mixed modify/add/delete batch writes in place, re-homes additions to the parent and parks deletions under the amendment, all under the same item ids; bookings on modified items survive; a stale modify (live value drifted since drafting) and a since-booked scheduled deletion each abort the WHOLE batch atomically (no partial writes, state stays Approved); applying against a non-Active parent is guarded; an integer-cents `from` compares semantically against the live Money value; two parallel amendments on the same item apply-then-conflict, on different items both apply; and a normal (non-amendment) plan's Approved->Active transition runs no applier at all. AmendmentRevertTest covers the inverse (Active->Approved): a full apply+revert round-trip restores the exact pre-apply state field-by-field, a post-apply edit aborts the revert atomically (state stays Active), re-applying after a revert is idempotent, and bookings on modified items stay intact throughout. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mand Covers activating an Approved amendment whose effective_date has arrived under an Active parent, leaving future-dated or still-Draft amendments untouched, a conflicting due amendment reporting failure (non-zero exit) while the run still processes other due amendments, the effective_date auto-prefill from approval_date on reaching Approved (and preservation of an explicitly pre-set date), and the command's daily registration in routes/console.php. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the plan-view diff
Extends LegacyBudgetViewsTest with the Nachtragshaushaltsplan-awareness added to haushaltsplan/
haushaltsgruppen/haushaltstitel: a draft amendment never appears as its own plan (parent stays
visible exactly once), amendment-owned items (drafted additions, parked deletions) are hidden
from the title/group views, an applied amendment's modified values and applied additions show up
while parked deletions stay hidden, and the root-leaf phantom-group logic is unaffected by an
unrelated amendment existing alongside.
AmendmentDiffViewTest covers the amendment's own plan-view: it renders only the changed items
(from -> to, with the recorded reason and action badges) rather than the full merged tree, and
falls back to a placeholder when nothing has been changed yet. History ("Stand zum Datum X") was
left as a TODO by the implementation, so that half of test-plan area H is intentionally not
covered.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt's own $changes property
B1: editing a base item's value in the amendment editor showed the new value correctly in the
"Begründungen" tab but snapped back to the old value in the titles table. Root cause was not the
suspected unit mismatch on the money-synth wire payload (verified empirically: MoneySynth already
hydrates the browser's raw input into a real Money instance before updatedItems() sees it) but a
naming collision — BudgetItemChange's `changes` JSON column shares its name with Eloquent's own
internal `HasAttributes::$changes` dirty-tracking property. Accessed from OUTSIDE the model (blade,
AmendmentApplier) that's fine, since `protected` access always goes through the magic accessor from
another class. Accessed from INSIDE the model, as fieldChange()/isEmpty() did via `$this->changes`,
PHP resolves the real declared property directly (Eloquent's own bookkeeping array, typically
empty) instead of invoking `__get()` — silently discarding the overlay's `to` value.
Fixed fieldChange()/isEmpty() to read via getAttribute('changes'), which forces the proper
cast/accessor path regardless of call-site scope. Also hardened ⚡amendment-edit's normalize() to
reuse DefaultMoneyFormater::inverse() for any non-Money 'value' input instead of a hand-rolled
(int) cast, covering plain integers, decimal-comma, thousands-separator, and €-suffixed strings.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dom finds the right one B2: deleting an unbooked base item in the Nachtrag editor appeared to do nothing. Root cause: the recursive <x-budgetplan.item-group-amendment :wire:key="..."> call passed a key at the component invocation, but the component's own root <div> never echoes $attributes, so Blade silently drops it — every row rendered with NO wire:key at all (confirmed empirically: the rendered HTML had zero wire:key occurrences for any budget-item row, only Flux's own tab keys). Without a stable per-row key, Livewire's morphdom has no reliable identity for each row, so the isDeleted-driven swap between the dropdown-menu and the undo-button — a structurally very different subtree — could get morph-patched onto the wrong sibling row instead of the row that was actually deleted, even though the server-side action + change row were always recorded correctly for the right item. Set wire:key explicitly on the component's own root element instead of relying on attribute forwarding, and drop the now-redundant (and never-effective) :wire:key props at both call sites. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fective dates from its view B3: approval_date and effective_date had no editing affordance anywhere. Amendments have no metadata editor of their own — ⚡plan-edit is exclusively for original plans and redirects amendments away — so plan-view only ever displayed the two dates as static text, with nothing writing to them except the model's existing Approved-with-null-effective_date prefill. plan-view now doubles as that editor for its amendment case: authorized users (the update policy — budget officers) get live date inputs while the amendment is Draft, Resolved, or Approved; once Active (or Completed, or for a user without update rights) the fields freeze back to the existing read-only <dd> display, since the apply/revert engine has already consumed them by then. The existing Approved-prefill behavior in BudgetPlan::booted() is untouched. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ble in the amendment editor F2: the parent plan owns the numbering scheme, so letting an amendment silently renumber a base item would drift out of sync the moment the amendment applies (or is abandoned mid-draft). The Titelnummer input now renders readonly for base items (still editable for items the amendment itself added), setField() refuses to record a short_name modify change for base items server-side (defense in depth for direct component calls that bypass the UI), and AmendmentApplier's verify/write paths now skip a short_name entry even if an older change row somehow still carried one. The now-dead short_name overlay branch in loadItems() (a change row can never carry it anymore) is removed. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent titles table F1: the row-level amber tint told you SOMETHING changed on a row, but not which field. The name and value inputs now get an amber ring when that specific field carries a modify change (per-field, via BudgetItemChange::fieldChange()), plus a native title tooltip showing the old value — a value edit no longer also rings an untouched name field on the same row, and vice versa. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F3: an amendment has no organization of its own (it inherits its parent's) and previously had no
name either, so it could only ever be called "the Nachtrag" wherever it's listed. New nullable
`budget_plan.name` column (separate migration — the already-committed NHHP migration is untouched)
plus an input in the amendment editor's Begründungen tab.
BudgetPlan::label() — already the single method called from every place a plan is displayed
(index sub-row, plan-view headline, breadcrumbs) — now branches for amendments: `name` if set,
else "Nachtrag vom {created_at}". This keeps the fallback in one place instead of scattering it
across views; the parent plan-view's open-amendments list (previously showing only the state, not
the amendment's own label) picks it up too. Normal plans are unaffected — they still use
organization.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he amendment editor F4: the amendment editor had no way back except the browser button, and its breadcrumb trail skipped straight from the plan index to "Nachtrag bearbeiten" — the amendment's own view was never a breadcrumb step of its own. Added an explicit back-to-view flux:button (mirrors edit-project's back-button pattern) to the editor's headline row. In routes/breadcrumbs.php, budget-plan.view now nests an amendment's crumb under its PARENT plan's view instead of the plan index directly (Home > Budget-Plans > Parent > Nachtrag ...), and budget-plan.amendment.edit now nests under the amendment's OWN view crumb instead of the parent's — so "back one level" via breadcrumbs matches the in-page back button. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ta and label the diff reason F5: neither the editor nor the amendment's view surfaced how much income/expense a Nachtrag adds or removes in sum. New App\Support\Budget\AmendmentDeltaSummary (wired through BudgetPlan::amendmentDeltaSummary()) sums, per budget type over the change rows: modify as `to - from`, add as the added leaf's current value, delete as its negated value — LEAF items only, since a group's value is always derived (the live sum of its children); counting it too would double-count every leaf underneath it (e.g. addGroup() records an `add` change for both the new group and its first leaf child). Rendered via a new x-budgetplan.amendment-delta-summary component in both the editor's Begründungen tab and the amendment's plan-view diff section. F6: the per-change reason text in the amendment's plan-view diff had no label at all — prefixed with "Begründung:" now. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions dropdown F7: it previously sat as its own header button (outline, next to the primary "Aktionen" dropdown). Moved into that dropdown as its first item (with a separator before the rest), keeping the existing disabled-with-tooltip behavior when the plan isn't Active or the user lacks permission — the same flux:tooltip+disabled flux:menu.item pattern already used elsewhere in this same menu. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… onward F8: ⚡plan-edit had no state guard at all — a normal plan stayed editable through every state, including Active/Completed, both via the UI and via direct route access. New BudgetPlan::isEditable() (Draft/Resolved only) backs both: plan-view's "Bearbeiten" menu item now renders disabled with a tooltip explaining the state once Approved or later (same flux:tooltip+disabled flux:menu.item pattern already used elsewhere in this menu), and ⚡plan-edit's mount() redirects to the read-only view for the same states — mirroring how the amendment editor already redirects once an amendment leaves Draft. The amendment editor's own guard (Draft only) is intentionally stricter and untouched. Adjusted AmendmentModelTest's "does not offer amendments as mount targets" case, which opened its own plan-edit instance on an originalPlan() (Active by default) purely as a vehicle to exercise the mount picker — now created Draft so it isn't itself blocked by the new guard. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aluation can parse them
Clicking "Löschen" in a budget item's ellipsis dropdown did nothing at all — no
request, no error, nothing in the network tab. Same defect in ⚡plan-edit and in
⚡show-project's delete-modal confirm button.
Root cause is the CSP-safe Livewire bundle, not the menu, the wire:key, wire:sort
or Flux's popover. resources/js/app.js imports `livewire.csp.esm`, which cannot use
`new Function`, so instead of compiling `wire:click="delete(1)"` it rewrites the
expression to `$wire.delete(1)` (js/evaluator.js `contextualizeExpression`) and
parses it with Alpine's hand-written CSP tokenizer. That tokenizer's
`readIdentifierOrKeyword` classifies `delete` as a KEYWORD regardless of position —
including after a dot — and `parseMember()` then does `consume("IDENTIFIER")` on the
property name, throwing `Expected IDENTIFIER but got KEYWORD "delete"`. Livewire
catches that in `evaluateActionExpression()` and only `console.warn`s it, so the
button is completely inert.
`delete` was the only action name in the app hitting the tokenizer's keyword list
(`true false null undefined new typeof void delete in instanceof`), which is why
every neighbouring item in the same dropdown — sort, addBudget, convertToBudget,
copyItem — worked fine.
So rename the actions instead of working around it in the view:
`delete()` -> `deleteItem()` on ⚡plan-edit and ⚡amendment-edit, `delete()` ->
`deleteProject()` on ⚡show-project. This also matches ⚡plan-view's existing
`deletePlan()`.
Adds a guard test that scans every blade for a `wire:` action bound to a
JS-keyword-named method, so the next one fails the suite instead of shipping as a
dead button.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…of window.confirm `wire:confirm` pops the browser's native window.confirm(), which was the last unstyled, non-Flux dialog in the app. Replace it with a flux:modal, mirroring the state-modal right above it and ⚡show-project's delete-modal. Refs: OP#581 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…top shadowing Eloquent internals
Eloquent's HasAttributes trait declares its own $changes dirty-tracking
property, which the changes column silently shadowed for any read from
inside the model (magic __get() only fires for external access) — this
already caused one production-grade bug, worked around with
getAttribute('changes'). Renaming the column removes the trap instead of
routing around it; the workaround is dropped now that $this->diff is safe.
Refs: OP#581
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tems budgetItemsTree()'s $constraint only seeds the recursive CTE's roots; the step that walks parent_id had no plan filter, so any item homed to an amendment but parented under a base-plan group surfaced inside the base plan's tree. Since BudgetPlanMeasures renders the plan view off this query and a group's value is the live sum of its children, a *draft* Nachtrag's added titles were already visible in the running plan and inflated its group totals -- breaking the rule that an amendment must not touch the running plan before it is applied. It also left a reverted amendment's added titles behind, which is how testing first hit this. withRecursiveQueryConstraint() applies the filter to every recursive step, so an excluded item's descendants never find a CTE row to join against either -- no post-filtering, no orphan promotion. The legacy SQL views already behaved this way; this brings the modern path in line. Refs: OP#581
The title detail view gave no hint that a Nachtrag touches the title, so its plan value could shift with no visible cause. It now lists every BudgetItemChange pointing at the item, each with its action badge, the amendment's label and state, a link to it, and the per-position reason. Parallel amendments are allowed (OP#581) and the change table's unique key is (budget_plan_id, budget_item_id), so one title can be touched by several amendments in different states at once -- the hint renders all of them rather than just the first. Reuses the existing action-label and state-badge keys instead of introducing a second vocabulary for them. Refs: OP#587
…te window, Nachtrag lockstep)
Implements points 4, 5 and 8 of OP#589; points 1, 2, 6 and 7 stay open.
F4: a Nachtrag may now be drafted against an Approved plan as well as an
Active one. Approved is already an agreed-upon document, so there was no
reason to make people wait for activation. The view flag and
createAmendment()'s abort_unless() are widened together so the button
cannot promise what the guard refuses.
F5: deletePlan() checked only the admin permission and no state at all, so
an approved or active plan could be wiped along with every one of its
items. It now also requires isEditable() (Draft/Resolved), enforced
server-side, and delete-plan-modal grew the condition checklist from
⚡show-project's delete-modal: a row per requirement with check/cross icons
and Confirm disabled until both hold.
F8: an amendment's Active <-> Completed arcs now move only in lockstep
with the plan it was applied to. Completing an original plan carries its
Active amendments to Completed, reactivating brings its Completed ones
back, parent and children in one DB transaction. The individual move is
refused in BudgetPlanPolicy, which the state-modal already reflects since
it disables each option via cannot('transition-to'). The cascade writes
children through the state machine, which never consults the Gate, so it
does not trip that guard. Approved <-> Active (apply/revert) stays
individually available.
Amendments still in Draft/Resolved/Approved were never applied and are
deliberately left untouched when the parent completes -- what should
happen to them is still an open question on OP#589.
Refs: OP#589
…the workflow changeState() checked only that the target state was syntactically reachable and that the user was allowed to move the plan; nothing looked at the plan's own data. A plan could reach Approved -- or an amendment could be applied onto its parent's live items -- carrying a duplicate Titelnummer, a nameless title or a negative value, with the collision only surfacing afterwards. Ports the pattern Project already uses: each BudgetPlanState describes the item rules that must hold to legitimately be in it, and the caller validates the TARGET state's rules before authorizing the transition. Uniqueness is scoped to the amendment plus its base plan, not the single plan row: an amendment's `add` rows live under its own budget_plan_id until apply re-homes them, so a per-plan check would let it introduce a Titelnummer the base plan already uses. A plain `budget_plan_id IN (...)` query covers every item exactly once -- `modify`/`delete` rows point at the live base row rather than a copy, so also walking itemChanges() would flag a base item as a duplicate of itself. Only forward steps are gated, via the new explicit BudgetPlanState::order() chain and advancesTo(). Moving backward only demotes data away from "official", so it must stay possible regardless of a violation it cannot fix -- otherwise legacy data would make an applied amendment un-revertible, which OP#581 rules out, and would strand a Completed plan. Refs: OP#584
Beschluss-, Genehmigungs- and Wirksam-ab-Datum were editable free-standing: the first two as plain inputs in ⚡plan-edit, the amendment's two directly on its view. Both were detached from the transition that gives each date its meaning, so a plan could sit in Genehmigt with no Genehmigungsdatum, or carry one while still a draft. They are now offered as optional fields inside the state-change dialog, per target state: resolution_date entering Resolved, approval_date entering Approved (plus effective_date there for an amendment), and effective_date entering Active if an amendment still lacks one. Never more than one group at a time, and none on a backward step -- reusing advancesTo() from OP#584. A blank field never blocks the transition. The date is set on the same plan instance the transition then saves, so state and date persist in one write and a supplied date cannot be dropped from a completed transition. The modal reacts to the chosen target state through wire:model.live on the existing select, so no JS is involved and the enforced CSP is unaffected. Both dates stay visible: the amendment's pair keeps its read-only list, and an ordinary plan's two now show in the view header, which is where they became legible once ⚡plan-edit stopped carrying them. effective_date remains amendment-only. BudgetPlan::booted()'s effective_date-from-approval_date prefill and the stufis:apply-due-amendments trigger are untouched and re-covered through the new path. Refs: OP#588
…and date capture Also widens the Nachtrag entry to "genehmigt oder aktiv" and notes the DATEV export button, which shipped in 08f39dd without a changelog line. Refs: OP#581
Both were failing on this branch before today's work. BudgetPlanConverter mapped a legacy plan's final/approved state to Active::class without importing it, so it resolved against the file's own namespace as App\Support\Budget\Active. PHPStan flagged the missing class; at runtime the legacy conversion would have written a nonexistent class string as the plan's state. Introduced by the Published -> Active rename in 067b535, which swapped the name but not the import. The translations check reported three empty values: a dead '' => '' entry left at the end of de.budget-plan since the file was created, konto.csv-draganddrop-light-text which nothing renders (the drag-and-drop area uses the fat/sub variants), and konto.hint.transaction.comment, which held a single space where its sibling column hints all carry example text. Refs: OP#581
… testing database The test read back the expected plan id with LegacyBudgetPlan::latest(), which queries the legacy `haushaltsplan` view. That view INNER JOINs fiscal_year, while the component forwards BudgetPlan::newest() straight off budget_plan with no join. A plan without a fiscal year is therefore invisible to the view but still the component's newest, so the two ids drift apart as soon as any such plan is left behind -- the persistent testing database had six of them, and the test only passed right after a migrate:fresh. It now creates the plan it expects, so the assertion no longer depends on what earlier tests left in the database. Verified by running the suite twice back-to-back on an already-drifted database without refreshing in between. Refs: OP#581
| { | ||
| $due = BudgetPlan::query() | ||
| ->whereNotNull('parent_plan_id') | ||
| ->where('state', Approved::$name) |
There was a problem hiding this comment.
there should be a whereState() from spatie with a cleaner signature
| public function handle(): int | ||
| { | ||
| $due = BudgetPlan::query() | ||
| ->whereNotNull('parent_plan_id') |
There was a problem hiding this comment.
add a more fluent selector to the model
| } catch (AmendmentConflictException|CouldNotPerformTransition $e) { | ||
| $failed++; | ||
| $this->error("Amendment #{$amendment->id} could not be activated: {$e->getMessage()}"); | ||
| Log::warning('stufis:apply-due-amendments failed for amendment', [ |
There was a problem hiding this comment.
back to OP WP: how should a failed apply be notified. To whom, and when, and how often?
| } catch (Throwable $e) { | ||
| $failed++; | ||
| $this->error("Amendment #{$amendment->id} could not be activated: {$e->getMessage()}"); | ||
| Log::error('stufis:apply-due-amendments unexpected failure for amendment', [ |
| * always the live item (see BudgetItemChange's class doc: nothing is copied, budget_item_id | ||
| * points straight at this row); for add it only resolves here once AmendmentApplier has | ||
| * rehomed the new item onto this row's plan. | ||
| */ |
There was a problem hiding this comment.
make this comment more relevant for readers in the future. It explains very verbose why its HasMany, it explains the design very complicated
| * Einheitliche Abmeldung (Single Logout): Ab- und Anmeldung sind nun zwischen StuFis und dem zentralen Login-Dienst (StuMV) gekoppelt. Meldest du dich beim zentralen Dienst ab, wirst du automatisch auch aus StuFis abgemeldet – und meldest du dich in StuFis ab, wirst du auch aus dem zentralen Login-Dienst abgemeldet. | ||
| * DATEV-Export: Die Auswahlliste der Haushaltspläne zeigt nun HHP-Nummer, Organisation und Haushaltsjahr, sodass Pläne derselben Organisation eindeutig unterscheidbar sind. | ||
| * Nachtragshaushaltsplan: Zu einem genehmigten oder aktiven Haushaltsplan kann jetzt ein Nachtrag erstellt werden, der Titel ändert, hinzufügt oder streicht, ohne den laufenden Plan direkt zu verändern. Der Nachtrag durchläuft denselben Freigabe-Workflow wie ein Haushaltsplan und wird erst mit seiner Aktivierung (sofort oder zu einem geplanten Wirksamkeitsdatum) im Hauptplan wirksam; bereits gebuchte Titel bleiben dabei unter derselben ID erhalten. Der Status „Veröffentlicht" heißt nun „Aktiv". Ein Nachtrag kann optional eine eigene Bezeichnung erhalten (z. B. für Sitzungsvorlagen); ohne Angabe wird ersatzweise das Erstellungsdatum angezeigt. | ||
| * Nachtragshaushaltsplan: Titel, die ein Nachtrag erst im Entwurf neu anlegt, tauchten unter Umständen bereits im laufenden Haushaltsplan auf und erhöhten dort die Summe ihrer Gruppe – obwohl der Nachtrag noch nicht beschlossen war. Ebenso blieben solche Titel stehen, wenn ein bereits wirksamer Nachtrag zurückgenommen wurde. Ein Nachtrag verändert den laufenden Plan nun tatsächlich erst mit seiner Aktivierung. |
There was a problem hiding this comment.
dont write fixes here where the bugs never surfaced to prod
| @endforeach | ||
| </ul> | ||
| @endif | ||
| <flux:textarea wire:model.live.blur="reasonInputs.{{ $change->id }}" |
There was a problem hiding this comment.
for OP: should we allow the flux editor for per titel and for the total justifications?
| use Livewire\Component; | ||
|
|
||
| /** | ||
| * The Nachtragshaushaltsplan (amendment) editor — OP#581. Renders the PARENT plan's item tree |
|
|
||
| Artisan::command('inspire', function (): void { | ||
| $this->comment(Inspiring::quote()); | ||
| })->purpose('Display an inspiring quote')->hourly(); |
There was a problem hiding this comment.
maybe we should remove that before going live ;)
|
|
||
| // Activate approved Nachtragshaushaltspläne (amendments) whose effective_date has arrived — | ||
| // see App\Console\Commands\stufis\ApplyDueAmendments. | ||
| Schedule::command('stufis:apply-due-amendments')->dailyAt('04:00'); |
…ivation_date Review feedback on PR #329: `effective_date` reads as a legal-effectiveness concept, but the column drives exactly one thing — when the amendment is activated. Renames the column, the model attribute, the Livewire property and the translation keys to `activation_date`, and follows through in the German UI strings ("Aktivierung zum", "Aktivierung überfällig"). Also tidies the two amendment migrations while 4.5.0 is still unreleased: - `2026_08_01_000001_nachtragshaushaltsplan` is renamed to `..._budget_plan_amendments`, so the filename is not half-German. - `2026_08_01_000000_rename_budget_plan_published_state_to_active` is dropped entirely. The `published` state never reached production, and the demo SQL dump already seeds `state = 'active'`, so there is nothing to migrate. Its coverage in ActiveStateTest goes with it. No data migration is needed for the rename either: the column was introduced by an unreleased migration, so the definition is simply corrected in place. Refs: OP#581
Addresses the remaining review points from PR #329, none of which change behaviour except where noted: - Query the state machine through spatie's whereState() instead of comparing the raw column against `State::$name`, in the command, appliedAmendments() and both cascade transitions. - Add `amendment()` and `dueForActivation()` scopes, so the scheduled command reads as a single selector. Folding the parent-plan check into whereHas() also drops the get()->filter() that used to load every approved amendment just to discard most of them. - Move the activation_date default off the model's saving() hook and onto the two arcs that actually reach Approved, sharing the rule through the new DefaultsActivationDateOnApproval trait. A hook firing on every save could be skipped by a write that bypasses the model; a transition cannot. - Rename advancesTo() to isAdvancement(), and move the policy's private isLockstepArc() onto BudgetPlanState as isCascadingArc() — it describes the state graph, not an authorization rule, and now sits beside isAdvancement(). - Replace BudgetItemChange's ACTION_* string constants with a backed BudgetItemChangeAction enum carrying label() and color(), which collapses the badge match() that was duplicated across three blade templates. - Rename AmendmentApplier::fromStorage() to decodeStoredValue(). - Drop the `inspire` sample command and its hourly schedule, and move the amendment activation run from 04:00 to 00:01 so an amendment goes live on the date it is due rather than four hours into it. - Comment pass: no German in English comments, no narrating past bugs, and trim the longest explanations down to what a future reader needs. - Changelog: drop the entry describing a bug that only ever existed on this branch, and the note renaming a state that never shipped. Refs: OP#581
…he policy Review feedback on PR #329: BudgetPlan::isEditable() sat between business logic and authorization, with the rule re-derived at each call site. The state now owns it — BudgetPlanState::isEditable() is false by default and overridden to true in Draft and Resolved, beside isAdvancement() and isCascadingArc(). BudgetPlanPolicy::update() and delete() delegate to it, and BudgetPlan::isEditable() is a thin forwarder that folds in an amendment's stricter Draft-only rule so ⚡amendment-edit stops keeping its own copy. Plan deletion goes through the policy rather than around it. deletePlan() had authorized 'admin' and then repeated the state check with its own abort_unless(); it now authorizes 'delete' once, so the server-side rule and the modal's checklist cannot drift apart. Deleting a plan consequently takes the budget officer role (ref-finanzen-hv) instead of admin — admins still pass via UserPolicy::before(). Nothing released changes, since the whole delete flow is new in 4.5.0. Two call sites needed care while narrowing 'update': - ⚡plan-edit::mount() now runs its state check before authorize(), so a budget officer opening a frozen plan still gets the redirect to the read-only view rather than a bare 403. - The "change state" menu item had been using @can('update') as a stand-in for "is budget officer". Left alone it would have vanished on every Approved, Active and Completed plan — exactly where transitioning matters — so it now checks the role directly, matching what transitionTo() enforces. Coverage for the new rule: a budget officer may delete, a plain user may not and never sees the modal, admins still may, and the state gate still holds. The existing "forbids a non-admin from deleting the plan" case asserted the rule this commit deliberately replaces and now covers a user without the role. Refs: OP#581
Summary
stufis:apply-due-amendmentsfor due effective dates).flux:modaldelete confirmation instead of a barewire:confirm.Publishedstate toActive.Test plan
composer fix)datevsetting on and finance permission grantedRefs: OP#581
🤖 Generated with Claude Code