first commit
This commit is contained in:
commit
624d6034b1
2
__init__.py
Normal file
2
__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
27
__manifest__.py
Normal file
27
__manifest__.py
Normal file
@ -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',
|
||||||
|
}
|
||||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
13
data/ir_sequence_data.xml
Normal file
13
data/ir_sequence_data.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Sequence for GA Service -->
|
||||||
|
<record id="seq_ga_asset_code" model="ir.sequence">
|
||||||
|
<field name="name">Asset Code</field>
|
||||||
|
<field name="code">ga.asset.code</field>
|
||||||
|
<field name="prefix">/</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
5
models/__init__.py
Normal file
5
models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from . import account_asset
|
||||||
|
from . import stock_move
|
||||||
|
from . import stock_picking
|
||||||
|
from . import account_move
|
||||||
|
from . import product_template
|
||||||
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/account_asset.cpython-312.pyc
Normal file
BIN
models/__pycache__/account_asset.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/account_move.cpython-312.pyc
Normal file
BIN
models/__pycache__/account_move.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/product_template.cpython-312.pyc
Normal file
BIN
models/__pycache__/product_template.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/stock_move.cpython-312.pyc
Normal file
BIN
models/__pycache__/stock_move.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/stock_picking.cpython-312.pyc
Normal file
BIN
models/__pycache__/stock_picking.cpython-312.pyc
Normal file
Binary file not shown.
62
models/account_asset.py
Normal file
62
models/account_asset.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
71
models/account_move.py
Normal file
71
models/account_move.py
Normal file
@ -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
|
||||||
26
models/product_template.py
Normal file
26
models/product_template.py
Normal file
@ -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},
|
||||||
|
}
|
||||||
7
models/stock_move.py
Normal file
7
models/stock_move.py
Normal file
@ -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)
|
||||||
114
models/stock_picking.py
Normal file
114
models/stock_picking.py
Normal file
@ -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
|
||||||
23
security/ga_asset_security.xml
Normal file
23
security/ga_asset_security.xml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="module_category_ga_asset" model="ir.module.category">
|
||||||
|
<field name="name">General Affair Assets</field>
|
||||||
|
<field name="description">Manage General Affair Assets</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_ga_asset_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="category_id" ref="module_category_ga_asset"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_ga_asset_manager" model="res.groups">
|
||||||
|
<field name="name">Manager</field>
|
||||||
|
<field name="category_id" ref="module_category_ga_asset"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_ga_asset_user'))]"/>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
3
security/ir.model.access.csv
Normal file
3
security/ir.model.access.csv
Normal file
@ -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
|
||||||
|
BIN
static/description/icon.png
Normal file
BIN
static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_ga_asset
|
||||||
131
tests/test_ga_asset.py
Normal file
131
tests/test_ga_asset.py
Normal file
@ -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")
|
||||||
99
views/account_asset_views.xml
Normal file
99
views/account_asset_views.xml
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- GA Asset Tree View -->
|
||||||
|
<record id="view_ga_account_asset_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ga.account.asset.tree</field>
|
||||||
|
<field name="model">account.asset</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Fixed Assets" sample="1">
|
||||||
|
<field name="asset_code"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="product_id" optional="show"/>
|
||||||
|
<field name="acquisition_date"/>
|
||||||
|
<field name="original_value"/>
|
||||||
|
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'open'" decoration-danger="state == 'close'"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- GA Asset Form View -->
|
||||||
|
<record id="view_ga_account_asset_form" model="ir.ui.view">
|
||||||
|
<field name="name">ga.account.asset.form</field>
|
||||||
|
<field name="model">account.asset</field>
|
||||||
|
<field name="priority">20</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Fixed Asset">
|
||||||
|
<header>
|
||||||
|
<button name="action_open_transfer_wizard" string="Transfer Asset" type="object" class="oe_highlight" invisible="state == 'model'"/>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="draft,open,close"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<field name="company_id" invisible="1"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" class="oe_edit_only"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="e.g. Laptop"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="asset_code" readonly="1"/>
|
||||||
|
<field name="acquisition_date"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="original_value"/>
|
||||||
|
<field name="model_id" string="Asset Model"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Description">
|
||||||
|
<field name="description"/>
|
||||||
|
</page>
|
||||||
|
<page string="Depreciation Board" invisible="1">
|
||||||
|
<!-- Hiding depreciation details for GA as per requirement to simplify -->
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<record id="action_ga_asset_management" model="ir.actions.act_window">
|
||||||
|
<field name="name">Assets</field>
|
||||||
|
<field name="res_model">account.asset</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="view_id" ref="view_ga_account_asset_tree"/>
|
||||||
|
<field name="search_view_id" ref="account_asset.view_account_asset_search"/>
|
||||||
|
<field name="domain">[('state', '!=', 'model')]</field> <!-- Filter out Asset Models -->
|
||||||
|
<field name="context">{'default_state': 'draft'}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ga_asset_tree_view_link" model="ir.actions.act_window.view">
|
||||||
|
<field name="sequence" eval="1"/>
|
||||||
|
<field name="view_mode">tree</field>
|
||||||
|
<field name="view_id" ref="view_ga_account_asset_tree"/>
|
||||||
|
<field name="act_window_id" ref="action_ga_asset_management"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ga_asset_form_view_link" model="ir.actions.act_window.view">
|
||||||
|
<field name="sequence" eval="2"/>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="view_id" ref="view_ga_account_asset_form"/>
|
||||||
|
<field name="act_window_id" ref="action_ga_asset_management"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu Items -->
|
||||||
|
<menuitem id="menu_ga_root" name="General Affair" web_icon="ga_asset_management,static/description/icon.png" sequence="10" groups="ga_asset_management.group_ga_asset_user"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_ga_asset_management"
|
||||||
|
name="Assets"
|
||||||
|
parent="menu_ga_root"
|
||||||
|
action="action_ga_asset_management"
|
||||||
|
sequence="1"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
15
views/product_template_views.xml
Normal file
15
views/product_template_views.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="product_template_form_view_inherit_ga_asset" model="ir.ui.view">
|
||||||
|
<field name="name">product.template.form.inherit.ga.asset</field>
|
||||||
|
<field name="model">product.template</field>
|
||||||
|
<field name="inherit_id" ref="product.product_template_only_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<div name="button_box" position="inside">
|
||||||
|
<button class="oe_stat_button" name="action_view_assets" type="object" icon="fa-building-o">
|
||||||
|
<field string="Assets" name="asset_count" widget="statinfo"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
41
views/stock_picking_views.xml
Normal file
41
views/stock_picking_views.xml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_picking_form_inherit_ga_asset" model="ir.ui.view">
|
||||||
|
<field name="name">stock.picking.form.inherit.ga.asset</field>
|
||||||
|
<field name="model">stock.picking</field>
|
||||||
|
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//button[@name='button_validate']" position="before">
|
||||||
|
<button name="action_generate_asset_codes"
|
||||||
|
string="Generate Asset Codes"
|
||||||
|
type="object"
|
||||||
|
class="oe_highlight"
|
||||||
|
invisible="state != 'assigned'"
|
||||||
|
groups="ga_asset_management.group_ga_asset_user"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='move_ids_without_package']/tree/field[@name='product_id']" position="after">
|
||||||
|
<field name="asset_code" optional="show"/>
|
||||||
|
<field name="asset_id" optional="hide" readonly="1"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Asset Receipts -->
|
||||||
|
<record id="action_asset_receipts" model="ir.actions.act_window">
|
||||||
|
<field name="name">Pending Asset Receipts</field>
|
||||||
|
<field name="res_model">stock.picking</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="domain">[('picking_type_code', '=', 'incoming'), ('state', 'not in', ('done', 'cancel')), ('has_asset_moves', '=', True)]</field>
|
||||||
|
<field name="context">{'create': False}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Add Menu under General Affair > Assets -->
|
||||||
|
<!-- Assuming the parent menu 'menu_ga_asset_root' is defined in account_asset_views.xml -->
|
||||||
|
<menuitem id="menu_asset_receipts"
|
||||||
|
name="Pending Asset Receipts"
|
||||||
|
parent="menu_ga_root"
|
||||||
|
action="action_asset_receipts"
|
||||||
|
sequence="20"
|
||||||
|
groups="ga_asset_management.group_ga_asset_user"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
1
wizard/__init__.py
Normal file
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import asset_transfer_wizard
|
||||||
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
wizard/__pycache__/asset_transfer_wizard.cpython-312.pyc
Normal file
BIN
wizard/__pycache__/asset_transfer_wizard.cpython-312.pyc
Normal file
Binary file not shown.
21
wizard/asset_transfer_views.xml
Normal file
21
wizard/asset_transfer_views.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_ga_asset_transfer_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">ga.asset.transfer.wizard.form</field>
|
||||||
|
<field name="model">ga.asset.transfer.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Transfer Asset">
|
||||||
|
<group>
|
||||||
|
<field name="asset_id"/>
|
||||||
|
<field name="current_company_id" readonly="1"/>
|
||||||
|
<field name="target_company_id" domain="[('id', '!=', current_company_id)]"/>
|
||||||
|
<field name="note"/>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button name="action_transfer" string="Transfer" type="object" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
97
wizard/asset_transfer_wizard.py
Normal file
97
wizard/asset_transfer_wizard.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class GaAssetTransferWizard(models.TransientModel):
|
||||||
|
_name = 'ga.asset.transfer.wizard'
|
||||||
|
_description = 'Asset Transfer Wizard'
|
||||||
|
|
||||||
|
asset_id = fields.Many2one('account.asset', string='Asset', required=True, readonly=True)
|
||||||
|
current_company_id = fields.Many2one('res.company', string='Current Company', required=True, readonly=True)
|
||||||
|
target_company_id = fields.Many2one('res.company', string='Target Company', required=True)
|
||||||
|
note = fields.Text(string='Transfer Note')
|
||||||
|
|
||||||
|
def action_transfer(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.target_company_id and self.target_company_id != self.current_company_id:
|
||||||
|
old_asset = self.asset_id
|
||||||
|
|
||||||
|
# CASE 1: Asset is Draft or Model -> Simple Move
|
||||||
|
# No depreciation history to preserve, so we just move it.
|
||||||
|
if old_asset.state in ('draft', 'model'):
|
||||||
|
old_asset.sudo().write({'company_id': self.target_company_id.id})
|
||||||
|
if self.note:
|
||||||
|
old_asset.sudo().message_post(body=f"Asset transferred to {self.target_company_id.name}. Note: {self.note}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': 'Transferred Asset',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'account.asset',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_id': old_asset.id,
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
# CASE 2: Asset is Running (Open/Parsed) -> Close and Clone
|
||||||
|
# We want to stop the old one and start fresh in new company
|
||||||
|
vals = {
|
||||||
|
'name': old_asset.name,
|
||||||
|
'product_id': old_asset.product_id.id,
|
||||||
|
'asset_code': old_asset.asset_code,
|
||||||
|
'original_value': old_asset.original_value,
|
||||||
|
'already_depreciated_amount_import': old_asset.original_value - old_asset.value_residual,
|
||||||
|
'acquisition_date': old_asset.acquisition_date,
|
||||||
|
'prorata_date': old_asset.prorata_date,
|
||||||
|
'method': old_asset.method,
|
||||||
|
'method_number': old_asset.method_number,
|
||||||
|
'method_period': old_asset.method_period,
|
||||||
|
'method_progress_factor': old_asset.method_progress_factor,
|
||||||
|
'prorata_computation_type': old_asset.prorata_computation_type,
|
||||||
|
'company_id': self.target_company_id.id,
|
||||||
|
'state': 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find Asset Model in Target Company to apply Accounts
|
||||||
|
if old_asset.product_id:
|
||||||
|
# Get Product with company context
|
||||||
|
product = old_asset.product_id.with_company(self.target_company_id)
|
||||||
|
account = product.property_account_expense_id or product.categ_id.property_account_expense_categ_id
|
||||||
|
|
||||||
|
if account and account.asset_model:
|
||||||
|
model = account.asset_model
|
||||||
|
vals['model_id'] = model.id
|
||||||
|
# Auto-populate Accounts from Model
|
||||||
|
vals['account_asset_id'] = model.account_asset_id.id
|
||||||
|
vals['account_depreciation_id'] = model.account_depreciation_id.id
|
||||||
|
vals['account_depreciation_expense_id'] = model.account_depreciation_expense_id.id
|
||||||
|
vals['journal_id'] = model.journal_id.id
|
||||||
|
|
||||||
|
# 2. Close the old asset WITHOUT generating Disposal Moves
|
||||||
|
# User request: "no need to use dispose method (gain/loss account)"
|
||||||
|
# We simply stop future depreciation and archive the old asset.
|
||||||
|
# NOTE: The Asset Value remains on the old company's Balance Sheet until manually adjusted/transferred.
|
||||||
|
|
||||||
|
# Cancel future draft moves (using sudo to bypass company rules if needed)
|
||||||
|
old_asset.depreciation_move_ids.filtered(lambda m: m.state == 'draft').with_context(force_delete=True).sudo().unlink()
|
||||||
|
|
||||||
|
# Close and Archive (using sudo to ensure we can modify the record even if we are switching context)
|
||||||
|
old_asset.sudo().write({
|
||||||
|
'state': 'close',
|
||||||
|
'active': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
old_asset.sudo().message_post(body=f"Asset transferred to {self.target_company_id.name}. Auto-disposal skipped.")
|
||||||
|
|
||||||
|
# 3. Create the new asset in target company (using sudo to create in a company we might not be active in)
|
||||||
|
new_asset = self.env['account.asset'].sudo().create(vals)
|
||||||
|
new_asset.sudo().message_post(body=f"Asset transferred from {self.current_company_id.name}. Note: {self.note}")
|
||||||
|
|
||||||
|
# 4. Open the new asset
|
||||||
|
return {
|
||||||
|
'name': 'Transferred Asset',
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'account.asset',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_id': new_asset.id,
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
Loading…
Reference in New Issue
Block a user