commit 624d6034b15f4a933ff3f1eae09766729b74d213 Author: Suherdy Yacob Date: Wed Feb 4 11:37:09 2026 +0700 first commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..1edb029 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,27 @@ +{ + 'name': 'GA Asset Management', + 'version': '17.0.1.0.0', + 'category': 'Operations', + 'summary': 'Asset Management for General Affair Team', + 'description': """ + This module provides a simplified interface for General Affair team to manage fixed assets. + Features: + - Simplified Asset Views + - Auto-generated Asset Code based on Product Barcode + - Asset Transfer Wizard + """, + 'author': 'Antigravity', + 'depends': ['base', 'account_asset', 'asset_code_field', 'product', 'stock', 'purchase'], + 'data': [ + 'security/ga_asset_security.xml', + 'security/ir.model.access.csv', + 'data/ir_sequence_data.xml', + 'views/account_asset_views.xml', + 'views/stock_picking_views.xml', + 'views/product_template_views.xml', + 'wizard/asset_transfer_views.xml', + ], + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b22de5c Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/data/ir_sequence_data.xml b/data/ir_sequence_data.xml new file mode 100644 index 0000000..2071aab --- /dev/null +++ b/data/ir_sequence_data.xml @@ -0,0 +1,13 @@ + + + + + + Asset Code + ga.asset.code + / + 5 + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..521c357 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +from . import account_asset +from . import stock_move +from . import stock_picking +from . import account_move +from . import product_template diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..389667e Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/account_asset.cpython-312.pyc b/models/__pycache__/account_asset.cpython-312.pyc new file mode 100644 index 0000000..cfcf250 Binary files /dev/null and b/models/__pycache__/account_asset.cpython-312.pyc differ diff --git a/models/__pycache__/account_move.cpython-312.pyc b/models/__pycache__/account_move.cpython-312.pyc new file mode 100644 index 0000000..bed932a Binary files /dev/null and b/models/__pycache__/account_move.cpython-312.pyc differ diff --git a/models/__pycache__/product_template.cpython-312.pyc b/models/__pycache__/product_template.cpython-312.pyc new file mode 100644 index 0000000..b06ad5c Binary files /dev/null and b/models/__pycache__/product_template.cpython-312.pyc differ diff --git a/models/__pycache__/stock_move.cpython-312.pyc b/models/__pycache__/stock_move.cpython-312.pyc new file mode 100644 index 0000000..a184b0e Binary files /dev/null and b/models/__pycache__/stock_move.cpython-312.pyc differ diff --git a/models/__pycache__/stock_picking.cpython-312.pyc b/models/__pycache__/stock_picking.cpython-312.pyc new file mode 100644 index 0000000..6abf3f2 Binary files /dev/null and b/models/__pycache__/stock_picking.cpython-312.pyc differ diff --git a/models/account_asset.py b/models/account_asset.py new file mode 100644 index 0000000..ad7c410 --- /dev/null +++ b/models/account_asset.py @@ -0,0 +1,62 @@ +from odoo import models, fields, api +import datetime + +class AccountAsset(models.Model): + _inherit = 'account.asset' + + product_id = fields.Many2one('product.product', string='Product') + description = fields.Text(string='Description') + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('asset_code'): + product_id = vals.get('product_id') + sequence = self.env['ir.sequence'].next_by_code('ga.asset.code') or '00000' + year = datetime.datetime.now().year + + prefix = 'AST' + if product_id: + product = self.env['product.product'].browse(product_id) + if product.barcode: + prefix = product.barcode + + # Format: [Barcode/AST]/[Year]/[Sequence] + # Note: Sequence defined in XML has prefix '/' so we just append it + # Wait, sequence next_by_code returns the full string including prefix/suffix if defined + # My XML defines prefix '/' and padding 5. So it returns '/00001' + + # Let's construct manually to control the format exactly as requested: + # [Barcode]/[Year]/[Sequence_Number] + + # If I use next_by_code, I get what is configured. + # To get just number, checking implementation... standard next_by_code returns full string. + + # Let's simple use sequence for the number part only. + # I will change sequence prefix to empty in XML or just use it here. + # Actually, standard way is to have sequence configured with year but here we have dynamic prefix (Product Barcode). + + # Revised Logic: + # 1. Get raw sequence number? No, next_by_code gives formatted string. + # Let's use a sequence with NO prefix in XML, and build format here. + + # Re-reading XML I created: prefix='/' + # So `self.env['ir.sequence'].next_by_code('ga.asset.code')` returns `/00001`. + + vals['asset_code'] = f"{prefix}/{year}{sequence}" + + return super(AccountAsset, self).create(vals_list) + + def action_open_transfer_wizard(self): + self.ensure_one() + return { + 'name': 'Transfer Asset', + 'type': 'ir.actions.act_window', + 'res_model': 'ga.asset.transfer.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_asset_id': self.id, + 'default_current_company_id': self.company_id.id, + } + } diff --git a/models/account_move.py b/models/account_move.py new file mode 100644 index 0000000..209f7aa --- /dev/null +++ b/models/account_move.py @@ -0,0 +1,71 @@ +from odoo import models + +class AccountMove(models.Model): + _inherit = 'account.move' + + def _auto_create_asset(self): + # We need to intercept standard behavior if we can link to an existing asset from Stock Move + + # This method iterates over self (invoices) and creates assets. + # We can run the standard method, BUT standard method creates new assets. + # We want to PREVENT creating new assets if one was already created by Stock Picking. + + # The standard method _auto_create_asset loops over invoice lines and creates assets. + # It doesn't seem to have a hook to check for existing assets easily. + # However, we can check if the invoice line is linked to a PO line, and that PO line linked to a Stock Move. + + # Strategy: + # 1. Let standard create assets? No, duplicate. + # 2. Override completely? risky for maintenance. + # 3. Pre-process: if we find a link, set the asset_id on the move_line? + # If `move_line.asset_ids` is set, `_auto_create_asset` might skip? + # Checking standard code: + # `and not move_line.asset_ids` -> YES! definition at line 188 of account_move.py. + + # So strategy is: + # Before calling super, try to find existing asset and link it to move_line. + + for move in self: + if move.is_invoice(): + for line in move.invoice_line_ids: + # check purchase line + if line.purchase_line_id: + # Find related stock moves + # purchase_line_id.move_ids returns stock moves + related_stock_moves = line.purchase_line_id.move_ids.filtered(lambda m: m.state == 'done' and m.asset_id) + + if related_stock_moves: + # Link the first found asset + asset = related_stock_moves[0].asset_id + line.asset_ids = [(4, asset.id)] + + # Also update asset value if needed? + # Usually creation from Stock might lack price if Price Unit was 0 or estimate. + # But let's assume standard behavior: Link is enough. + + # Log connection + asset.message_post(body=f"Linked to Vendor Bill: {move.name}") + + # Call super to generate new assets (if not linked above) + created_assets = super(AccountMove, self)._auto_create_asset() + + # Post-process: Ensure Product ID is set and Asset Code is correct + for asset in created_assets: + # key: use sudo() to allow system to correct data even if user has no write access to asset + # (e.g. Bill Clerk creating asset but not managing it) + if not asset.product_id and asset.original_move_line_ids: + # Find product from the first line + # (Standard logic usually groups lines by account/product context, so one asset usually comes from one product type) + product = asset.original_move_line_ids[0].product_id + + if product: + asset.sudo().product_id = product.id + + # Fix Asset Code if it defaults to AST but product has barcode + if asset.asset_code and asset.asset_code.startswith('AST') and product.barcode: + # Replace 'AST' prefix with Barcode + # Format is expected to be AST/YYYY/SEQ + new_code = asset.asset_code.replace('AST', product.barcode, 1) + asset.sudo().asset_code = new_code + + return created_assets diff --git a/models/product_template.py b/models/product_template.py new file mode 100644 index 0000000..69ae91d --- /dev/null +++ b/models/product_template.py @@ -0,0 +1,26 @@ +from odoo import models, fields, api + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + asset_count = fields.Integer(string='Assets', compute='_compute_asset_count') + + def _compute_asset_count(self): + # account.asset has product_id which links to product.product + # We need to count assets for all variants of this template + for product in self: + # Domain: product_id.product_tmpl_id = product.id + product.asset_count = self.env['account.asset'].search_count([ + ('product_id.product_tmpl_id', '=', product.id) + ]) + + def action_view_assets(self): + self.ensure_one() + return { + 'name': 'Assets', + 'type': 'ir.actions.act_window', + 'res_model': 'account.asset', + 'view_mode': 'tree,form', + 'domain': [('product_id.product_tmpl_id', '=', self.id)], + 'context': {'default_product_id': self.product_variant_id.id}, + } diff --git a/models/stock_move.py b/models/stock_move.py new file mode 100644 index 0000000..d412248 --- /dev/null +++ b/models/stock_move.py @@ -0,0 +1,7 @@ +from odoo import models, fields + +class StockMove(models.Model): + _inherit = 'stock.move' + + asset_code = fields.Char(string='Asset Code', copy=False) + asset_id = fields.Many2one('account.asset', string='Created Asset', copy=False) diff --git a/models/stock_picking.py b/models/stock_picking.py new file mode 100644 index 0000000..ab696c4 --- /dev/null +++ b/models/stock_picking.py @@ -0,0 +1,114 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import datetime + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + def action_generate_asset_codes(self): + self.ensure_one() + for move in self.move_ids_without_package: + # Only generate if product creates asset (optional check, but good practice) + # However, user wants to manually decide, so we generate for all lines that look like assets? + # Let's assume user triggers this button for asset receipts. + # We can check if product has an asset model set in accounting? + # Or just blindly generate if missing? + # Re-using logic from account.asset.create: + + if not move.asset_code and move.product_id: + sequence = self.env['ir.sequence'].next_by_code('ga.asset.code') or '00000' + year = datetime.datetime.now().year + + # Logic from AccountAsset create: [Barcode or AST]/[Year]/[Sequence] + prefix = move.product_id.barcode or 'AST' + + # Note: Sequence next_by_code returns '/0000X' as configured in XML + move.asset_code = f"{prefix}/{year}{sequence}" + + has_asset_moves = fields.Boolean(compute='_compute_has_asset_moves', store=True) + + @api.depends('move_ids.product_id', 'picking_type_code') + def _compute_has_asset_moves(self): + for pick in self: + has_asset = False + if pick.picking_type_code == 'incoming': + for move in pick.move_ids_without_package: + # Check if product is configured to create assets + # Logic: Check Product's Expense Account or Category's Expense Account + account = move.product_id.property_account_expense_id or move.product_id.categ_id.property_account_expense_categ_id + if account and account.create_asset != 'no': + has_asset = True + break + pick.has_asset_moves = has_asset + + def button_validate(self): + # Validation Check + for pick in self: + if pick.has_asset_moves: + for move in pick.move_ids_without_package: + account = move.product_id.property_account_expense_id or move.product_id.categ_id.property_account_expense_categ_id + if account and account.create_asset != 'no' and not move.asset_code: + raise UserError(_( + "Asset Code is missing for product '%s'. \n" + "Please click 'Generate Asset Codes' before validating.", + move.product_id.name + )) + return super(StockPicking, self).button_validate() + + def _action_done(self): + res = super(StockPicking, self)._action_done() + + # After validation, create assets for moves with asset_code + for pick in self: + for move in pick.move_ids_without_package: + if move.asset_code and not move.asset_id: + # Get Account to check configuration + account = move.product_id.property_account_expense_id or move.product_id.categ_id.property_account_expense_categ_id + + # Prepare basic values + vals_base = { + 'name': move.product_id.name, + 'product_id': move.product_id.id, + 'acquisition_date': fields.Date.today(), + 'company_id': move.company_id.id, + 'state': 'draft', + } + + # Add Model if configured on Account + if account and account.asset_model: + vals_base['model_id'] = account.asset_model.id + + qty = int(move.quantity) + + # CASE 1: Multiple Assets per Line + if account and account.multiple_assets_per_line and qty > 1: + # 1st Asset uses the pre-generated code + vals = vals_base.copy() + vals['asset_code'] = move.asset_code + vals['original_value'] = move.price_unit # Per unit value + + asset = self.env['account.asset'].create(vals) + move.asset_id = asset.id # Link first one to move + + # Create remaining assets + for i in range(qty - 1): + vals_extra = vals_base.copy() + vals_extra['original_value'] = move.price_unit + + # Generate new code for extra assets + sequence = self.env['ir.sequence'].next_by_code('ga.asset.code') or '00000' + year = datetime.datetime.now().year + prefix = move.product_id.barcode or 'AST' + vals_extra['asset_code'] = f"{prefix}/{year}{sequence}" + + self.env['account.asset'].create(vals_extra) + + # CASE 2: Single Assset (default) + else: + vals = vals_base.copy() + vals['asset_code'] = move.asset_code + vals['original_value'] = move.price_unit * move.quantity # Total Value + + asset = self.env['account.asset'].create(vals) + move.asset_id = asset.id + return res diff --git a/security/ga_asset_security.xml b/security/ga_asset_security.xml new file mode 100644 index 0000000..394ec80 --- /dev/null +++ b/security/ga_asset_security.xml @@ -0,0 +1,23 @@ + + + + + General Affair Assets + Manage General Affair Assets + 10 + + + + User + + + + + + Manager + + + + + + diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..7dffc28 --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ga_asset_transfer_wizard_user,ga.asset.transfer.wizard.user,model_ga_asset_transfer_wizard,group_ga_asset_user,1,1,1,0 +access_ga_asset_transfer_wizard_manager,ga.asset.transfer.wizard.manager,model_ga_asset_transfer_wizard,group_ga_asset_manager,1,1,1,1 diff --git a/static/description/icon.png b/static/description/icon.png new file mode 100644 index 0000000..96186e2 Binary files /dev/null and b/static/description/icon.png differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..209b1eb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ga_asset diff --git a/tests/test_ga_asset.py b/tests/test_ga_asset.py new file mode 100644 index 0000000..5cc2628 --- /dev/null +++ b/tests/test_ga_asset.py @@ -0,0 +1,131 @@ +from odoo.tests.common import TransactionCase +from datetime import datetime +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestGAAssetManagement(TransactionCase): + + def setUp(self): + super(TestGAAssetManagement, self).setUp() + self.Asset = self.env['account.asset'] + self.Product = self.env['product.product'] + self.Wizard = self.env['ga.asset.transfer.wizard'] + self.Company = self.env['res.company'] + self.StockPicking = self.env['stock.picking'] + self.PurchaseOrder = self.env['purchase.order'] + self.AccountMove = self.env['account.move'] + + # Create a product with barcode + self.product_with_barcode = self.Product.create({ + 'name': 'Test Product Barcode', + 'barcode': '123456789', + 'type': 'product', # Storable generic + }) + + # Create a product without barcode + self.product_no_barcode = self.Product.create({ + 'name': 'Test Product No Barcode', + 'type': 'product', + }) + + self.company_a = self.env.user.company_id + self.company_b = self.Company.create({'name': 'Company B'}) + self.partner = self.env['res.partner'].create({'name': 'Vendor A'}) + + def test_asset_creation_with_barcode(self): + """Test asset code generation with product barcode""" + asset = self.Asset.create({ + 'name': 'Asset Barcode', + 'product_id': self.product_with_barcode.id, + 'original_value': 1000, + 'acquisition_date': '2025-01-01', + 'company_id': self.company_a.id, + }) + + year = datetime.now().year + self.assertTrue(asset.asset_code.startswith(f"123456789/{year}")) + + def test_asset_creation_no_barcode(self): + """Test asset code generation without product barcode (fallback to AST)""" + asset = self.Asset.create({ + 'name': 'Asset No Barcode', + 'product_id': self.product_no_barcode.id, + 'original_value': 1000, + 'acquisition_date': '2025-01-01', + 'company_id': self.company_a.id, + }) + + year = datetime.now().year + self.assertTrue(asset.asset_code.startswith(f"AST/{year}")) + + def test_asset_transfer(self): + """Test asset transfer wizard""" + asset = self.Asset.create({ + 'name': 'Asset Transfer', + 'original_value': 1000, + 'acquisition_date': '2025-01-01', + 'company_id': self.company_a.id, + }) + + wizard = self.Wizard.create({ + 'asset_id': asset.id, + 'current_company_id': self.company_a.id, + 'target_company_id': self.company_b.id, + 'note': 'Moving to Company B' + }) + + wizard.action_transfer() + + self.assertEqual(asset.company_id, self.company_b) + + def test_receipt_to_asset_flow(self): + """Test PO -> Receipt -> Asset Gen -> Validation -> Asset Created -> Bill -> Link""" + # 1. Create PO + po = self.PurchaseOrder.create({ + 'partner_id': self.partner.id, + 'order_line': [(0, 0, { + 'product_id': self.product_with_barcode.id, + 'name': 'Laptop', + 'product_qty': 1.0, + 'price_unit': 1500.0, + })] + }) + po.button_confirm() + + # 2. Get Receipt + picking = po.picking_ids[0] + self.assertEqual(picking.state, 'assigned') + + # 3. Generate Asset Codes + picking.action_generate_asset_codes() + move = picking.move_ids_without_package[0] + year = datetime.now().year + self.assertTrue(move.asset_code.startswith(f"123456789/{year}")) + + # 4. Validate Receipt + picking.button_validate() + self.assertEqual(picking.state, 'done') + + # 5. Check Asset Creation + self.assertTrue(move.asset_id, "Asset should be linked to Stock Move") + asset = move.asset_id + self.assertEqual(asset.asset_code, move.asset_code) + self.assertEqual(asset.state, 'draft') + + # 6. Create Vendor Bill from PO + action = po.action_create_invoice() + invoice = self.AccountMove.browse(action['res_id']) + + # 7. Post Bill (Triggers _auto_create_asset which we overrode) + invoice.action_post() + + # 8. Verify Link (Invoice Line should be linked to the SAME asset) + inv_line = invoice.invoice_line_ids[0] + # inv_line.asset_ids is Many2many + self.assertIn(asset, inv_line.asset_ids, "Bill line should be linked to the existing asset from Receipt") + + # Ensure no duplicate asset created + assets_for_this_product = self.Asset.search([('product_id', '=', self.product_with_barcode.id)]) + # We might have other tests creating assets, so filter by code + assets_for_this_code = self.Asset.search([('asset_code', '=', move.asset_code)]) + self.assertEqual(len(assets_for_this_code), 1, "Should be exactly one asset for this code") diff --git a/views/account_asset_views.xml b/views/account_asset_views.xml new file mode 100644 index 0000000..7dc5434 --- /dev/null +++ b/views/account_asset_views.xml @@ -0,0 +1,99 @@ + + + + + + ga.account.asset.tree + account.asset + + + + + + + + + + + + + + + + ga.account.asset.form + account.asset + 20 + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + Assets + account.asset + tree,form + + + [('state', '!=', 'model')] + {'default_state': 'draft'} + + + + + tree + + + + + + + form + + + + + + + + + +
diff --git a/views/product_template_views.xml b/views/product_template_views.xml new file mode 100644 index 0000000..86ad731 --- /dev/null +++ b/views/product_template_views.xml @@ -0,0 +1,15 @@ + + + + product.template.form.inherit.ga.asset + product.template + + +
+ +
+
+
+
diff --git a/views/stock_picking_views.xml b/views/stock_picking_views.xml new file mode 100644 index 0000000..ea44800 --- /dev/null +++ b/views/stock_picking_views.xml @@ -0,0 +1,41 @@ + + + + stock.picking.form.inherit.ga.asset + stock.picking + + + +