diff options
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 |
commit | fe0ca581f1c13116b9378befb5b62c4d2f7d2947 (patch) | |
tree | add5df98b7f2d24f66f965b99d1b03e46dc14c1e |
init
-rw-r--r-- | doc.go | 213 | ||||
-rw-r--r-- | go.mod | 3 | ||||
-rw-r--r-- | zeitgeist.go | 457 |
3 files changed, 673 insertions, 0 deletions
@@ -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). + + + +*/ @@ -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 +} |