disaster-fund-calculation-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.4
2
+ Name: disaster-fund-calculation-engine
3
+ Version: 0.1.0
4
+ Summary: Disaster Reconstruction & Relief Fund Calculation Engine: Multi-Tranche Liabilities, Donation Offsets, and District Pooling
5
+ Author-email: Pujan Pandey <arghakhanchipujan@gmail.com>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/arghakhanchipujan/Fund-Management
8
+ Keywords: disaster-relief,fund-management,reconstruction,nepal,grants,calculation-engine,tranches
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
18
+ Classifier: Development Status :: 3 - Alpha
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: disaster-fund-plan>=0.1.0
22
+ Requires-Dist: pydantic>=2
23
+
24
+ # disaster-fund-calculation-engine
25
+
26
+ **Turn a validated disaster plan plus beneficiary counts into exact rupee figures: what a household receives, what each government owes, and what each district collects and forwards.**
27
+
28
+ It is the calculation half of the system. The plan itself (structure, grant rates, liability split) comes from [`disaster-fund-plan`](https://pypi.org/project/disaster-fund-plan/), which is installed automatically.
29
+
30
+ - Python 3.10 or newer
31
+ - Dependencies: `disaster-fund-plan`, `pydantic>=2`
32
+ - Import name: `fm_calculation_engine` (the PyPI name is `disaster-fund-calculation-engine`)
33
+
34
+ ```bash
35
+ pip install disaster-fund-calculation-engine
36
+ ```
37
+
38
+ ---
39
+
40
+ ## The three outputs at a glance
41
+
42
+ | Question you are asking | Function | Who asks it |
43
+ | --- | --- | --- |
44
+ | *"What does one household receive, tranche by tranche?"* | `receivable_amounts(plan, profile)` | Caseworker, beneficiary-facing app |
45
+ | *"How much does the Federal / a Province / a Local government owe, after donations?"* | `calculate_government_liability(plan, profile, counts)` | Finance office of that government |
46
+ | *"How much must a district collect from each level, and how much does it forward to each local government?"* | `calculate_district_fund(plan, profile, counts)` | District coordination office |
47
+
48
+ All three read the **same** internal schedule, so they can never disagree with each other (see [Guarantees](#guarantees)).
49
+
50
+ ## Inputs
51
+
52
+ **1. The plan**: loaded with `disaster-fund-plan`:
53
+
54
+ ```python
55
+ from fm_plan import load_plan
56
+ plan = load_plan("example_plan.json")
57
+ ```
58
+
59
+ **2. Beneficiary counts** (outputs 2 and 3): how many households fall in each category. A list of dictionaries:
60
+
61
+ ```python
62
+ counts = [
63
+ {"district": "Gorkha", "local_level": "Gorkha Municipality",
64
+ "disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 10},
65
+ {"district": "Sindhupalchok", "local_level": "Melamchi Municipality",
66
+ "disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 20},
67
+ {"district": "Sindhupalchok", "local_level": "Helambu Rural Municipality",
68
+ "disaster_type": "Earthquake", "solution_type": "Retrofitting", "count": 5},
69
+ ]
70
+ ```
71
+
72
+ | Field | Required | Notes |
73
+ | --- | --- | --- |
74
+ | `district` | yes | Name **or code** (`"Sindhupalchok"` or `"23"`) |
75
+ | `disaster_type`, `solution_type` | yes | Must exist in the plan; capital letters do not matter |
76
+ | `count` | yes | Number of households, `0` or more |
77
+ | `local_level` | no | Name or code. If omitted, see [use case 6](#6-counts-without-a-local-level) |
78
+ | `region` | no | Taken from the plan's district. If you supply it, it must match the plan |
79
+ | `district_code`, `local_level_code` | no | Optional cross-check (non-empty strings). If given, they must match the plan's code for that district / local level, otherwise the row is rejected (a local-level code needs a `local_level`) |
80
+
81
+ **3. A profile** for the question you are asking (shown in each use case below). Every profile carries the `plan_id`, which must match the loaded plan: this stops results being calculated against the wrong plan version.
82
+
83
+ ---
84
+
85
+ ## Use cases
86
+
87
+ ### 1. What does one household receive?
88
+
89
+ Given a household's region, disaster type and solution type, get its tranche schedule.
90
+
91
+ ```python
92
+ from fm_calculation_engine import receivable_amounts
93
+
94
+ out = receivable_amounts(plan, {
95
+ "plan_id": plan.plan_id,
96
+ "region": "Pahad",
97
+ "disaster_type": "Earthquake",
98
+ "solution_type": "Reconstruction",
99
+ })
100
+
101
+ print(out.total) # 300000
102
+ for t in out.tranches:
103
+ print(t.tranche, t.stage, t.amount)
104
+ # 1 shelter 15000
105
+ # 2 shelter 15000
106
+ # 3 main 100000
107
+ # 4 main 100000
108
+ # 5 main 70000
109
+ ```
110
+
111
+ ### 2. What does a government owe? (Federal, Province or Local)
112
+
113
+ The same function works for every level. The engine works out which level you mean from the name or code.
114
+
115
+ ```python
116
+ from fm_calculation_engine import calculate_government_liability
117
+
118
+ fed = calculate_government_liability(
119
+ plan,
120
+ {"plan_id": plan.plan_id, "gov_entity_name": "Government of Nepal"}, # or "FED"
121
+ counts,
122
+ )
123
+ print(fed.gov_type, fed.liability_percentage) # federal 50.0
124
+ print(fed.total_gross_liability) # 4750000
125
+ print(fed.total_net_payable) # 4750000 (no donations given)
126
+ ```
127
+
128
+ Other levels, using the plan from the example file:
129
+
130
+ | `gov_entity_name` | Resolves to | Pays |
131
+ | --- | --- | --- |
132
+ | `"Government of Nepal"` or `"FED"` | federal | 50% of every district's cost |
133
+ | `"Gandaki Province"` or `"P4"` | province | 30%, **only** for districts inside that province (Gorkha) |
134
+ | `"Melamchi Municipality"` or `"23001"` | local | 20% of its district's cost, split between the district's local levels in proportion to what each needs |
135
+
136
+ Each result has a per-tranche, per-district breakdown (`fed.tranches[i].districts`) and a summary of who the beneficiaries are (`fed.beneficiary_summary`).
137
+
138
+ ### 3. Apply donations, with automatic rollover
139
+
140
+ INGO, NGO and other donors give money to a government. It reduces that government's **cash outflow**, starting at tranche 1; whatever is not used rolls over to the next tranche.
141
+
142
+ ```python
143
+ fed = calculate_government_liability(
144
+ plan,
145
+ {
146
+ "plan_id": plan.plan_id,
147
+ "gov_entity_name": "Government of Nepal",
148
+ "donations": [
149
+ {"donor_name": "Red Cross", "amount": 250000, "received_date": "2015-10-01"},
150
+ {"donor_name": "UNICEF", "amount": 150000, "received_date": "2015-10-05"},
151
+ ],
152
+ },
153
+ counts,
154
+ )
155
+ for t in fed.tranches[:3]:
156
+ print(t.tranche, t.gross_liability, t.donation_applied, t.net_payable, t.remaining_donation)
157
+ ```
158
+
159
+ ```text
160
+ tranche gross donation applied net payable donation left
161
+ 1 350,000 350,000 0 50,000 ← 400,000 donated, tranche 1 consumes 350,000
162
+ 2 350,000 50,000 300,000 0 ← the 50,000 left over rolls into tranche 2
163
+ 3 1,500,000 0 1,500,000 0 ← donations are used up
164
+ ```
165
+
166
+ Totals: Rs. 400,000 received, Rs. 4,750,000 gross, **Rs. 4,350,000 net cash payable**. The relief inside each tranche is shared across districts in proportion to what each district needs (`tranches[i].districts[j].donation_offset`).
167
+
168
+ Notes: `received_date` is optional and only affects the order donations are listed (oldest first). It does not decide *which tranche* a donation can pay for, because the plan has no tranche due dates.
169
+
170
+ ### 4. What must a district collect, and whom does it forward to?
171
+
172
+ A district office needs two answers per tranche: how much to **collect** from Federal, Province and Local, and how much to **forward** to each local government.
173
+
174
+ ```python
175
+ from fm_calculation_engine import calculate_district_fund
176
+
177
+ d = calculate_district_fund(
178
+ plan,
179
+ {"plan_id": plan.plan_id, "district_name": "Sindhupalchok"}, # or "23"
180
+ counts,
181
+ )
182
+ print(d.province_name, d.total_beneficiaries, d.grand_total) # Bagmati Province 25 6500000
183
+
184
+ t1 = d.tranches[0]
185
+ print(t1.collectible) # federal=275000 province=165000 local=110000 total=550000
186
+ for x in t1.disbursements:
187
+ print(x.local_gov, x.amount)
188
+ # Melamchi Municipality 300000
189
+ # Helambu Rural Municipality 250000
190
+ ```
191
+
192
+ Tranche 1 here is 550,000 in total: Rs. 275,000 (50%) from Federal, Rs. 165,000 (30%) from the Province and Rs. 110,000 (20%) from Local, which is then forwarded as 300,000 and 250,000 to the two local governments according to their own beneficiaries.
193
+
194
+ ### 5. Reconcile everything (audit and sanity check)
195
+
196
+ Because all outputs come from one schedule, they must add up. This is a useful check for auditors and for your own integration tests:
197
+
198
+ ```python
199
+ levels = ["FED", "P3", "P4", "Gorkha Municipality", "Melamchi Municipality", "Helambu Rural Municipality"]
200
+ total_by_government = sum(
201
+ calculate_government_liability(plan, {"plan_id": plan.plan_id, "gov_entity_name": g}, counts).total_gross_liability
202
+ for g in levels
203
+ )
204
+ total_by_district = sum(
205
+ calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": d}, counts).grand_total
206
+ for d in ("Gorkha", "Sindhupalchok")
207
+ )
208
+ assert total_by_government == total_by_district == 9_500_000
209
+ ```
210
+
211
+ ### 6. Counts without a local level
212
+
213
+ Sometimes only district-level totals are known. Leave `local_level` out:
214
+
215
+ ```python
216
+ calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": "23"},
217
+ [{"district": "23", "disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 10}])
218
+ # tranche 1 -> Melamchi Municipality 75000, Helambu Rural Municipality 75000
219
+ ```
220
+
221
+ The money for such households is shared **equally** between the district's local levels (odd rupees go to the first ones in the plan). For a local government's own report, these households are shown separately as `unassigned_beneficiaries` and are not included in `total_beneficiaries`.
222
+
223
+ ### 7. Catch bad input with readable errors
224
+
225
+ Every problem in the input is reported at once, in plain words, as an `EngineInputError`:
226
+
227
+ ```python
228
+ from fm_calculation_engine import EngineInputError
229
+
230
+ try:
231
+ calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": "Gorkha"}, bad_counts)
232
+ except EngineInputError as error:
233
+ print(error)
234
+ ```
235
+
236
+ ```text
237
+ 3 problem(s) in beneficiary counts:
238
+ - item 0: district 'Kathmandu' is not in plan's districts ['Gorkha', 'Sindhupalchok']
239
+ - item 0: disaster_type 'Flood' is not in plan's disaster types ['Earthquake']
240
+ - item 1: region 'Terai' does not match district 'Gorkha', which is in region 'Pahad' in this plan
241
+ ```
242
+
243
+ You can also validate a count list on its own, before calculating, and get it back normalised to the plan's spelling and codes:
244
+
245
+ ```python
246
+ from fm_calculation_engine import validate_beneficiary_counts
247
+ clean = validate_beneficiary_counts(plan, counts)
248
+ ```
249
+
250
+ ### 8. A local government whose name exists in two districts
251
+
252
+ Nepal has repeated municipality names. If a local government's name appears in more than one district, the engine refuses to guess; say which district you mean:
253
+
254
+ ```python
255
+ calculate_government_liability(plan, {
256
+ "plan_id": plan.plan_id,
257
+ "gov_entity_name": "Some Municipality",
258
+ "district": "Sindhupalchok",
259
+ }, counts)
260
+ ```
261
+
262
+ (`district` is only accepted for local governments. Using the official code, such as `"23001"`, avoids the ambiguity altogether.)
263
+
264
+ ---
265
+
266
+ ## Guarantees
267
+
268
+ - **Exact rupees, no drift.** Every split (liability shares, local-level shares, donation offsets) uses the *largest remainder method* on whole rupees: each part gets what it is owed, and leftover rupees go to the parts with the largest fractions. The parts always add up to the total; `round()` and floating point are never used for money.
269
+ - **One schedule per district.** District total = Federal + Province + Local share, and the local share = the sum of the shares given to its local levels, exactly. Outputs 2 and 3 therefore always reconcile.
270
+ - **Deterministic.** The same input always gives the same output (ties are broken by plan order).
271
+ - **Wrong plan is rejected.** A `plan_id` that does not match the loaded plan is an error.
272
+ - **Names or codes everywhere.** Districts, provinces, local levels and the Federal government can all be given by name or official code, in any capitalisation.
273
+
274
+ ## API summary
275
+
276
+ | Name | Purpose |
277
+ | --- | --- |
278
+ | `receivable_amounts(plan, profile)` → `ReceivableAmounts` | Output 1: tranche schedule for one household |
279
+ | `calculate_government_liability(plan, profile, counts)` → `GovernmentLiabilityOutput` | Output 2: what a government owes, with donation offsets and rollover |
280
+ | `calculate_district_fund(plan, profile, counts)` → `DistrictOutput` | Output 3: what a district collects and forwards |
281
+ | `validate_beneficiary_counts(plan, counts)` | Check counts against the plan; returns normalised `BeneficiaryCount` objects |
282
+ | `EngineInputError` | Raised for any invalid input; the message lists every problem |
283
+ | `BeneficiaryProfile`, `GovEntityProfile`, `DistrictProfile`, `BeneficiaryCount`, `Donation` | Input models (a plain `dict` with the same keys works too) |
284
+
285
+ Profiles and counts can be passed as dictionaries or as these model objects. Unknown keys are rejected, so a typo is an error rather than being silently ignored.
286
+
287
+ ## Limits
288
+
289
+ - Amounts are whole rupees.
290
+ - The Federal/Province/Local split is the same for every tranche and every stage (shelter and main).
291
+ - Donations are pooled per government and used from tranche 1 onward; they are not tied to a particular tranche or district.
292
+ - A tranche round exists up to the longest grant in the plan. A grant with fewer tranches simply has Rs. 0 in the later rounds.
293
+
294
+ ## License
295
+
296
+ MIT
@@ -0,0 +1,11 @@
1
+ fm_calculation_engine/__init__.py,sha256=ObTeAsx8O9bAublsgOiC2k2MlEf3HMCJ2wVG4KEVYSg,1238
2
+ fm_calculation_engine/beneficiary.py,sha256=Tv1_FQLObfNskuyz1pIeIi5pBqP_pPvBZ4gTgwDS8gg,2585
3
+ fm_calculation_engine/common.py,sha256=ggC6WxXciO7tNteDgRkjPcGAqfgs1_mojJLeKDOdcJ4,7685
4
+ fm_calculation_engine/core.py,sha256=6_VF_R8A82M3gX-tRCsGuB3btMUbBwf0pA9HPn46IBI,6765
5
+ fm_calculation_engine/district.py,sha256=L2_QYtaVYmtdkPwnjW197ab5wFWg1fhFx4Jx5UxUh64,3661
6
+ fm_calculation_engine/government.py,sha256=cJtplOnqSxHVrdkdZNDzDH73ObOJUzirUD2RGN5Jrbo,11323
7
+ fm_calculation_engine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ disaster_fund_calculation_engine-0.1.0.dist-info/METADATA,sha256=RAhgoRC5KWHpbpPshGL3MUaQLwSg0hfdHUG4Upud6Uw,14052
9
+ disaster_fund_calculation_engine-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ disaster_fund_calculation_engine-0.1.0.dist-info/top_level.txt,sha256=y6f0oj8S941rl9Q3zToNEPiu0DaOeE8ZtH4k9PjHt4U,22
11
+ disaster_fund_calculation_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fm_calculation_engine
@@ -0,0 +1,51 @@
1
+ from fm_calculation_engine.beneficiary import (
2
+ BeneficiaryProfile,
3
+ ReceivableAmounts,
4
+ TrancheAmount,
5
+ receivable_amounts,
6
+ )
7
+ from fm_calculation_engine.common import (
8
+ BeneficiaryCount,
9
+ Donation,
10
+ EngineInputError,
11
+ validate_beneficiary_counts,
12
+ )
13
+ from fm_calculation_engine.district import (
14
+ DistrictCollectible,
15
+ DistrictOutput,
16
+ DistrictProfile,
17
+ DistrictTrancheOutput,
18
+ LocalGovDisbursement,
19
+ calculate_district_fund,
20
+ )
21
+ from fm_calculation_engine.government import (
22
+ BeneficiaryGroupSummary,
23
+ GovDistrictPayment,
24
+ GovEntityProfile,
25
+ GovernmentLiabilityOutput,
26
+ GovTrancheLiability,
27
+ calculate_government_liability,
28
+ )
29
+
30
+ __all__ = [
31
+ "BeneficiaryProfile",
32
+ "ReceivableAmounts",
33
+ "TrancheAmount",
34
+ "receivable_amounts",
35
+ "BeneficiaryCount",
36
+ "Donation",
37
+ "EngineInputError",
38
+ "validate_beneficiary_counts",
39
+ "DistrictProfile",
40
+ "DistrictCollectible",
41
+ "LocalGovDisbursement",
42
+ "DistrictTrancheOutput",
43
+ "DistrictOutput",
44
+ "calculate_district_fund",
45
+ "GovEntityProfile",
46
+ "GovDistrictPayment",
47
+ "GovTrancheLiability",
48
+ "BeneficiaryGroupSummary",
49
+ "GovernmentLiabilityOutput",
50
+ "calculate_government_liability",
51
+ ]
@@ -0,0 +1,76 @@
1
+ from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
2
+
3
+ from fm_calculation_engine.common import EngineInputError, match_name, parse
4
+
5
+
6
+ class BeneficiaryProfile(StrictModel):
7
+ """Input: which plan, and which cell of the grant matrix this beneficiary falls in."""
8
+
9
+ plan_id: NonEmptyStr
10
+ region: NonEmptyStr
11
+ disaster_type: NonEmptyStr
12
+ solution_type: NonEmptyStr
13
+
14
+
15
+ class TrancheAmount(StrictModel):
16
+ tranche: int # 1, 2, 3, ...
17
+ stage: str
18
+ amount: int
19
+
20
+
21
+ class ReceivableAmounts(StrictModel):
22
+ """Output: what the beneficiary receives in each tranche."""
23
+
24
+ plan_id: str
25
+ region: str
26
+ disaster_type: str
27
+ solution_type: str
28
+ tranches: list[TrancheAmount]
29
+ total: int
30
+
31
+
32
+ def receivable_amounts(plan: ImplementationPlan, profile) -> ReceivableAmounts:
33
+ """Receivable amount for each tranche for one beneficiary."""
34
+ p = parse(BeneficiaryProfile, profile)
35
+
36
+ problems = []
37
+ if norm(p.plan_id) != norm(plan.plan_id):
38
+ problems.append(
39
+ f"plan_id '{p.plan_id}' does not match this plan ('{plan.plan_id}')")
40
+ region = match_name(p.region, plan.regions)
41
+ if region is None:
42
+ problems.append(
43
+ f"region '{p.region}' is not in this plan's regions {plan.regions}")
44
+ disaster = match_name(p.disaster_type, plan.disaster_types)
45
+ if disaster is None:
46
+ problems.append(
47
+ f"disaster_type '{p.disaster_type}' is not in this plan's disaster types {plan.disaster_types}"
48
+ )
49
+ solution = match_name(p.solution_type, plan.solution_types)
50
+ if solution is None:
51
+ problems.append(
52
+ f"solution_type '{p.solution_type}' is not in this plan's solution types {plan.solution_types}"
53
+ )
54
+ if problems:
55
+ raise EngineInputError(
56
+ f"{len(problems)} problem(s) in the input:\n" +
57
+ "\n".join(f" - {x}" for x in problems)
58
+ )
59
+
60
+ row = next(
61
+ r for r in plan.grant_matrix
62
+ if (norm(r.region), norm(r.disaster_type), norm(r.solution_type))
63
+ == (norm(region), norm(disaster), norm(solution))
64
+ ) # a row always exists: the plan guarantees every combination has one
65
+
66
+ return ReceivableAmounts(
67
+ plan_id=plan.plan_id,
68
+ region=region,
69
+ disaster_type=disaster,
70
+ solution_type=solution,
71
+ tranches=[
72
+ TrancheAmount(tranche=number, stage=t.stage, amount=t.amount)
73
+ for number, t in enumerate(row.tranches, start=1)
74
+ ],
75
+ total=row.total,
76
+ )
@@ -0,0 +1,184 @@
1
+ from datetime import date
2
+ from typing import Annotated
3
+
4
+ from pydantic import Field, ValidationError
5
+
6
+ from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
7
+
8
+
9
+ class EngineInputError(Exception):
10
+ """The input given to the engine is not valid."""
11
+
12
+
13
+ def parse(model_class, data):
14
+ """Build model_class from a dictionary (or an existing object).
15
+
16
+ A problem becomes an EngineInputError with one readable line per problem.
17
+ """
18
+ try:
19
+ return model_class.model_validate(data)
20
+ except ValidationError as error:
21
+ lines = []
22
+ for err in error.errors():
23
+ where = ".".join(str(part) for part in err["loc"]) or "input"
24
+ message = err["msg"].removeprefix("Value error, ")
25
+ for problem in message.split("; "):
26
+ lines.append(f" - {where}: {problem}")
27
+ raise EngineInputError(
28
+ f"{len(lines)} problem(s) in the input:\n" + "\n".join(lines)
29
+ ) from None
30
+
31
+
32
+ class Donation(StrictModel):
33
+ """A donation received by a government entity from INGO, NGO, or private/public source.
34
+
35
+ Donations are pooled: the whole amount is available from tranche 1 and any surplus
36
+ rolls over to the next tranche. `received_date` is recorded for transparency (the
37
+ output lists donations oldest first) but does not decide which tranche a donation
38
+ can pay for, because the plan does not give tranches due dates.
39
+ """
40
+
41
+ donor_name: NonEmptyStr
42
+ amount: Annotated[int, Field(
43
+ gt=0, description="Exact donation amount in whole rupees")]
44
+ received_date: date | None = None
45
+
46
+
47
+ class BeneficiaryCount(StrictModel):
48
+ """Beneficiary count for a specific category within a district (and optional local level).
49
+
50
+ `district` and `local_level` accept either official names or codes.
51
+ `region` is optional. A district belongs to exactly one region in the plan, so it
52
+ is taken from the plan. If you do give it, it must match the plan's region for
53
+ that district.
54
+ """
55
+
56
+ district: NonEmptyStr
57
+ disaster_type: NonEmptyStr
58
+ solution_type: NonEmptyStr
59
+ count: Annotated[int, Field(
60
+ ge=0, description="Number of beneficiary households")]
61
+ region: NonEmptyStr | None = None
62
+ local_level: NonEmptyStr | None = None
63
+ district_code: NonEmptyStr | None = None
64
+ local_level_code: NonEmptyStr | None = None
65
+
66
+
67
+ def match_name(value: str, allowed: list[str]) -> str | None:
68
+ """The name in `allowed` that equals `value`, ignoring capital letters, or None."""
69
+ for name in allowed:
70
+ if norm(name) == norm(value):
71
+ return name
72
+ return None
73
+
74
+
75
+ def validate_beneficiary_counts(
76
+ plan: ImplementationPlan,
77
+ counts: list[BeneficiaryCount | dict],
78
+ ) -> list[BeneficiaryCount]:
79
+ """Check every count against the plan and return them with the plan's own spelling and codes.
80
+
81
+ Accepts district and local level by name or code.
82
+ A count is rejected when:
83
+ - its district, disaster type or solution type is not in the plan;
84
+ - its region does not match the region the plan gives that district;
85
+ - its local level does not belong to its district;
86
+ - its district (or local level) is not inside the plan's affected areas.
87
+
88
+ In the returned counts `region` is always filled in, taken from the plan.
89
+ """
90
+ parsed_counts = [parse(BeneficiaryCount, c) for c in counts]
91
+ problems: list[str] = []
92
+ checked: list[BeneficiaryCount] = []
93
+
94
+ for idx, b in enumerate(parsed_counts):
95
+ district = plan.government.find_district(b.district)
96
+ if district is None:
97
+ problems.append(
98
+ f"item {idx}: district '{b.district}' is not in plan's districts "
99
+ f"{[d.name for d in plan.government.districts]}"
100
+ )
101
+
102
+ if b.region is not None:
103
+ matched_region = match_name(b.region, plan.regions)
104
+ if matched_region is None:
105
+ problems.append(
106
+ f"item {idx}: region '{b.region}' is not in plan's regions {plan.regions}"
107
+ )
108
+ elif district is not None and norm(matched_region) != norm(district.region):
109
+ problems.append(
110
+ f"item {idx}: region '{b.region}' does not match district '{district.name}', "
111
+ f"which is in region '{district.region}' in this plan"
112
+ )
113
+
114
+ disaster = match_name(b.disaster_type, plan.disaster_types)
115
+ if disaster is None:
116
+ problems.append(
117
+ f"item {idx}: disaster_type '{b.disaster_type}' is not in plan's disaster types "
118
+ f"{plan.disaster_types}"
119
+ )
120
+
121
+ solution = match_name(b.solution_type, plan.solution_types)
122
+ if solution is None:
123
+ problems.append(
124
+ f"item {idx}: solution_type '{b.solution_type}' is not in plan's solution types "
125
+ f"{plan.solution_types}"
126
+ )
127
+
128
+ local_level_obj = None
129
+ if b.local_level and district is not None:
130
+ matching_lls = plan.government.find_local_level(b.local_level, district.name)
131
+ if not matching_lls:
132
+ problems.append(
133
+ f"item {idx}: local level '{b.local_level}' does not belong to district "
134
+ f"'{district.name}' in this plan"
135
+ )
136
+ else:
137
+ local_level_obj = matching_lls[0]
138
+
139
+ if district is not None and b.district_code is not None:
140
+ if not district.code or norm(b.district_code) != norm(district.code):
141
+ problems.append(
142
+ f"item {idx}: district_code '{b.district_code}' does not match district "
143
+ f"'{district.name}' (code '{district.code}' in this plan)"
144
+ )
145
+ if b.local_level_code is not None:
146
+ if not b.local_level:
147
+ problems.append(
148
+ f"item {idx}: local_level_code '{b.local_level_code}' was given without a local_level"
149
+ )
150
+ elif local_level_obj is not None and (
151
+ not local_level_obj.code or norm(b.local_level_code) != norm(local_level_obj.code)
152
+ ):
153
+ problems.append(
154
+ f"item {idx}: local_level_code '{b.local_level_code}' does not match local level "
155
+ f"'{local_level_obj.name}' (code '{local_level_obj.code}' in this plan)"
156
+ )
157
+
158
+ local_level_name = local_level_obj.name if local_level_obj else None
159
+ if district is not None and (local_level_name or not b.local_level):
160
+ if not plan.is_affected(district.name, local_level_name):
161
+ where = f"local level '{b.local_level}' in district '{district.name}'" if b.local_level else f"district '{district.name}'"
162
+ problems.append(
163
+ f"item {idx}: {where} is not in this plan's affected areas")
164
+
165
+ if district is not None and disaster and solution and (local_level_name or not b.local_level):
166
+ checked.append(
167
+ BeneficiaryCount(
168
+ district=district.name,
169
+ district_code=district.code,
170
+ region=district.region,
171
+ disaster_type=disaster,
172
+ solution_type=solution,
173
+ count=b.count,
174
+ local_level=local_level_name,
175
+ local_level_code=local_level_obj.code if local_level_obj else None,
176
+ )
177
+ )
178
+
179
+ if problems:
180
+ raise EngineInputError(
181
+ f"{len(problems)} problem(s) in beneficiary counts:\n"
182
+ + "\n".join(f" - {p}" for p in problems)
183
+ )
184
+ return checked
@@ -0,0 +1,183 @@
1
+ """Shared calculation core.
2
+
3
+ Every number the engine reports about a district comes from ONE schedule built here,
4
+ so the three outputs cannot disagree:
5
+
6
+ district total = federal share + province share + local share (exact)
7
+ local share = sum of the shares given to the district's local levels (exact)
8
+
9
+ Splitting uses the largest remainder method on whole rupees: each part gets the
10
+ whole rupees it is owed, and the few rupees left over go to the parts with the
11
+ largest fractions. The parts therefore always add up to the total, with no
12
+ rounding drift and no dependence on Python's round().
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+ from decimal import Decimal
17
+
18
+ from fm_plan import ImplementationPlan, norm
19
+
20
+ from fm_calculation_engine.common import BeneficiaryCount, EngineInputError
21
+
22
+
23
+ def split_by_weights(total: int, weights) -> list[int]:
24
+ """Split a whole-rupee `total` in proportion to `weights`. The parts add up to `total`.
25
+
26
+ Ties between equal fractions go to the earlier item, so the result is always the
27
+ same for the same input.
28
+ """
29
+ weight_list = [Decimal(str(w)) for w in weights]
30
+ if any(w < 0 for w in weight_list):
31
+ raise ValueError("weights cannot be negative")
32
+ weight_sum = sum(weight_list)
33
+ if total == 0:
34
+ return [0] * len(weight_list)
35
+ if weight_sum == 0:
36
+ raise ValueError(
37
+ "cannot split a non-zero amount across weights that add up to zero")
38
+
39
+ quotas = [Decimal(total) * w / weight_sum for w in weight_list]
40
+ # quotas are never negative, so int() is the floor
41
+ parts = [int(q) for q in quotas]
42
+ leftover = total - sum(parts)
43
+ by_largest_fraction = sorted(
44
+ range(len(parts)), key=lambda i: (-(quotas[i] - parts[i]), i)
45
+ )
46
+ for i in by_largest_fraction[:leftover]:
47
+ parts[i] += 1
48
+ return parts
49
+
50
+
51
+ def split_liability(plan: ImplementationPlan, amount: int) -> tuple[int, int, int]:
52
+ """(federal, province, local) shares of `amount`, adding up to `amount`."""
53
+ g = plan.government_liability
54
+ federal, province, local = split_by_weights(
55
+ amount, [g.federal, g.province, g.local])
56
+ return federal, province, local
57
+
58
+
59
+ def max_tranche_count(plan: ImplementationPlan) -> int:
60
+ """Number of tranche rounds: the longest tranche list in the grant matrix."""
61
+ return max((len(r.tranches) for r in plan.grant_matrix), default=0)
62
+
63
+
64
+ @dataclass
65
+ class TrancheRound:
66
+ """Everything one district needs for one tranche round."""
67
+
68
+ tranche: int # 1, 2, 3, ...
69
+ stages: list[str]
70
+ # local level -> money it must receive and pay out
71
+ required: dict[str, int]
72
+ total: int # money the district needs this round
73
+ federal: int # federal share of `total`
74
+ province: int # province share of `total`
75
+ local: int # local share of `total`
76
+ local_shares: dict[str, int] # `local` split over the local levels
77
+
78
+
79
+ @dataclass
80
+ class DistrictSchedule:
81
+ name: str
82
+ code: str | None
83
+ province: str
84
+ province_code: str | None
85
+ region: str
86
+ local_levels: list[str]
87
+ local_level_codes: dict[str, str | None]
88
+ rounds: list[TrancheRound]
89
+
90
+
91
+ def build_district_schedule(
92
+ plan: ImplementationPlan,
93
+ district_name: str,
94
+ district_counts: list[BeneficiaryCount],
95
+ ) -> DistrictSchedule:
96
+ """Work out every tranche round for one district.
97
+
98
+ `district_counts` must already be validated (see validate_beneficiary_counts)
99
+ and belong to this district. `district_name` can be a name or code.
100
+ """
101
+ district = plan.government.find_district(district_name)
102
+ if district is None:
103
+ raise EngineInputError(
104
+ f"District '{district_name}' was not found in this plan's districts."
105
+ )
106
+
107
+ prov_obj = plan.government.find_province(district.province)
108
+ province_name = prov_obj.name if prov_obj else district.province
109
+ province_code = prov_obj.code if prov_obj else None
110
+
111
+ matching_levels = [
112
+ ll for ll in plan.government.local_levels
113
+ if norm(ll.district) == norm(district.code)
114
+ ]
115
+ local_levels = [ll.name for ll in matching_levels]
116
+ local_level_codes = {ll.name: ll.code for ll in matching_levels}
117
+
118
+ if district_counts and not local_levels:
119
+ raise EngineInputError(
120
+ f"District '{district.name}' has beneficiaries but no local levels in this plan's "
121
+ "government section, so its money cannot be forwarded. Add its local levels."
122
+ )
123
+
124
+ matrix = {
125
+ (norm(r.region), norm(r.disaster_type), norm(r.solution_type)): r
126
+ for r in plan.grant_matrix
127
+ }
128
+
129
+ rounds: list[TrancheRound] = []
130
+ for tranche in range(1, max_tranche_count(plan) + 1):
131
+ required = {ll: 0 for ll in local_levels}
132
+ unassigned = 0 # beneficiaries who did not name a local level
133
+ stages: set[str] = set()
134
+
135
+ for b in district_counts:
136
+ row = matrix[(norm(b.region), norm(
137
+ b.disaster_type), norm(b.solution_type))]
138
+ if tranche > len(row.tranches):
139
+ # this grant has no payment in this round (its amount is Rs. 0)
140
+ continue
141
+ payment = row.tranches[tranche - 1]
142
+ stages.add(payment.stage)
143
+ amount = b.count * payment.amount
144
+ if b.local_level:
145
+ required[b.local_level] += amount
146
+ else:
147
+ unassigned += amount
148
+
149
+ # Money for beneficiaries with no local level is shared equally between the
150
+ # district's local levels. Any odd rupees go to the first ones in the plan.
151
+ if unassigned:
152
+ for ll, share in zip(local_levels, split_by_weights(unassigned, [1] * len(local_levels))):
153
+ required[ll] += share
154
+
155
+ total = sum(required.values())
156
+ federal, province, local = split_liability(plan, total)
157
+ local_shares = dict(
158
+ zip(local_levels, split_by_weights(
159
+ local, [required[ll] for ll in local_levels]))
160
+ )
161
+ rounds.append(
162
+ TrancheRound(
163
+ tranche=tranche,
164
+ stages=sorted(stages),
165
+ required=required,
166
+ total=total,
167
+ federal=federal,
168
+ province=province,
169
+ local=local,
170
+ local_shares=local_shares,
171
+ )
172
+ )
173
+
174
+ return DistrictSchedule(
175
+ name=district.name,
176
+ code=district.code,
177
+ province=province_name,
178
+ province_code=province_code,
179
+ region=district.region,
180
+ local_levels=local_levels,
181
+ local_level_codes=local_level_codes,
182
+ rounds=rounds,
183
+ )
@@ -0,0 +1,119 @@
1
+ from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
2
+
3
+ from fm_calculation_engine.common import (
4
+ BeneficiaryCount,
5
+ EngineInputError,
6
+ parse,
7
+ validate_beneficiary_counts,
8
+ )
9
+ from fm_calculation_engine.core import build_district_schedule
10
+
11
+
12
+ class DistrictProfile(StrictModel):
13
+ """Input profile for District fund calculation (accepts district name or code)."""
14
+
15
+ plan_id: NonEmptyStr
16
+ district_name: NonEmptyStr
17
+
18
+
19
+ class DistrictCollectible(StrictModel):
20
+ """Breakdown of funds collected from Federal, Province, and Local government levels."""
21
+
22
+ federal: int
23
+ province: int
24
+ local: int
25
+ total: int
26
+
27
+
28
+ class LocalGovDisbursement(StrictModel):
29
+ """Total fund to be forwarded to a specific local government for a tranche."""
30
+
31
+ local_gov: str
32
+ amount: int
33
+ local_gov_code: str | None = None
34
+
35
+
36
+ class DistrictTrancheOutput(StrictModel):
37
+ """Collectible and disbursement amounts for a district in a single tranche round."""
38
+
39
+ tranche: int
40
+ stages: list[str]
41
+ collectible: DistrictCollectible
42
+ disbursements: list[LocalGovDisbursement]
43
+ total_amount: int
44
+
45
+
46
+ class DistrictOutput(StrictModel):
47
+ """Fund collection and disbursement responsibility for a district."""
48
+
49
+ plan_id: str
50
+ district_name: str
51
+ province_name: str
52
+ region: str
53
+ total_beneficiaries: int
54
+ tranches: list[DistrictTrancheOutput]
55
+ grand_total: int
56
+ district_code: str | None = None
57
+ province_code: str | None = None
58
+
59
+
60
+ def calculate_district_fund(
61
+ plan: ImplementationPlan,
62
+ profile: DistrictProfile | dict,
63
+ beneficiary_counts: list[BeneficiaryCount | dict],
64
+ ) -> DistrictOutput:
65
+ """Calculate collectible amounts from Fed/Prov/Local and forwarding amounts to local governments."""
66
+ prof = parse(DistrictProfile, profile)
67
+
68
+ if norm(prof.plan_id) != norm(plan.plan_id):
69
+ raise EngineInputError(
70
+ f"plan_id '{prof.plan_id}' does not match this plan ('{plan.plan_id}')"
71
+ )
72
+
73
+ district_obj = plan.government.find_district(prof.district_name)
74
+ if district_obj is None:
75
+ district_names = [d.name for d in plan.government.districts]
76
+ raise EngineInputError(
77
+ f"District '{prof.district_name}' was not found in the plan's districts {district_names}."
78
+ )
79
+
80
+ valid_counts = validate_beneficiary_counts(plan, beneficiary_counts)
81
+ district_counts = [b for b in valid_counts if norm(
82
+ b.district) == norm(district_obj.name)]
83
+
84
+ schedule = build_district_schedule(plan, district_obj.name, district_counts)
85
+
86
+ tranche_outputs = [
87
+ DistrictTrancheOutput(
88
+ tranche=r.tranche,
89
+ stages=r.stages,
90
+ collectible=DistrictCollectible(
91
+ federal=r.federal,
92
+ province=r.province,
93
+ local=r.local,
94
+ total=r.total,
95
+ ),
96
+ disbursements=[
97
+ LocalGovDisbursement(
98
+ local_gov=ll,
99
+ local_gov_code=schedule.local_level_codes.get(ll),
100
+ amount=r.required[ll],
101
+ )
102
+ for ll in schedule.local_levels
103
+ ],
104
+ total_amount=r.total,
105
+ )
106
+ for r in schedule.rounds
107
+ ]
108
+
109
+ return DistrictOutput(
110
+ plan_id=plan.plan_id,
111
+ district_name=schedule.name,
112
+ district_code=schedule.code,
113
+ province_name=schedule.province,
114
+ province_code=schedule.province_code,
115
+ region=schedule.region,
116
+ total_beneficiaries=sum(b.count for b in district_counts),
117
+ tranches=tranche_outputs,
118
+ grand_total=sum(r.total for r in schedule.rounds),
119
+ )
@@ -0,0 +1,303 @@
1
+ from datetime import date
2
+
3
+ from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
4
+
5
+ from fm_calculation_engine.common import (
6
+ BeneficiaryCount,
7
+ Donation,
8
+ EngineInputError,
9
+ parse,
10
+ validate_beneficiary_counts,
11
+ )
12
+ from fm_calculation_engine.core import (
13
+ build_district_schedule,
14
+ max_tranche_count,
15
+ split_by_weights,
16
+ )
17
+
18
+
19
+ class GovEntityProfile(StrictModel):
20
+ """Input profile for Federal, Provincial, or Local government entity."""
21
+
22
+ plan_id: NonEmptyStr
23
+ gov_entity_name: NonEmptyStr
24
+ # "federal", "province", or "local" (optional: auto-detected if omitted)
25
+ gov_type: NonEmptyStr | None = None
26
+ # Only for a local government whose name is used in more than one district.
27
+ district: NonEmptyStr | None = None
28
+ donations: list[Donation] = []
29
+
30
+
31
+ class GovDistrictPayment(StrictModel):
32
+ """Payment responsibility towards a specific district for a single tranche."""
33
+
34
+ district: str
35
+ gross_liability: int
36
+ donation_offset: int
37
+ net_payable: int
38
+ district_code: str | None = None
39
+
40
+
41
+ class GovTrancheLiability(StrictModel):
42
+ """Fund liability breakdown for a government entity for a specific tranche round."""
43
+
44
+ tranche: int
45
+ stages: list[str]
46
+ gross_liability: int
47
+ donation_applied: int
48
+ net_payable: int
49
+ remaining_donation: int
50
+ districts: list[GovDistrictPayment]
51
+
52
+
53
+ class BeneficiaryGroupSummary(StrictModel):
54
+ """Summary count of beneficiaries by disaster, region, and solution type under this entity."""
55
+
56
+ disaster_type: str
57
+ region: str
58
+ solution_type: str
59
+ count: int
60
+
61
+
62
+ class GovernmentLiabilityOutput(StrictModel):
63
+ """Complete liability and disbursement responsibility for a government entity."""
64
+
65
+ plan_id: str
66
+ gov_entity_name: str
67
+ gov_type: str # "federal", "province", or "local"
68
+ liability_percentage: float
69
+ total_donations_received: int
70
+ donations: list[Donation] # oldest first; donations with no date come last
71
+ total_beneficiaries: int
72
+ # Local governments only: households in the same district that did not name a local
73
+ # level. They are not counted in total_beneficiaries, but their money is shared
74
+ # equally between the district's local levels, so it is part of the liability.
75
+ unassigned_beneficiaries: int = 0
76
+ beneficiary_summary: list[BeneficiaryGroupSummary]
77
+ tranches: list[GovTrancheLiability]
78
+ total_gross_liability: int
79
+ total_donation_applied: int
80
+ total_net_payable: int
81
+ final_remaining_donation: int
82
+ gov_entity_code: str | None = None
83
+
84
+
85
+ def _resolve_entity(
86
+ plan: ImplementationPlan,
87
+ entity_name: str,
88
+ requested_type: str | None = None,
89
+ district: str | None = None,
90
+ ):
91
+ """Find which government this is (by name or code). Returns (type, canonical name, code, its district names)."""
92
+ federal = plan.government.federal
93
+ canonical_code = None
94
+
95
+ if norm(entity_name) == norm(federal.name) or norm(entity_name) == norm(federal.code):
96
+ entity_type = "federal"
97
+ canonical_name = federal.name
98
+ canonical_code = federal.code
99
+ managed_districts = [d.name for d in plan.government.districts]
100
+ elif (prov_obj := plan.government.find_province(entity_name)) is not None:
101
+ entity_type = "province"
102
+ canonical_name = prov_obj.name
103
+ canonical_code = prov_obj.code
104
+ managed_districts = [
105
+ d.name
106
+ for d in plan.government.districts
107
+ if norm(d.province) == norm(prov_obj.code)
108
+ ]
109
+ else:
110
+ # Check local levels
111
+ dist_filter_name = None
112
+ if district is not None:
113
+ dist_filter = plan.government.find_district(district)
114
+ if dist_filter is None:
115
+ raise EngineInputError(f"District '{district}' is not in this plan's districts.")
116
+ dist_filter_name = dist_filter.name
117
+
118
+ locals_found = plan.government.find_local_level(entity_name, district_identifier=dist_filter_name)
119
+ if not locals_found:
120
+ # Check if entity exists in another district when district was given
121
+ if district is not None:
122
+ all_matching = plan.government.find_local_level(entity_name)
123
+ if all_matching:
124
+ raise EngineInputError(
125
+ f"Local government '{all_matching[0].name}' is not in district '{district}' in this plan."
126
+ )
127
+ raise EngineInputError(
128
+ f"Government entity '{entity_name}' was not found in the plan's government structure."
129
+ )
130
+
131
+ entity_type = "local"
132
+ canonical_name = locals_found[0].name
133
+ canonical_code = locals_found[0].code
134
+ managed_districts = [
135
+ (plan.government.find_district(ll.district).name if plan.government.find_district(ll.district) else ll.district)
136
+ for ll in locals_found
137
+ ]
138
+ if len(set(managed_districts)) > 1:
139
+ raise EngineInputError(
140
+ f"'{canonical_name}' is a local government in more than one district "
141
+ f"{managed_districts}. Say which one with the 'district' field."
142
+ )
143
+
144
+ if district is not None and entity_type != "local":
145
+ raise EngineInputError(
146
+ "'district' can only be given for a local government "
147
+ f"('{canonical_name}' is a {entity_type} government)."
148
+ )
149
+
150
+ if requested_type is not None and norm(requested_type) != norm(entity_type):
151
+ raise EngineInputError(
152
+ f"Specified gov_type '{requested_type}' does not match entity '{entity_name}' (expected '{entity_type}')."
153
+ )
154
+
155
+ return entity_type, canonical_name, canonical_code, managed_districts
156
+
157
+
158
+ def calculate_government_liability(
159
+ plan: ImplementationPlan,
160
+ profile: GovEntityProfile | dict,
161
+ beneficiary_counts: list[BeneficiaryCount | dict],
162
+ ) -> GovernmentLiabilityOutput:
163
+ """Calculate government liability, donation offsets across tranches, and district breakdowns."""
164
+ prof = parse(GovEntityProfile, profile)
165
+
166
+ if norm(prof.plan_id) != norm(plan.plan_id):
167
+ raise EngineInputError(
168
+ f"plan_id '{prof.plan_id}' does not match this plan ('{plan.plan_id}')"
169
+ )
170
+
171
+ entity_type, canonical_name, canonical_code, managed_districts = _resolve_entity(
172
+ plan, prof.gov_entity_name, prof.gov_type, prof.district
173
+ )
174
+
175
+ valid_counts = validate_beneficiary_counts(plan, beneficiary_counts)
176
+
177
+ # Counts inside this government's districts
178
+ managed_norms = {norm(d) for d in managed_districts}
179
+ in_scope = [b for b in valid_counts if norm(b.district) in managed_norms]
180
+
181
+ if entity_type == "local":
182
+ relevant_counts = [
183
+ b for b in in_scope if b.local_level and norm(b.local_level) == norm(canonical_name)
184
+ ]
185
+ unassigned_beneficiaries = sum(
186
+ b.count for b in in_scope if not b.local_level)
187
+ else:
188
+ relevant_counts = in_scope
189
+ unassigned_beneficiaries = 0
190
+
191
+ liability = plan.government_liability
192
+ liability_pct = {
193
+ "federal": liability.federal,
194
+ "province": liability.province,
195
+ "local": liability.local,
196
+ }[entity_type]
197
+
198
+ # Beneficiary summary (names are already in the plan's spelling and region comes from the plan)
199
+ grouped: dict[tuple[str, str, str], int] = {}
200
+ for b in relevant_counts:
201
+ key = (b.disaster_type, b.region, b.solution_type)
202
+ grouped[key] = grouped.get(key, 0) + b.count
203
+ summary_list = [
204
+ BeneficiaryGroupSummary(
205
+ disaster_type=dis, region=reg, solution_type=sol, count=n)
206
+ for (dis, reg, sol), n in grouped.items()
207
+ ]
208
+ total_beneficiaries = sum(b.count for b in relevant_counts)
209
+
210
+ # Districts with beneficiaries, in the plan's order
211
+ active_districts = [
212
+ d.name
213
+ for d in plan.government.districts
214
+ if norm(d.name) in managed_norms and any(norm(b.district) == norm(d.name) for b in in_scope)
215
+ ]
216
+
217
+ # One schedule per district: the same numbers the district output uses
218
+ schedules = {
219
+ d: build_district_schedule(
220
+ plan, d, [b for b in in_scope if norm(b.district) == norm(d)]
221
+ )
222
+ for d in active_districts
223
+ }
224
+
225
+ def share_of_round(district: str, tranche_round) -> int:
226
+ if entity_type == "federal":
227
+ return tranche_round.federal
228
+ if entity_type == "province":
229
+ return tranche_round.province
230
+ local_level = next(
231
+ ll for ll in schedules[district].local_levels if norm(ll) == norm(canonical_name)
232
+ )
233
+ return tranche_round.local_shares[local_level]
234
+
235
+ # Donations are pooled and spent from tranche 1 onward; the surplus rolls over.
236
+ donations = sorted(
237
+ prof.donations, key=lambda d: (
238
+ d.received_date is None, d.received_date or date.min)
239
+ )
240
+ total_donations_received = sum(d.amount for d in donations)
241
+ available_donation = total_donations_received
242
+
243
+ tranche_outputs: list[GovTrancheLiability] = []
244
+ total_gross = 0
245
+ total_donations_used = 0
246
+
247
+ for index in range(max_tranche_count(plan)):
248
+ district_gross = {d: share_of_round(
249
+ d, schedules[d].rounds[index]) for d in active_districts}
250
+ stages = sorted(
251
+ {s for d in active_districts for s in schedules[d].rounds[index].stages})
252
+
253
+ tranche_gross = sum(district_gross.values())
254
+ donation_applied = min(available_donation, tranche_gross)
255
+ net_payable = tranche_gross - donation_applied
256
+ available_donation -= donation_applied
257
+
258
+ total_gross += tranche_gross
259
+ total_donations_used += donation_applied
260
+
261
+ # The donation relief is shared between districts in proportion to what each one needs
262
+ offsets = split_by_weights(
263
+ donation_applied, [district_gross[d] for d in active_districts])
264
+ district_payments = [
265
+ GovDistrictPayment(
266
+ district=d,
267
+ district_code=schedules[d].code,
268
+ gross_liability=district_gross[d],
269
+ donation_offset=offset,
270
+ net_payable=district_gross[d] - offset,
271
+ )
272
+ for d, offset in zip(active_districts, offsets)
273
+ ]
274
+
275
+ tranche_outputs.append(
276
+ GovTrancheLiability(
277
+ tranche=index + 1,
278
+ stages=stages,
279
+ gross_liability=tranche_gross,
280
+ donation_applied=donation_applied,
281
+ net_payable=net_payable,
282
+ remaining_donation=available_donation,
283
+ districts=district_payments,
284
+ )
285
+ )
286
+
287
+ return GovernmentLiabilityOutput(
288
+ plan_id=plan.plan_id,
289
+ gov_entity_name=canonical_name,
290
+ gov_entity_code=canonical_code,
291
+ gov_type=entity_type,
292
+ liability_percentage=liability_pct,
293
+ total_donations_received=total_donations_received,
294
+ donations=donations,
295
+ total_beneficiaries=total_beneficiaries,
296
+ unassigned_beneficiaries=unassigned_beneficiaries,
297
+ beneficiary_summary=summary_list,
298
+ tranches=tranche_outputs,
299
+ total_gross_liability=total_gross,
300
+ total_donation_applied=total_donations_used,
301
+ total_net_payable=total_gross - total_donations_used,
302
+ final_remaining_donation=available_donation,
303
+ )
File without changes