From 39a53c477abe4f67c4824f68a7c86987e50f70bc Mon Sep 17 00:00:00 2001 From: MrWaradana Date: Tue, 23 Dec 2025 17:57:48 +0700 Subject: [PATCH] feat: Introduce linear projection for plant cost breakdown and decouple plant simulation from simulation_id. --- .../__pycache__/service.cpython-311.pyc | Bin 14396 -> 14413 bytes src/masterdata/service.py | 2 +- src/masterdata_simulations/service.py | 2 +- src/modules/plant/run_plant_simulation.py | 281 +++++++++++------- src/plant_fs_transaction_data/router.py | 36 ++- src/plant_fs_transaction_data/schema.py | 7 +- src/plant_fs_transaction_data/service.py | 130 +++++++- .../__pycache__/service.cpython-311.pyc | Bin 14611 -> 14629 bytes src/plant_transaction_data/service.py | 4 +- .../service.py | 4 +- 10 files changed, 344 insertions(+), 122 deletions(-) diff --git a/src/masterdata/__pycache__/service.cpython-311.pyc b/src/masterdata/__pycache__/service.cpython-311.pyc index 47aea57482897f2b943194d3d6e7dbf7e46da032..24d5f9c046f23d0dd78a0c52b7966e48390905f2 100644 GIT binary patch delta 70 zcmdl}aJGPVIWI340}yz|cxA?I%Ni50C&)cjg)6B?N ZHhGh5BIgBTpNnF?SHyfbOUo%r0RSTY7d8L@ delta 53 zcmX?Gu&01`IWI340}zzDI%WE8 float: + v = float(a * yr + b) + # optional: kalau tidak boleh negatif, clamp + return max(0.0, v) + + # mode target_years: prediksi untuk tahun tertentu + if target_years is not None: + return {int(yr): _predict(int(yr)) for yr in target_years} + + # mode lama: generate dari tahun setelah histori + n_hist = len(years_clean) + if iterations <= n_hist: + raise ValueError( + f"iterations ({iterations}) harus lebih besar dari jumlah data historis ({n_hist})" + ) + start_year = max(years_clean) + 1 + n_projection = iterations - n_hist + return {start_year + i: _predict(start_year + i) for i in range(n_projection)} -def main(): - if not SIMULATION_ID: - print("Environment variable PLANT_SIMULATION_ID is required for simulations.") - sys.exit(1) + +def main(): connections = get_connection() conn = connections[0] if isinstance(connections, tuple) else connections if conn is None: @@ -98,22 +149,18 @@ def main(): cur = conn.cursor() - # ### LOCKING: kunci tabel simulasi + # ### LOCKING: kunci tabel lcc_plant_tr_data # Mode SHARE ROW EXCLUSIVE: # - Menghalangi INSERT/UPDATE/DELETE di tabel ini # - Menghalangi lock SHARE ROW EXCLUSIVE lain → script ngantri satu per satu - cur.execute(f"LOCK TABLE {PLANT_TABLE} IN SHARE ROW EXCLUSIVE MODE") + cur.execute("LOCK TABLE lcc_plant_tr_data IN SHARE ROW EXCLUSIVE MODE") # 0 Mendapatkan master parameter dari tabel lcc_ms_master - cur.execute( - f""" - SELECT name, - value_num AS value - FROM {MASTER_TABLE} - WHERE simulation_id = %s - """, - (SIMULATION_ID,), - ) + cur.execute(""" + SELECT name, + value_num AS value + FROM lcc_ms_master + """) param_rows = cur.fetchall() param_map = {name: val for (name, val) in param_rows} @@ -124,43 +171,29 @@ def main(): # 0-1 Generate New data Projection (is_actual=0) if not exist # Hapus data projection lama (is_actual = 0) - cur.execute( - f""" - DELETE - FROM {PLANT_TABLE} - WHERE is_actual = 0 - AND simulation_id = %s - """, - (SIMULATION_ID,), - ) + cur.execute(""" + DELETE + FROM lcc_plant_tr_data + WHERE is_actual = 0 + """) # Hitung kebutuhan jumlah baris projection baru agar total (actual + projection) # sama dengan parameter umur_teknis - cur.execute( - f""" - SELECT COALESCE(COUNT(*), 0) - FROM {PLANT_TABLE} - WHERE is_actual = 1 - AND simulation_id = %s - """, - (SIMULATION_ID,), - ) + cur.execute(""" + SELECT COALESCE(COUNT(*), 0) + FROM lcc_plant_tr_data + WHERE is_actual = 1 + """) count_actual = cur.fetchone()[0] if cur.rowcount != -1 else 0 umur_teknis = int(get_param("umur_teknis")) proj_needed = max(0, umur_teknis - int(count_actual)) # Ambil seq dan tahun terakhir sebagai titik awal penomoran berikutnya - cur.execute( - f"SELECT COALESCE(MAX(seq), 0) FROM {PLANT_TABLE} WHERE simulation_id = %s", - (SIMULATION_ID,), - ) + cur.execute("SELECT COALESCE(MAX(seq), 0) FROM lcc_plant_tr_data") last_seq = int(cur.fetchone()[0]) - cur.execute( - f"SELECT COALESCE(MAX(tahun), 0) FROM {PLANT_TABLE} WHERE simulation_id = %s", - (SIMULATION_ID,), - ) + cur.execute("SELECT COALESCE(MAX(tahun), 0) FROM lcc_plant_tr_data") last_year = int(cur.fetchone()[0]) # Jika belum ada tahun sama sekali, gunakan tahun_cod-1 sebagai dasar @@ -181,29 +214,62 @@ def main(): next_year += 1 insert_sql = ( - f"INSERT INTO {PLANT_TABLE} (id, seq, tahun, is_actual, simulation_id, created_at, created_by) " - "VALUES (%s, %s, %s, 0, %s, CURRENT_TIMESTAMP, 'SYS')" + "INSERT INTO lcc_plant_tr_data (id, seq, tahun, is_actual, created_at, created_by) " + "VALUES (%s, %s, %s, 0, CURRENT_TIMESTAMP, 'SYS')" ) - sim_values = [(*val, SIMULATION_ID) for val in values] - cur.executemany(insert_sql, sim_values) + cur.executemany(insert_sql, values) # 1. Ambil data awal - select_sql = f""" - SELECT * - FROM {PLANT_TABLE} - WHERE simulation_id = %s - ORDER BY seq \ - """ - cur.execute(select_sql, (SIMULATION_ID,)) + select_sql = """ + SELECT * + FROM lcc_plant_tr_data + ORDER BY seq \ + """ + cur.execute(select_sql) col_names = [desc[0] for desc in cur.description] rows = cur.fetchall() + # ============================================================ + # PROYEKSI LINIER untuk COST BD berdasarkan histori (is_actual=1) + # ============================================================ + hist_years_om, hist_vals_om = [], [] + hist_years_pm, hist_vals_pm = [], [] + hist_years_bd, hist_vals_bd = [], [] + + projection_years = [] + + for r in rows: + d = dict(zip(col_names, r)) + yr = d.get("tahun") + if yr is None: + continue + yr = int(yr) + + if d.get("is_actual") == 1: + # ambil histori (boleh 0 kalau null) + hist_years_om.append(yr) + hist_vals_om.append(validate_number(d.get("cost_bd_om"))) + + hist_years_pm.append(yr) + hist_vals_pm.append(validate_number(d.get("cost_bd_pm_nonmi"))) + + hist_years_bd.append(yr) + hist_vals_bd.append(validate_number(d.get("cost_bd_bd"))) + else: + # tahun-tahun projection yang ingin diprediksi + projection_years.append(yr) + + # buat mapping prediksi per tahun untuk masing-masing komponen cost BD + proj_cost_bd_om = getproyeksilinier(hist_years_om, hist_vals_om, target_years=projection_years) + proj_cost_bd_pm = getproyeksilinier(hist_years_pm, hist_vals_pm, target_years=projection_years) + proj_cost_bd_bd = getproyeksilinier(hist_years_bd, hist_vals_bd, target_years=projection_years) + print(f"Jumlah baris yang akan di-update: {len(rows)}") # 2. Siapkan data untuk bulk UPDATE - update_sql = f""" - UPDATE {PLANT_TABLE} + update_sql = """ + UPDATE lcc_plant_tr_data SET net_capacity_factor = %s, eaf = %s, production_bruto = %s, @@ -267,12 +333,12 @@ def main(): chart_oem_periodic_maintenance_cost = %s, chart_oem_annualized = %s, chart_capex_component_a = %s, - chart_capex_biaya_investasi_tambahan = %s, - chart_capex_acquisition_cost = %s, - chart_capex_annualized = %s - WHERE seq = %s - AND simulation_id = %s \ - """ + chart_capex_biaya_investasi_tambahan = %s, + chart_capex_acquisition_cost = %s, + chart_capex_annualized = %s, + cost_disposal_cost = %s + WHERE seq = %s \ + """ # Ambil parameter dari tabel (fungsi get_param sudah kamu buat sebelumnya) discount_rate = get_param("discount_rate") / 100 @@ -305,7 +371,7 @@ def main(): tahun_cod = get_param("tahun_cod") daya_terpasang = get_param("daya_terpasang") equity = get_param("equity") - + params = [] revenue_total_array = [] cost_a_acquisition_array = [] @@ -333,32 +399,25 @@ def main(): net_capacity_factor_v = 0 eaf_v = 0 - # Prefetch CF/EAF master data once to avoid repeated queries per row - cur.execute( - """ - SELECT year as tahun, cf, eaf - FROM lcc_ms_year_data - """ - ) + # Prefetch master data CF dan EAF sekali saja di luar loop + cur.execute(""" + SELECT year as tahun, cf, eaf + FROM lcc_ms_year_data + order by year asc + """) year_rows = cur.fetchall() - year_data_map = { - int(t): (validate_number(cf), validate_number(eaf)) - for (t, cf, eaf) in year_rows - if t is not None - } + year_data_map = {int(t): (validate_number(cf), validate_number(eaf)) for (t, cf, eaf) in year_rows if + t is not None} for row in rows: # row adalah tuple sesuai urutan select_sql data = dict(zip(col_names, row)) seq = data["seq"] # primary key / unique key untuk WHERE + yr = int(data["tahun"]) if data.get("tahun") is not None else None - # Ambil net_capacity_factor dan eaf dari year-data cache berdasarkan tahun - cf_eaf = ( - year_data_map.get(int(data["tahun"])) - if data.get("tahun") is not None - else None - ) + # Ambil net_capacity_factor dan eaf dari cache berdasarkan tahun + cf_eaf = year_data_map.get(int(data["tahun"])) if data.get("tahun") is not None else None if cf_eaf: net_capacity_factor_v, eaf_v = cf_eaf else: @@ -366,8 +425,6 @@ def main(): eaf_v = 0 if data["is_actual"] == 1: - net_capacity_factor = validate_number(data["net_capacity_factor"]) - eaf = validate_number(data["eaf"]) production_bruto = validate_number(data["production_bruto"]) production_netto = validate_number(data["production_netto"]) energy_sales = production_netto @@ -381,8 +438,6 @@ def main(): cost_bd_pm_nonmi = validate_number(data["cost_bd_pm_nonmi"]) cost_bd_bd = validate_number(data["cost_bd_bd"]) else: - net_capacity_factor = net_capacity_factor # last value - eaf = eaf # last value production_netto = net_capacity_factor * 8760 * daya_mampu_netto / 100 production_bruto = production_netto / (100 - (auxiliary + susut_trafo)) * 100 energy_sales = production_netto @@ -392,9 +447,15 @@ def main(): revenue_c = price_c * production_netto * 1000 / 1000000 revenue_d = price_d * production_netto * 1000 / 1000000 cost_c_fuel = fuel_consumption * harga_bahan_bakar / 1000000 - cost_bd_om = cost_bd_om # last value - cost_bd_pm_nonmi = cost_bd_pm_nonmi # last value - cost_bd_bd = cost_bd_bd # last value + # default fallback tetap pakai last value kalau tahun kosong / prediksi tidak ada + if yr is not None: + cost_bd_om = proj_cost_bd_om.get(yr, cost_bd_om) + cost_bd_pm_nonmi = proj_cost_bd_pm.get(yr, cost_bd_pm_nonmi) + cost_bd_bd = proj_cost_bd_bd.get(yr, cost_bd_bd) + else: + cost_bd_om = cost_bd_om + cost_bd_pm_nonmi = cost_bd_pm_nonmi + cost_bd_bd = cost_bd_bd net_capacity_factor = net_capacity_factor_v eaf = eaf_v @@ -439,7 +500,8 @@ def main(): + cost_a_pm # + cost_a_pinjaman # + cost_a_depreciation - ) + ) + else: cost_a_replacement = 0 cost_a_pm = 0 @@ -455,11 +517,15 @@ def main(): chart_capex_component_a = cost_a_acquisition chart_capex_annualized = cost_a_annualized + + cost_disposal_cost = -npf.pmt(discount_rate, seq, 0, 0.05 * total_project_cost) else: chart_capex_component_a = total_project_cost chart_capex_annualized = 0 cost_a_pv = 0 cost_a_annualized = 0 + cost_disposal_cost = 0 + chart_capex_biaya_investasi_tambahan = 0 chart_capex_acquisition_cost = 0 @@ -544,6 +610,8 @@ def main(): calc4_free_cash_flow_on_equity_array.append(calc4_free_cash_flow_on_equity) calc4_discounted_fcf_on_equity = hitung_pv(wacc_on_equity, seq, calc4_free_cash_flow_on_equity) + + row_params = ( net_capacity_factor, eaf, @@ -611,8 +679,8 @@ def main(): chart_capex_biaya_investasi_tambahan, chart_capex_acquisition_cost, chart_capex_annualized, - seq, - SIMULATION_ID, + cost_disposal_cost, + seq # <-- penting: ini untuk WHERE ) params.append(tuple(normalize_db_value(v) for v in row_params)) @@ -639,12 +707,11 @@ def main(): ROA_TO_L = sum(calc2_earning_after_tax_array_sampai_sekarang) / sum( total_residual_value_array_sampai_sekarang) * 100 # dalam % - update_kpi_sql = f""" - UPDATE {MASTER_TABLE} - SET value_num = %s - WHERE name = %s - AND simulation_id = %s \ - """ + update_kpi_sql = """ + UPDATE lcc_ms_master + SET value_num = %s + WHERE name = %s \ + """ kpi_params_raw = [ (IRR_ON_EQUITY * 100, "calc_on_equity_irr"), @@ -656,11 +723,7 @@ def main(): ] kpi_params = [ - ( - None if (value is None or isinstance(value, float) and math.isnan(value)) else value, - key, - SIMULATION_ID, - ) + (None if (value is None or isinstance(value, float) and math.isnan(value)) else value, key) for value, key in kpi_params_raw ] diff --git a/src/plant_fs_transaction_data/router.py b/src/plant_fs_transaction_data/router.py index 1161643..65115c3 100644 --- a/src/plant_fs_transaction_data/router.py +++ b/src/plant_fs_transaction_data/router.py @@ -8,12 +8,15 @@ from src.database.service import CommonParameters from src.models import StandardResponse from .schema import ( - PlantFSTransactionDataCreate, - PlantFSTransactionDataPagination, - PlantFSTransactionDataRead, - PlantFSTransactionDataUpdate, + PlantFSTransactionDataCreate, + PlantFSTransactionDataImport, + PlantFSTransactionDataPagination, + PlantFSTransactionDataRead, + PlantFSTransactionDataUpdate, ) -from .service import create, delete, get, get_all, update +from .service import create, delete, get, get_all, update, update_fs_charts_from_matrix + +from typing import List router = APIRouter() @@ -119,3 +122,26 @@ async def delete_fs_transaction( return StandardResponse(data=record, message="Data deleted successfully") + + + +@router.post( + "/import/charts", + response_model=StandardResponse[List[PlantFSTransactionDataRead]], +) +async def import_fs_charts( + db_session: DbSession, + payload: PlantFSTransactionDataImport, + current_user: CurrentUser, +): + updated, missing = await update_fs_charts_from_matrix( + db_session=db_session, + payload=payload, + updated_by=getattr(current_user, "user_id", None) if current_user else None, + ) + + msg = "Data imported successfully." + if missing: + msg += f" Note: Years {missing} were not found." + + return StandardResponse(data=updated, message=msg) diff --git a/src/plant_fs_transaction_data/schema.py b/src/plant_fs_transaction_data/schema.py index 67d8580..b85cc3b 100644 --- a/src/plant_fs_transaction_data/schema.py +++ b/src/plant_fs_transaction_data/schema.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID from pydantic import Field @@ -84,3 +84,8 @@ class PlantFSTransactionDataRead(PlantFSTransactionDataBase): class PlantFSTransactionDataPagination(Pagination): items: List[PlantFSTransactionDataRead] = [] + + +class PlantFSTransactionDataImport(DefaultBase): + data: List[List[Any]] + seq: Optional[int] = None diff --git a/src/plant_fs_transaction_data/service.py b/src/plant_fs_transaction_data/service.py index bb25b2e..96431be 100644 --- a/src/plant_fs_transaction_data/service.py +++ b/src/plant_fs_transaction_data/service.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Any, Dict, List, Optional from sqlalchemy import Delete, Select, String, cast @@ -9,6 +9,7 @@ from src.database.service import search_filter_sort_paginate from src.plant_fs_transaction_data.model import PlantFSTransactionData from src.plant_fs_transaction_data.schema import ( PlantFSTransactionDataCreate, + PlantFSTransactionDataImport, PlantFSTransactionDataUpdate, ) @@ -116,3 +117,130 @@ async def delete(*, db_session: DbSession, fs_transaction_id: str) -> None: ) await db_session.execute(query) await db_session.commit() + + +def _safe_float(x: object) -> float: + """Safely convert `x` to float, returning 0.0 for None or invalid values.""" + try: + if x is None: + return 0.0 + return float(x) + except Exception: + return 0.0 + + +_FS_LABEL_FIELD_MAP: Dict[str, str] = { + "Total Revenue": "fs_chart_total_revenue", + "Revenue A": "fs_chart_revenue_a", + "Revenue B": "fs_chart_revenue_b", + "Revenue C": "fs_chart_revenue_c", + "Revenue D": "fs_chart_revenue_d", + "Revenue Annualized": "fs_chart_revenue_annualized", + "Fuel Cost (Component C)": "fs_chart_fuel_cost_component_c", + "Fuel Cost": "fs_chart_fuel_cost", + "Fuel Cost Annualized": "fs_chart_fuel_cost_annualized", + "O and M Cost (Component B and D)": "fs_chart_oem_component_bd", + "O and M Cost": "fs_chart_oem_bd_cost", + "Periodic Maintenance Cost (NonMI)": "fs_chart_oem_periodic_maintenance_cost", + "O and M Cost Annualized": "fs_chart_oem_annualized", + "Capex (Component A)": "fs_chart_capex_component_a", + "Biaya Investasi Tambahan": "fs_chart_capex_biaya_investasi_tambahan", + "Acquisition Cost": "fs_chart_capex_acquisition_cost", + "Capex Annualized": "fs_chart_capex_annualized", +} + + +def _extract_years(header_row: List[Any]) -> List[int]: + years: List[int] = [] + for cell in header_row[2:]: + if cell is None: + continue + try: + years.append(int(float(cell))) + except Exception: + continue + return years + + +def _resolve_label(row: List[Any]) -> Optional[str]: + for candidate in row[:2]: + if isinstance(candidate, str): + label = candidate.strip() + if label: + return label + return None + + +def _build_fs_year_value_map(matrix: List[List[Any]]) -> Dict[int, Dict[str, float]]: + if not matrix: + return {} + + header = matrix[0] + years = _extract_years(header) + if not years: + return {} + + year_map: Dict[int, Dict[str, float]] = {year: {} for year in years} + + for row in matrix[1:]: + label = _resolve_label(row) + if not label: + continue + + field_name = _FS_LABEL_FIELD_MAP.get(label) + if not field_name: + continue + + for idx, year in enumerate(years): + col_idx = idx + 2 + if col_idx >= len(row): + continue + value = row[col_idx] + if value is None: + continue + try: + year_map[year][field_name] = _safe_float(value) + except Exception: + continue + + return year_map + + +async def update_fs_charts_from_matrix( + *, + db_session: DbSession, + payload: PlantFSTransactionDataImport, + updated_by: Optional[str] = None, +): + """Update fs_* chart columns based on a transposed matrix payload.""" + + year_value_map = _build_fs_year_value_map(payload.data) + if not year_value_map: + return [], [] + + updated_records: List[PlantFSTransactionData] = [] + missing_years: List[int] = [] + + for year, field_values in year_value_map.items(): + if not field_values: + continue + + query = Select(PlantFSTransactionData).where(PlantFSTransactionData.tahun == year) + if payload.seq is not None: + query = query.where(PlantFSTransactionData.seq == payload.seq) + + result = await db_session.execute(query) + records = result.scalars().all() + if not records: + missing_years.append(year) + continue + + for record in records: + for field_name, value in field_values.items(): + setattr(record, field_name, value) + if updated_by: + record.updated_by = updated_by + updated_records.append(record) + + await db_session.commit() + return updated_records, missing_years diff --git a/src/plant_transaction_data/__pycache__/service.cpython-311.pyc b/src/plant_transaction_data/__pycache__/service.cpython-311.pyc index 7a03b801eaddd1e01e730b3f3927ceb69f2fa15f..9ececfd61e67aaaeb68ebc3dd1a4268052022bbd 100644 GIT binary patch delta 96 zcmbPSw6utKIWI340}$lIcx7g6*Oddt4OqydvbedA6n~<7R%9O)QMhCLhwch$2(L4**=mBRv2B delta 78 zcmZ2lG`Wa(IWI340}wo$XqD-)k#~avD|=CCp3&w@3T=#xdnZRKt>joyc~Qvnijd{z YwVI-gn-x?xu`oWJd|BfnvP1