summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorÖzgür Kesim <oec@codeblau.de>2024-11-26 16:09:27 +0100
committerÖzgür Kesim <oec@codeblau.de>2024-11-26 16:09:27 +0100
commitfe0ca581f1c13116b9378befb5b62c4d2f7d2947 (patch)
treeadd5df98b7f2d24f66f965b99d1b03e46dc14c1e
init
-rw-r--r--doc.go213
-rw-r--r--go.mod3
-rw-r--r--zeitgeist.go457
3 files changed, 673 insertions, 0 deletions
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..d8794b1
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,213 @@
+package main
+
+/*
+
+A. Personnel costs
+
+A.1 Costs for employees (or equivalent) are eligible as personnel costs if they
+fulfil the general eligibility conditions and are related to personnel working
+for the beneficiary under an employment contract (or equivalent appointing act)
+and assigned to the action.
+
+They must be limited to salaries (including net payments during parental
+leave), social security contributions, taxes and other costs linked to the
+remuneration, if they arise from national law or the employment contract (or
+equivalent appointing act) and be calculated on the basis of the costs actually
+incurred, in accordance with the following method:
+
+ {daily rate for the person
+ multiplied by
+ number of day-equivalents worked on the action (rounded up or down to the nearest half-day)}.
+
+The daily rate must be calculated as:
+ {annual personnel costs for the person
+ divided by
+ 215}.
+
+The number of day-equivalents declared for a person must be identifiable and
+verifiable (see Article 20).
+
+The actual time spent on parental leave by a person assigned to the action may
+be deducted from the 215 days indicated in the above formula.
+
+The total number of day-equivalents declared in EU grants, for a person for a
+year, cannot be higher than 215, minus time spent on parental leave (if any).
+
+For personnel which receives supplementary payments for work in projects
+(project-based remuneration), the personnel costs must be calculated at a rate
+which:
+
+- corresponds to the actual remuneration costs paid by the beneficiary for the
+ time worked by the person in the action over the reporting period
+
+- does not exceed the remuneration costs paid by the beneficiary for work in
+similar projects funded by national schemes (‘national projects reference’)
+
+- is defined based on objective criteria allowing to determine the amount to
+which the person is entitled
+and
+- reflects the usual practice of the beneficiary to pay consistently bonuses or
+supplementary payments for work in projects funded by national schemes.
+
+The national projects reference is the remuneration defined in national law,
+collective labour agreement or written internal rules of the beneficiary
+applicable to work in projects funded by national
+schemes
+
+*/
+
+/*
+
+ARTICLE 20 — RECORD-KEEPING
+
+20.1 Keeping records and supporting documents
+
+The beneficiaries must — at least until the time-limit set out in the Data
+Sheet (see Point 6) — keep records and other supporting documents to prove the
+proper implementation of the action in line with the accepted standards in the
+respective field (if any).
+
+In addition, the beneficiaries must — for the same period — keep the following
+to justify the amounts declared:
+
+(a) for actual costs: adequate records and supporting documents to prove the
+ costs declared (such as contracts, subcontracts, invoices and accounting
+ records); in addition, the beneficiaries’ usual accounting and internal
+ control procedures must enable direct reconciliation between the amounts
+ declared, the amounts recorded in their accounts and the amounts stated in
+ the supporting documents
+
+(b) for flat-rate costs and contributions (if any): adequate records and
+ supporting documents to prove the eligibility of the costs or contributions
+ to which the flat-rate is applied
+
+(c) for the following simplified costs and contributions: the beneficiaries do
+ not need to keep specific records on the actual costs incurred, but must
+ keep:
+
+ (i) for unit costs and contributions (if any): adequate records and
+ supporting documents to prove the number of units declared
+
+ (ii) for lump sum costs and contributions (if any): adequate records and
+ supporting documents to prove proper implementation of the work as
+ described in Annex 1
+
+ (iii) for financing not linked to costs (if any): adequate records and
+ supporting documents to prove the achievement of the results or the
+ fulfilment of the conditions as described in Annex 1
+
+(d) for unit, flat-rate and lump sum costs and contributions according to usual
+cost accounting practices (if any): the beneficiaries must keep any adequate
+records and supporting documents to prove that their cost accounting practices
+have been applied in a consistent manner, based on objective criteria,
+regardless of the source of funding, and that they comply with the eligibility
+conditions set out in Articles 6.1 and 6.2.
+
+Moreover, the following is needed for specific budget categories:
+
+(e) for personnel costs: time worked for the beneficiary under the action must
+be supported by declarations signed monthly by the person and their supervisor,
+unless another reliable time-record system is in place; the granting authority
+may accept alternative evidence supporting the time worked for the action
+declared, if it considers that it offers an adequate level of assurance
+
+The records and supporting documents must be made available upon request (see
+Article 19) or in the context of checks, reviews, audits or investigations (see
+Article 25).
+
+If there are on-going checks, reviews, audits, investigations, litigation or
+other pursuits of claims under the Agreement (including the extension of
+findings; see Article 25), the beneficiaries must keep these records and other
+supporting documentation until the end of these procedures.
+
+The beneficiaries must keep the original documents. Digital and digitalised
+documents are considered originals if they are authorised by the applicable
+national law. The granting authority may accept non-original documents if they
+offer a comparable level of assurance.
+
+*/
+
+/*
+
+ARTICLE 21 — REPORTING
+
+[...]
+
+21.2 Periodic reporting: Technical reports and financial statements
+
+In addition, the beneficiaries must provide reports to request payments, in
+accordance with the schedule and modalities set out in the Data Sheet (see
+Point 4.2):
+
+ - for additional prefinancings (if any): an additional prefinancing report
+
+ - for interim payments (if any) and the final payment: a periodic report.
+
+The prefinancing and periodic reports include a technical and financial part.
+
+The technical part includes an overview of the action implementation. It must
+be prepared using the template available in the Portal Periodic Reporting tool.
+
+The financial part of the additional prefinancing report includes a statement
+on the use of the previous prefinancing payment.
+
+The financial part of the periodic report includes:
+
+ - the financial statements (individual and consolidated; for all
+ beneficiaries/affiliated entities)
+
+ - the explanation on the use of resources (or detailed cost reporting
+
+ - the certificates on the financial statements (CFS) (if required; see
+ Article 24.2 and Data Sheet, Point 4.3).
+
+The financial statements must detail the eligible costs and contributions for
+each budget category and, for the final payment, also the revenues for the
+action (see Articles 6 and 22).
+
+All eligible costs and contributions incurred should be declared, even if they
+exceed the amounts indicated in the estimated budget (see Annex 2). Amounts
+that are not declared in the individual financial statements will not be taken
+into account by the granting authority.
+
+By signing the financial statements (directly in the Portal Periodic Reporting
+tool), the beneficiaries confirm that:
+
+ - the information provided is complete, reliable and true
+
+ - the costs and contributions declared are eligible (see Article 6)
+
+ - the costs and contributions can be substantiated by adequate records and
+ supporting documents (see Article 20) that will be produced upon request
+ (see Article 19) or in the context of checks, reviews, audits and
+ investigations (see Article 25)
+
+ - for the final periodic report: all the revenues have been declared (if
+ required; see Article 22).
+
+Beneficiaries will have to submit also the financial statements of their
+affiliated entities (if any). In case of recoveries (see Article 22),
+beneficiaries will be held responsible also for the financial statements of
+their affiliated entities.
+
+
+*/
+
+/*
+
+22.2 Recoveries
+
+Recoveries will be made, if — at beneficiary termination, final payment or
+afterwards — it turns out that the granting authority has paid too much and
+needs to recover the amounts undue.
+
+Each beneficiary’s financial responsibility in case of recovery is in principle
+limited to their own debt and undue amounts of their affiliated entities.
+
+In case of enforced recoveries (see Article 22.4), affiliated entities will be
+held liable for repaying debts of their beneficiaries, if required by the
+granting authority (see Data Sheet, Point 4.4).
+
+
+
+*/
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..23b872e
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module kesim.org/zeitgeist
+
+go 1.23.3
diff --git a/zeitgeist.go b/zeitgeist.go
new file mode 100644
index 0000000..7cdc968
--- /dev/null
+++ b/zeitgeist.go
@@ -0,0 +1,457 @@
+package main
+
+import (
+ "bufio"
+ "embed"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "text/template"
+ "time"
+)
+
+var (
+ fl_project = flag.String("pr", "project.json", "project-file")
+ fl_timelogs = flag.String("lt", "", "directory to search for timelogs")
+ fl_plandir = flag.String("pl", "", "directory to search for plans")
+ fl_templates = flag.String("tm", "", "directory to search for the templates")
+ fl_report = flag.String("rp", "report.t", "default report template")
+)
+
+type Project struct {
+ Name string
+ Grant string
+ Start Date
+ End Date
+ Beneficiary string
+ Users map[string]User
+ Timelogs string
+ Planning string
+ Workpackages map[string]Workpackage
+ Holidays map[Date]string
+ Timeline Timeline
+ Planned Timeline
+}
+
+type Timeline map[Date]Entries
+
+type Entries []Entry
+
+func (en Entries) Total() Amount {
+ a := Amount(0.0)
+ for _, e := range en {
+ a += e.Amount
+ }
+ return a
+}
+
+func (en Entries) WPs() string {
+ wps := []string{}
+ for _, e := range en {
+ wps = append(wps, e.WP)
+ }
+ wps = slices.Compact(wps)
+ return strings.Join(wps, ", ")
+}
+
+type User struct {
+ Name string
+ Type string
+}
+
+type Workpackage struct {
+ Title string
+ Short string
+ StartMonth uint
+ EndMonth uint
+ TotalTime Amount
+ Tasks map[string]Task
+}
+
+type Date time.Time
+
+func (d Date) IsWorkday() bool {
+ wd := (time.Time(d)).Weekday()
+ return wd > time.Sunday && wd < time.Saturday
+}
+
+func (d *Date) UnmarshalText(b []byte) error {
+ t, e := time.Parse(time.DateOnly, string(b))
+ if e != nil {
+ return e
+ }
+ *d = Date(t)
+ return e
+}
+
+func (d Date) String() string {
+ return (time.Time(d)).Format(time.DateOnly)
+}
+
+func (d Date) MarshalJSON() ([]byte, error) {
+ return []byte(d.String()), nil
+}
+
+func (d Date) Year() int {
+ return (time.Time(d)).Year()
+}
+
+type Amount float64
+
+const (
+ Days = 215
+ DaysInMonth = float64(215) / 12
+)
+
+func (u Amount) String() string {
+ return fmt.Sprintf("%5.2f", u)
+}
+
+func (b Amount) AsHour() string {
+ return b.String() + "h"
+}
+
+func (b Amount) AsDay() string {
+ return (b / 8).String() + "d"
+}
+
+func (b Amount) AsMonth() string {
+ return (b / 8 / Amount(DaysInMonth)).String() + "m"
+}
+
+func (u *Amount) UnmarshalText(b []byte) error {
+ return u.parseString(string(b))
+}
+
+func (u *Amount) parseString(s string) error {
+ f, e := strconv.ParseFloat(s[:len(s)-1], 64)
+ if e != nil {
+ return fmt.Errorf("not a float: %v", s)
+ }
+ switch s[len(s)-1] {
+ case 'm', 'M':
+ f *= DaysInMonth
+ fallthrough
+ case 'd', 'D':
+ f *= 8
+ fallthrough
+ case 'h', 'H':
+ *u = Amount(f)
+ default:
+ return fmt.Errorf("unknown unit in: %v", s)
+ }
+ return nil
+}
+
+type Task struct {
+ ID string
+ Title string
+ Budget Amount
+}
+
+type Entry struct {
+ User string
+ WP string
+ Task string
+ Amount Amount
+}
+
+type Deliverable struct {
+ ID string
+ Title string
+ Description string
+}
+
+func main() {
+ flag.Parse()
+
+ p, e := loadProject(*fl_project)
+ if e != nil {
+ log.Fatalf("error loading project: %v", e)
+ }
+ if p.Timeline == nil {
+ p.Timeline = make(Timeline)
+ }
+
+ e = p.readPlans(*fl_plandir)
+ if e != nil {
+ log.Fatalf("error loading plans: %v", e)
+ }
+
+ e = p.readTimelogs(*fl_timelogs)
+ if e != nil {
+ log.Fatalf("error loading timelog: %v", e)
+ }
+
+ p.printReport(*fl_report)
+}
+
+var min2w = regexp.MustCompile("[\t ]+")
+
+func loadProject(f string) (p *Project, e error) {
+ fd, e := os.Open(f)
+ if e != nil {
+ return nil, e
+ }
+ defer fd.Close()
+
+ p = &Project{}
+ d := json.NewDecoder(fd)
+ d.DisallowUnknownFields()
+ e = d.Decode(p)
+
+ return p, e
+}
+
+func (p *Project) readPlans(dir string) error {
+ if len(dir) == 0 {
+ dir = p.Planning
+ }
+ glob := dir + "/*.plan"
+ matches, e := filepath.Glob(glob)
+ if e != nil {
+ return fmt.Errorf("filepath.Glob(%q): %w", glob, e)
+ }
+
+ if len(matches) == 0 {
+ slog.Warn("no plans found:", "glob", glob)
+ }
+
+ for _, name := range matches {
+ f, e := os.Open(name)
+ if e != nil {
+ return fmt.Errorf("os.Open(%q) failed: %w", name, e)
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ nr := 1
+ last := time.Time{}
+ for scanner.Scan() {
+ // # Ignore line
+ // # Tab-separated fields
+ // 2024-02 wp2:short 1m [comment]
+ // 2024-02-11 wp2:short 1d [comment]
+ // 2024-02-11..2024-02-23 wp2:short *8h [comment]
+ // 2024-02-11..2024-02-23 wp2:short 3d [comment]
+ var line = scanner.Text()
+ if strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ parts := min2w.Split(scanner.Text(), 3)
+ if len(parts) < 3 {
+ return fmt.Errorf("file %q, line %d: not enough tab-separated fields", name, nr)
+ }
+
+ d, e := time.Parse(time.DateOnly, parts[0])
+ if e != nil {
+ return fmt.Errorf("file %q, line %d: couldn't parse date: %w", name, nr, e)
+ }
+ if last.After(d) {
+ return fmt.Errorf("file %q, line %d: dates not monotonic, %s older", name, nr, Date(d))
+ }
+ last = d
+ if !Date(d).IsWorkday() {
+ return fmt.Errorf("file %q, line %d: date is a weekend: %v", name, nr, d)
+ }
+ if h, found := p.Holidays[Date(d)]; found {
+ return fmt.Errorf("file %q, line %d: date %v is a holiday: %s", name, nr, Date(d), h)
+ }
+
+ wps := strings.Split(parts[1], ":")
+ if len(wps) < 2 {
+ return fmt.Errorf("file %q, line %d: no such workpackage:task %q", name, nr, parts[1])
+ }
+ if wp, ok := p.Workpackages[wps[0]]; !ok {
+ return fmt.Errorf("file %q, line %d: no such workpackage %q", name, nr, wps[0])
+ } else if _, ok := wp.Tasks[wps[1]]; !ok {
+ return fmt.Errorf("file %q, line %d: no such task %q in %q", name, nr, wps[1], wps[0])
+ }
+
+ var amount Amount
+ e = (&amount).parseString(parts[2])
+ if e != nil {
+ return fmt.Errorf("file %q, line %d: not acceptible budget: %q", name, nr, parts[2])
+ }
+
+ entry := Entry{
+ User: parts[1],
+ WP: wps[0],
+ Task: wps[1],
+ Amount: amount}
+
+ entries := p.Timeline[Date(d)]
+ entries = append(entries, entry)
+ p.Timeline[Date(d)] = entries
+
+ nr += 1
+ }
+
+ }
+ return nil
+}
+
+func (p *Project) readTimelogs(dir string) error {
+ if len(dir) == 0 {
+ dir = p.Timelogs
+ }
+ glob := dir + "/*.timelog"
+ matches, e := filepath.Glob(glob)
+ if e != nil {
+ return fmt.Errorf("filepath.Glob(%q): %w", glob, e)
+ }
+
+ if len(matches) == 0 {
+ slog.Warn("no timelogs found:", "glob", glob)
+ }
+
+ for _, name := range matches {
+ var year uint16
+ var user string
+ {
+ s := strings.Replace(filepath.Base(name), ".", " ", 2)
+ n, e := fmt.Sscanf(s, "%d %s timelog", &year, &user)
+ if e != nil {
+ return fmt.Errorf("fmt.Sscanf(%q): %w", s, e)
+ } else if n != 2 {
+ return fmt.Errorf("file %q not in the format <year>.<user>.timelog")
+ }
+ }
+
+ if _, ok := p.Users[user]; !ok {
+ return fmt.Errorf("Unknown user: %v", user)
+ }
+ if int(year) < p.Start.Year() || p.End.Year() < int(year) {
+ return fmt.Errorf("year out of range: %d (not in %d-%d)", year, p.Start.Year(), p.End.Year())
+ }
+
+ f, e := os.Open(name)
+ if e != nil {
+ return fmt.Errorf("os.Open(%q) failed: %w", name, e)
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ nr := 1
+ last := time.Time{}
+ for scanner.Scan() {
+ // # Ignore line
+ // # Tab-separated fields
+ // 2024-12-11 wp2:short 8h [comment]
+ var line = scanner.Text()
+ if strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ parts := min2w.Split(scanner.Text(), 3)
+ if len(parts) < 3 {
+ return fmt.Errorf("file %q, line %d: not enough tab-separated fields", name, nr)
+ }
+
+ d, e := time.Parse(time.DateOnly, parts[0])
+ if e != nil {
+ return fmt.Errorf("file %q, line %d: couldn't parse date: %w", name, nr, e)
+ }
+ if d.Year() != int(year) {
+ return fmt.Errorf("file %q, line %d: wrong year: %d", name, nr, d.Year())
+ }
+ if last.After(d) {
+ return fmt.Errorf("file %q, line %d: dates not monotonic, %s older", name, nr, Date(d))
+ }
+ last = d
+ if !Date(d).IsWorkday() {
+ return fmt.Errorf("file %q, line %d: date is a weekend: %v", name, nr, d)
+ }
+ if h, found := p.Holidays[Date(d)]; found {
+ return fmt.Errorf("file %q, line %d: date %v is a holiday: %s", name, nr, Date(d), h)
+ }
+
+ wps := strings.Split(parts[1], ":")
+ if len(wps) < 2 {
+ return fmt.Errorf("file %q, line %d: no such workpackage:task %q", name, nr, parts[1])
+ }
+ if wp, ok := p.Workpackages[wps[0]]; !ok {
+ return fmt.Errorf("file %q, line %d: no such workpackage %q", name, nr, wps[0])
+ } else if _, ok := wp.Tasks[wps[1]]; !ok {
+ return fmt.Errorf("file %q, line %d: no such task %q in %q", name, nr, wps[1], wps[0])
+ }
+
+ var amount Amount
+ e = (&amount).parseString(parts[2])
+ if e != nil {
+ return fmt.Errorf("file %q, line %d: not acceptible budget: %q", name, nr, parts[2])
+ }
+
+ entry := Entry{
+ User: parts[1],
+ WP: wps[0],
+ Task: wps[1],
+ Amount: amount}
+
+ entries := p.Timeline[Date(d)]
+ entries = append(entries, entry)
+ p.Timeline[Date(d)] = entries
+
+ nr += 1
+ }
+ }
+ return nil
+}
+
+//go:embed templates/*.t
+var embedFS embed.FS
+
+func (p *Project) printReport(name string) {
+ var tmp *template.Template
+ var e error
+
+ if *fl_templates != "" {
+ tmp, e = template.ParseGlob(*fl_templates + "/*.t")
+ } else {
+ tmp, e = template.ParseFS(embedFS, "templates/*.t")
+ }
+ if e != nil {
+ log.Fatalf("Couldn't parse templates: %v\n", e)
+ }
+
+ if nil == tmp.Lookup(name) {
+ log.Fatalf("Couldn't find template %q\n", name)
+ }
+
+ e = tmp.ExecuteTemplate(os.Stdout, name, p)
+ if e != nil {
+ log.Fatalf("Couldn't execute template %q: %v\n", name, e)
+ }
+}
+
+type FullDate struct {
+ Date
+ IsWorkday bool
+ IsHoliday bool
+}
+
+func (fd *FullDate) IsWeekend() bool {
+ return !fd.IsWorkday
+}
+
+func (p *Project) IsHoliday(d Date) bool {
+ _, ok := p.Holidays[d]
+ return ok
+}
+
+func (p *Project) Days() []FullDate {
+ dates := []FullDate{}
+ for d := time.Time(p.Start); d.Before(time.Time(p.End)); d = d.AddDate(0, 0, 1) {
+ dates = append(dates, FullDate{Date(d), Date(d).IsWorkday(), p.IsHoliday(Date(d))})
+ }
+ return dates
+}