From 25cd2323dfd72e32c43afe2a37f31c1f541cceac Mon Sep 17 00:00:00 2001 From: MrWaradana Date: Thu, 11 Dec 2025 15:33:11 +0700 Subject: [PATCH] update manpower and recalculation plant --- src/manpower_master/model.py | 13 + src/manpower_master/router.py | 95 +++++ src/manpower_master/schema.py | 33 ++ src/manpower_master/service.py | 94 ++++ src/masterdata/model.py | 3 +- src/masterdata/schema.py | 2 + src/masterdata/service.py | 400 +++++++++--------- .../insert_actual_data.cpython-311.pyc | Bin 49467 -> 53899 bytes 8 files changed, 442 insertions(+), 198 deletions(-) create mode 100644 src/manpower_master/model.py create mode 100644 src/manpower_master/router.py create mode 100644 src/manpower_master/schema.py create mode 100644 src/manpower_master/service.py diff --git a/src/manpower_master/model.py b/src/manpower_master/model.py new file mode 100644 index 0000000..9bff91f --- /dev/null +++ b/src/manpower_master/model.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Float, Integer, String +from src.database.core import Base +from src.models import DefaultMixin, IdentityMixin + + +class ManpowerMaster(Base, DefaultMixin, IdentityMixin): + __tablename__ = "lcc_ms_manpower" + + staff_job_level = Column(String, nullable=False) + salary_per_month_idr = Column(Float, nullable=False) + salary_per_day_idr = Column(Float, nullable=False) + salary_per_hour_idr = Column(Float, nullable=False) + diff --git a/src/manpower_master/router.py b/src/manpower_master/router.py new file mode 100644 index 0000000..45c4d8d --- /dev/null +++ b/src/manpower_master/router.py @@ -0,0 +1,95 @@ +from typing import Optional +from fastapi import APIRouter, HTTPException, status, Query + +from src.manpower_cost.model import ManpowerCost +from src.manpower_cost.schema import ManpowerCostPagination, ManpowerCostRead, ManpowerCostCreate, ManpowerCostUpdate +from src.manpower_cost.service import get, get_all, create, update, delete + +from src.database.service import CommonParameters, search_filter_sort_paginate +from src.database.core import DbSession +from src.auth.service import CurrentUser +from src.models import StandardResponse + +router = APIRouter() + + +@router.get("", response_model=StandardResponse[ManpowerCostPagination]) +async def get_yeardatas( + db_session: DbSession, + common: CommonParameters, + items_per_page: Optional[int] = Query(5), + search: Optional[str] = Query(None), +): + """Get all acquisition_cost_data pagination.""" + get_acquisition_cost_data = await get_all( + db_session=db_session, + items_per_page=items_per_page, + search=search, + common=common, + ) + # return + return StandardResponse( + data=get_acquisition_cost_data, + message="Data retrieved successfully", + ) + + +@router.get("/{acquisition_cost_data_id}", response_model=StandardResponse[ManpowerCostRead]) +async def get_acquisition_cost_data(db_session: DbSession, acquisition_cost_data_id: str): + acquisition_cost_data = await get(db_session=db_session, acquisition_cost_data_id=acquisition_cost_data_id) + if not acquisition_cost_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A data with this id does not exist.", + ) + + return StandardResponse(data=acquisition_cost_data, message="Data retrieved successfully") + + +@router.post("", response_model=StandardResponse[ManpowerCostRead]) +async def create_acquisition_cost_data( + db_session: DbSession, acquisition_cost_data_in: ManpowerCostCreate, current_user: CurrentUser +): + acquisition_cost_data_in.created_by = current_user.name + acquisition_cost_data = await create(db_session=db_session, acquisition_data_in=acquisition_cost_data_in) + + return StandardResponse(data=acquisition_cost_data, message="Data created successfully") + + +@router.put("/{acquisition_cost_data_id}", response_model=StandardResponse[ManpowerCostRead]) +async def update_acquisition_cost_data( + db_session: DbSession, + acquisition_cost_data_id: str, + acquisition_cost_data_in: ManpowerCostUpdate, + current_user: CurrentUser, +): + acquisition_cost_data = await get(db_session=db_session, acquisition_cost_data_id=acquisition_cost_data_id) + + if not acquisition_cost_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A data with this id does not exist.", + ) + acquisition_cost_data_in.updated_by = current_user.name + + return StandardResponse( + data=await update( + db_session=db_session, acquisition_data=acquisition_cost_data, acquisition_data_in=acquisition_cost_data_in + ), + message="Data updated successfully", + ) + + +@router.delete("/{acquisition_cost_data_id}", response_model=StandardResponse[ManpowerCostRead]) +async def delete_acquisition_cost_data(db_session: DbSession, acquisition_cost_data_id: str): + acquisition_cost_data = await get(db_session=db_session, acquisition_cost_data_id=acquisition_cost_data_id) + + if not acquisition_cost_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A data with this id does not exist."}], + ) + + await delete(db_session=db_session, acquisition_cost_data_id=acquisition_cost_data_id) + + return StandardResponse(message="Data deleted successfully", data=acquisition_cost_data) diff --git a/src/manpower_master/schema.py b/src/manpower_master/schema.py new file mode 100644 index 0000000..0c56142 --- /dev/null +++ b/src/manpower_master/schema.py @@ -0,0 +1,33 @@ +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from pydantic import Field +from src.models import DefaultBase, Pagination + + +class ManpowerCostBase(DefaultBase): + staff_job_level: str = Field(..., nullable=False) + salary_per_month_idr: float = Field(..., nullable=False) + salary_per_day_idr: float = Field(..., nullable=False) + salary_per_hour_idr: float = Field(..., nullable=False) + created_at: Optional[datetime] = Field(None, nullable=True) + updated_at: Optional[datetime] = Field(None, nullable=True) + created_by: Optional[str] = Field(None, nullable=True) + updated_by: Optional[str] = Field(None, nullable=True) + + +class ManpowerCostCreate(ManpowerCostBase): + pass + + +class ManpowerCostUpdate(ManpowerCostBase): + pass + + +class ManpowerCostRead(ManpowerCostBase): + id: UUID + + +class ManpowerCostPagination(Pagination): + items: List[ManpowerCostRead] = [] diff --git a/src/manpower_master/service.py b/src/manpower_master/service.py new file mode 100644 index 0000000..d2ba2ae --- /dev/null +++ b/src/manpower_master/service.py @@ -0,0 +1,94 @@ +from sqlalchemy import Select, Delete, cast, String +from src.manpower_cost.model import ManpowerCost +from src.manpower_cost.schema import ManpowerCostCreate, ManpowerCostUpdate +from src.database.service import search_filter_sort_paginate +from typing import Optional + +from src.database.core import DbSession +from src.auth.service import CurrentUser +from src.equipment.model import Equipment + + +def _calculate_cost_unit_3(cost_unit_3_n_4: Optional[float]) -> Optional[float]: + """Derive cost_unit_3 by splitting the combined unit 3&4 cost evenly.""" + if cost_unit_3_n_4 is None: + return None + return cost_unit_3_n_4 / 2 + + +async def _sync_equipment_acquisition_costs( + *, db_session: DbSession, category_no: Optional[str], cost_unit_3: Optional[float] +): + """Keep equipment manpower cost in sync for the affected category.""" + if not category_no or cost_unit_3 is None: + return + + equipment_query = Select(Equipment).filter(Equipment.category_no == category_no) + equipment_result = await db_session.execute(equipment_query) + equipments = equipment_result.scalars().all() + + for equipment in equipments: + if equipment.proportion is None: + continue + equipment.acquisition_cost = (equipment.proportion * 0.01) * cost_unit_3 + + +async def get(*, db_session: DbSession, manpower_cost_id: str) -> Optional[ManpowerCost]: + """Returns a document based on the given document id.""" + query = Select(ManpowerCost).filter(ManpowerCost.id == manpower_cost_id) + result = await db_session.execute(query) + return result.scalars().one_or_none() + + +async def get_all( + *, + db_session: DbSession, + items_per_page: Optional[int], + search: Optional[str] = None, + common, +): + """Returns all documents.""" + query = Select(ManpowerCost).order_by(ManpowerCost.salary_per_month_idr.asc()) + if search: + query = query.filter( + (cast(ManpowerCost.staff_job_level, String).ilike(f"%{search}%")) + | (cast(ManpowerCost.salary_per_month_idr, String).ilike(f"%{search}%")) + ) + + common["items_per_page"] = items_per_page + results = await search_filter_sort_paginate(model=query, **common) + + # return results.scalars().all() + return results + + +async def create(*, db_session: DbSession, manpower_cost_in: ManpowerCostCreate): + """Creates a new document.""" + data = manpower_cost_in.model_dump() + + manpower_cost = ManpowerCost(**data) + db_session.add(manpower_cost) + + await db_session.commit() + return manpower_cost + +async def update( + *, db_session: DbSession, manpower_cost: ManpowerCost, manpower_cost_in: ManpowerCostUpdate +): + """Updates a document.""" + data = manpower_cost_in.model_dump() + update_data = manpower_cost_in.model_dump(exclude_defaults=True) + for field in data: + if field in update_data: + setattr(manpower_cost, field, update_data[field]) + + await db_session.commit() + + return manpower_cost + + +async def delete(*, db_session: DbSession, manpower_cost_id: str): + """Deletes a document.""" + query = Delete(ManpowerCost).where(ManpowerCost.id == manpower_cost_id) + await db_session.execute(query) + await db_session.commit() diff --git a/src/masterdata/model.py b/src/masterdata/model.py index daa3bf5..5eae81f 100644 --- a/src/masterdata/model.py +++ b/src/masterdata/model.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Float, String +from sqlalchemy import Column, Float, String, Integer from src.database.core import Base from src.models import DefaultMixin, IdentityMixin @@ -15,3 +15,4 @@ class MasterData(Base, DefaultMixin, IdentityMixin): unit_of_measurement = Column(String, nullable=True) value_num = Column(Float, nullable=True) value_str = Column(String, nullable=True) + seq = Column(Integer, nullable=True) diff --git a/src/masterdata/schema.py b/src/masterdata/schema.py index 4def6a0..0f55cff 100644 --- a/src/masterdata/schema.py +++ b/src/masterdata/schema.py @@ -18,6 +18,7 @@ class MasterdataBase(DefaultBase): None, nullable=True, le=1_000_000_000_000_000 # 1 quadrillion ) value_str: Optional[str] = Field(None, nullable=True) + seq: Optional[int] = Field(None, nullable=True) created_at: Optional[datetime] = Field(None, nullable=True) updated_at: Optional[datetime] = Field(None, nullable=True) created_by: Optional[str] = Field(None, nullable=True) @@ -32,6 +33,7 @@ class MasterDataCreate(MasterdataBase): ..., nullable=True, le=1_000_000_000_000_000 # 1 quadrillion ) value_str: str = Field(..., nullable=True) + seq: int = Field(..., nullable=True) class MasterDataUpdate(MasterdataBase): diff --git a/src/masterdata/service.py b/src/masterdata/service.py index 6a12d3a..49e896e 100644 --- a/src/masterdata/service.py +++ b/src/masterdata/service.py @@ -7,12 +7,181 @@ from datetime import datetime from src.database.service import search_filter_sort_paginate from .model import MasterData from .schema import MasterDataCreate, MasterDataUpdate -from typing import Optional, List +from typing import Optional, List, Dict from src.database.core import DbSession from src.auth.service import CurrentUser +MASTERDATA_ATTR_FIELDS = { + "name", + "description", + "unit_of_measurement", + "value_num", + "value_str", + "created_by", + "updated_by", +} + + +async def _apply_masterdata_update_logic( + *, + db_session: DbSession, + masterdata: MasterData, + masterdata_in: MasterDataUpdate, + records_by_name: Dict[str, MasterData], +): + """Apply the same field update logic used in bulk_update.""" + update_data = masterdata_in.model_dump(exclude_defaults=True) + + async def get_value(name: str) -> float: + record = records_by_name.get(name) + if record is not None and record.value_num is not None: + return record.value_num + + query_val = Select(MasterData).where(MasterData.name == name) + res_val = await db_session.execute(query_val) + row = res_val.scalars().one_or_none() + if row: + records_by_name[row.name] = row + return row.value_num if row.value_num is not None else 0 + return 0 + + run_plant_calculation = False + umur_changed = False + umur_new_value = None + discount_rate_changed = False + + def flag_special(record: MasterData): + """ + Menandai perubahan pada field khusus dalam data master. + Fungsi ini memeriksa record MasterData dan mengidentifikasi perubahan + pada field 'umur_teknis' dan 'discount_rate'. Jika field tersebut + ditemukan, fungsi akan mengatur flag nonlocal dan menyimpan nilai baru. + Args: + record (MasterData): Object MasterData yang akan diperiksa. + Side Effects: + - Mengatur umur_changed = True jika rec_name == "umur_teknis" + - Mengatur umur_new_value dengan nilai numerik dari record + - Mengatur discount_rate_changed = True jika rec_name == "discount_rate" + """ + nonlocal run_plant_calculation + rec_name = getattr(record, "name", None) + if rec_name in [ + "umur_teknis", + "discount_rate", + "loan_portion", + "interest_rate", + "loan_tenor", + "corporate_tax_rate", + "wacc_on_equity", + "auxiliary", + "susut_trafo", + "sfc", + "electricity_price_a", + "electricity_price_b", + "electricity_price_c", + "electricity_price_d", + "harga_bahan_bakar", + "inflation_rate", + "loan", + "wacc_on_project", + "principal_interest_payment", + "equity", + ]: + run_plant_calculation = True + + for field, val in update_data.items(): + if field in MASTERDATA_ATTR_FIELDS: + setattr(masterdata, field, val) + flag_special(masterdata) + else: + query_other = Select(MasterData).where(MasterData.name == field) + res_other = await db_session.execute(query_other) + other = res_other.scalars().one_or_none() + if other: + if isinstance(val, (int, float)): + other.value_num = val + flag_special(other) + else: + other.value_str = str(val) + if other.name: + records_by_name[other.name] = other + + if "loan_portion" in update_data: + equity_portion = 100 - await get_value("loan_portion") + setattr(masterdata, "equity_portion", equity_portion) + + total_project_cost = await get_value("total_project_cost") + loan = total_project_cost * (await get_value("loan_portion") / 100) + setattr(masterdata, "loan", loan) + + equity = total_project_cost * (equity_portion / 100) + setattr(masterdata, "equity", equity) + + if any(field in update_data for field in ["loan", "interest_rate", "loan_tenor"]): + pmt = calculate_pmt( + rate=await get_value("interest_rate"), + nper=await get_value("loan_tenor"), + pv=await get_value("loan"), + ) + setattr(masterdata, "principal_interest_payment", pmt) + + if any( + field in update_data + for field in [ + "loan_portion", + "interest_rate", + "corporate_tax_rate", + "wacc_on_equity", + "equity_portion", + ] + ): + wacc = ( + await get_value("loan_portion") + * ( + await get_value("interest_rate") + * (1 - await get_value("corporate_tax_rate")) + ) + ) + ( + await get_value("wacc_on_equity") * await get_value("equity_portion") + ) + setattr(masterdata, "wacc_on_project", wacc) + + return masterdata, run_plant_calculation + + +async def _trigger_masterdata_recalculation( + *, db_session: DbSession, run_plant_calculation: bool = False +): + """Run downstream recalculation when special masterdata values change.""" + if not run_plant_calculation: + return + + try: + current_year = datetime.now(datetime.timezone.utc).year + + directory_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "../modules/plant") + ) + script_path = os.path.join(directory_path, "run2.py") + + process = await asyncio.create_subprocess_exec( + "python", + script_path, + stdout=PIPE, + stderr=PIPE, + cwd=directory_path, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + print(f"Plant recalc error: {stderr.decode()}") + else: + print(f"Plant recalc output: {stdout.decode()}") + except Exception as e: + print(f"Error during umur_teknis recalculation: {e}") + + def calculate_pmt(rate, nper, pv): """ rate: interest rate per period @@ -40,7 +209,7 @@ async def get_all( *, db_session: DbSession, items_per_page: int, search: str = None, common ) -> list[MasterData]: """Returns all documents.""" - query = Select(MasterData) + query = Select(MasterData).order_by(MasterData.seq.asc()) if search: query = query.filter(MasterData.name.ilike(f"%{search}%")) @@ -61,65 +230,28 @@ async def create(*, db_session: DbSession, masterdata_in: MasterDataCreate): async def update( *, db_session: DbSession, masterdata: MasterData, masterdata_in: MasterDataUpdate ): - """Updates a document.""" - data = masterdata_in.model_dump() - update_data = masterdata_in.model_dump(exclude_defaults=True) - - def get_value(data_list, name): - return next((m.value_num for m in data_list if m.name == name), 0) - - # First update the direct values from update_data - for field in update_data: - setattr(masterdata, field, update_data[field]) - - # Then check which formulas need to be recalculated based on updated fields - if "loan_portion" in update_data: - # Update equity_portion when loan_portion changes - equity_portion = 100 - get_value(masterdata, "loan_portion") - setattr(masterdata, "equity_portion", equity_portion) - - # Update loan amount when loan_portion changes - total_project_cost = get_value(masterdata, "total_project_cost") - loan = total_project_cost * (get_value(masterdata, "loan_portion") / 100) - setattr(masterdata, "loan", loan) - - # Update equity when loan_portion changes - equity = total_project_cost * (equity_portion / 100) - setattr(masterdata, "equity", equity) + """Updates a document using the same logic as bulk_update.""" + records_by_name: Dict[str, MasterData] = {} + if masterdata.name: + records_by_name[masterdata.name] = masterdata + + ( + _, + run_plant_calculation, + ) = await _apply_masterdata_update_logic( + db_session=db_session, + masterdata=masterdata, + masterdata_in=masterdata_in, + records_by_name=records_by_name, + ) - if any(field in update_data for field in ["loan", "interest_rate", "loan_tenor"]): - # Recalculate PMT when loan, interest_rate, or loan_tenor changes - pmt = calculate_pmt( - rate=get_value(masterdata, "interest_rate"), - nper=get_value(masterdata, "loan_tenor"), - pv=get_value(masterdata, "loan"), - ) - setattr(masterdata, "principal_interest_payment", pmt) + await db_session.commit() - if any( - field in update_data - for field in [ - "loan_portion", - "interest_rate", - "corporate_tax_rate", - "wacc_on_equity", - "equity_portion", - ] - ): - # Recalculate WACC when any of its components change - wacc = ( - get_value(masterdata, "loan_portion") - * ( - get_value(masterdata, "interest_rate") - * (1 - get_value(masterdata, "corporate_tax_rate")) - ) - ) + ( - get_value(masterdata, "wacc_on_equity") - * get_value(masterdata, "equity_portion") - ) - setattr(masterdata, "wacc_on_project", wacc) + await _trigger_masterdata_recalculation( + db_session=db_session, + run_plant_calculation=run_plant_calculation, + ) - await db_session.commit() return masterdata @@ -147,10 +279,6 @@ async def bulk_update( records_map = {str(record.id): record for record in records} records_by_name = {record.name: record for record in records} - # Track if umur_teknis was changed in this batch - umur_changed = False - umur_new_value = None - # Process updates in batches updated_records = [] for masterdata_id, masterdata_in in zip(ids, updates): @@ -159,148 +287,26 @@ async def bulk_update( print("Processing update for ID:", masterdata) if not masterdata: continue + ( + _, + run_plant_calculation, + ) = await _apply_masterdata_update_logic( + db_session=db_session, + masterdata=masterdata, + masterdata_in=masterdata_in, + records_by_name=records_by_name, + ) - data = masterdata_in.model_dump() - update_data = masterdata_in.model_dump(exclude_defaults=True) - - async def get_value(name): - # Prefer values from the current batch (records_by_name) - rd = records_by_name.get(name) - if rd is not None and rd.value_num is not None: - return rd.value_num - - # If not found in batch, try to fetch from DB - query_val = Select(MasterData).where(MasterData.name == name) - res_val = await db_session.execute(query_val) - row = res_val.scalars().one_or_none() - return row.value_num if row and row.value_num is not None else 0 - - # Update direct values (attributes present on MasterData) - # Recognised attribute fields on MasterData - attr_fields = { - "name", - "description", - "unit_of_measurement", - "value_num", - "value_str", - "created_by", - "updated_by", - } - - for field, val in update_data.items(): - if field in attr_fields: - setattr(masterdata, field, val) - # If this record itself represents umur_teknis, detect change - if getattr(masterdata, "name", None) == "umur_teknis": - umur_changed = True - try: - umur_new_value = float(masterdata.value_num) - except Exception: - umur_new_value = None - else: - # Field is not a direct attribute: treat it as a named masterdata - # e.g. payload included {"discount_rate": 5.0} - query_other = Select(MasterData).where(MasterData.name == field) - res_other = await db_session.execute(query_other) - other = res_other.scalars().one_or_none() - if other: - # Update numeric or string value depending on payload - if isinstance(val, (int, float)): - other.value_num = val - if other.name == "umur_teknis": - umur_changed = True - try: - umur_new_value = float(other.value_num) - except Exception: - umur_new_value = None - else: - other.value_str = str(val) - # keep updated record available for batch calculations - records_by_name[other.name] = other - - # Handle interdependent calculations - if "loan_portion" in update_data: - equity_portion = 100 - await get_value("loan_portion") - setattr(masterdata, "equity_portion", equity_portion) - - total_project_cost = await get_value("total_project_cost") - loan = total_project_cost * (await get_value("loan_portion") / 100) - setattr(masterdata, "loan", loan) - - equity = total_project_cost * (equity_portion / 100) - setattr(masterdata, "equity", equity) - - if any(field in update_data for field in ["loan", "interest_rate", "loan_tenor"]): - pmt = calculate_pmt( - rate=await get_value("interest_rate"), - nper=await get_value("loan_tenor"), - pv=await get_value("loan"), - ) - setattr(masterdata, "principal_interest_payment", pmt) - - if any( - field in update_data - for field in [ - "loan_portion", - "interest_rate", - "corporate_tax_rate", - "wacc_on_equity", - "equity_portion", - ] - ): - wacc = ( - await get_value("loan_portion") - * ( - await get_value("interest_rate") - * (1 - await get_value("corporate_tax_rate")) - ) - ) + ( - await get_value("wacc_on_equity") * await get_value("equity_portion") - ) - setattr(masterdata, "wacc_on_project", wacc) updated_records.append(masterdata) print("Updated masterdata:", updated_records) # Commit all changes in a single transaction await db_session.commit() - # If umur_teknis changed, clear projection rows and trigger recalculation - if umur_changed: - try: - # Determine current year - current_year = datetime.now(datetime.timezone.utc).year - - # Import model locally to avoid circular imports - from src.plant_transaction_data.model import PlantTransactionData - - # Delete projection rows (is_actual == 0) from current year onward - del_q = Delete(PlantTransactionData).where( - PlantTransactionData.is_actual == 0, - PlantTransactionData.tahun >= current_year, - ) - await db_session.execute(del_q) - await db_session.commit() - - # Trigger recalculation by running the plant module script - directory_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), "../modules/plant") - ) - script_path = os.path.join(directory_path, "run.py") - - process = await asyncio.create_subprocess_exec( - "python", - script_path, - stdout=PIPE, - stderr=PIPE, - cwd=directory_path, - ) - stdout, stderr = await process.communicate() - if process.returncode != 0: - print(f"Plant recalc error: {stderr.decode()}") - else: - print(f"Plant recalc output: {stdout.decode()}") - except Exception as e: - print(f"Error during umur_teknis recalculation: {e}") + await _trigger_masterdata_recalculation( + db_session=db_session, + run_plant_calculation=run_plant_calculation, + ) return updated_records diff --git a/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc b/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc index efcca10405ad35f4a6564b3b65d5e1d0cc1d58c7..a685b17439630a984e9479d0f1cfbe886ffebc2e 100644 GIT binary patch delta 9464 zcmc&)3v`pmmHy|~`(@ddWXZB*OSWZOe(*BHyc|O@1Sl8+Boqh;S-%WUY{?nPI3~X$ zXgM20NMbTcplQ<3c1cOdgVcRw+pT_k=PnU@Mcq<0h#Ey9 zEZo_I5`2JbWF4a}BM~IBm4EzoWAjA2mh53`^{aKA>$YxN*R^V!XY4`!HigmG^4+d) zccp6=W@;CXy>Cho)3YM22Sc<0;8`|gZqUsK>H+wC$j+K?Vt-$BAbDBufoJRL4XeRW z|D5V|ou012{_ZfX_B12k73>dJBPI17=#BM8Bh4NZZ;r-#O&dLZp-`Yd7U&N~2BJe@>gnf2ec_(CC%Jypx~{y!WN9E2jm147 zuCl4ivu(?|jSU+%ZC|~mVP>;_zo&g>8T26@?CA;I8SM`Ag%5=L{C;kM&cy+k2N1)d z_IRiVzH%k|!tsCle{A*xK;L#ro>*pJNhe5e|^#aOs69Ey7y zJv}tq?-`1MfqQ!Uqk+C)cbohf-F? zRqW*lou$u!(R0&=i69Fsc(m5IRq(62s1HmZEN7!edyy}8@OAy_^2{jm^u|1qXxtMS?CaC- zqtU?uPxr7#k9NzN_0!g7)L_M?9Z6i9eq~M_92f{kB)*z+s`%hQUzjgyzl!>y%M^V( zX9>n)FsnVieQ{Wap}yc?ESysh1Zl88M$xNcDgd-Nxd?32voSe^{FUf((0T^d<3bNE zgsjt*Qrfd7^Px3ajd?;4Ri13+l9aY0t1Wuibj&omKdr6GXsc4%stIMONjqT`)a4U4 zq=YGeb<`lJ4EJ|^wQF?IQ@+#cueUtel2$fklnp6m!`Ls)>qXNi#KR}x6VgoEnWwZ@ zeKYiecCd#_n^kLp{Q20$(gkAcmr&Xc@Cj-8gy?c4kP`y|zmj%>ET`^^#cA&VtwXKV z0Dc8s$9%SVsx82bkL|QwR+24jv2zD`pFQF{)z*!QIPr8Z!YTkip&dvE5wIm1La8#Z z!KEPqcO53K|{CX=rdL5D)I7-K^EsrW%HVYhzKD zQQW5ma_Yeec(%3^J z)e&Pt7e`BzkECYLRW>FEisCr=laH)^hJY8qC;ue$vOcz=SCBN4mJvzEQP0u9(a6!n zv52FYV=>1Pj-?ze9IYH}9LoTcM6z?n!BHy}{wse{dj@uRQINGVbfVBj6NX8-ZkF6A z8>mVKJZKPv2$8f2)1;<;mS$0MvcNE_z|7@DGR~40Ps&ZRz4rf0f*vY>2M1?QyPK1*I9 zD`}&wWG}bcEzZJKbJ&)bSIm<0sc_Ax=lcA5t+V93p1W5#)-0c zxe7PH<8gR_*8B_Fy@fi5D|t;mD8$QNvb&OzqWH8ofooP5Ryfu)%UQ*drV-6Z^N0#U zr5bQqFO0qy_uO;SFPo^AjfonN>*ONYELT4RPJM3Lu@kj&Ny5iVOJ$3^X?Ag)Y)jPh z;xgI(naT~k#3@&Nro@k%fNWqtuU)@DvdQJJ8&zro#mVsJq z5LDs3G!&1tU$!_;wVqi1=<<}Y>axK$8h&bf+ThC=d=moEm1ixEkqr}~LgxX!r&>=9 zetqeaOVgIRjHNDRto!(KvFA)<%G`pwo*OXJCYVhbyQ(G>pz-m?AID0d>E|j|s)bk8 zo|S&Zs|^G&r=&rM1&W|7G`)0y!FS|yu@i{L2+W2!H5*CXhL0*_p>PFr87Y(fsE3XFAgm!QR z;9)_|@;T9cP7A%`u|8i-A+R|8;D*IVcLYY`G380&s6Hkh)om4eg#^%@!YJ8Yk{~iU zJza%8c*ex!kXanCvcqS}Swpc|oe*K6E7-~s=jMbWVy_Y6IB1hk9KwXDr$?+6sDcPP zgyXkPE%T$s!x~v})OZ)4c1n)#nYoYhHslrNQrIn(g_DZAr6B>~GVGQoj~enZ_2Ual zEh<@ch#Y@uwi&Ogu>R*;G$z!t8Z7>cESy%e6FyssdY@3}+wAU@WtH+QYXrvXxCO#| z4OG-Zd|zDzr^1$mR@Q?%$w$`4Z7fplbQM~U+olu*A#P_U9&oY?_M%3N z918nSgyUOf4P9DbQ}ab^R^)9a(dJeG=i9I8f?ypl3>0KSPB(o~$S*8aKHgplWqu>CWna*Ehse||$tD*D0^ zzbRM5jR*MJ?FEf|;{gmwi+Dc*!{HzeKy;eZ-{7Zcyv^j)`GrsMP|B4C4}@uOUs&R| zTy8w4owi=m9gKy!7bo%li`!1F4DXkpz3Ao2X8TiLG9!4-whbd) zuz#D%FS|XbE7R_I8TY(V{iy!q%U1VO2a_pFBW&BI@*9vXc8qFaw>Fi-Zhgh&XnIgUfy}ByfaMa>>z>cFfH$A(BU!Jxs&R7;-X!}KL>XsepRXa1QcBYr@$}HQJwqV7Mtl5*&dibIEig~40 zd{tYr(xrN}f&lu}c}GzMBf~jmcQo3UcRhakqnG(x*lYexi+_r%-o)4NRN{vI7-Teo z@E2HGx<42T&~Rvw#(EEgc|L_6W6h18)(24MIKqPn4D#EZKN_ z@(Corfq-FDPMi14UMXjsbQ}A()uVnL@l!_HARMYHP~Ad)&<1M(zAp^rzyCD;hhA{PREuskhpeOK^se(wsg5n|fhLy? zWKQ>cI@3shTIb5>ToWomWgmO4=`As76e>L_r88@GrOef*#R);BS`DiZaYO;DW;Awk z=#imQ_onSl8GBR8zV2-7rJ?7B&flA!yDBqx6$mcUv}Jw9vVPpM`I2RG+Oj2M*)pQd zn(esqY+0iiJRMofXnC#`KrX&WZAg3&dh z5Oq%W&>Tn7I$}s!Ysak(m#htEooVaBjCEmJyC|bwl+rGG&tx6ldi0LtcT8vmRoR5m zqw=1qpAg{d3qvOn-1Aeed1=>zjB7zkS$@U25OX#5+R;Q-Tb|5in%j%{?96&mQ&CM*oQn2HsQf0JkW$|!ss-rq4`r|&&^w^B|F)Bi~m+d&%-)m z-&@jX;f{v!OHK_dxW6|}?_wV;d58?L`<8A}eE_luc@nq1LM><#97Yzk5_s;CYUb2-f8Q!P< zH&hv7&kV02G4}rOX0ej6j)S+TmcR(VJodoBh9Y%0T#@egv-zQ$#y%JcD9EL;O~>ye zV!R&G!WH$-I5sf($Vz#3!lpvfBwX<(_Ui}hYyR$-U$#j4gAk)6Eb*49T7od*hZLOs zh*q|-2Op|26_l`z5BcqtpV2M{Z3p}Gq3WW-B6b1PJ%utbUIFUm%>9s8=apQEYMme} zFa&nV)pGGse~(xg^+I-AaoW!AeyoPc4;$?@k!H9>Xm9iv?hRFmTK4qAHd4#3c3C&< z5F{Z{dtdGG-{-%F$kc@*KL18FNB~sIRkBC+9`(zRMR*2Go|`tC@X0l?-{B03OG*z=Plr>3%HsBuC{>l6>xP0Ts?AlKZOSxY$z!77jTUQ zTvGwpT)?#yaB~W{xdq%j&h46NettpWf !fLmC=Eh^w{n#r*roY<87Gmb638jSCs zZEW-HL-YR+V=J}I7-@7d_h(!TM*g4x%Rk{76_5Xlbwrz3?lC9JtZQVIk2kXFM;l2U z3qIcHOw`MIs)cl(9`A_KVoaSt(sOa5ft`4?UQu`2#a*@dF{PnhHp0(WH_P?YGBs&n z_Q$p>@{)Eq+zK0tkbLd)k2lY+otYGq*2}fC`#k065pCYj$4n=GC0eh;K+=ClNCO1F z1^EU&OKn*(*7C%V4o=`R-)WvR>tynw_BcW@LKdL+9XK{N$Y)^wb4nb;B*j!R2+r`5XR67kkDU3)-2uBduR>P<^*L&40)3<_nfJTj`2xzS z|An%sQ!>@rFVF?iq#RrNteUj)Blj4-(*p=!B=~1fzu;O(zmDoR0{<@%zvtL%&#i>h zT6L~n_ZHOtg!t$`vM-;z8A7IG=Z5TVin}qzL=>$+$RFu=hO>FktCIXk#|hy<8T|uN z`~>Im4nM)0QHFay|5=#-CP@Da*-sI8^n@1!ig8>%IO0b;Keiih9NRfZC>n{t@5OLj z2L@zes(1b7@I8hy8spr2mb)q|7@QQlEmc0 zdKKYk2(KahoW1hG+^P~R#w$xsKM;xs_|Kv-z5rv5poKS_N46VS%ZnDnS3qkBmS5~8 zBRg@>#`eCbA%krHi>rw|_Tr1hgbcI4d1+i56Ls6g>q3lu^Zak!@3~se_M}~lGOk4< z8{hLSePzkT{b}E(jBiuQSj`T0@*!f+S{+%dH*0OYUZSfk{YU^9 z)lZa-%^QQZn%KKP-jJ-jqe*zZY(bY!`G%GN{f188wLtTR#n#oLd80vvk`@(87Ti(} z%$r8*?OyRs530WD)o-_|-fXsRC(3bw06k9h+lw^g1`%l!LApqRbcqQ1;}))CU8M&m zS7qI~P{<`)(Nb=qepkIB*J0gd72mQDpx?6UcX?HBRfw(P-V{72ZjD z^?T+h-f1C-3#* zoAi4ZYpyI3k!~YMFIFJE+!`!VUM)tQt0nqirRHh{>Rfdbq$^S9YV~SsuuXaOCa%<` z?^bHA5mdUSAV@1w>6+Hs?N(lMq0Tk8zPnL#t)A=n3DS+q?m6uJpAI|mZo>cXLOxxk z><{)v=q^}f-)Dy|%=gj}D2Ft8JAO6iG{M+#B-9(lU+E)K5TarB`h^Wi9%ft75|6cc z9IXj<#|DD&{WOAdFRt)vT;XtqiuCmEo8j=>hj|`RDfNcp)Qc5nx${jZTaK_60e=~% z!wBfp8z|nvC|*t|#?KU;B&Q7o@B$JD@YjLgwjrxo__m*Dm-mB%_l0k!`=JK>39%=~ ze)HEGNJp`bz~z1#%GAhCL$M24fU~Rk*KAnMsxQVYw<-wa24-N7nw`D4q;yzGAU`kz K?_nQaT>C#i@GCz6 delta 6097 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 Mb8tIb`u<)24gFW10ssI2