Private/TimeStore.ps1
|
# Helpers for time reporting (tidrapportering). Everything is stored as plain, # tab-separated text under the journal's 'time/' directory: # # time/resources.txt ResourceId, Name, EmployeeNumber, SupplierNumber, CostRate, IsDefault # time/projects.txt ProjectNumber, Name, CustomerNumber, HourlyRate, Status # time/yyyy-MM.txt EntryId, Date, ResourceId, ProjectNumber, CustomerNumber, # Hours, Billable, Rate, Text, InvoiceNumber # # Time entries are split into one file per month so the files stay small and # readable. Entry ids are unique across all months. An entry is 'Invoiced' # while the invoice it is linked to exists and is not credited; crediting the # invoice makes the entry 'Open' again so it can be invoiced anew. function Get-LedgerTimeDirectory { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath, [switch]$Create ) $dir = Join-Path $JournalPath 'time' if ($Create -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } return $dir } function ConvertTo-LedgerTimeField { <# .SYNOPSIS Makes a value safe for a tab-separated field (no tabs or line breaks). #> [CmdletBinding()] param ( [Parameter()] [AllowNull()] [AllowEmptyString()] [object]$Value ) if ($null -eq $Value) { return '' } return ([string]$Value -replace '[\t\r\n]+', ' ').Trim() } function ConvertFrom-LedgerHours { <# .SYNOPSIS Parses hours written as a decimal ('7.5', '7,5') or as a duration ('7:30', '07:30:00'). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) $t = $Text.Trim() -replace '(?i)\s*(h|tim|timmar)$', '' if ($t -match '^(\d+):(\d{1,2})(?::(\d{1,2}))?$') { $seconds = [int]$Matches[1] * 3600 + [int]$Matches[2] * 60 + $(if ($Matches[3]) { [int]$Matches[3] } else { 0 }) return [Math]::Round([decimal]$seconds / 3600, 2) } $value = [decimal]0 if ([decimal]::TryParse($t.Replace(',', '.'), [System.Globalization.NumberStyles]::AllowDecimalPoint, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$value)) { return [Math]::Round($value, 2) } throw "Could not parse hours '$Text'." } function Test-LedgerHours { [CmdletBinding()] param ( [Parameter(Mandatory)] [decimal]$Hours ) if ($Hours -le 0 -or $Hours -gt 24) { throw "Hours must be greater than 0 and at most 24 (got $Hours)." } } function Read-LedgerTimeResources { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath ) $file = Join-Path (Get-LedgerTimeDirectory -JournalPath $JournalPath) 'resources.txt' if (-not (Test-Path $file)) { return } foreach ($line in (Get-Content -Path $file -Encoding UTF8)) { if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue } $p = $line -split "`t" [PSCustomObject]@{ ResourceId = $p[0] Name = if ($p.Count -ge 2) { $p[1] } else { '' } EmployeeNumber = if ($p.Count -ge 3) { $p[2] } else { '' } SupplierNumber = if ($p.Count -ge 4) { $p[3] } else { '' } CostRate = if ($p.Count -ge 5 -and $p[4]) { ConvertFrom-LedgerInvoiceAmount -Text $p[4] } else { $null } IsDefault = ($p.Count -ge 6 -and $p[5] -eq '1') } } } function Save-LedgerTimeResources { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Resources ) $dir = Get-LedgerTimeDirectory -JournalPath $JournalPath -Create $lines = foreach ($r in $Resources) { $cost = if ($null -ne $r.CostRate) { Format-LedgerInvoiceAmount -Value ([decimal]$r.CostRate) } else { '' } @( (ConvertTo-LedgerTimeField $r.ResourceId) (ConvertTo-LedgerTimeField $r.Name) (ConvertTo-LedgerTimeField $r.EmployeeNumber) (ConvertTo-LedgerTimeField $r.SupplierNumber) $cost $(if ($r.IsDefault) { '1' } else { '' }) ) -join "`t" } Set-LedgerFileContent -Path (Join-Path $dir 'resources.txt') -Value @($lines) } function Read-LedgerProjects { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath ) $file = Join-Path (Get-LedgerTimeDirectory -JournalPath $JournalPath) 'projects.txt' if (-not (Test-Path $file)) { return } foreach ($line in (Get-Content -Path $file -Encoding UTF8)) { if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue } $p = $line -split "`t" [PSCustomObject]@{ ProjectNumber = $p[0] Name = if ($p.Count -ge 2) { $p[1] } else { '' } CustomerNumber = if ($p.Count -ge 3) { $p[2] } else { '' } HourlyRate = if ($p.Count -ge 4 -and $p[3]) { ConvertFrom-LedgerInvoiceAmount -Text $p[3] } else { $null } Status = if ($p.Count -ge 5 -and $p[4]) { $p[4] } else { 'Active' } } } } function Save-LedgerProjects { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Projects ) $dir = Get-LedgerTimeDirectory -JournalPath $JournalPath -Create $lines = foreach ($p in $Projects) { $rate = if ($null -ne $p.HourlyRate) { Format-LedgerInvoiceAmount -Value ([decimal]$p.HourlyRate) } else { '' } @( (ConvertTo-LedgerTimeField $p.ProjectNumber) (ConvertTo-LedgerTimeField $p.Name) (ConvertTo-LedgerTimeField $p.CustomerNumber) $rate $p.Status ) -join "`t" } Set-LedgerFileContent -Path (Join-Path $dir 'projects.txt') -Value @($lines) } function Read-LedgerTimeEntries { <# .SYNOPSIS Reads the stored time entries from all month files, oldest first. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath ) $dir = Get-LedgerTimeDirectory -JournalPath $JournalPath if (-not (Test-Path $dir)) { return } $files = Get-ChildItem -Path $dir -File | Where-Object { $_.Name -match '^\d{4}-\d{2}\.txt$' } | Sort-Object Name $entries = foreach ($f in $files) { foreach ($line in (Get-Content -Path $f.FullName -Encoding UTF8)) { if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue } $p = $line -split "`t" if ($p.Count -lt 6) { continue } [PSCustomObject]@{ EntryId = [int]$p[0] Date = [datetime]::ParseExact($p[1], 'yyyy-MM-dd', $null) ResourceId = $p[2] ProjectNumber = $p[3] CustomerNumber = $p[4] Hours = ConvertFrom-LedgerInvoiceAmount -Text $p[5] Billable = ($p.Count -lt 7 -or $p[6] -ne '0') Rate = if ($p.Count -ge 8 -and $p[7]) { ConvertFrom-LedgerInvoiceAmount -Text $p[7] } else { $null } Text = if ($p.Count -ge 9) { $p[8] } else { '' } InvoiceNumber = if ($p.Count -ge 10 -and $p[9]) { [int]$p[9] } else { $null } } } } @($entries) | Sort-Object Date, EntryId } function Save-LedgerTimeEntries { <# .SYNOPSIS Rewrites the month files for the given months from the full entry set. A month without entries has its file removed. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Entries, [Parameter(Mandatory)] [string[]]$Months ) $dir = Get-LedgerTimeDirectory -JournalPath $JournalPath -Create foreach ($month in ($Months | Select-Object -Unique)) { $path = Join-Path $dir "$month.txt" $inMonth = @($Entries | Where-Object { $_.Date.ToString('yyyy-MM') -eq $month } | Sort-Object Date, EntryId) if (-not $inMonth) { if (Test-Path $path) { Remove-Item -LiteralPath $path -Force } continue } $lines = foreach ($e in $inMonth) { @( $e.EntryId $e.Date.ToString('yyyy-MM-dd') (ConvertTo-LedgerTimeField $e.ResourceId) (ConvertTo-LedgerTimeField $e.ProjectNumber) (ConvertTo-LedgerTimeField $e.CustomerNumber) (Format-LedgerInvoiceAmount -Value ([decimal]$e.Hours)) $(if ($e.Billable) { '1' } else { '0' }) $(if ($null -ne $e.Rate) { Format-LedgerInvoiceAmount -Value ([decimal]$e.Rate) } else { '' }) (ConvertTo-LedgerTimeField $e.Text) $(if ($e.InvoiceNumber) { $e.InvoiceNumber } else { '' }) ) -join "`t" } Set-LedgerFileContent -Path $path -Value @($lines) } } function Get-LedgerTimeInvoiceStatusMap { <# .SYNOPSIS Returns a hashtable of invoice number -> status for resolving entry status. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath ) $map = @{} foreach ($inv in @(Get-LedgerInvoice -JournalPath $JournalPath)) { $map[[int]$inv.InvoiceNumber] = $inv.Status } return $map } function Test-LedgerTimeEntryInvoiced { [CmdletBinding()] param ( [Parameter(Mandatory)] [psobject]$Entry, [Parameter(Mandatory)] [hashtable]$InvoiceStatus ) return [bool]($Entry.InvoiceNumber -and $InvoiceStatus.ContainsKey([int]$Entry.InvoiceNumber) -and $InvoiceStatus[[int]$Entry.InvoiceNumber] -ne 'Credited') } function Resolve-LedgerTimeRate { <# .SYNOPSIS Returns the hourly rate for time on a project/customer: the project's rate, otherwise the customer's rate, otherwise $null. #> [CmdletBinding()] param ( [psobject]$Project, [psobject]$Customer ) if ($Project -and $null -ne $Project.HourlyRate) { return [decimal]$Project.HourlyRate } if ($Customer -and $Customer.PSObject.Properties['HourlyRate'] -and $null -ne $Customer.HourlyRate) { return [decimal]$Customer.HourlyRate } return $null } function Resolve-LedgerTimeEntryTarget { <# .SYNOPSIS Validates resource, project and customer for a new or changed time entry and resolves the customer, billable flag and rate. .DESCRIPTION The customer follows the project. Time on a project without a customer is internal and never billable. Billable time needs an hourly rate: the given rate, the project's rate or the customer's rate. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [hashtable]$Context, [string]$ResourceId, [string]$ProjectNumber, [string]$CustomerNumber, [bool]$Billable = $true, [Nullable[decimal]]$Rate ) if (-not $ResourceId) { $default = @($Context.Resources | Where-Object IsDefault | Select-Object -First 1) if (-not $default) { if (-not $Context.Resources) { throw 'No time resources exist. Add yourself with Add-LedgerTimeResource first.' } throw 'No default time resource. Specify -Resource or mark one with Set-LedgerTimeResource -Default.' } $ResourceId = $default[0].ResourceId } elseif (-not ($Context.Resources | Where-Object ResourceId -eq $ResourceId)) { throw "Time resource '$ResourceId' does not exist." } $project = $null if ($ProjectNumber) { $project = $Context.Projects | Where-Object ProjectNumber -eq $ProjectNumber | Select-Object -First 1 if (-not $project) { throw "Project '$ProjectNumber' does not exist." } if ($project.Status -eq 'Closed') { throw "Project '$ProjectNumber' is closed." } if ($CustomerNumber -and $project.CustomerNumber -and $CustomerNumber -ne $project.CustomerNumber) { throw "Project '$ProjectNumber' belongs to customer $($project.CustomerNumber), not $CustomerNumber." } $CustomerNumber = $project.CustomerNumber } elseif (-not $CustomerNumber) { throw 'Specify -Project or -CustomerNumber.' } $customer = $null if ($CustomerNumber) { $customer = $Context.Customers | Where-Object CustomerNumber -eq $CustomerNumber | Select-Object -First 1 if (-not $customer) { throw "Customer '$CustomerNumber' does not exist." } } else { $Billable = $false } if ($null -eq $Rate) { $Rate = Resolve-LedgerTimeRate -Project $project -Customer $customer } if ($Billable -and $null -eq $Rate) { $where = if ($project) { "project $ProjectNumber or customer $CustomerNumber" } else { "customer $CustomerNumber" } throw "No hourly rate for $where. Specify -Rate, or set it with Set-LedgerProject/Set-LedgerCustomer -HourlyRate." } return @{ ResourceId = $ResourceId ProjectNumber = [string]$ProjectNumber CustomerNumber = [string]$CustomerNumber Billable = $Billable Rate = $Rate } } function Get-LedgerTimeContext { <# .SYNOPSIS Loads the registers needed to validate and describe time entries. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath ) return @{ Resources = @(Read-LedgerTimeResources -JournalPath $JournalPath) Projects = @(Read-LedgerProjects -JournalPath $JournalPath) Customers = @(Get-LedgerCustomer -JournalPath $JournalPath) } } function ConvertTo-LedgerTimeEntryOutput { <# .SYNOPSIS Turns a stored entry into the public output object with names, amount and status resolved. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [psobject]$Entry, [Parameter(Mandatory)] [hashtable]$Context, [Parameter(Mandatory)] [hashtable]$InvoiceStatus ) $resource = $Context.Resources | Where-Object ResourceId -eq $Entry.ResourceId | Select-Object -First 1 $project = if ($Entry.ProjectNumber) { $Context.Projects | Where-Object ProjectNumber -eq $Entry.ProjectNumber | Select-Object -First 1 } $customer = if ($Entry.CustomerNumber) { $Context.Customers | Where-Object CustomerNumber -eq $Entry.CustomerNumber | Select-Object -First 1 } $invoiced = Test-LedgerTimeEntryInvoiced -Entry $Entry -InvoiceStatus $InvoiceStatus $amount = if ($Entry.Billable -and $null -ne $Entry.Rate) { [Math]::Round([decimal]$Entry.Hours * [decimal]$Entry.Rate, 2) } else { [decimal]0 } [PSCustomObject]@{ EntryId = $Entry.EntryId Date = $Entry.Date ResourceId = $Entry.ResourceId ResourceName = if ($resource) { $resource.Name } else { '' } ProjectNumber = $Entry.ProjectNumber ProjectName = if ($project) { $project.Name } else { '' } CustomerNumber = $Entry.CustomerNumber CustomerName = if ($customer) { $customer.Name } else { '' } Hours = $Entry.Hours Billable = $Entry.Billable Rate = $Entry.Rate Amount = $amount Text = $Entry.Text Status = if ($invoiced) { 'Invoiced' } else { 'Open' } InvoiceNumber = if ($invoiced) { $Entry.InvoiceNumber } else { $null } } } |