24 Commits

Author SHA1 Message Date
Stéphan Sainléger
cc719291eb [MIG] hr_luncheon_voucher: migrate to 18.0 2026-05-30 00:34:22 +02:00
Stéphan Sainléger
e4cc346dfe [MIG] timesheet_timesheet_analysis_report: migrate to 18.0 2026-05-29 23:16:32 +02:00
Stéphan Sainléger
ca1a5981b2 [MIG] hr_expense_analytic_account_preselect_with_projec: migrate to 18.0 2026-05-05 17:26:12 +02:00
Stéphan Sainléger
69bc951b3e [MIG] hr_effective_attendance_period: migrate to 18.0 2026-05-05 17:24:48 +02:00
Stéphan Sainléger
494f2b4926 [MIG] timesheet_timesheet_analysis_report: migrate to 18.0 2026-04-03 14:41:08 +02:00
Stéphan Sainléger
a2421b89da [MIG] hr_usability: migrate to 18.0 2026-01-16 16:16:58 +01:00
f4d9cd008c init 18.0 2025-10-09 11:07:40 +02:00
3bfe2752b5 [ADD]hr_employee_stats_sheet_lunch_voucher 2025-10-07 07:33:02 +00:00
eecdf0c727 [ADD]hr_employee_stats_sheet 2025-10-07 07:32:52 +00:00
0dc8075a81 [IMP]hr_timesheet_import_from_calendar: display unit amount with time format widget instead of float 2025-10-01 08:04:58 +02:00
6c008cce4d [FIX]allow_negative_leave_and_allocation:fix recorsed send to _check_holidays 2025-09-30 18:10:41 +02:00
dc5fd2897a [MIG]l10n_fr_hr_holidays:backport from v17 to 16 2025-09-24 07:06:22 +00:00
f206606a96 Sync config from odoo-elabore-ci:16.0
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 7m49s
2025-09-17 13:40:10 +00:00
e8a819d594 [FIX]allow_negative_leave_and_allocation:fix duration check 2025-09-15 11:27:50 +02:00
0437a9c9be [ADD]hr_timesheet_sheet_usability 2025-09-15 11:13:30 +02:00
d52b8c424e [ADD]allow_negative_leave_and_allocation_hr_holidays_attendance 2025-04-16 15:06:21 +02:00
4a00e642e0 [ADD]allow_negative_leave_and_allocation 2025-04-16 15:05:12 +02:00
Stéphan Sainléger
beb64d034d [FIX] hr_usability: add missing add-ons dependency to hr_holidays 2025-02-21 16:18:07 +01:00
ec7c402a26 [ADD]timesheet_timesheet_analysis_report 2025-02-03 17:50:37 +01:00
clementthomas
fd45cf17a2 [NEW] hr_timesheet_import_from_calendar 2024-12-16 14:05:05 +01:00
091fefc763 [ADD]project_timesheet_holidays_type 2024-06-27 09:19:00 +02:00
583b6e30b2 [ADD]project_timesheet_holidays_type 2024-06-27 09:19:00 +02:00
34abfad364 [ADD]hr_usability_elabore 2024-02-15 16:10:22 +01:00
edc8cffc69 Initial Commit 2023-12-21 12:02:04 +01:00
61 changed files with 1971 additions and 582 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Configuration for known file extensions
[*.{css,js,json,less,md,py,rst,sass,scss,xml,yaml,yml}]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,yml,yaml,rst,md}]
indent_size = 2
# Do not configure editor for libs and autogenerated content
[{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}]
charset = unset
end_of_line = unset
indent_size = unset
indent_style = unset
insert_final_newline = false
trim_trailing_whitespace = false

188
.eslintrc.yml Normal file
View File

@@ -0,0 +1,188 @@
env:
browser: true
es6: true
# See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449
parserOptions:
ecmaVersion: 2019
overrides:
- files:
- "**/*.esm.js"
parserOptions:
sourceType: module
# Globals available in Odoo that shouldn't produce errorings
globals:
_: readonly
$: readonly
fuzzy: readonly
jQuery: readonly
moment: readonly
odoo: readonly
openerp: readonly
owl: readonly
luxon: readonly
# Styling is handled by Prettier, so we only need to enable AST rules;
# see https://github.com/OCA/maintainer-quality-tools/pull/618#issuecomment-558576890
rules:
accessor-pairs: warn
array-callback-return: warn
callback-return: warn
capitalized-comments:
- warn
- always
- ignoreConsecutiveComments: true
ignoreInlineComments: true
complexity:
- warn
- 15
constructor-super: warn
dot-notation: warn
eqeqeq: warn
global-require: warn
handle-callback-err: warn
id-blacklist: warn
id-match: warn
init-declarations: error
max-depth: warn
max-nested-callbacks: warn
max-statements-per-line: warn
no-alert: warn
no-array-constructor: warn
no-caller: warn
no-case-declarations: warn
no-class-assign: warn
no-cond-assign: error
no-const-assign: error
no-constant-condition: warn
no-control-regex: warn
no-debugger: error
no-delete-var: warn
no-div-regex: warn
no-dupe-args: error
no-dupe-class-members: error
no-dupe-keys: error
no-duplicate-case: error
no-duplicate-imports: error
no-else-return: warn
no-empty-character-class: warn
no-empty-function: error
no-empty-pattern: error
no-empty: warn
no-eq-null: error
no-eval: error
no-ex-assign: error
no-extend-native: warn
no-extra-bind: warn
no-extra-boolean-cast: warn
no-extra-label: warn
no-fallthrough: warn
no-func-assign: error
no-global-assign: error
no-implicit-coercion:
- warn
- allow: ["~"]
no-implicit-globals: warn
no-implied-eval: warn
no-inline-comments: warn
no-inner-declarations: warn
no-invalid-regexp: warn
no-irregular-whitespace: warn
no-iterator: warn
no-label-var: warn
no-labels: warn
no-lone-blocks: warn
no-lonely-if: error
no-mixed-requires: error
no-multi-str: warn
no-native-reassign: error
no-negated-condition: warn
no-negated-in-lhs: error
no-new-func: warn
no-new-object: warn
no-new-require: warn
no-new-symbol: warn
no-new-wrappers: warn
no-new: warn
no-obj-calls: warn
no-octal-escape: warn
no-octal: warn
no-param-reassign: warn
no-path-concat: warn
no-process-env: warn
no-process-exit: warn
no-proto: warn
no-prototype-builtins: warn
no-redeclare: warn
no-regex-spaces: warn
no-restricted-globals: warn
no-restricted-imports: warn
no-restricted-modules: warn
no-restricted-syntax: warn
no-return-assign: error
no-script-url: warn
no-self-assign: warn
no-self-compare: warn
no-sequences: warn
no-shadow-restricted-names: warn
no-shadow: warn
no-sparse-arrays: warn
no-sync: warn
no-this-before-super: warn
no-throw-literal: warn
no-undef-init: warn
no-undef: error
no-unmodified-loop-condition: warn
no-unneeded-ternary: error
no-unreachable: error
no-unsafe-finally: error
no-unused-expressions: error
no-unused-labels: error
no-unused-vars: error
no-use-before-define: error
no-useless-call: warn
no-useless-computed-key: warn
no-useless-concat: warn
no-useless-constructor: warn
no-useless-escape: warn
no-useless-rename: warn
no-void: warn
no-with: warn
operator-assignment: [error, always]
prefer-const: warn
radix: warn
require-yield: warn
sort-imports: warn
spaced-comment: [error, always]
strict: [error, function]
use-isnan: error
valid-jsdoc:
- warn
- prefer:
arg: param
argument: param
augments: extends
constructor: class
exception: throws
func: function
method: function
prop: property
return: returns
virtual: abstract
yield: yields
preferType:
array: Array
bool: Boolean
boolean: Boolean
number: Number
object: Object
str: String
string: String
requireParamDescription: false
requireReturn: false
requireReturnDescription: false
requireReturnType: false
valid-typeof: warn
yoda: warn

View File

@@ -0,0 +1,42 @@
name: pre-commit
on:
pull_request:
branches:
- "18.0*"
jobs:
pre-commit:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Get python version
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
# - uses: actions/cache@v4
# with:
# path: ~/.cache/pre-commit
# key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure --color=always
env:
# Consider valid a PR that changes README fragments but doesn't
# change the README.rst file itself. It's not really a problem
# because the bot will update it anyway after merge. This way, we
# lower the barrier for functional contributors that want to fix the
# readme fragments, while still letting developers get README
# auto-generated (which also helps functionals when using runboat).
# DOCS https://pre-commit.com/#temporarily-disabling-hooks
SKIP: oca-gen-addon-readme
- name: Check that all files generated by pre-commit are in git
run: |
newfiles="$(git ls-files --others --exclude-from=.gitignore)"
if [ "$newfiles" != "" ] ; then
echo "Please check-in the following files:"
echo "$newfiles"
exit 1
fi

