commit 359b1a2278b8bd54d90e54c1c35adda4ab76904b Author: Suherdy Yacob Date: Wed Jan 21 17:03:32 2026 +0700 first commit diff --git a/README_IMPORT.md b/README_IMPORT.md new file mode 100644 index 0000000..7028663 --- /dev/null +++ b/README_IMPORT.md @@ -0,0 +1,31 @@ +# Fixed Asset Import Script + +This script imports fixed assets from `Fixed Asset Kipas.xlsx` into Odoo 17 database `kipasdbclone5`. + +## Features +- **Avoids Double Posting**: Sets assets to 'Running' (Open) state manually, bypassing the Journal Entry creation for Asset Recognition. +- **Depreciation Calculation**: Adjusts the "Accumulated Depreciation Per Dec 31" from Excel to "Per Oct 31" (Opening Balance Date) by subtracting 2 months of depreciation. +- **Model Handling**: Automatically maps categories. Creates `Peralatan Dapur` model if missing (copying from `Peralatan Inventaris`). +- **Cutoff Date**: Skips assets acquired after Oct 31, 2025. + +## Usage +Run the script using the Odoo virtual environment: +```bash +/home/suherdy/Pythoncode/odoo17/.venv/bin/python /home/suherdy/Pythoncode/odoo17/scripts/import_fixed_assets.py +``` + +## Logic Details +1. **Accumulated Depreciation**: + - Uses Excel column R (Accum Depr Dec 31). + - `Accum Oct 31 = Accum Dec 31 - (2 * Monthly Depreciation)`. + - If asset is fully depreciated (End Date <= Oct 31), uses `Accum Dec 31` as is. +2. **State**: + - Assets are created in `draft`. + - `compute_depreciation_board()` is called to generate Draft moves for future depreciation (subtracted by imported amount). + - `state` is manually Set to `open`. + - Result: No historical moves posted. Future moves are Draft (to be posted by cron/user). No Asset Recognition entry. + +## Prerequisites +- `openpyxl` +- `python-dateutil` +- Odoo configuration file at `../odoo.conf` diff --git a/delete_all_assets.py b/delete_all_assets.py new file mode 100644 index 0000000..5f0f464 --- /dev/null +++ b/delete_all_assets.py @@ -0,0 +1,77 @@ +import sys +import os +# ---------------- CONFIGURATION ---------------- +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +os.chdir(PROJECT_ROOT) + +ODOO_PATH = os.path.join(PROJECT_ROOT, 'odoo') +CONF_FILE = os.path.join(PROJECT_ROOT, 'odoo.conf') +DB_NAME = 'kipasdbclone5' + +if ODOO_PATH not in sys.path: + sys.path.append(ODOO_PATH) + +import odoo +from odoo import api, SUPERUSER_ID + +def delete_assets(): + print(f"Initializing Odoo Environment for database: {DB_NAME}...") + try: + odoo.tools.config.parse_config(['-c', CONF_FILE]) + registry = odoo.registry(DB_NAME) + except Exception as e: + print(f"Error initializing Odoo: {e}") + return + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + print("Connected to Odoo.") + + # 1. Search for non-model assets + assets = env['account.asset'].search([('state', '!=', 'model')]) + print(f"Found {len(assets)} assets to delete.") + + if not assets: + print("No assets to delete.") + return + + try: + # 2. Find and Delete Linked Depreciation Moves + moves = assets.mapped('depreciation_move_ids') + posted_moves = moves.filtered(lambda m: m.state == 'posted') + + if posted_moves: + print(f"Resetting {len(posted_moves)} posted moves to draft...") + posted_moves.button_draft() + + if moves: + print(f"Deleting {len(moves)} depreciation moves...") + moves.unlink() # Deleting moves first cleans up the relation + + # 3. Reset Assets to Draft + # Some assets might be 'open' or 'close' or 'cancelled'. + # To delete, they often need to be in draft or cancelled state depending on logic, + # but unlink() in Odoo 17 account_asset usually checks if they are NOT in open/paused/close. + # So we must write state = draft. + + # Check for assets that are not draft + non_draft_assets = assets.filtered(lambda a: a.state != 'draft') + if non_draft_assets: + print(f"Setting {len(non_draft_assets)} assets to draft state...") + non_draft_assets.write({'state': 'draft'}) + + # 4. Delete Assets + print(f"Deleting {len(assets)} assets...") + assets.unlink() + + cr.commit() + print("Successfully deleted all assets and related entries.") + + except Exception as e: + cr.rollback() + print(f"An error occurred. Transaction rolled back.\nError: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + delete_assets() diff --git a/import_fixed_assets.py b/import_fixed_assets.py new file mode 100644 index 0000000..5e5ee7e --- /dev/null +++ b/import_fixed_assets.py @@ -0,0 +1,235 @@ +import sys +import os +import openpyxl +from datetime import datetime, date +from dateutil.relativedelta import relativedelta + +# ---------------- CONFIGURATION ---------------- +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +# Change CWD to Project Root so relative paths in odoo.conf work +os.chdir(PROJECT_ROOT) + +ODOO_PATH = os.path.join(PROJECT_ROOT, 'odoo') +CONF_FILE = os.path.join(PROJECT_ROOT, 'odoo.conf') +EXCEL_FILE = os.path.join(PROJECT_ROOT, 'Fixed Asset Kipas.xlsx') +DB_NAME = 'kipasdbclone5' +CUTOFF_DATE = date(2025, 10, 31) + +if ODOO_PATH not in sys.path: + sys.path.append(ODOO_PATH) + +import odoo +from odoo import api, SUPERUSER_ID + +def import_assets(): + print(f"Initializing Odoo Environment for database: {DB_NAME}...") + try: + odoo.tools.config.parse_config(['-c', CONF_FILE]) + registry = odoo.registry(DB_NAME) + except Exception as e: + print(f"Error initializing Odoo: {e}") + return + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + print("Connected to Odoo.") + try: + ensure_models(env) + process_import(env) + cr.commit() + print("Changes committed to database.") + except Exception as e: + cr.rollback() + print(f"An error occurred during import. Transaction rolled back.\nError: {e}") + import traceback + traceback.print_exc() + +def ensure_models(env): + missing_models = { + 'PERALATAN DAPUR': { + 'fallback': 'Peralatan Inventaris', + 'name': 'Peralatan Dapur', + 'method_number': 60, + 'method_period': '1' + } + } + + for key, config in missing_models.items(): + exists = env['account.asset'].search([('state', '=', 'model'), ('name', '=ilike', config['name'])], limit=1) + if exists: + continue + + print(f"Creating missing model '{config['name']}'...") + fallback = env['account.asset'].search([('state', '=', 'model'), ('name', '=ilike', config['fallback'])], limit=1) + + vals = { + 'name': config['name'], + 'state': 'model', + 'active': True, + } + + if fallback: + vals.update({ + 'method': fallback.method, + 'method_number': fallback.method_number, + 'method_period': fallback.method_period, + 'prorata_computation_type': fallback.prorata_computation_type, + 'method_progress_factor': fallback.method_progress_factor, + 'account_asset_id': fallback.account_asset_id.id, + 'account_depreciation_id': fallback.account_depreciation_id.id, + 'account_depreciation_expense_id': fallback.account_depreciation_expense_id.id, + 'journal_id': fallback.journal_id.id, + 'analytic_distribution': fallback.analytic_distribution, + }) + else: + any_model = env['account.asset'].search([('state', '=', 'model')], limit=1) + if any_model: + vals.update({ + 'account_asset_id': any_model.account_asset_id.id, + 'account_depreciation_id': any_model.account_depreciation_id.id, + 'account_depreciation_expense_id': any_model.account_depreciation_expense_id.id, + 'journal_id': any_model.journal_id.id, + }) + vals.update({ + 'method': 'linear', + 'method_number': config['method_number'], + 'method_period': config['method_period'], + 'prorata_computation_type': 'constant_periods', + }) + + env['account.asset'].create(vals) + print(f"Created model '{config['name']}'.") + +def process_import(env): + print(f"Reading Excel file: {EXCEL_FILE}...") + wb = openpyxl.load_workbook(EXCEL_FILE, data_only=True) + ws = wb.active + + current_category_name = None + count = 0 + models_cache = {} + skipped_count = 0 + + for row_idx, row in enumerate(ws.iter_rows(min_row=10, values_only=True), start=10): + col_0 = row[0] + col_1 = row[1] + col_name = row[2] + + if col_0 and isinstance(col_0, str) and not col_1 and not col_name: + current_category_name = col_0.strip() + print(f"\n--- Category Detected: {current_category_name} ---") + continue + + if not col_name: + continue + + acq_date = row[7] + if isinstance(acq_date, datetime): + acq_date = acq_date.date() + + if not isinstance(acq_date, date): + continue + + if acq_date > CUTOFF_DATE: + if skipped_count < 5: # Limit detailed skip logs + print(f"Skipping '{col_name}': Acquired {acq_date} (After Cutoff)") + skipped_count += 1 + continue + + original_value = row[8] + if not isinstance(original_value, (int, float)): + original_value = 0.0 + + accum_depr_dec31 = row[17] + if not isinstance(accum_depr_dec31, (int, float)): + accum_depr_dec31 = 0.0 + + if not current_category_name: + continue + + model_name_search = current_category_name + if current_category_name == 'PERALATAN DAPUR': + model_name_search = 'Peralatan Dapur' + + model = models_cache.get(model_name_search) + if not model: + model = env['account.asset'].search([ + ('state', '=', 'model'), + ('name', '=ilike', model_name_search) + ], limit=1) + + if not model: + print(f"SKIPPING: Asset Model '{model_name_search}' not found.") + continue + models_cache[model_name_search] = model + + # Calculate Logic + # Determine total duration in months + period_multiple = int(model.method_period) # 1 or 12 + total_months = model.method_number * period_multiple + + end_date = acq_date + relativedelta(months=total_months) + + if end_date <= CUTOFF_DATE: + # Asset should be fully depreciated by Cutoff + accum_depr_oct31 = accum_depr_dec31 + # Sanity check: Should ideally be equal to original_value, but use Excel data. + else: + # Asset is still active or naturally finishes after Cutoff + # We need to back-calculate from Dec 31 + monthly_depr = original_value / total_months if total_months > 0 else 0 + + # Adjustment for Nov, Dec (2 months) + adjustment = 2 * monthly_depr + accum_depr_oct31 = accum_depr_dec31 - adjustment + + # Clamp values + if accum_depr_oct31 < 0: + accum_depr_oct31 = 0.0 + if accum_depr_oct31 > original_value: + accum_depr_oct31 = original_value + + vals = { + 'name': col_name, + 'asset_code': col_1, # Added Asset Code + 'original_value': original_value, + 'acquisition_date': acq_date, + 'model_id': model.id, + 'method': model.method, + 'method_number': model.method_number, + 'method_period': model.method_period, + 'prorata_computation_type': model.prorata_computation_type, + 'already_depreciated_amount_import': accum_depr_oct31, + 'state': 'draft', + 'account_asset_id': model.account_asset_id.id, + 'account_depreciation_id': model.account_depreciation_id.id, + 'account_depreciation_expense_id': model.account_depreciation_expense_id.id, + 'journal_id': model.journal_id.id, + 'method_progress_factor': model.method_progress_factor, + 'analytic_distribution': model.analytic_distribution, + } + + asset = env['account.asset'].create(vals) + # 9. Compute Board and Set to Running + asset.compute_depreciation_board() + + # 10. Clean up Historical Moves + # To strictly preserve Opening Balance, we must NOT have any draft moves + # dated before or on the Cutoff Date. We assume the 'already_depreciated_amount_import' + # covers the past. Any "catch-up" entries generated by Odoo for the past look like + # expenses in prior years, which affects Retained Earnings. We delete them. + + historical_moves = asset.depreciation_move_ids.filtered(lambda m: m.date <= CUTOFF_DATE) + if historical_moves: + print(f" Removing {len(historical_moves)} historical catch-up moves for {asset.name}...") + historical_moves.unlink() + + asset.write({'state': 'open'}) + + print(f"Imported: {asset.name} | Val: {original_value} | Oct31: {accum_depr_oct31:,.2f}") + count += 1 + + print(f"\nTotal Assets Imported: {count}") + +if __name__ == "__main__": + import_assets() diff --git a/list_asset_models.py b/list_asset_models.py new file mode 100644 index 0000000..5c83a85 --- /dev/null +++ b/list_asset_models.py @@ -0,0 +1,32 @@ +import sys +import os + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +ODOO_PATH = os.path.join(PROJECT_ROOT, 'odoo') +CONF_FILE = os.path.join(PROJECT_ROOT, 'odoo.conf') +DB_NAME = 'kipasdbclone5' + +if ODOO_PATH not in sys.path: + sys.path.append(ODOO_PATH) + +import odoo +from odoo import api, SUPERUSER_ID + +def list_models(): + try: + odoo.tools.config.parse_config(['-c', CONF_FILE]) + registry = odoo.registry(DB_NAME) + except Exception as e: + print(f"Error: {e}") + return + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + models = env['account.asset'].search([('state', '=', 'model')]) + print(f"Found {len(models)} Asset Models:") + for m in models: + print(f"- Name: '{m.name}' | Method: {m.method} | Duration: {m.method_number} {m.method_period}") + print(f" Accounts: Asset={m.account_asset_id.code}, Depr={m.account_depreciation_id.code}, Exp={m.account_depreciation_expense_id.code}") + +if __name__ == "__main__": + list_models() diff --git a/post_depreciation.py b/post_depreciation.py new file mode 100644 index 0000000..b73fc76 --- /dev/null +++ b/post_depreciation.py @@ -0,0 +1,75 @@ +import sys +import os +from datetime import date + +# ---------------- CONFIGURATION ---------------- +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +# Change CWD to Project Root so relative paths in odoo.conf work +os.chdir(PROJECT_ROOT) + +ODOO_PATH = os.path.join(PROJECT_ROOT, 'odoo') +CONF_FILE = os.path.join(PROJECT_ROOT, 'odoo.conf') +DB_NAME = 'kipasdbclone5' +POST_UP_TO_DATE = date(2026, 1, 21) # User specified "now" + +if ODOO_PATH not in sys.path: + sys.path.append(ODOO_PATH) + +import odoo +from odoo import api, SUPERUSER_ID + +def post_depreciation(): + print(f"Initializing Odoo Environment for database: {DB_NAME}...") + try: + odoo.tools.config.parse_config(['-c', CONF_FILE]) + registry = odoo.registry(DB_NAME) + except Exception as e: + print(f"Error initializing Odoo: {e}") + return + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + print("Connected to Odoo.") + + # Search for Draft Depreciation MOVES (account.move) + # Linked to an asset, in draft state, date <= target date + moves_to_post = env['account.move'].search([ + ('asset_id', '!=', False), + ('state', '=', 'draft'), + ('date', '<=', POST_UP_TO_DATE) + ]) + + if not moves_to_post: + print(f"No draft depreciation entries found on or before {POST_UP_TO_DATE}.") + return + + print(f"Found {len(moves_to_post)} moves to post up to {POST_UP_TO_DATE}.") + + # Group by date for nicer logging + moves_by_date = {} + for move in moves_to_post: + d = move.date + if d not in moves_by_date: + moves_by_date[d] = [] + moves_by_date[d].append(move) + + count = 0 + try: + for d in sorted(moves_by_date.keys()): + batch = moves_by_date[d] + batch_moves = env['account.move'].browse([m.id for m in batch]) + print(f"Posting {len(batch)} entries for date {d}...") + batch_moves.action_post() + count += len(batch) + + cr.commit() + print(f"Successfully posted {count} depreciation entries.") + + except Exception as e: + cr.rollback() + print(f"An error occurred. Transaction rolled back.\nError: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + post_depreciation()