150
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,150 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
# END NOT INSTALLABLE ADDONS
# Files and folders generated by bots, to avoid loops
^setup/|/static/description/index\.html$|
# We don't want to mess with tool-generated files
.svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|^eslint.config.cjs|^prettier.config.cjs|
# Maybe reactivate this when all README files include prettier ignore tags?
^README\.md$|
# Library files can have extraneous formatting (even minimized)
/static/(src/)?lib/|
# Repos using Sphinx to generate docs don't need prettying
^docs/_templates/.*\.html$|
# Don't bother non-technical authors with formatting issues in docs
readme/.*\.(rst|md)$|
# Ignore build and dist directories in addons
/build/|/dist/|
# Ignore test files in addons
/tests/samples/.*|
# You don't usually want a bot to modify your legal texts
(LICENSE.*|COPYING.*)
default_language_version:
python: python3
node: "16.17.0"
repos:
- repo: local
hooks:
# These files are most likely copier diff rejection junks; if found,
# review them manually, fix the problem (if needed) and remove them
- id: forbidden-files
name: forbidden files
entry: found forbidden files; remove them
language: fail
files: "\\.rej$"
- id: en-po-files
name: en.po files cannot exist
entry: found a en.po file
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/oca/maintainer-tools
rev: f9b919b9868143135a9c9cb03021089cabba8223
hooks:
# update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons
- id: oca-fix-manifest-website
entry:
bash -c 'oca-fix-manifest-website "https://git.elabore.coop/elabore/$(basename
$(git rev-parse --show-toplevel))"'
- id: oca-gen-addon-readme
entry:
bash -c 'oca-gen-addon-readme
--addons-dir=.
--branch=$(git symbolic-ref
refs/remotes/origin/HEAD | sed "s@^refs/remotes/origin/@@")
--repo-name=$(basename $(git rev-parse --show-toplevel))
--org-name="Elabore"
--if-source-changed --keep-source-digest'
- repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.1.4
hooks:
- id: oca-checks-odoo-module
- id: oca-checks-po
args:
- --disable=po-pretty-format
- repo: local
hooks:
- id: prettier
name: prettier (with plugin-xml)
entry: prettier
args:
- --write
- --list-different
- --ignore-unknown
types: [text]
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
language: node
additional_dependencies:
- "prettier@2.7.1"
- "@prettier/plugin-xml@2.2.0"
- repo: local
hooks:
- id: eslint
name: eslint
entry: eslint
args:
- --color
- --fix
verbose: true
types: [javascript]
language: node
additional_dependencies:
- "eslint@8.24.0"
- "eslint-plugin-jsdoc@"
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: end-of-file-fixer
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: debug-statements
- id: fix-encoding-pragma
args: ["--remove"]
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-merge-conflict
# exclude files where underlines are not distinguishable from merge conflicts
exclude: /README\.rst$|^docs/.*\.rst$
- id: check-symlinks
- id: check-xml
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.7
hooks:
- id: docformatter
args: [
"--in-place", # modify the files
"--recursive", # run on all the files
"--wrap-summaries",
"88", # max length of 1st line
"--wrap-descriptions",
"88", # max length of other lines
"--pre-summary-newline", # new line before a long summary
"--make-summary-multi-line", # force summary on multilines
]
additional_dependencies: ["tomli"] # if Python <3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/OCA/pylint-odoo
rev: v9.1.3
hooks:
- id: pylint_odoo
name: pylint with optional checks
args:
- --rcfile=.pylintrc
- --exit-zero
verbose: true
- id: pylint_odoo
args:
- --rcfile=.pylintrc-mandatory

8
.prettierrc.yml Normal file
View File

@@ -0,0 +1,8 @@
# Defaults for all prettier-supported languages.
# Prettier will complete this with settings from .editorconfig file.
bracketSpacing: false
printWidth: 88
proseWrap: always
semi: true
trailingComma: "es5"
xmlWhitespaceSensitivity: "strict"

123
.pylintrc Normal file
View File

@@ -0,0 +1,123 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=18.0
[MESSAGES CONTROL]
disable=all
# This .pylintrc contains optional AND mandatory checks and is meant to be
# loaded in an IDE to have it check everything, in the hope this will make
# optional checks more visible to contributors who otherwise never look at a
# green travis to see optional checks that failed.
# .pylintrc-mandatory containing only mandatory checks is used the pre-commit
# config as a blocking check.
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout,
# messages that do not cause the lint step to fail
consider-merging-classes-inherited,
create-user-wo-reset-password,
dangerous-filter-wo-user,
deprecated-module,
file-not-used,
invalid-commit,
missing-manifest-dependency,
missing-newline-extrafiles,
missing-readme,
no-utf8-coding-comment,
odoo-addons-relative-import,
old-api7-method-defined,
redefined-builtin,
too-complex,
unnecessary-utf8-coding-comment
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no

98
.pylintrc-mandatory Normal file
View File

@@ -0,0 +1,98 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=18.0
[MESSAGES CONTROL]
disable=all
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no

31
.ruff.toml Normal file
View File

@@ -0,0 +1,31 @@
target-version = "py310"
fix = true
[lint]
extend-select = [
"B",
"C90",
"E501", # line too long (default 88)
"I", # isort
"UP", # pyupgrade
]
extend-safe-fixes = ["UP008"]
exclude = ["setup/*"]
[format]
exclude = ["setup/*"]
[lint.per-file-ignores]
"__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py
"__manifest__.py" = ["B018"] # useless expression
[lint.isort]
section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"]
[lint.isort.sections]
"odoo" = ["odoo"]
"odoo-addons" = ["odoo.addons"]
[lint.mccabe]
max-complexity = 16

View File

@@ -2,7 +2,18 @@
hr_effective_attendance_period
==============================
Provide indication that attendance are effectively worked periods or not
Provide indication that attendance periods are effectively worked or not.
Description
===========
This module adds a boolean field on working time attendance lines to mark
which periods are effectively worked by employees. It provides utility methods
on the resource calendar to determine:
- Whether a day is a working day
- Whether a day is a full working day (both morning and afternoon)
- Whether all attendances of a day are effectively worked (no leaves)
Installation
============
@@ -10,17 +21,30 @@ Installation
Use Odoo normal module installation procedure to install
``hr_effective_attendance_period``.
This module depends on:
* ``base`` (Odoo core)
* ``resource`` (Odoo core)
Configuration
=============
Go to ``Configuration > Technical > Resource > Working Times`` and create
Working Times for your company. For each attendance line, check
``Effective Attendance Period`` to indicate which periods are effectively
attended by employees.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/hr-tools/issues>`_. In case of
trouble, please check there if your issue has already been
reported. If you spotted it first, help us smashing it by providing a
detailed and welcomed feedback.
Bugs are tracked on `our issues website <https://github.com/elabore-coop/hr-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing detailed and welcomed feedback.
Credits
=======
@@ -34,8 +58,8 @@ Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
* Elabore (https://elabore.coop)
Maintainer
----------

View File

@@ -1,3 +1 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -3,35 +3,22 @@
{
"name": "hr_effective_attendance_period",
"version": "14.0.2.0.1",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"website": "https://git.elabore.coop/elabore/hr-tools",
"maintainer": "Stéphan Sainléger",
"license": "AGPL-3",
"category": "Tools",
"summary": "Provide indication that attendance are effectively worked periods or not",
"summary": "Indicate whether attendance periods are effectively worked",
# any module necessary for this one to work correctly
"depends": [
"base",
"resource",
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/resource_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}
}

View File

@@ -1 +1 @@
from . import resource
from . import resource

View File

@@ -1,14 +1,15 @@
import math
from datetime import timedelta
import pytz
from datetime import datetime, timedelta
from pytz import timezone
from odoo import models, fields
from odoo import fields, models
class ResourceCalendarAttendance(models.Model):
_inherit = "resource.calendar.attendance"
effective_attendance_period = fields.Boolean('Effective Attendance Period', store=True)
effective_attendance_period = fields.Boolean(store=True)
class ResourceCalendar(models.Model):
@@ -18,7 +19,7 @@ class ResourceCalendar(models.Model):
domain = [
("calendar_id", "=", self.id),
("dayofweek", "=", day.weekday()),
("effective_attendance_period", "=", True)
("effective_attendance_period", "=", True),
]
if self.two_weeks_calendar:
# Employee has Even/Odd weekly calendar
@@ -38,28 +39,40 @@ class ResourceCalendar(models.Model):
def is_full_working_day(self, day):
day_attendances = self._retrieve_day_matching_attendances(day)
morning_worked = len(day_attendances.filtered(lambda x: x.day_period == "morning")) > 0
afternoon_worked = len(day_attendances.filtered(lambda x: x.day_period == "afternoon")) > 0
morning_worked = (
len(day_attendances.filtered(lambda x: x.day_period == "morning")) > 0
)
afternoon_worked = (
len(day_attendances.filtered(lambda x: x.day_period == "afternoon")) > 0
)
return morning_worked and afternoon_worked
def _compute_datetime_in_utc_tz(self, date):
user_tz = pytz.timezone(self.env.context.get('tz', 'utc') or 'utc')
return user_tz.localize(date).astimezone(pytz.utc)
user_tz = pytz.timezone(self.env.context.get("tz", "utc") or "utc")
return user_tz.localize(date).astimezone(pytz.utc)
def _is_worked_attendance(self, resource, day, attendance):
attendance_start = fields.Datetime.to_datetime(day.date()) + timedelta(hours=attendance.hour_from)
attendance_end = fields.Datetime.to_datetime(day.date()) + timedelta(hours=attendance.hour_to)
attendance_start = fields.Datetime.to_datetime(day.date()) + timedelta(
hours=attendance.hour_from
)
attendance_end = fields.Datetime.to_datetime(day.date()) + timedelta(
hours=attendance.hour_to
)
utc_start = self._compute_datetime_in_utc_tz(attendance_start)
utc_end = self._compute_datetime_in_utc_tz(attendance_end)
resource_leaves = self.env["resource.calendar.leaves"].sudo().search(
[
("company_id", "=", resource.company_id.id),
("date_from", "<=", utc_start),
("date_to", ">=", utc_end),
"|",
("resource_id", "=", resource.id),
("resource_id", "=", None),
]
resource_leaves = (
self.env["resource.calendar.leaves"]
.sudo()
.search(
[
("company_id", "=", resource.company_id.id),
("date_from", "<=", utc_start),
("date_to", ">=", utc_end),
"|",
("resource_id", "=", resource.id),
("resource_id", "=", None),
]
)
)
if resource_leaves:
return False
@@ -81,4 +94,4 @@ class ResourceCalendar(models.Model):
for attendance in day_attendances:
if not self._is_worked_attendance(resource, day, attendance):
return False
return True
return True

View File

@@ -1,10 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_resource_calendar_attendance_tree_inherit_effective_attendance"
model="ir.ui.view">
<field name="name">resource.calendar.attendance.view.tree.inherit</field>
<record
id="view_resource_calendar_attendance_tree_inherit_effective_attendance"
model="ir.ui.view"
>
<field name="name">resource.calendar.attendance.view.list.inherit</field>
<field name="model">resource.calendar.attendance</field>
<field name="inherit_id" ref="resource.view_resource_calendar_attendance_tree" />
<field
name="inherit_id"
ref="resource.view_resource_calendar_attendance_tree"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='week_type']" position="after">
<field name="effective_attendance_period" />
@@ -12,15 +17,20 @@
</field>
</record>
<record id="view_resource_calendar_attendance_form_inherit_effective_attendance"
model="ir.ui.view">
<field name="name">resource.calendar.attendance.view.tree.inherit</field>
<record
id="view_resource_calendar_attendance_form_inherit_effective_attendance"
model="ir.ui.view"
>
<field name="name">resource.calendar.attendance.view.list.inherit</field>
<field name="model">resource.calendar.attendance</field>
<field name="inherit_id" ref="resource.view_resource_calendar_attendance_form" />
<field
name="inherit_id"
ref="resource.view_resource_calendar_attendance_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='day_period']" position="after">
<field name="effective_attendance_period" />
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -1,8 +1,16 @@
==============================
hr_expense_analytic_account_preselect_with_project
==============================
==================================================
HR Expense Analytic Account Preselect with Project
==================================================
In expenses, preselect analytic account with project
Preselect the analytic distribution on expenses based on the selected project.
Description
===========
This module adds a ``Project`` field to expense forms. When a project is
selected, the analytic distribution is automatically set to the project's
analytic account (100%), so the user does not need to manually set the
analytic distribution.
Installation
============
@@ -10,17 +18,32 @@ Installation
Use Odoo normal module installation procedure to install
``hr_expense_analytic_account_preselect_with_project``.
This module depends on:
* ``base`` (Odoo core)
* ``hr_expense`` (Odoo core)
* ``project`` (Odoo core)
Usage
=====
When creating an expense:
1. Select a ``Project`` in the expense form.
2. The analytic distribution is automatically populated with the project's
analytic account at 100%.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/hr-tools/issues>`_. In case of
trouble, please check there if your issue has already been
reported. If you spotted it first, help us smashing it by providing a
detailed and welcomed feedback.
Bugs are tracked on `our issues website <https://github.com/elabore-coop/hr-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing detailed and welcomed feedback.
Credits
=======
@@ -34,8 +57,8 @@ Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
* Elabore (https://elabore.coop)
Maintainer
----------

View File

@@ -1,3 +1 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -3,35 +3,23 @@
{
"name": "hr_expense_analytic_account_preselect_with_project",
"version": "14.0.0.0.0",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"website": "https://git.elabore.coop/elabore/hr-tools",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Provide indication that attendance are effectively worked periods or not",
"summary": "Preselect analytic distribution on expenses from project",
# any module necessary for this one to work correctly
"depends": [
"base",
"hr_expense",
"project",
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/hr_expense_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}
}

View File

@@ -1 +1 @@
from . import hr_expense
from . import hr_expense

View File

@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
from odoo import fields, models, api
class HrExpense(models.Model):
_inherit = "hr.expense"
project_id = fields.Many2one('project.project', string="Projet", required=True)
project_id = fields.Many2one("project.project", string="Projet", required=True)
@api.onchange('project_id')
@api.onchange("project_id")
def set_analytic_account(self):
self.analytic_account_id = self.project_id.analytic_account_id
if self.project_id.account_id:
self.analytic_distribution = {str(self.project_id.account_id.id): 100}
else:
self.analytic_distribution = {}

View File

@@ -1,13 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="hr_expense_view_form_analytic_account_preselect_with_project" model="ir.ui.view">
<field name="name">hr.expense.view.form.analytic.account.preselect.with.project</field>
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
<record
id="hr_expense_view_form_analytic_account_preselect_with_project"
model="ir.ui.view"
>
<field
name="name"
>hr.expense.view.form.analytic.account.preselect.with.project</field>
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_view_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='analytic_account_id']" position="before">
<xpath expr="//field[@name='analytic_distribution']" position="before">
<field name="project_id" />
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -1,47 +0,0 @@
==============================
hr_expense_report_merge_attachment
==============================
Merge attachments in expense report.
Managed attachment types :
* PDF
* Images
Installation
============
Use Odoo normal module installation procedure to install
``hr_expense_report_merge_attachment``.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/hr-tools/issues>`_. In case of
trouble, please check there if your issue has already been
reported. If you spotted it first, help us smashing it by providing a
detailed and welcomed feedback.
Credits
=======
Contributors
------------
* Clément Thomas
Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
Maintainer
----------
This module is maintained by Elabore.

View File

@@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import controllers

View File

@@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import main

View File

@@ -1,69 +0,0 @@
import odoo.addons.web.controllers.main as main
import ast
import base64
import io
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
import PyPDF2
import logging
from odoo import http
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from reportlab.lib.pagesizes import letter, A4
logger = logging.getLogger(__name__)
class Extension(main.ReportController):
@http.route(['/report/download'], type='http', auth="user")
def report_download(self, data, token, context=None):
"""
In case of hr expense sheet report : merge PDF with other attachments
"""
res = super(Extension,self).report_download(data, token, context)
if "hr_expense.report_expense_sheet" in ast.literal_eval(data)[0]: #check if we are generating expense sheet report pdf
writer = OdooPdfFileWriter() #Open a file writer to create new PDF
# Open main pdf and read it and write it page by page in new pdf
main_pdf_reader = OdooPdfFileReader(io.BytesIO(res.data), strict=False)
for n in range(main_pdf_reader.getNumPages()):
writer.addPage(main_pdf_reader.getPage(n))
# Parse data (last char after "/") to get id of current sheet
expense_sheet_id = int(ast.literal_eval(data)[0].split('/')[-1])
# Get ids of related expenses
expense_ids = [e.id for e in http.request.env['hr.expense.sheet'].browse(expense_sheet_id).expense_line_ids]
# Get related attachments
attachments = http.request.env['ir.attachment'].search([('res_id','in',expense_ids),('res_model','=','hr.expense')])
# Open each attachments, and write it page by page in new pdf
for att in attachments:
if att.mimetype == "application/pdf":
try:
attachment_reader = OdooPdfFileReader(io.BytesIO(base64.b64decode(att.datas)), strict=False)
for n in range(attachment_reader.getNumPages()):
writer.addPage(attachment_reader.getPage(n))
except PyPDF2.utils.PdfReadError: # Case of non-pdf attachments
logger.info('Attachment %s cannot be merged in expense report'%(att.name,))
elif 'image/' in att.mimetype:
packet = io.BytesIO()
can = canvas.Canvas(packet)
img = ImageReader(io.BytesIO(base64.b64decode(att.datas)))
can.drawImage(img, 0, 0, A4[0]*0.9, A4[1]*0.9, preserveAspectRatio=True)
can.save()
packet.seek(0)
attachment_reader = OdooPdfFileReader(packet)
writer.addPage(attachment_reader.getPage(0))
# Write new pdf to res.data
buffer = io.BytesIO()
writer.write(buffer)
pdf_content = buffer.getvalue()
res.data = pdf_content
return res

View File

@@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,36 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "hr_expense_specific_journal",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Allow user to only select expense journal in expenses",
# any module necessary for this one to work correctly
"depends": [
"hr_expense","account"
],
"qweb": [
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
'views/account_journal_views.xml',
'views/hr_expense_sheet_views.xml'
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

@@ -1,46 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_expense_specific_journal
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-11 07:29+0000\n"
"PO-Revision-Date: 2023-10-11 07:29+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_expense_specific_journal
#: model:ir.model.fields,field_description:hr_expense_specific_journal.field_account_journal__display_name
msgid "Display Name"
msgstr "Afficher Nom"
#. module: hr_expense_specific_journal
#: model_terms:ir.ui.view,arch_db:hr_expense_specific_journal.view_account_journal_form_hr_expense_specific_journal_inherit
msgid "Expense journal"
msgstr "Journal des notes de frais"
#. module: hr_expense_specific_journal
#: model:ir.model.fields,field_description:hr_expense_specific_journal.field_account_journal__id
msgid "ID"
msgstr ""
#. module: hr_expense_specific_journal
#: model:ir.model.fields,field_description:hr_expense_specific_journal.field_account_journal__is_expense_journal
msgid "Is expense journal"
msgstr "Est un journal de notes de frais"
#. module: hr_expense_specific_journal
#: model:ir.model,name:hr_expense_specific_journal.model_account_journal
msgid "Journal"
msgstr ""
#. module: hr_expense_specific_journal
#: model:ir.model.fields,field_description:hr_expense_specific_journal.field_account_journal____last_update
msgid "Last Modified on"
msgstr "Dernière modification le"

View File

@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
from . import account_journal
from . import hr_expense

View File

@@ -1,6 +0,0 @@
from odoo import models, fields
class AccountJournal(models.Model):
_inherit = 'account.journal'
is_expense_journal = fields.Boolean('Is expense journal')

View File

@@ -1,13 +0,0 @@
from odoo import models, fields, api
class HrExpenseSheet(models.Model):
_inherit = 'hr.expense.sheet'
@api.model
def _default_journal_id(self):
""" The journal is determining the company of the accounting entries generated from expense. We need to force journal company and expense sheet company to be the same. """
default_company_id = self.default_get(['company_id'])['company_id']
journal = self.env['account.journal'].search([('company_id', '=', default_company_id), ('is_expense_journal','=',True)], limit=1)
return journal.id
journal_id = fields.Many2one(default=_default_journal_id)

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_journal_form_hr_expense_specific_journal_inherit" model="ir.ui.view">
<field name="name">view.account.journal.form.hr.expense.specific.journal.inherit</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form" />
<field name="arch" type="xml">
<xpath expr="//group[@name='group_alias_edit']" position="after">
<group string="Expense journal">
<field name="is_expense_journal" />
</group>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="hr_expense_sheet_form_view_hr_expense_specific_journal_inherit" model="ir.ui.view">
<field name="name">hr.expense.sheet.form.hr.expense.specific.journal.inherit</field>
<field name="model">hr.expense.sheet</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
<field name="arch" type="xml">
<field name="journal_id" position="attributes">
<attribute name="domain">[('is_expense_journal','=',True)]</attribute>
</field>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,170 @@
# HR Luncheon Voucher
[![Beta](https://img.shields.io/badge/maturity-Beta-yellow.png)](https://odoo-community.org/page/development-status)
[![License: AGPL-3](https://img.shields.io/badge/licence-AGPL--3-blue.png)](http://www.gnu.org/licenses/agpl-3.0-standalone.html)
[![elabore/hr-tools](https://img.shields.io/badge/github-elabore--coop%2Fhr--tools-lightgray.png?logo=github)](https://github.com/elabore-coop/hr-tools)
Manage luncheon vouchers credit and distribution for employees.
## Description
This module allows the management of luncheon vouchers attribution and
distribution. Employees can indicate which days are not concerned by luncheon
vouchers. HR managers can adjust the number of luncheon vouchers to distribute
and follow each employee's credit.
### Key Models
- **Luncheon Voucher Allocation** (`hr.lv.allocation`): tracks each distribution
campaign per employee, with a state workflow (Draft → Confirmed → Distributed)
and computed counters (acquired, due, distributed, balance).
- **Employee extensions** (`hr.employee`): additional fields store running totals
of acquired, distributed, and remaining vouchers, plus a default monthly
distribution value.
- **Meeting type extension** (`calendar.event.type`): a boolean field
`remove_luncheon_voucher` lets you mark event categories that cancel the daily
voucher (e.g. off-site meetings, free lunch).
### Attribution Rules
A luncheon voucher is acquired for a working day if:
- The employee worked on one or all the attendances of the day (depending on
whether the option **Half working days cancel luncheon vouchers** is enabled).
- There is no meeting that cancels the voucher during that day (e.g. off-site
or free lunch meetings).
- An attendance is considered worked as long as there is no leave covering the
whole attendance time slot.
The calculation is fully automated when an allocation request is created: the
system iterates through each day of the period, checks the working calendar,
validates attendance coverage, verifies leaves, and cross-references calendar
events.
### Allocation Lifecycle
| State | Description |
|-------|-------------|
| **Draft** | Allocation request created by the wizard. HR can adjust the number of vouchers to distribute. |
| **Confirmed** | HR manager validates the figures. Employee counters are updated. |
| **Distributed** | Vouchers have been effectively handed out. Final counters are set. |
### Employee Dashboard
In the employee form view, three stat buttons display the current running
totals: Acquired, Distributed, and Remaining (Due) vouchers. HR can also
refresh these values on demand.
## Installation
Use Odoo normal module installation procedure to install `hr_luncheon_voucher`.
### Dependencies
- `base` (Odoo core)
- `calendar` (Odoo core)
- `hr` (Odoo core)
- `hr_holidays` (Odoo core)
- `resource` (Odoo core)
- `hr_effective_attendance_period` (this repository)
### Security / Access Rights
| Model | HR Users | HR Managers |
|-------|----------|-------------|
| `hr.lv.allocation` | Read only | Full CRUD |
| `generate.lv.allocation.requests` | Read only | Full CRUD |
## Configuration
1. **Define cancelling meeting categories.**
Go to *Configuration > Technical > Calendar > Meeting Types* and enable the
*Remove luncheon voucher* flag on the relevant categories (e.g. Off-site,
Free lunch). Two default categories are pre-loaded by the module.
2. **Toggle the half-day rule.**
Go to *Configuration > General Settings > Employees* and check/uncheck
*Half working days cancel luncheon vouchers* depending on whether partial
attendance should still grant a voucher.
3. **Set default monthly distribution per employee.**
Open each employee's form, go to the *Luncheon Vouchers* section under HR
settings, and enter the *Default monthly distribution* value. This value is
pre-filled when a new allocation is created.
4. **Configure effective attendance periods.**
Go to *Configuration > Technical > Resource > Working Times* and create
Working Time entries for each attendance combination used in your company.
Make sure to indicate which periods are effectively attended — the module
relies on `hr_effective_attendance_period` to determine daily presence.
## Usage
### Calendar Events
When creating a calendar event that cancels luncheon voucher distribution
(off-site meeting, free lunch, etc.), simply assign the corresponding meeting
category to the event.
### Generating Allocations
For each distribution period, the HR manager should:
1. Go to the **Employees** list view.
2. Select all employees concerned by luncheon vouchers distribution.
3. Click the header button **Generate Luncheon Vouchers Allocations**.
4. Fill in the wizard: set a campaign name (e.g. "January 2026"), a start
date, and an end date.
5. Click **Create allocations requests**.
A voucher allocation request is created for each selected employee in **Draft**
state. The number of acquired vouchers is computed automatically based on the
attribution rules.
### Allocation Workflow
1. **Confirm** an allocation request when the figures are verified. Employee
running counters are updated.
2. **Distribute** the allocation when the vouchers have been effectively handed
out. Click *Distribute Vouchers* to mark the request as distributed.
3. **Adjust distribution** at any time in Draft state to override the
pre-filled distributed quantity.
4. **Back to draft** allows correcting an already confirmed or distributed
allocation.
5. The **Vouchers balance** column shows the difference between due and
distributed vouchers for each allocation.
### Monitoring
Each employee form displays three stat buttons — *Acquired*, *Distributed*,
and *Due* — giving a real-time summary of the employee's luncheon voucher
status. Use the **Refresh Luncheon Vouchers** button in the form header to
recompute these values if needed.
## Known Issues / Roadmap
None yet.
## Bug Tracker
Bugs are tracked on [Gitea Issues](https://git.elabore.coop/Elabore/hr-tools/issues).
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing a detailed and welcomed
feedback.
## Credits
### Contributors
- Stéphan Sainléger <https://github.com/stephansainleger>
### Funders
The development of this module has been financially supported by:
- Elabore (https://elabore.coop)
- Amaco (https://amaco.org)
### Maintainer
This module is maintained by Elabore.

View File

@@ -1,80 +0,0 @@
===================
HR Luncheon Voucher
===================
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--analytic-lightgray.png?logo=github
:target: https://github.com/elabore-coop/hr-tools
:alt: elabore/hr-tools
|badge1| |badge2| |badge3|
This module allows the management of Luncheon Vouchers attribution and distribution.
Employees can indicate which days are not concerned by luncheon vouchers.
HR managers can ajust the number of luncheon vouchers to distribute, and follow each employee credit.
Installation
============
Use Odoo normal module installation procedure to install ``hr_luncheon_voucher``.
Configuration
=============
1. Go to ``Configuration > Technical > Calendar > Meeting Types`` and define the meeting categories which cancel the daily luncheon voucher distribution.
2. Go to ``Configuration > General Settings > Employees`` and define if employees need to work the whole day to get a luncheon voucher.
3. Go to ``Employees`` and define for each employee the default number of luncheon vouchers to distribute in each distribution campaign.
4. Go to ``Configuration > Technical > Resource > Working Times`` and create Working Times for each combination of attendance that you have in your company. Do not forget to indicate which period are effectively attended.
Use
===
- when an calendar event makes the luncheon voucher distribution cancelled (Off-site or free lunch for instance), add the corresponding category to the event.
- for each distribution period, the HR manager should:
- go in ``Employees`` tree view
- select all the employees concerned by luncheon vouchers distribution
- click on header button ``Generate luncheon vouchers allocation``
- Fill the wizard form
- a voucher allocation request is created for each employee
- HR manager confirms an allocation request when the figures are confirmed
- HR manager marks the requests as "Distributed" when the vouchers has been effectively distributed
- HR manager can correct the allocation requests with ``Back to draft`` button
- employees' luncheon voucher counters are updated considering the vouchers acquired, dued and distributed at each campaign.
**Attribution rules:**
- a luncheon voucher is acquired for a working day if:
- the employee worked on one or all the attendances of the day (depending if option ``Half working days cancel luncheon vouchers`` is True or not)
- there is no meeting which cancel the voucher during that day (``Site off`` or ``Free lunch`` meeting for instance)
- an attendance is considered as worked as long as there is no leave on the whole attendance time slot
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues
<https://github.com/elabore-coop/hr-tools/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
Credits
=======
Contributors
------------
- Stéphan Sainléger <https://github.com/stephansainleger>
Funders
-------
The development of this module has been financially supported by:
- Elabore (https://elabore.coop)
- Amaco (https://amaco.org)
Maintainer
----------
This module is maintained by ELABORE.

7
hr_luncheon_voucher/__manifest__.py Executable file → Normal file
View File

@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
{
"name": "HR Luncheon Voucher",
"category": "Human Resources",
"version": "14.0.2.0.1",
"version": "18.0.1.0.0",
"summary": "Manage luncheon vouchers credit and distribution",
"author": "Elabore",
"website": "https://elabore.coop/",
"website": "https://git.elabore.coop/elabore/hr-tools",
"license": "AGPL-3",
"installable": True,
"application": True,
"auto_install": False,
@@ -27,5 +27,4 @@
"wizard/generate_lv_allocations_wizard.xml",
"data/event_type_data.xml",
],
"qweb": [],
}

View File

@@ -1,16 +1,14 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="categ_meet_free_lunch" model="calendar.event.type">
<field name="name">Free lunch</field>
<field name="ref">categ_meet_free_lunch</field>
<field name="remove_luncheon_voucher">1</field>
</record>
<?xml version="1.0" ?>
<odoo noupdate="1">
<record id="categ_meet_free_lunch" model="calendar.event.type">
<field name="name">Free lunch</field>
<field name="ref">categ_meet_free_lunch</field>
<field name="remove_luncheon_voucher">1</field>
</record>
<record id="categ_meet_offsite" model="calendar.event.type">
<field name="name">Off-site</field>
<field name="ref">categ_meet_offsite</field>
<field name="remove_luncheon_voucher">1</field>
</record>
</data>
</odoo>
<record id="categ_meet_offsite" model="calendar.event.type">
<field name="name">Off-site</field>
<field name="ref">categ_meet_offsite</field>
<field name="remove_luncheon_voucher">1</field>
</record>
</odoo>

View File

@@ -2,7 +2,6 @@ from odoo import fields, models
class MeetingType(models.Model):
_inherit = "calendar.event.type"
ref = fields.Char(

View File

@@ -1,5 +1,4 @@
from xml.dom.minicompat import EmptyNodeList
from odoo import fields, models, api
from odoo import fields, models
class HrEmployeeBase(models.AbstractModel):
@@ -30,7 +29,10 @@ class HrEmployeeBase(models.AbstractModel):
def _compute_total_acquired_lv(self):
for record in self:
allocations = self.env["hr.lv.allocation"].search(
[("employee_id", "=", record.id), ("state", "=", ["confirmed", "distributed"])]
[
("employee_id", "=", record.id),
("state", "in", ["confirmed", "distributed"]),
]
)
record.total_acquired_lv = sum(allocations.mapped("number_acquired_lv"))
@@ -56,12 +58,14 @@ class HrEmployeeBase(models.AbstractModel):
self.env["hr.lv.allocation"].create(values)
def action_lv_allocations(self):
action = self.env["ir.actions.act_window"]._for_xml_id("hr_luncheon_voucher.act_lv_allocations")
action['context'] = {
'search_default_employee_id': self.id,
'default_employee_id': self.id,
action = self.env["ir.actions.act_window"]._for_xml_id(
"hr_luncheon_voucher.act_lv_allocations"
)
action["context"] = {
"search_default_employee_id": self.id,
"default_employee_id": self.id,
}
action['domain'] = [('employee_id', '=', self.id)]
action["domain"] = [("employee_id", "=", self.id)]
return action
def action_lv_allocations_requests_wizard(self):
@@ -72,12 +76,13 @@ class HrEmployeeBase(models.AbstractModel):
ctx["active_ids"] = self.ids
action["context"] = ctx
return action
@api.model
def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
def _search_display_name(self, operator, value):
"""
On lv allocation, allow employee search on all companies
On lv allocation, allow employee search on all companies.
"""
if self.env.context.get('search_all_campanies'):
return super(HrEmployeeBase, self.sudo())._name_search(name, args, operator, limit, name_get_uid)
return super(HrEmployeeBase, self)._name_search(name, args, operator, limit, name_get_uid)
if self.env.context.get("search_all_campanies"):
return super(HrEmployeeBase, self.sudo())._search_display_name(
operator, value
)
return super()._search_display_name(operator, value)

View File

@@ -1,16 +1,17 @@
from datetime import datetime, timedelta, time
from dateutil.rrule import rrule, DAILY
from datetime import datetime, time, timedelta
from dateutil.rrule import DAILY, rrule
from pytz import UTC
from odoo import fields, models, api
from odoo import api, fields, models
class LuncheonVouchersAllocation(models.Model):
_name = "hr.lv.allocation"
_description = "Luncheon Vouchers Allocation"
name = fields.Char('Name')
distrib_campaign_name = fields.Char('Distribution campaign')
name = fields.Char()
distrib_campaign_name = fields.Char("Distribution campaign")
state = fields.Selection(
[
("draft", "Draft"),
@@ -21,7 +22,12 @@ class LuncheonVouchersAllocation(models.Model):
readonly=True,
copy=False,
default="draft",
help="The status is set to 'Draft', when an allocation request is created. The status is 'Confirmed', when an allocation request is confirmed by HR manager. The status is 'Distributed', when the luncheon vouchers have been distributed.",
help=(
"The status is set to 'Draft', when an allocation request is created. "
"The status is 'Confirmed', when an allocation request is confirmed by "
"HR manager. The status is 'Distributed', when the luncheon vouchers "
"have been distributed."
),
)
date_from = fields.Datetime(
string="Start Date",
@@ -83,16 +89,22 @@ class LuncheonVouchersAllocation(models.Model):
},
)
lv_balance = fields.Integer('Vouchers balance', compute="_get_lv_balance", help="Vouchers available after distribution. Dued vouchers - Distributed vouchers")
lv_balance = fields.Integer(
"Vouchers balance",
compute="_compute_lv_balance",
help="Vouchers available after distribution. Dued vouchers - Distributed.",
)
@api.depends("number_dued_lv", "number_distributed_lv")
def _get_lv_balance(self):
def _compute_lv_balance(self):
for allocation in self:
allocation.lv_balance = allocation.number_dued_lv - allocation.number_distributed_lv
allocation.lv_balance = (
allocation.number_dued_lv - allocation.number_distributed_lv
)
@api.model_create_multi
def create(self, values):
res = super(LuncheonVouchersAllocation, self).create(values)
res = super().create(values)
res._calculate_number_acquired_lv()
res._calculate_number_dued_lv()
res._default_number_distributed_lv()
@@ -104,16 +116,21 @@ class LuncheonVouchersAllocation(models.Model):
record.number_distributed_lv = record.employee_id.default_monthly_lv
def _has_cancelling_voucher_event(self, day):
category_no_voucher_ids = self.env["calendar.event.type"].search([("remove_luncheon_voucher", "=", True)])
category_no_voucher_ids = self.env["calendar.event.type"].search(
[("remove_luncheon_voucher", "=", True)]
)
events = self.env["calendar.event"].search(
[
("categ_ids", "in", category_no_voucher_ids.ids),
("partner_ids", "in", self.employee_id.user_id.partner_id.id)
("partner_ids", "in", self.employee_id.user_id.partner_id.id),
]
)
day_start = fields.Datetime.to_datetime(day.date())
day_end = fields.Datetime.to_datetime(day.date()) + timedelta(hours=24)
cancelling_events = events.filtered(lambda x: not((x.start < day_start) and (x.stop <= day_start)) and not((x.start >= day_end) and (x.stop > day_end)) )
cancelling_events = events.filtered(
lambda x: not ((x.start < day_start) and (x.stop <= day_start))
and not ((x.start >= day_end) and (x.stop > day_end))
)
if len(cancelling_events) > 0:
return True
else:
@@ -121,8 +138,12 @@ class LuncheonVouchersAllocation(models.Model):
def _calculate_number_acquired_lv(self):
nb_eligible_days = 0
dfrom = datetime.combine(fields.Date.from_string(self.date_from), time.min).replace(tzinfo=UTC)
dto = datetime.combine(fields.Date.from_string(self.date_to), time.max).replace(tzinfo=UTC)
dfrom = datetime.combine(
fields.Date.from_string(self.date_from), time.min
).replace(tzinfo=UTC)
dto = datetime.combine(fields.Date.from_string(self.date_to), time.max).replace(
tzinfo=UTC
)
period_days = rrule(DAILY, dfrom, until=dto)
calendar_resource = self.employee_id.resource_calendar_id
for day in period_days:
@@ -130,15 +151,23 @@ class LuncheonVouchersAllocation(models.Model):
if not calendar_resource.is_working_day(day):
continue
# The employee should work this day but...
if self.env.company.hr_half_day_cancels_voucher and not calendar_resource.is_full_working_day(day):
# The luncheon voucher is acquired only if the employee has worked the entire day
if (
self.env.company.hr_half_day_cancels_voucher
and not calendar_resource.is_full_working_day(day)
):
# Voucher requires full-day attendance
continue
# Check leaves
if not calendar_resource.is_worked_day(self.employee_id, day):
continue
# The employee has worked this day but...
if self.env.company.hr_half_day_cancels_voucher and not calendar_resource.all_attendances_worked(self.employee_id.resource_id, day):
# The luncheon voucher is acquired only if the employee has worked the entire day
if (
self.env.company.hr_half_day_cancels_voucher
and not calendar_resource.all_attendances_worked(
self.employee_id.resource_id, day
)
):
# Voucher requires all attendances to be worked
continue
# Check there is no event cancelling the voucher
if self._has_cancelling_voucher_event(day):
@@ -165,7 +194,7 @@ class LuncheonVouchersAllocation(models.Model):
def back_to_draft(self):
for record in self:
if record.state in ["confirmed","distributed"]:
if record.state in ["confirmed", "distributed"]:
record.state = "draft"
record.employee_id._compute_total_acquired_lv()
record.employee_id._compute_distributed_lv()
@@ -182,5 +211,6 @@ class LuncheonVouchersAllocation(models.Model):
for record in self:
for record in self:
if record.state == "draft":
record.number_distributed_lv = record.employee_id.dued_lv + record.number_acquired_lv
record.number_distributed_lv = (
record.employee_id.dued_lv + record.number_acquired_lv
)

View File

@@ -2,6 +2,8 @@ from odoo import fields, models
class Company(models.Model):
_inherit = 'res.company'
_inherit = "res.company"
hr_half_day_cancels_voucher = fields.Boolean(string="Half working days cancel luncheon vouchers")
hr_half_day_cancels_voucher = fields.Boolean(
string="Half working days cancel luncheon vouchers"
)

View File

@@ -2,7 +2,10 @@ from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
hr_half_day_cancels_voucher = fields.Boolean(string="Half working days cancel luncheon vouchers", related="company_id.hr_half_day_cancels_voucher", readonly=False)
_inherit = "res.config.settings"
hr_half_day_cancels_voucher = fields.Boolean(
string="Half working days cancel luncheon vouchers",
related="company_id.hr_half_day_cancels_voucher",
readonly=False,
)

View File

@@ -2,4 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_lv_allocation_user,access_lv_allocation_user,model_hr_lv_allocation,hr.group_hr_user,1,0,0,0
access_lv_allocation_manager,access_lv_allocation_manager,model_hr_lv_allocation,hr.group_hr_manager,1,1,1,1
access_lv_allocation_wizard_user,access_lv_allocation_wizard_user,model_generate_lv_allocation_requests,hr.group_hr_user,1,0,0,0
access_lv_allocation_wizard_manager,access_lv_allocation_wizard_manager,model_generate_lv_allocation_requests,hr.group_hr_manager,1,1,1,1
access_lv_allocation_wizard_manager,access_lv_allocation_wizard_manager,model_generate_lv_allocation_requests,hr.group_hr_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_lv_allocation_user access_lv_allocation_user model_hr_lv_allocation hr.group_hr_user 1 0 0 0
3 access_lv_allocation_manager access_lv_allocation_manager model_hr_lv_allocation hr.group_hr_manager 1 1 1 1
4 access_lv_allocation_wizard_user access_lv_allocation_wizard_user model_generate_lv_allocation_requests hr.group_hr_user 1 0 0 0
5 access_lv_allocation_wizard_manager access_lv_allocation_wizard_manager model_generate_lv_allocation_requests hr.group_hr_manager 1 1 1 1

View File

@@ -0,0 +1,3 @@
from . import test_hr_employee
from . import test_hr_lv_allocation
from . import test_generate_lv_allocations_wizard

View File

@@ -0,0 +1,112 @@
from odoo.tests.common import TransactionCase
class TestGenerateLvAllocationsWizard(TransactionCase):
"""Tests for generate.lv.allocation.requests wizard."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee_admin = cls.env.ref("hr.employee_admin")
cls.employee_other = cls.env["hr.employee"].create(
{
"name": "Other Employee",
"resource_calendar_id": cls.employee_admin.resource_calendar_id.id,
}
)
# ------------------------------------------------------------------
# generate_lv_allocations
# ------------------------------------------------------------------
def test_generate_for_single_employee(self):
"""Wizard creates one allocation for the selected employee."""
wizard = self._create_wizard()
# Simulate single employee selection
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
allocations = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(len(allocations), 1)
self.assertEqual(allocations.employee_id, self.employee_admin)
def test_generate_for_multiple_employees(self):
"""Wizard creates one allocation per selected employee."""
wizard = self._create_wizard()
ctx = {
"active_ids": [
self.employee_admin.id,
self.employee_other.id,
]
}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
allocations = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(len(allocations), 2)
def test_generate_allocation_name_format(self):
"""Allocation name follows 'campaign - employee' pattern."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
expected_name = f"March 2026 - {self.employee_admin.name}"
self.assertEqual(alloc.name, expected_name)
def test_generate_allocations_have_dates(self):
"""Allocations carry the wizard's date range."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(alloc.date_from, wizard.date_from)
self.assertEqual(alloc.date_to, wizard.date_to)
def test_generate_allocations_start_in_draft(self):
"""New allocations start in 'draft' state."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(alloc.state, "draft")
def test_generate_returns_action(self):
"""The wizard returns an action dict (act_window)."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
action = wizard.generate_lv_allocations()
self.assertIn("type", action)
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(action["res_model"], "hr.lv.allocation")
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _create_wizard(self):
return self.env["generate.lv.allocation.requests"].create(
{
"distrib_campaign_name": "March 2026",
"date_from": "2026-03-01",
"date_to": "2026-03-31",
}
)

View File

@@ -0,0 +1,169 @@
from odoo.tests.common import TransactionCase
class TestHrEmployee(TransactionCase):
"""Tests for hr.employee extensions (_compute*, generate_lv_allocation)."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Use a demo employee (created by hr module)
cls.employee = cls.env.ref("hr.employee_admin")
# Ensure a clean slate
cls.employee.write(
{
"total_acquired_lv": 0,
"distributed_lv": 0,
"dued_lv": 0,
}
)
# ------------------------------------------------------------------
# _compute_total_acquired_lv
# ------------------------------------------------------------------
def test_compute_total_acquired_lv_no_allocations(self):
"""No allocations → total_acquired_lv is 0."""
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 0)
def test_compute_total_acquired_lv_with_allocations(self):
"""Confirmed + distributed allocations are summed."""
self._create_allocation(state="confirmed", number_acquired_lv=5)
self._create_allocation(state="distributed", number_acquired_lv=3)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 8)
def test_compute_total_acquired_lv_excludes_draft(self):
"""Draft allocations are NOT counted."""
self._create_allocation(state="draft", number_acquired_lv=10)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 0)
# ------------------------------------------------------------------
# _compute_distributed_lv
# ------------------------------------------------------------------
def test_compute_distributed_lv_no_allocations(self):
"""No allocations → distributed_lv is 0."""
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 0)
def test_compute_distributed_lv_only_distributed(self):
"""Only 'distributed' state allocations are counted."""
self._create_allocation(state="distributed", number_distributed_lv=4)
self._create_allocation(state="confirmed", number_distributed_lv=2)
self._create_allocation(state="draft", number_distributed_lv=1)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 4)
def test_compute_distributed_lv_multiple_distributed(self):
"""Multiple distributed allocations are summed."""
self._create_allocation(state="distributed", number_distributed_lv=3)
self._create_allocation(state="distributed", number_distributed_lv=7)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 10)
# ------------------------------------------------------------------
# _compute_dued_lv
# ------------------------------------------------------------------
def test_compute_dued_lv(self):
"""dued_lv = total_acquired_lv - distributed_lv."""
self.employee.write(
{"total_acquired_lv": 20, "distributed_lv": 6}
)
self.employee._compute_dued_lv()
self.assertEqual(self.employee.dued_lv, 14)
def test_compute_dued_lv_zero_when_equal(self):
"""dued_lv is 0 when acquired equals distributed."""
self.employee.write(
{"total_acquired_lv": 10, "distributed_lv": 10}
)
self.employee._compute_dued_lv()
self.assertEqual(self.employee.dued_lv, 0)
# ------------------------------------------------------------------
# generate_lv_allocation
# ------------------------------------------------------------------
def test_generate_lv_allocation_creates_record(self):
"""generate_lv_allocation creates an hr.lv.allocation with correct values."""
campaign_name = "Test Campaign"
values = {
"distrib_campaign_name": campaign_name,
"date_from": "2026-01-01",
"date_to": "2026-01-31",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[
("employee_id", "=", self.employee.id),
("distrib_campaign_name", "=", campaign_name),
]
)
self.assertTrue(alloc)
self.assertEqual(len(alloc), 1)
self.assertEqual(alloc.distrib_campaign_name, campaign_name)
self.assertEqual(alloc.employee_id, self.employee)
self.assertIn(self.employee.name, alloc.name)
def test_generate_lv_allocation_sets_employee_id(self):
"""The passed employee_id in values is overridden by the record's own id."""
other = self.env["hr.employee"].create(
{
"name": "Other Employee",
"resource_calendar_id": self.employee.resource_calendar_id.id,
}
)
values = {
"distrib_campaign_name": "Campaign Override",
"employee_id": other.id,
"date_from": "2026-02-01",
"date_to": "2026-02-28",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "Campaign Override")]
)
self.assertEqual(alloc.employee_id, self.employee)
def test_generate_lv_allocation_updates_counters(self):
"""After creation, acquired and due are computed (triggers create logic)."""
values = {
"distrib_campaign_name": "Counters Check",
"date_from": "2026-03-01",
"date_to": "2026-03-31",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "Counters Check")]
)
# number_acquired_lv should be computed (may be 0 on non-working days)
self.assertIsNotNone(alloc.number_acquired_lv)
self.assertIsNotNone(alloc.number_dued_lv)
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _create_allocation(self, **kwargs):
# Extract numeric fields that create() overrides, to set them after creation
numeric_fields = {
k: kwargs.pop(k)
for k in ["number_acquired_lv", "number_dued_lv", "number_distributed_lv"]
if k in kwargs
}
defaults = {
"employee_id": self.employee.id,
"distrib_campaign_name": "Test",
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"name": "Test - Employee",
}
defaults.update(kwargs)
alloc = self.env["hr.lv.allocation"].create(defaults)
if numeric_fields:
alloc.write(numeric_fields)
return alloc

View File

@@ -0,0 +1,317 @@
from odoo.tests.common import TransactionCase
class TestHrLvAllocation(TransactionCase):
"""Tests for hr.lv.allocation model methods."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee = cls.env.ref("hr.employee_admin")
cls.calendar = cls.env.ref("resource.resource_calendar_std")
cls.employee.resource_calendar_id = cls.calendar
# Ensure the calendar has attendances marked as effective periods
# (required by hr_effective_attendance_period for is_working_day etc.)
Attendance = cls.env["resource.calendar.attendance"]
existing = Attendance.search([("calendar_id", "=", cls.calendar.id)])
if existing:
existing.write({"effective_attendance_period": True})
else:
for day_idx, day_name in enumerate(
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
):
Attendance.create(
{
"calendar_id": cls.calendar.id,
"name": f"{day_name} Morning",
"dayofweek": str(day_idx),
"day_period": "morning",
"hour_from": 8,
"hour_to": 12,
"effective_attendance_period": True,
}
)
Attendance.create(
{
"calendar_id": cls.calendar.id,
"name": f"{day_name} Afternoon",
"dayofweek": str(day_idx),
"day_period": "afternoon",
"hour_from": 13,
"hour_to": 17,
"effective_attendance_period": True,
}
)
cls.allocation = cls._build_allocation(cls)
# Ensure a cancelling event type exists
cls.cancelling_categ = cls.env.ref(
"hr_luncheon_voucher.categ_meet_free_lunch",
raise_if_not_found=False,
)
if not cls.cancelling_categ:
cls.cancelling_categ = cls.env["calendar.event.type"].create(
{"name": "Free Lunch Test", "remove_luncheon_voucher": True}
)
else:
cls.cancelling_categ.remove_luncheon_voucher = True
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _build_allocation(self, **overrides):
vals = {
"employee_id": self.employee.id,
"distrib_campaign_name": "Test Campaign",
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"name": "Test Campaign - Admin",
}
vals.update(overrides)
return self.env["hr.lv.allocation"].create(vals)
# ------------------------------------------------------------------
# _compute_lv_balance
# ------------------------------------------------------------------
def test_lv_balance_positive(self):
"""Balance = number_dued_lv - number_distributed_lv (positive)."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 10, "number_distributed_lv": 3}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, 7)
def test_lv_balance_zero(self):
"""Balance is 0 when due equals distributed."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 5, "number_distributed_lv": 5}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, 0)
def test_lv_balance_negative(self):
"""Balance can be negative (over-distribution)."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 2, "number_distributed_lv": 5}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, -3)
# ------------------------------------------------------------------
# _has_cancelling_voucher_event
# ------------------------------------------------------------------
def test_has_cancelling_event_true(self):
"""Returns True when a cancelling event exists for the employee."""
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Team Lunch",
"start": "2026-01-15 12:00:00",
"stop": "2026-01-15 14:00:00",
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
day = self.env.cr.now().replace(day=15, month=1, year=2026)
self.assertTrue(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_false_no_event(self):
"""Returns False when no cancelling event exists."""
day = self.env.cr.now().replace(day=10, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_false_non_cancelling_category(self):
"""Returns False when events exist but without the cancelling flag."""
categ = self.env["calendar.event.type"].create(
{"name": "Regular Meeting", "remove_luncheon_voucher": False}
)
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Stand-up",
"start": "2026-01-15 09:00:00",
"stop": "2026-01-15 09:30:00",
"categ_ids": [(6, 0, categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
day = self.env.cr.now().replace(day=15, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_outside_day(self):
"""Returns False when the event is entirely outside the target day."""
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Late Dinner",
"start": "2026-01-15 22:00:00",
"stop": "2026-01-16 00:30:00",
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
# Check a different day
day = self.env.cr.now().replace(day=14, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
# ------------------------------------------------------------------
# _calculate_number_acquired_lv
# ------------------------------------------------------------------
def test_calculate_acquired_on_non_working_day(self):
"""Acquired is 0 for a period of non-working days (e.g. weekend-only)."""
alloc = self._build_allocation(
date_from="2026-01-03", # Saturday
date_to="2026-01-04", # Sunday
)
alloc._calculate_number_acquired_lv()
self.assertEqual(alloc.number_acquired_lv, 0)
def test_calculate_acquired_basic(self):
"""Acquired is > 0 for a normal working period (employee works)."""
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# Standard 5-day calendar: 5 working days, no leaves, no events
self.assertEqual(alloc.number_acquired_lv, 5)
def test_calculate_acquired_with_leave(self):
"""Acquired excludes days covered by leave."""
# Create a leave covering Wednesday 2026-01-07
self.env["resource.calendar.leaves"].create(
{
"name": "Sick leave",
"date_from": "2026-01-07 00:00:00",
"date_to": "2026-01-07 23:59:59",
"calendar_id": self.calendar.id,
"resource_id": self.employee.resource_id.id,
}
)
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# 5 working days - 1 leave = 4
self.assertEqual(alloc.number_acquired_lv, 4)
def test_calculate_acquired_with_half_day_cancel(self):
"""When hr_half_day_cancels_voucher is enabled, partial days are excluded."""
self.env.company.hr_half_day_cancels_voucher = True
# Modify calendar to have only morning attendance on Wednesday
wed_attendance = self.env["resource.calendar.attendance"].search(
[
("calendar_id", "=", self.calendar.id),
("dayofweek", "=", "2"), # Wednesday
("day_period", "=", "afternoon"),
]
)
wed_attendance.unlink()
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# 4 full days + 1 half-day excluded = 4
self.assertEqual(alloc.number_acquired_lv, 4)
# ------------------------------------------------------------------
# _calculate_number_dued_lv
# ------------------------------------------------------------------
def test_calculate_dued_draft(self):
"""In non-distributed state: dued = employee.dued_lv + number_acquired_lv."""
self.employee.write({"dued_lv": 5})
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 10
alloc._calculate_number_dued_lv()
self.assertEqual(alloc.number_dued_lv, 15)
def test_calculate_dued_distributed(self):
"""In distributed: dued = employee.dued_lv only (no addition)."""
self.employee.write({"dued_lv": 8})
alloc = self._build_allocation(state="distributed")
alloc.number_acquired_lv = 20
alloc._calculate_number_dued_lv()
self.assertEqual(alloc.number_dued_lv, 8)
# ------------------------------------------------------------------
# confirm_allocation
# ------------------------------------------------------------------
def test_confirm_draft(self):
"""confirm_allocation transitions draft → confirmed and updates counters."""
self.employee.write({"total_acquired_lv": 0, "dued_lv": 0})
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 6
alloc.confirm_allocation()
self.assertEqual(alloc.state, "confirmed")
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 6)
def test_confirm_already_confirmed_does_not_change(self):
"""confirm_allocation on a confirmed record is a no-op."""
alloc = self._build_allocation(state="confirmed")
alloc.confirm_allocation()
self.assertEqual(alloc.state, "confirmed")
# ------------------------------------------------------------------
# back_to_draft
# ------------------------------------------------------------------
def test_back_to_draft_from_confirmed(self):
"""back_to_draft transitions confirmed → draft."""
alloc = self._build_allocation(state="confirmed")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
def test_back_to_draft_from_distributed(self):
"""back_to_draft transitions distributed → draft."""
alloc = self._build_allocation(state="distributed")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
def test_back_to_draft_draft_is_noop(self):
"""back_to_draft on a draft record is a no-op."""
alloc = self._build_allocation(state="draft")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
# ------------------------------------------------------------------
# distribute_allocation
# ------------------------------------------------------------------
def test_distribute_confirmed(self):
"""distribute_allocation transitions confirmed → distributed."""
alloc = self._build_allocation(state="confirmed")
alloc.distribute_allocation()
self.assertEqual(alloc.state, "distributed")
def test_distribute_draft_is_noop(self):
"""distribute_allocation on draft is a no-op."""
alloc = self._build_allocation(state="draft")
alloc.distribute_allocation()
self.assertEqual(alloc.state, "draft")
# ------------------------------------------------------------------
# adjust_distribution
# ------------------------------------------------------------------
def test_adjust_distribution(self):
"""Adjust distribution sets number_distributed_lv = dued + acquired."""
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 5
alloc.employee_id.dued_lv = 3
alloc.adjust_distribution()
self.assertEqual(alloc.number_distributed_lv, 8)

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_event_type_luncheonvoucher_tree" model="ir.ui.view">
<field name="name">event.type.luncheonvoucher</field>
@@ -10,4 +10,4 @@
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_employee_form_lv" model="ir.ui.view">
@@ -7,16 +7,44 @@
<field name="inherit_id" ref="hr.view_employee_form" />
<field name="arch" type="xml">
<header position="inside">
<button type="object" name="refresh_lv_values" string="Refresh Luncheon Vouchers" class="btn-secundary" />
<button
type="object"
name="refresh_lv_values"
string="Refresh Luncheon Vouchers"
class="btn-secundary"
/>
</header>
<xpath expr="//div[@name='button_box']" position="inside">
<button type="object" name="action_lv_allocations" class="oe_stat_button" icon="fa-ticket">
<field name="total_acquired_lv" widget="statinfo" string="Acquired" />
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field
name="total_acquired_lv"
widget="statinfo"
string="Acquired"
/>
</button>
<button type="object" name="action_lv_allocations" class="oe_stat_button" icon="fa-ticket">
<field name="distributed_lv" widget="statinfo" string="Distributed" />
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field
name="distributed_lv"
widget="statinfo"
string="Distributed"
/>
</button>
<button type="object" name="action_lv_allocations" class="oe_stat_button" icon="fa-ticket">
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field name="dued_lv" widget="statinfo" string="Dued" />
</button>
</xpath>
@@ -34,12 +62,17 @@
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_tree" />
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<xpath expr="//list" position="inside">
<header>
<button type="object" name="action_lv_allocations_requests_wizard" string="Generate Luncheon Vouchers Allocations" class="btn-primary" />
<button
type="object"
name="action_lv_allocations_requests_wizard"
string="Generate Luncheon Vouchers Allocations"
class="btn-primary"
/>
</header>
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="hr_lv_allocation_search" model="ir.ui.view">
@@ -9,28 +9,55 @@
<field name="state" />
<field name="employee_id" context="{'search_all_campanies':True}" />
<field name="distrib_campaign_name" />
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]" />
<filter name="confirmed" string="Confirmed" domain="[('state', '=', 'confirmed')]" />
<filter name="distributed" string="Distributed"
domain="[('state', '=', 'distributed')]" />
<filter
name="draft"
string="Draft"
domain="[('state', '=', 'draft')]"
/>
<filter
name="confirmed"
string="Confirmed"
domain="[('state', '=', 'confirmed')]"
/>
<filter
name="distributed"
string="Distributed"
domain="[('state', '=', 'distributed')]"
/>
</search>
</field>
</record>
<record id="hr_lv_allocation_tree" model="ir.ui.view">
<field name="name">hr.lv.allocation.tree</field>
<field name="name">hr.lv.allocation.list</field>
<field name="model">hr.lv.allocation</field>
<field name="arch" type="xml">
<tree string="Luncheon vouchers allocations">
<list>
<header>
<button type="object" name="confirm_allocation" string="Confirm"
class="btn-primary" />
<button type="object" name="distribute_allocation" string="Distribute Vouchers"
class="btn-primary" />
<button type="object" name="back_to_draft" string="Back to draft"
class="btn-secundary" />
<button type="object" name="adjust_distribution" string="Adjust distribution"
class="btn-secundary" />
<button
type="object"
name="confirm_allocation"
string="Confirm"
class="btn-primary"
/>
<button
type="object"
name="distribute_allocation"
string="Distribute Vouchers"
class="btn-primary"
/>
<button
type="object"
name="back_to_draft"
string="Back to draft"
class="btn-secundary"
/>
<button
type="object"
name="adjust_distribution"
string="Adjust distribution"
class="btn-secundary"
/>
</header>
<field name="distrib_campaign_name" />
<field name="employee_id" />
@@ -41,7 +68,7 @@
<field name="number_dued_lv" />
<field name="number_distributed_lv" />
<field name="lv_balance" />
</tree>
</list>
</field>
</record>
@@ -51,14 +78,34 @@
<field name="arch" type="xml">
<form string="">
<header>
<button type="object" name="confirm_allocation" string="Confirm"
class="btn-primary" attrs="{'invisible': [('state', '!=', 'draft')]}" />
<button type="object" name="distribute_allocation" string="Distribute Vouchers"
class="btn-primary" attrs="{'invisible': [('state', '!=', 'confirmed')]}" />
<button type="object" name="back_to_draft" string="Back to draft"
class="btn-secundary" attrs="{'invisible': [('state', '=', 'draft')]}" />
<button type="object" name="adjust_distribution" string="Adjust distribution"
class="btn-secundary" attrs="{'invisible': [('state', '!=', 'draft')]}" />
<button
type="object"
name="confirm_allocation"
string="Confirm"
class="btn-primary"
invisible="state != 'draft'"
/>
<button
type="object"
name="distribute_allocation"
string="Distribute Vouchers"
class="btn-primary"
invisible="state != 'confirmed'"
/>
<button
type="object"
name="back_to_draft"
string="Back to draft"
class="btn-secundary"
invisible="state == 'draft'"
/>
<button
type="object"
name="adjust_distribution"
string="Adjust distribution"
class="btn-secundary"
invisible="state != 'draft'"
/>
</header>
<sheet>
<h1>
@@ -84,6 +131,6 @@
<record id="act_lv_allocations" model="ir.actions.act_window">
<field name="name">Luncheon vouchers allocations</field>
<field name="res_model">hr.lv.allocation</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">list,form</field>
</record>
</odoo>
</odoo>

View File

@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<menuitem id="menu_hr_lv_allocations" action="act_lv_allocations" parent="hr.menu_hr_employee_payroll" sequence="1" name="Luncheon vouchers" />
<menuitem
id="menu_hr_lv_allocations"
action="act_lv_allocations"
parent="hr.menu_hr_employee_payroll"
sequence="1"
name="Luncheon vouchers"
/>
</odoo>
</odoo>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_lv_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.lv</field>
@@ -6,11 +6,17 @@
<field name="priority" eval="99" />
<field name="inherit_id" ref="hr.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='employee_rights_setting_container']/.." position="inside">
<xpath
expr="//block[@name='employee_rights_setting_container']/.."
position="inside"
>
<h2>Employee Luncheon Vouchers</h2>
<div class="row mt16 o_settings_container" name="employee_lv_container">
<div class="col-12 col-lg-6 o_setting_box" id="employee_lv_halfday_cancel"
title="Luncheon Vouchers Half-day Cancel">
<div
class="col-12 col-lg-6 o_setting_box"
id="employee_lv_halfday_cancel"
title="Luncheon Vouchers Half-day Cancel"
>
<div class="o_setting_left_pane">
<field name="hr_half_day_cancels_voucher" />
</div>
@@ -26,4 +32,4 @@
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -4,16 +4,10 @@ from odoo import fields, models
class GenerateLVAllocationRequests(models.TransientModel):
_name = "generate.lv.allocation.requests"
_description = "Generate Luncheon Vouchers Allocations Requests"
distrib_campaign_name = fields.Char('Distribution campaign', required=True)
date_from = fields.Datetime(
string="Start Date",
required=True
)
date_to = fields.Datetime(
string="End Date",
required=True
)
distrib_campaign_name = fields.Char("Distribution campaign", required=True)
date_from = fields.Datetime(string="Start Date", required=True)
date_to = fields.Datetime(string="End Date", required=True)
def generate_lv_allocations(self):
values = {}
@@ -27,4 +21,6 @@ class GenerateLVAllocationRequests(models.TransientModel):
)
employees.generate_mass_lv_allocation(values)
# Open lv allocation tree view
return self.env["ir.actions.act_window"]._for_xml_id("hr_luncheon_voucher.act_lv_allocations")
return self.env["ir.actions.act_window"]._for_xml_id(
"hr_luncheon_voucher.act_lv_allocations"
)

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="lv_allocations_requests_wizard" model="ir.ui.view">
<record id="lv_allocations_requests_wizard" model="ir.ui.view">
<field name="name">lv.allocations.requests.wizard</field>
<field name="model">generate.lv.allocation.requests</field>
<field name="arch" type="xml">
@@ -14,19 +13,30 @@
</group>
</group>
<footer>
<button string="Create allocations requests" name="generate_lv_allocations" type="object" class="btn-primary" />
<button string="Cancel" class="btn-secondary" special="cancel" />
<button
string="Create allocations requests"
name="generate_lv_allocations"
type="object"
class="btn-primary"
/>
<button
string="Cancel"
class="btn-secondary"
special="cancel"
/>
</footer>
</form>
</field>
</record>
<record id="lv_allocations_requests_wizard_action" model="ir.actions.act_window">
<record
id="lv_allocations_requests_wizard_action"
model="ir.actions.act_window"
>
<field name="name">Create Luncheon Vouchers allocations requests</field>
<field name="res_model">generate.lv.allocation.requests</field>
<field name="view_mode">form</field>
<field name="view_id" ref="lv_allocations_requests_wizard" />
<field name="target">new</field>
</record>
</data>
</odoo>
</odoo>

0
hr_usability/__init__.py Normal file
View File

View File

@@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
"name": "hr_usability Elabore",
"version": "18.0.1.0.0",
"depends": [
"base",
"hr",
"hr_holidays",
],
"author": "Élabore",
"category": "Human Resources/Employees",
"summary": "In times off type form view, add 'create_calendar_meeting' field",
"description": """
Go to Times off app > "Setings" > "Type time off"
Select a type
The 'create_calendar_meeting' is check by default
Uncheck it if do not want to display times off in the calendar
One the checkbox is unchecked for a time off type, the next approuved times off (of that type) won't be created as a meeting and won't appear in the calendar
""",
"data": [
"views/hr_leave_type_views.xml",
],
"demo": [],
"application": False,
"license": "LGPL-3",
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_leave_type_form_inherit" model="ir.ui.view">
<field name="name">hr.leave.type.form.inherit</field>
<field name="model">hr.leave.type</field>
<field name="inherit_id" ref="hr_holidays.edit_holiday_status_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='color']" position="before">
<field name="create_calendar_meeting"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,54 @@
# timesheet_timesheet_analysis_report
Add a `timesheet_id` Many2one relation to the `timesheets.analysis.report` model,
linking each analysis report line back to its source `account.analytic.line` record.
This allows filtering and grouping the Timesheet Analysis Report by individual timesheet
entry.
## Installation
Use the standard Odoo module installation procedure to install
`timesheet_timesheet_analysis_report`.
**Dependencies:** `base`, `hr_timesheet`
## Configuration
No specific configuration is required. The `timesheet_id` field is automatically
available on the Timesheet Analysis Report once the module is installed.
## Usage
After installation, the Timesheet Analysis Report (`timesheets.analysis.report`)
includes a new **Timesheet** field. You can use it to:
- **Filter** the analysis report by specific timesheet entries.
- **Group by** timesheet to aggregate data per individual time entry.
- **Search** for report lines linked to a particular timesheet record.
## Known Issues / Roadmap
None yet.
## Bug Tracker
Bugs are tracked on
[our issues website](https://git.elabore.coop/Elabore/hr-tools/issues). In case of
trouble, please check there if your issue has already been reported. If you spotted it
first, help us smash it by providing detailed feedback.
## Credits
### Contributors
- [Elabore](mailto:laetitia.dacosta@elabore.coop)
### Funders
The development of this module has been financially supported by:
- [Elabore](https://elabore.coop)
### Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -1,27 +1,26 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# Copyright 2025 Elabore ()
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "hr_expense_report_merge_attachment",
"version": "14.0.0.0.0",
"name": "timesheet_timesheet_analysis_report",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"website": "https://git.elabore.coop/elabore/hr-tools",
"maintainer": "Elabore",
"license": "AGPL-3",
"category": "Tools",
"summary": "Merge attachments in expense report",
"category": "HR",
"summary": "add a timesheet_id relation to timesheet.analysis.report model",
# any module necessary for this one to work correctly
"depends": [
"hr_expense",
],
"qweb": [
"base",
"hr_timesheet",
],
"qweb": [],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
],
"data": [],
# only loaded in demonstration mode
"demo": [],
"js": [],
@@ -31,4 +30,4 @@
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}
}

View File

@@ -0,0 +1 @@
from . import timesheet_analysis_report

View File

@@ -0,0 +1,21 @@
from odoo import api, fields, models
class TimesheetsAnalysisReport(models.Model):
_inherit = "timesheets.analysis.report"
timesheet_id = fields.Many2one(
"account.analytic.line",
string="Timesheet",
readonly=True,
help="Feuille de temps",
)
@api.model
def _select(self):
return (
super()._select()
+ """,
A.id AS timesheet_id
"""
)