From f634eaee1a5ecc0fcbb71f472f1597220a6fd534 Mon Sep 17 00:00:00 2001 From: swtmath Date: Mon, 9 Mar 2026 08:54:42 +0700 Subject: [PATCH] init --- .gitignore | 405 ++++++++++++++++++ AGENTS.md | 198 +++++++++ QWEN.md | 103 +++++ README.md | 164 +++++++ excel_pajak.slnx | 4 + excel_pajak/Examples/EmployeeJsonExample.cs | 78 ++++ excel_pajak/Models/AppSettings.cs | 13 + excel_pajak/Models/EmployeeInfo.cs | 12 + excel_pajak/Models/EmployeePayrollInfo.cs | 106 +++++ excel_pajak/Models/ExcelSettings.cs | 7 + excel_pajak/Program.cs | 203 +++++++++ excel_pajak/Services/AGENTS.md | 39 ++ excel_pajak/Services/DatabaseService.cs | 54 +++ excel_pajak/Services/ExcelService.cs | 206 +++++++++ excel_pajak/appsettings.json | 20 + excel_pajak/excel_pajak.csproj | 29 ++ excel_pajak/simulasi_perhitungan_ak.xlsx | Bin 0 -> 57694 bytes .../DatabaseServiceIntegrationTests.cs | 147 +++++++ excel_pajak_test/DatabaseServiceTests.cs | 147 +++++++ excel_pajak_test/ExcelServiceTests.cs | 372 ++++++++++++++++ excel_pajak_test/MSTestSettings.cs | 1 + excel_pajak_test/Test1.cs | 11 + excel_pajak_test/appsettings.json | 15 + excel_pajak_test/excel_pajak_test.csproj | 34 ++ openspec/config.yaml | 20 + .../specs/excel-service-write-long/spec.md | 10 + openspec/specs/file-overwrite-control/spec.md | 39 ++ openspec/specs/formula-refresh/spec.md | 56 +++ openspec/specs/postgresql-query/spec.md | 89 ++++ query_employee_data.sql | 176 ++++++++ 30 files changed, 2758 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 QWEN.md create mode 100644 README.md create mode 100644 excel_pajak.slnx create mode 100644 excel_pajak/Examples/EmployeeJsonExample.cs create mode 100644 excel_pajak/Models/AppSettings.cs create mode 100644 excel_pajak/Models/EmployeeInfo.cs create mode 100644 excel_pajak/Models/EmployeePayrollInfo.cs create mode 100644 excel_pajak/Models/ExcelSettings.cs create mode 100644 excel_pajak/Program.cs create mode 100644 excel_pajak/Services/AGENTS.md create mode 100644 excel_pajak/Services/DatabaseService.cs create mode 100644 excel_pajak/Services/ExcelService.cs create mode 100644 excel_pajak/appsettings.json create mode 100644 excel_pajak/excel_pajak.csproj create mode 100644 excel_pajak/simulasi_perhitungan_ak.xlsx create mode 100644 excel_pajak_test/DatabaseServiceIntegrationTests.cs create mode 100644 excel_pajak_test/DatabaseServiceTests.cs create mode 100644 excel_pajak_test/ExcelServiceTests.cs create mode 100644 excel_pajak_test/MSTestSettings.cs create mode 100644 excel_pajak_test/Test1.cs create mode 100644 excel_pajak_test/appsettings.json create mode 100644 excel_pajak_test/excel_pajak_test.csproj create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/excel-service-write-long/spec.md create mode 100644 openspec/specs/file-overwrite-control/spec.md create mode 100644 openspec/specs/formula-refresh/spec.md create mode 100644 openspec/specs/postgresql-query/spec.md create mode 100644 query_employee_data.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70e139b --- /dev/null +++ b/.gitignore @@ -0,0 +1,405 @@ +# Visual Studio and .NET Core +## Build results +.vs/ + +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/git/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/git/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +# JetBrains IDEs +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# AI assistant files +.qwen.md +.agent/ +.qwen/ +.opencode/ +.claude/ +.gemini/ +.kilocode/ +openspec/changes/ + +# Local output folder +output/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1fda5c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,198 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-02-09 +**Commit:** 107722278e9274073236e8ee4cba21f832b2ed86 +**Branch:** main + +## OVERVIEW +.NET 10.0 console application for extracting employee tax data from PostgreSQL multi-schema databases. Uses static service classes with synchronous operations. + +## PROJECT STRUCTURE +``` +./ +├── excel_pajak/ # Main application +│ ├── Models/ # Data models (EmployeeInfo) +│ ├── Services/ # Static services (DatabaseService, ConfigurationService) +│ ├── Examples/ # Code examples +│ ├── appsettings.json # Configuration +│ └── excel_pajak.csproj # Main project +├── excel_pajak_test/ # MSTest project +└── openspec/ # OpenSpec change management artifacts +``` + +## COMMANDS + +### Build +```bash +# Build entire solution +dotnet build + +# Build specific project +dotnet build excel_pajak +dotnet build excel_pajak_test +``` + +### Run Tests +```bash +# Run all tests +dotnet test + +# Run single test class +dotnet test --filter "FullyQualifiedName~DatabaseServiceTests" + +# Run single test method +dotnet test --filter "FullyQualifiedName~DatabaseServiceTests.ExecuteScalar_NullConnectionString_ThrowsArgumentException" + +# Run tests with specific logger +dotnet test --logger "console;verbosity=detailed" + +# Run tests without building +dotnet test --no-build +``` + +### Run Application +```bash +# Run with default project +dotnet run --project excel_pajak + +# Run with arguments +dotnet run --project excel_pajak -- --schema=company_2024 --year=2024 +``` + +### Clean and Rebuild +```bash +# Clean solution +dotnet clean + +# Clean and rebuild +dotnet clean && dotnet build -c Release +``` + +## CODE STYLE GUIDELINES + +### General Principles +- Target .NET 10.0 with latest C# features +- Enable `ImplicitUsings` and `Nullable` reference types +- No comments unless required for complex logic + +### Imports +- Use file-scoped namespaces (`namespace excel_pajak.Services;`) +- Group imports: System namespaces first, then third-party +- Order: `System.*` → `Microsoft.*` → `ThirdParty.*` → `Project.*` +```csharp +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace excel_pajak.Services; +``` + +### Naming Conventions +- **Classes**: PascalCase (`DatabaseService`, `EmployeeInfo`) +- **Methods**: PascalCase (`ExecuteScalar`, `Initialize`) +- **Properties**: PascalCase (`EmployeeNumber`, `ConnectionString`) +- **Local variables**: camelCase (`connectionString`, `result`) +- **Constants**: UPPER_SNAKE_CASE for values, PascalCase for statics +- **Private fields**: camelCase with underscore prefix (`_connectionString`) +- **Parameters**: camelCase (`schema`, `year`) + +### Types +- Use nullable reference types (`string?`, `int?`) +- Prefer `var` when type is obvious from context +- Use explicit types for public APIs and method returns +- Use `string?` instead of `string` with null annotations for nullable properties + +### Error Handling +- Validate arguments with `ArgumentException` for null/empty checks +- Wrap database exceptions with `InvalidOperationException` +- Use result tuples for recoverable errors: `(bool success, string? result, Exception? error)` +- Re-throw exceptions with context using `throw new ExceptionType("message", inner)` +```csharp +if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + +try { /* ... */ } +catch (NpgsqlException ex) +{ + throw new InvalidOperationException($"Database error: {ex.Message}", ex); +} +``` + +### Static Service Pattern +- Services use static classes with `Initialize()` pattern +- Lazy initialization for dependencies +- No async database operations (intentional - see ANTI-PATTERNS) +```csharp +public static class DatabaseService +{ + private static string? _connectionString; + + public static void Initialize(string connectionString) + { + _connectionString = connectionString; + } +} +``` + +### JSON Serialization +- Use `System.Text.Json` with `[JsonPropertyName]` attributes +- Snake_case for JSON property names +```csharp +public class EmployeeInfo +{ + [JsonPropertyName("employee_number")] + public string? EmployeeNumber { get; set; } +} +``` + +### Configuration +- Use `appsettings.json` for all configuration +- Access via `Microsoft.Extensions.Configuration` +- Bind options with `ConfigurationBinder.Bind()` + +### Validation +- Schema names: `^[a-zA-Z0-9_-]+$` +- Years: `^\d{4}$` +```csharp +private static readonly Regex SchemaValidation = new(@"^[a-zA-Z0-9_-]+$", RegexOptions.Compiled); +``` + +### Database Operations +- Synchronous only using Npgsql +- Use `json_agg(row_to_json(t))` for PostgreSQL JSON aggregation +- Placeholder replacement: `query.Replace("{schema}", schema)` +- Dispose resources with `using` statements or `using var` + +### Testing +- Use MSTest with `[TestClass]` and `[TestMethod]` +- Use Shouldly for assertions: `Should.Throw<>`, `ShouldBe()`, `ShouldBeNull()` +- Use Moq for mocking +- Mark integration tests with `[Ignore]` if they require database connection +- Use region blocks (`#region`) for grouping related tests + +## WHERE TO LOOK +| Task | Location | +|------|----------| +| Database operations | `excel_pajak/Services/DatabaseService.cs` | +| Configuration handling | `excel_pajak/Services/ConfigurationService.cs` | +| Data models | `excel_pajak/Models/EmployeeInfo.cs` | +| Tests | `excel_pajak_test/DatabaseServiceTests.cs` | +| Configuration | `excel_pajak/appsettings.json` | + +## ANTI-PATTERNS TO AVOID +- No async database operations (intentional design, may need future migration) +- No connection pooling or disposal tracking in services +- Static state requires explicit `Initialize()` before use + +## KEY DEPENDENCIES +- **Npgsql**: PostgreSQL driver (sync operations) +- **NPOI**: Excel file manipulation +- **Microsoft.Extensions.Configuration**: Configuration management +- **Shouldly**: Test assertions +- **Moq**: Mocking framework +- **MSTest**: Test framework + +## NOTES +- All tests use MSTest framework +- Configuration requires valid connection string in `appsettings.json` +- Schema and Year parameters drive query execution +- Connection strings validated with regex for security diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..1213eb9 --- /dev/null +++ b/QWEN.md @@ -0,0 +1,103 @@ +# Excel Pajak Application + +## Project Overview + +Excel Pajak is a .NET 10.0 console application that connects to a PostgreSQL database to handle tax-related data processing. The application is designed to execute database queries and process employee information, with a focus on connecting to specific database schemas containing employee data. + +### Architecture +- **Main Application**: Console application in the `excel_pajak` directory +- **Test Project**: Unit and integration tests in the `excel_pajak_test` directory +- **Technology Stack**: + - .NET 10.0 + - PostgreSQL database connectivity via Npgsql + - Microsoft.Extensions.Configuration for configuration management + - MSTest for unit testing + - Moq for mocking dependencies + - Shouldly for assertions + +### Key Components + +1. **DatabaseService** (`Services/DatabaseService.cs`): Handles PostgreSQL connections and executes scalar queries +2. **EmployeeInfo Model** (`Models/EmployeeInfo.cs`): Represents employee data structure with employee number and schema +3. **Configuration**: Managed through `appsettings.json` with connection strings and application settings +4. **Examples**: Demonstrates JSON deserialization of employee data + +## Building and Running + +### Prerequisites +- .NET 10.0 SDK +- PostgreSQL database server (version compatible with Npgsql 10.0.1) +- Access to the configured database (default connection points to localhost:55432) + +### Build Commands +```bash +# Restore dependencies +dotnet restore + +# Build the application +dotnet build + +# Run the application +dotnet run --project excel_pajak/excel_pajak.csproj + +# Run tests +dotnet test +``` + +### Configuration +The application uses `appsettings.json` for configuration: +- Connection string for PostgreSQL database +- Schema list (currently contains "_onx4pzkwkeortehfjthgyfkb7c") +- Year setting (currently "2025") + +## Development Conventions + +### Coding Standards +- Uses nullable reference types (`#nullable enable`) +- Follows .NET naming conventions +- Implements asynchronous programming patterns where appropriate +- Includes proper error handling with specific exception types + +### Testing Approach +- Unit tests for business logic with mocked dependencies +- Integration tests that connect to actual PostgreSQL database +- Tests cover both positive and negative scenarios +- Uses Shouldly for readable assertions + +### Error Handling +- Validates input parameters and throws appropriate exceptions +- Handles database connection errors gracefully +- Provides detailed error messages for debugging + +## Project Structure +``` +excel_pajak/ +├── excel_pajak/ # Main application +│ ├── Models/ # Data models +│ │ └── EmployeeInfo.cs # Employee data structure +│ ├── Services/ # Business logic services +│ │ └── DatabaseService.cs # PostgreSQL database operations +│ ├── Examples/ # Usage examples +│ │ └── EmployeeJsonExample.cs # JSON processing example +│ ├── Program.cs # Application entry point +│ ├── appsettings.json # Configuration file +│ └── excel_pajak.csproj # Project file +├── excel_pajak_test/ # Test project +│ ├── DatabaseServiceTests.cs # Unit tests +│ ├── DatabaseServiceIntegrationTests.cs # Integration tests +│ └── excel_pajak_test.csproj # Test project file +└── openspec/ # OpenAPI specification directory +``` + +## Database Connection +The application connects to PostgreSQL using Npgsql with the connection string defined in `appsettings.json`. The default configuration connects to: +- Server: localhost +- Port: 55432 +- Database: andal_kharisma +- User: postgres +- Password: Release@2024 + +## Testing +The project includes both unit tests (with mocked dependencies) and integration tests (requiring a live database connection). Unit tests validate the logic without external dependencies, while integration tests verify actual database connectivity and query execution. + +To run integration tests, ensure that a PostgreSQL database is accessible with the configured connection string. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4285525 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Excel Pajak + +Employee tax data extraction and Excel report generation tool for Indonesian payroll processing. + +## Overview + +.NET 10.0 console application that extracts employee tax data from PostgreSQL multi-schema databases and generates Excel reports using NPOI library. + +## Prerequisites + +- .NET 10.0 SDK or later +- PostgreSQL database +- Valid connection string in configuration + +## Project Structure + +``` +excel_pajak/ +├── Models/ # Data models (EmployeeInfo, EmployeePayrollInfo) +├── Services/ # Static services (DatabaseService, ExcelService) +├── Examples/ # Code examples +├── appsettings.json # Application configuration +└── excel_pajak.csproj # Main project + +excel_pajak_test/ # MSTest unit tests +``` + +## Configuration + +Edit [`excel_pajak/appsettings.json`](excel_pajak/appsettings.json) to configure: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=payroll;Username=your_user;Password=your_password" + }, + "ExcelSettings": { + "TemplatePath": "simulasi_perhitungan_ak.xlsx", + "OutputDirectory": "Output" + } +} +``` + +## Building + +```bash +# Build entire solution +dotnet build + +# Build specific project +dotnet build excel_pajak +dotnet build excel_pajak_test +``` + +## Running + +```bash +# Run with default settings +dotnet run --project excel_pajak + +# Run with command-line arguments +dotnet run --project excel_pajak -- --schema=company_2024 --year=2024 + +# Run specific schema and year +dotnet run --project excel_pajak -- --schema=company_2025 --year=2025 +``` + +### Command-Line Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `--schema` | Database schema name | Yes | +| `--year` | Tax year (4 digits) | Yes | +| `--help` | Show help information | No | + +## Testing + +```bash +# Run all tests +dotnet test + +# Run specific test class +dotnet test --filter "FullyQualifiedName~DatabaseServiceTests" + +# Run specific test method +dotnet test --filter "FullyQualifiedName~DatabaseServiceTests.ExecuteScalar_NullConnectionString_ThrowsArgumentException" + +# Run with detailed output +dotnet test --logger "console;verbosity=detailed" +``` + +## Workflow + +### 1. Configuration Phase +- Load connection string from `appsettings.json` +- Validate schema name and year parameters +- Initialize DatabaseService with connection string + +### 2. Data Extraction Phase +- Connect to PostgreSQL database +- Execute query to extract employee tax data +- Query uses `json_agg(row_to_json(t))` for JSON aggregation +- Placeholder replacement: `{schema}` in query template + +### 3. Excel Generation Phase +- Load template Excel file (`simulasi_perhitungan_ak.xlsx`) +- Write employee data to specific cells +- Optionally refresh formulas +- Save to Output directory with naming convention: `{EmployeeNumber}_{Year}.xlsx` + +### 4. Output Phase +- Generate Excel files in `Output/` directory +- Each file named after employee number and year +- Example: `AA001_2025.xlsx` + +## Key Services + +### DatabaseService +- Static service for PostgreSQL operations +- Synchronous database operations using Npgsql +- Query execution with schema placeholders + +### ExcelService +- Excel file manipulation using NPOI +- Template loading and data writing +- Formula refresh support +- File overwrite control + +### ConfigurationService +- Manages application settings +- Loads configuration from appsettings.json + +## Architecture + +- **Pattern**: Static service classes with Initialize() +- **Database**: Synchronous operations (no async) +- **Serialization**: System.Text.Json with snake_case +- **Validation**: Regex-based schema/year validation + +## Anti-Patterns Avoided + +- No async database operations (synchronous by design) +- No connection pooling in services +- Static state requires explicit Initialize() before use + +## Troubleshooting + +### Connection Issues +- Verify PostgreSQL server is running +- Check connection string in appsettings.json +- Ensure database user has appropriate permissions + +### Schema Not Found +- Verify schema name exists in database +- Schema names should match pattern: `^[a-zA-Z0-9_-]+$` + +### Excel Output Issues +- Ensure Output directory exists +- Check write permissions +- Verify template file exists + +## License + +Internal use only. diff --git a/excel_pajak.slnx b/excel_pajak.slnx new file mode 100644 index 0000000..893d949 --- /dev/null +++ b/excel_pajak.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/excel_pajak/Examples/EmployeeJsonExample.cs b/excel_pajak/Examples/EmployeeJsonExample.cs new file mode 100644 index 0000000..832e706 --- /dev/null +++ b/excel_pajak/Examples/EmployeeJsonExample.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using excel_pajak.Models; + +namespace excel_pajak.Examples; + +public static class EmployeeJsonExample +{ + public static void RunExample() + { + // Sample JSON from PostgreSQL query + var json = @"[ + {""employee_number"":""PS2MA001"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""}, + {""employee_number"":""PS2MA002"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""}, + {""employee_number"":""PS2MA003"",""schema"":""_onx4pzkwkeortehfjthgyfkb7c""} + ]"; + + // Configure options + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + try + { + // Deserialize JSON array to EmployeeInfo[] + var employees = JsonSerializer.Deserialize(json, options); + + if (employees != null) + { + Console.WriteLine($"Deserialized {employees.Length} employees:"); + foreach (var employee in employees) + { + Console.WriteLine($" - {employee.EmployeeNumber} (schema: {employee.Schema})"); + } + } + } + catch (JsonException ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"JSON deserialization error: {ex.Message}"); + Console.ResetColor(); + } + } + + public static void TestEmptyArray() + { + // Test empty array + var emptyJson = "[]"; + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var employees = JsonSerializer.Deserialize(emptyJson, options); + Console.WriteLine($"Empty array test: {(employees?.Length == 0 ? "PASS" : "FAIL")}"); + } + + public static void TestMalformedJson() + { + // Test malformed JSON + var malformedJson = "[{invalid json}]"; + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + try + { + var employees = JsonSerializer.Deserialize(malformedJson, options); + Console.WriteLine("Malformed JSON test: FAIL - should have thrown exception"); + } + catch (JsonException) + { + Console.WriteLine("Malformed JSON test: PASS - exception thrown as expected"); + } + } +} diff --git a/excel_pajak/Models/AppSettings.cs b/excel_pajak/Models/AppSettings.cs new file mode 100644 index 0000000..ae55fe2 --- /dev/null +++ b/excel_pajak/Models/AppSettings.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace excel_pajak.Models; + +public class AppSettings +{ + public List SchemaList { get; set; } = new List(); + + [RegularExpression(@"^\d{4}$", ErrorMessage = "Year must be a 4-digit number")] + public string Year { get; set; } = string.Empty; + + public ExcelSettings? ExcelSettings { get; set; } +} \ No newline at end of file diff --git a/excel_pajak/Models/EmployeeInfo.cs b/excel_pajak/Models/EmployeeInfo.cs new file mode 100644 index 0000000..d4ef1e2 --- /dev/null +++ b/excel_pajak/Models/EmployeeInfo.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace excel_pajak.Models; + +public class EmployeeInfo +{ + [JsonPropertyName("employee_number")] + public string? EmployeeNumber { get; set; } + + [JsonPropertyName("schema")] + public string? Schema { get; set; } +} diff --git a/excel_pajak/Models/EmployeePayrollInfo.cs b/excel_pajak/Models/EmployeePayrollInfo.cs new file mode 100644 index 0000000..5769b25 --- /dev/null +++ b/excel_pajak/Models/EmployeePayrollInfo.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Serialization; + +namespace excel_pajak.Models; + +/// +/// Represents employee payroll and tax data for a specific payment period. +/// Used for extracting tax-related information from PostgreSQL databases. +/// +public class EmployeePayrollInfo +{ + [JsonPropertyName("employee_number")] + public string? EmployeeNumber { get; set; } + + [JsonPropertyName("employee_name")] + public string? EmployeeName { get; set; } + + [JsonPropertyName("employee_npwp")] + public string? EmployeeNpwp { get; set; } + + [JsonPropertyName("tax_marital_status")] + public string? TaxMaritalStatus { get; set; } + + [JsonPropertyName("employee_tax_status")] + public int EmployeeTaxStatus { get; set; } + + [JsonPropertyName("payment_period")] + public string? PaymentPeriod { get; set; } + + [JsonPropertyName("npwp_pemotong")] + public string? NpwpPemotong { get; set; } + + [JsonPropertyName("tax_type")] + public string? TaxType { get; set; } + + [JsonPropertyName("basic_salary")] + public decimal BasicSalary { get; set; } + + [JsonPropertyName("overtime")] + public decimal Overtime { get; set; } + + [JsonPropertyName("allowance")] + public decimal Allowance { get; set; } + + [JsonPropertyName("deduction")] + public decimal Deduction { get; set; } + + [JsonPropertyName("benefit_in_kind")] + public decimal BenefitInKind { get; set; } + + [JsonPropertyName("severance")] + public decimal Severance { get; set; } + + [JsonPropertyName("jkk")] + public decimal Jkk { get; set; } + + [JsonPropertyName("jkm")] + public decimal Jkm { get; set; } + + [JsonPropertyName("bpjs_kesehatan_company")] + public decimal BpjsKesehatanCompany { get; set; } + + [JsonPropertyName("jht_emp")] + public decimal JhtEmp { get; set; } + + [JsonPropertyName("jp_emp")] + public decimal JpEmp { get; set; } + + [JsonPropertyName("pension_emp")] + public decimal PensionEmp { get; set; } + + [JsonPropertyName("employee_condition")] + public int EmployeeCondition { get; set; } + + [JsonPropertyName("resign_type")] + public string? ResignType { get; set; } + + [JsonPropertyName("allowance_tax_regular")] + public decimal AllowanceTaxRegular { get; set; } + + [JsonPropertyName("tax_regular")] + public decimal TaxRegular { get; set; } + + [JsonPropertyName("allowance_irregular")] + public decimal AllowanceIrregular { get; set; } + + [JsonPropertyName("benefit_in_kind_irregular")] + public decimal BenefitInKindIrregular { get; set; } + + [JsonPropertyName("allowance_tax_irregular")] + public decimal AllowanceTaxIrregular { get; set; } + + [JsonPropertyName("tax_irregular")] + public decimal TaxIrregular { get; set; } + + [JsonPropertyName("severance_tax")] + public decimal SeveranceTax { get; set; } + + [JsonPropertyName("allowance_severance_tax")] + public decimal AllowanceSeveranceTax { get; set; } + + [JsonPropertyName("beginning_salary_netto")] + public decimal BeginningSalaryNetto { get; set; } + + [JsonPropertyName("beginning_pph21")] + public decimal BeginningPph21 { get; set; } +} diff --git a/excel_pajak/Models/ExcelSettings.cs b/excel_pajak/Models/ExcelSettings.cs new file mode 100644 index 0000000..be9d5b3 --- /dev/null +++ b/excel_pajak/Models/ExcelSettings.cs @@ -0,0 +1,7 @@ +namespace excel_pajak.Models; + +public class ExcelSettings +{ + public string TemplatePath { get; set; } = string.Empty; + public string OutputFolder { get; set; } = string.Empty; +} diff --git a/excel_pajak/Program.cs b/excel_pajak/Program.cs new file mode 100644 index 0000000..be5e72a --- /dev/null +++ b/excel_pajak/Program.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using excel_pajak.Services; +using excel_pajak.Models; +using System.Text.Json; + +namespace excel_pajak; + +class Program +{ + static void Main(string[] args) + { + // Initialize configuration + var builder = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + + IConfigurationRoot configuration = builder.Build(); + var schemaList = configuration.GetSection("SchemaList").Get>() ?? []; + var year = configuration.GetValue("Year") ?? string.Empty; + var connectionString = configuration.GetConnectionString("DefaultConnection") ?? string.Empty; + var outputFolder = configuration.GetValue("ExcelSettings:OutputFolder") ?? string.Empty; + var templatePath = configuration.GetValue("ExcelSettings:TemplatePath") ?? string.Empty; + + + + + // Process each schema in the configuration + var allEmployeeInfos = new List(); + + foreach (var schema in schemaList) + { + Console.WriteLine($"\nProcessing schema: {schema}"); + string query = + $""" + select json_agg(row_to_json(t)) from ( + select distinct e."NIK" as employee_number , '{schema}' as schema + from {schema}."EmployeeReportPeriodics" erp + inner join {schema}."Employees" e on erp."EmployeeId" =e."Id" + inner join {schema}."EmployeeTaxStatuses" ets on e."EmployeeTaxStatusRefId" =ets."Id" + where erp."PaymentPeriod" between '{year}01' and '{year}12' + and ets."EmployeeTaxStatusCode" =1 + and erp."TaxCalculationType" <>1 AND ( + e."LastWorkingDate" IS NULL + OR e."LastWorkingDate" >= extract(epoch from DATE '{year}-01-01')*1000 + ) + ) t + """; + + var result = DatabaseService.ExecuteScalar(query, connectionString); + if (result != null) + { + try + { + // Deserialize the JSON result to a list of EmployeeInfo objects + var employeeInfos = JsonSerializer.Deserialize>(result); + + if (employeeInfos != null) + { + // Add the retrieved employees to the overall list + allEmployeeInfos.AddRange(employeeInfos); + Console.WriteLine($"Retrieved {employeeInfos.Count} employee records from schema '{schema}'"); + } + } + catch (JsonException ex) + { + Console.WriteLine($"Error deserializing JSON from schema '{schema}': {ex.Message}"); + } + } + else + { + Console.WriteLine($"No data returned from schema '{schema}'"); + } + } + + // Output the aggregated employee information to the console + Console.WriteLine($"\nAggregated {allEmployeeInfos.Count} employee records from all schemas:"); + foreach (var employeeInfo in allEmployeeInfos) + { + Console.WriteLine($"Employee Number: {employeeInfo.EmployeeNumber}, Schema: {employeeInfo.Schema}"); + ExcelService.InitExcel($"{employeeInfo.EmployeeNumber}_{year}.xlsx", templatePath, outputFolder); + string queryEmployeeData = $""" + select json_agg(row_to_json(t)) from ( + select e."NIK" as employee_number, e."Name" as employee_name,e."NPWP" as employee_npwp, + (case + when erp."TaxStatus"=1 then 'T0' + when erp."TaxStatus"=2 then 'T1' + when erp."TaxStatus"=3 then 'T2' + when erp."TaxStatus"=4 then 'T3' + when erp."TaxStatus"=5 then 'K0' + when erp."TaxStatus"=6 then 'K1' + when erp."TaxStatus"=7 then 'K2' + when erp."TaxStatus"=8 then 'K3' + end + ) as tax_marital_status, + ets."EmployeeTaxStatusCode" as employee_tax_status, + erp."PaymentPeriod" as payment_period, + erp."NPWPPemotong" as npwp_pemotong, + (case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type, + erp."BasicSalary" as basic_salary, + erp."Overtime" as overtime, + erp."AllowanceRegular" as allowance, + erp."Deductions" as deduction, + erp."Natura" as benefit_in_kind, + erp."Severance" as severance, + erp."JKKCompany" as jkk, + erp."JKMCompany" as jkm, + erp."BPJSKesCompany" +erp."OtherInsuranceCompany" as bpjs_kesehatan_company, + erp."JHTEmployee" as jht_emp, + erp."JPEmployee" as jp_emp, + 0 as pension_emp, + erp."EmployeeCondition" as employee_condition, + erp."ResignType" as resign_type, + erp."AllowanceTaxRegular" as allowance_tax_regular, + erp."TaxRegular" as tax_regular , + erp."AllowanceIrregular" as allowance_irregular, + 0 as benefit_in_kind_irregular, + erp."AllowanceTaxIrregular" as allowance_tax_irregular, + erp."TaxIrregular" as tax_irregular, + 0 as severance_tax, + 0 as allowance_severance_tax, + erp."BegSalaryNetto" as beginning_salary_netto, + erp."BegPPh21" as beginning_pph21 + from {employeeInfo.Schema}."EmployeeReportPeriodics" erp + inner join {employeeInfo.Schema}."Employees" e on erp."EmployeeId" = e."Id" + inner join {employeeInfo.Schema}."EmployeeTaxStatuses" ets on e."EmployeeTaxStatusRefId" =ets."Id" + where erp."TaxCalculationType" <>1 + and erp."PaymentPeriod" between '{year}01' and '{year}12' + and e."NIK" ='{employeeInfo.EmployeeNumber}' + ) t + """; + string resultPayroll = DatabaseService.ExecuteScalar(queryEmployeeData, connectionString) ?? string.Empty; + if (resultPayroll != "") + { + try + { + // Deserialize the JSON result to a list of EmployeeInfo objects + var employeeInfos = JsonSerializer.Deserialize>(resultPayroll); + + if (employeeInfos != null) + { + string taxTypeBasal = employeeInfos.LastOrDefault(x => x.BasicSalary > 0)?.TaxType ?? "PaidByEmployee"; + string taxMaritalStatus = employeeInfos.FirstOrDefault()?.TaxMaritalStatus ?? "T0"; + string periodStart = employeeInfos.OrderBy(x => x.PaymentPeriod).FirstOrDefault()?.PaymentPeriod ?? ""; + string periodEnd = employeeInfos.OrderBy(x => x.PaymentPeriod).LastOrDefault()?.PaymentPeriod ?? ""; + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", taxMaritalStatus, 3, 3); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", int.Parse(periodStart.Substring(4, 2)), 2, 7); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", int.Parse(periodEnd.Substring(4, 2)), 3, 7); + List periodList = employeeInfos.Select(x => x.PaymentPeriod).Distinct().ToList() ?? new(); + foreach (string period in periodList) + { + var employeeInfoDetail = employeeInfos.FirstOrDefault(x => x.PaymentPeriod == period && x.TaxType == taxTypeBasal); + int month = int.Parse(period?.Substring(4, 2) ?? "01"); + if (employeeInfoDetail != null) + { + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BasicSalary, 6, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Overtime, 8, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Allowance, 7, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", Math.Abs(employeeInfoDetail.Deduction) * -1, 10, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BenefitInKind, 9, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkk, 11, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkm, 12, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BpjsKesehatanCompany, 13, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.JhtEmp, 18, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.JpEmp, 19, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.PensionEmp, 20, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxRegular, 14, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceIrregular + employeeInfoDetail.BenefitInKindIrregular, 36, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxIrregular, 37, month + 1); + } + employeeInfoDetail = employeeInfos.FirstOrDefault(x => x.PaymentPeriod == period && x.TaxType != taxTypeBasal); + if (employeeInfoDetail != null) + { + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BasicSalary, 52, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Overtime, 54, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Allowance, 53, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", Math.Abs(employeeInfoDetail.Deduction) * -1, 56, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BenefitInKind, 55, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkk, 57, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.Jkm, 58, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.BpjsKesehatanCompany, 59, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxRegular, 60, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceIrregular + employeeInfoDetail.BenefitInKindIrregular, 85, month + 1); + ExcelService.WriteExcel($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx", employeeInfoDetail.AllowanceTaxIrregular, 86, month + 1); + } + } + + ExcelService.RefreshFormulas($"{outputFolder}\\{employeeInfo.EmployeeNumber}_{year}.xlsx"); + } + } + catch (JsonException ex) + { + Console.WriteLine($"Error deserializing JSON from schema '{employeeInfo.Schema}': {ex.Message}"); + } + } + else + { + Console.WriteLine($"No data returned from schema '{employeeInfo.Schema}'"); + } + } + } +} diff --git a/excel_pajak/Services/AGENTS.md b/excel_pajak/Services/AGENTS.md new file mode 100644 index 0000000..38cace0 --- /dev/null +++ b/excel_pajak/Services/AGENTS.md @@ -0,0 +1,39 @@ +# SERVICES DIRECTORY + +**Generated:** 2026-02-06 +**Path:** excel_pajak/Services/ + +## OVERVIEW +Static service classes for database and configuration operations. + +## FILES +| File | Purpose | +|------|---------| +| `DatabaseService.cs` | PostgreSQL query execution with sync operations | +| `ConfigurationService.cs` | Configuration access with lazy initialization | + +## CONVENTIONS +- Static classes with `Initialize()` pattern for dependency injection +- Lazy initialization in ConfigurationService: creates default config on first access +- Error handling via result tuples: `(bool success, string? result, Exception? error)` +- Schema name validation regex: `^[a-zA-Z0-9_-]+$` +- Year validation regex: `^\d{4}$` + +## ERROR HANDLING +```csharp +// Pattern: TryExecute pattern for schema iteration +var (success, result, error) = DatabaseService.ExecuteEmployeeTaxQueryWithErrorHandling(schema, year); +if (success) { /* use result */ } +else { /* handle error, continue processing */ } +``` + +## INITIALIZATION +```csharp +// Before using services: +ConfigurationService.Initialize(configuration); +DatabaseService.Initialize(connectionString); +``` + +## QUERY PATTERNS +- Use `json_agg(row_to_json(t))` for PostgreSQL JSON aggregation +- Placeholder replacement for schema/year: `query.Replace("{schema}", schema)` diff --git a/excel_pajak/Services/DatabaseService.cs b/excel_pajak/Services/DatabaseService.cs new file mode 100644 index 0000000..9441fab --- /dev/null +++ b/excel_pajak/Services/DatabaseService.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace excel_pajak.Services; + +public static class DatabaseService +{ + + + public static string? ExecuteScalar(string query, string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + } + + if (string.IsNullOrWhiteSpace(query)) + { + throw new ArgumentException("Query cannot be null or empty.", nameof(query)); + } + + try + { + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + + using var command = new NpgsqlCommand(query, connection); + var result = command.ExecuteScalar(); + connection.Close(); + // Handle empty result sets (no rows) + if (result == null || result == DBNull.Value) + { + return null; + } + + return result.ToString(); + } + catch (Npgsql.PostgresException ex) + { + // Re-throw PostgreSQL exceptions with context + throw new InvalidOperationException( + $"PostgreSQL error executing query: {ex.MessageText ?? ex.Message}", + ex); + } + catch (NpgsqlException ex) + { + // Re-throw Npgsql exceptions with context + throw new InvalidOperationException( + $"Database error executing query: {ex.Message}", + ex); + } + } + +} diff --git a/excel_pajak/Services/ExcelService.cs b/excel_pajak/Services/ExcelService.cs new file mode 100644 index 0000000..d1c7f7a --- /dev/null +++ b/excel_pajak/Services/ExcelService.cs @@ -0,0 +1,206 @@ +using System.IO; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using NPOI.XSSF.UserModel.Helpers; +using excel_pajak.Models; + +namespace excel_pajak.Services; + +public static class ExcelService +{ + + + /// + /// Initializes an Excel workbook from a template file. + /// + /// The name of the output Excel file (without .xlsx extension). + /// The relative path to the template file from the application base directory. + /// The folder where the output file will be created. + /// If true, will overwrite an existing file. If false, will throw IOException if file exists. + /// A tuple containing success status, output file path on success, or exception on failure. + public static (bool success, string? result, Exception? error) InitExcel(string outputName, string templatePath, string outputFolder, bool overwrite = true) + { + if (string.IsNullOrWhiteSpace(outputName)) + { + return (false, null, new ArgumentException("File name cannot be null or empty")); + } + + try + { + var templateFullPath = Path.IsPathRooted(templatePath) + ? templatePath + : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, templatePath); + + if (!File.Exists(templateFullPath)) + { + return (false, null, new FileNotFoundException($"Template file not found: {templateFullPath}")); + } + + if (!Directory.Exists(outputFolder)) + { + Directory.CreateDirectory(outputFolder); + } + + var outputFileName = outputName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase) + ? outputName + : outputName + ".xlsx"; + var outputFullPath = Path.Combine(outputFolder, outputFileName); + + if (!overwrite && File.Exists(outputFullPath)) + { + return (false, null, new IOException($"File already exists: {outputFullPath}. Set overwrite=true to replace it.")); + } + + using (var sourceStream = new FileStream(templateFullPath, FileMode.Open, FileAccess.Read)) + { + var workbook = new XSSFWorkbook(sourceStream); + using (var destinationStream = new FileStream(outputFullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write)) + { + workbook.Write(destinationStream, false); + } + workbook.Close(); + } + + return (true, outputFullPath, null); + } + catch (Exception ex) + { + return (false, null, ex); + } + } + + public static bool WriteExcel(string fileName, string value, int row, int column) + { + return InternalWriteExcel(fileName, value, row, column); + } + + public static bool WriteExcel(string fileName, long value, int row, int column) + { + return InternalWriteExcel(fileName, value, row, column); + } + + public static bool WriteExcel(string fileName, decimal value, int row, int column) + { + return InternalWriteExcel(fileName, value, row, column); + } + + private static bool InternalWriteExcel(string fileName, object value, int row, int column) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + if (row < 1) + { + return false; + } + + if (column < 1) + { + return false; + } + + if (!File.Exists(fileName)) + { + return false; + } + + try + { + XSSFWorkbook workbook; + using (var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read)) + { + workbook = new XSSFWorkbook(fileStream); + } + + ISheet sheet = workbook.NumberOfSheets > 0 ? workbook.GetSheetAt(0) : workbook.CreateSheet(); + + IRow dataRow = sheet.GetRow(row - 1) ?? sheet.CreateRow(row - 1); + ICell cell = dataRow.GetCell(column - 1) ?? dataRow.CreateCell(column - 1); + + if (value is string strValue) + { + if (string.IsNullOrEmpty(strValue)) + { + cell.SetCellValue(string.Empty); + } + else if (double.TryParse(strValue, out double numericValue)) + { + cell.SetCellValue(numericValue); + } + else + { + cell.SetCellValue(strValue); + } + } + else if (value is long longValue) + { + cell.SetCellValue((double)longValue); + } + else if (value is decimal decimalValue) + { + cell.SetCellValue((double)decimalValue); + } + else if (value is double doubleValue) + { + cell.SetCellValue(doubleValue); + } + + using (var fileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write)) + { + workbook.Write(fileStream, false); + + } + workbook.Close(); + + return true; + } + catch (Exception) + { + return false; + } + } + + public static bool RefreshFormulas(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + if (!File.Exists(fileName)) + { + return false; + } + + try + { + XSSFWorkbook workbook; + using (var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read)) + { + workbook = new XSSFWorkbook(fileStream); + } + + for (int i = 0; i < workbook.NumberOfSheets; i++) + { + ISheet sheet = workbook.GetSheetAt(i); + sheet.ForceFormulaRecalculation = true; + } + + XSSFFormulaEvaluator.EvaluateAllFormulaCells(workbook); + + using (var fileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write)) + { + workbook.Write(fileStream, false); + } + workbook.Close(); + + return true; + } + catch + { + return false; + } + } +} diff --git a/excel_pajak/appsettings.json b/excel_pajak/appsettings.json new file mode 100644 index 0000000..d70a98c --- /dev/null +++ b/excel_pajak/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port=55432;Database=andal_kharisma;User Id=postgres;Password=Release@2024;Timeout=60;CommandTimeout=120;ServerCompatibilityMode=NoTypeLoading;" + }, + "SchemaList": [ + "_fpjs5fybbsar9cxyttn9ui5ndw" + ], + "Year": "2025", + "ExcelSettings": { + "TemplatePath": "simulasi_perhitungan_ak.xlsx", + "OutputFolder": "Output" + } +} \ No newline at end of file diff --git a/excel_pajak/excel_pajak.csproj b/excel_pajak/excel_pajak.csproj new file mode 100644 index 0000000..9a2f899 --- /dev/null +++ b/excel_pajak/excel_pajak.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/excel_pajak/simulasi_perhitungan_ak.xlsx b/excel_pajak/simulasi_perhitungan_ak.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3690f5c3dd48968718c61c83ca23fa7b981d5aa9 GIT binary patch literal 57694 zcmeFXW0YmhmM)yOZM)L8ZQHhO+o-f{JG0W2R;6vb(*CmE^WN@0-RJc9{@=UD*lX{x z;+YXMV#U-GOI`{X1O)&L00IC2fDmAPmQueS5CDJ{8~^|r00Kx$$j;W;#MW6)*~8w% zNr%?m#+sl21c)LJ0O$+<|33d0uRxQEj2t!tLbuv3UzN9r1~{CGipfC0cWe~M7ho{2 z2CE^x2J-l~*H|_w<@|V+WcwBPwcE|iqZ@bEM%Nb4p=6osn6`@20>Roq)*m*5TSuLv z@CqhCwS8c5G0XaBY`0N%jYl$r+73o8LMxg5z7po-2)JM?5D^ zz69q_cwY`6JchB+W5z5HlXwCQ+m1G;*b&m`392uK3BR>)u&C{c)@f2To(|07uLSISOI;D<0%**3;+wPrRaVI z^b!t~y;jwZ9~eH-;sc1erm8z_*_(4XTz6{=duspWDM4UrT@D_GcE6G(^+dviK*n6Z zFZDU{9v*pr-jA-=2Ee+nU$;!5Y)d+6rS}>!@mrhx{IciIPhbG~f3kFB-5(}EUoBf- zh7A2>>3WVP)=qS^f3E+}!v8Ne>;E$KszeznUF@e%>G4G!?~kGa&iO5Po>{eSIUecvrVWMeg$Q1*NZ&;u1G7x$I#v6n-rL&15tAH6 zW6s6<1Bk3E&MNPjVyF)Cm+yHat9A>5X-Pl*FpSqsASlMK1Eyg|VMvIBR#g_;&V1J;AUE}R{#yqGBB>ALZqzC0lM?R&15oWj#Q&Lp=*}dwS zY8AA_3t7ehkY@oLOR4Y~w@l1RY2rX;sq9UJ)KCPKdNyWhHfFYQ{Hd&=&iXS)MNu~& zurIY#Kb(3G5?bWf_bNr;aAQyv{+>~fjc9WdM<{KHgOch=;bazm?%#oxsdy-uCcApP zDuTa;fz~g>t(=j3@!lXq`|!3vN($9dxDyHx(>a07tq6Sf`TEcZ!3mFJjqI@!jU*l> zT;Lw7iXLTEH}S^3K#8B?W+B8Ymm=Z&yvQ>;k4y`rZllr9ZR2YM&R&RVTuPvfQJNGK z#!#hznL~<~8-ie07RSG%>o*_e@CYpq=}xiz64wx26RZ{(A?8*NJ({#p%LOr@h8i*! zLDh>v0>upTX&QTtW+1=m?XK2{+J>F!z)xBW@Q~xJP|nNF($9-+^6MY^RYs3P80Q|DVf5fl`SvP`+&ikSk!7&molv-pY89z?euq$ml z>zwJjK@PEh`}DvhO+HlGEQ7zubA9vpUX+!uBD>U*_*U)S(XmI3pbi_;NZ}C|V0Swn zH3`kif1^n6g0AjdFW@LNzX6Sy+=nK(%h^<~#JL4D^h>P(xH$}Jh)QOuUyjG!cYoAX z)3Il6DGSvHJ!#sjv!M!=m-BwJpcomENPcS&ybj1agyh*STUeAjC;jg6>%rF7Zup-LepaW!OF{_%z+MLk0OJesw}W>wH!*Q` zqWkNI;g6@E@Ud{h5lcLHWXSyF56U05M;YmWZyI4xJ;>I`(^PysZ)ISHlw`h{35?g| zS$o}j97;%W@K8=s8?&|`0u*_Qy1s*mo7>pS{Cs<#8#e3V;r4yMKDZrkoW0#yX$+gT z>*;#EejZLW^Lr=k)X3p^eR2ML-gqCT%f;8tdB5E`Tk-qx^62+^h_8p|_wh0}FfhlI zeEobj-k6({SBZ3;7ff;q(myh_5uJ`-7fg^I2k-dT8SMux6`%?HGZyTxCyNCE) z-VMHww=+66yB_ZsP?5Lyfnj*L&*YFVCGQ?u-fnJFhhKaA2Hie+Gd6pC9$tw*u=lQt zszpn@SbSctA8y8%=&o{e3ir_?%|36{=v3)0w=N}r_k89Viq4_O!T0zez%HY*9M&^u zT#RA4g~+6ZOuB_YI`C|MyzFd@H-09?G)~$l8nc}(HO@{trxmePny~h0#FP%~c=Bq} zCfES!kZi1s7&MGZ}xC|@pQb8JF|0m-JD{t1bsa0?BMIgY~}K7e7?Vi#lhR< z;>nL6pRas+KmR^?ZG9}b`OdWGcO@M6R)!=({_&bh#~0Nv*&AB+nEE;mKkfVZf$j{d zF~xUZOR;w{PB%I5S~@u(w!L)QX!dBA%lr0lO@lx;{qb>daxz|e#ryU&m!zCdUexXP z_L~nrmvlOj<%Xr;E!B)dGO2>mIeq)}?DP9pCtiqt1yY}|Ry)v-oJ;>Fm64J1>D{Rc zN0a%8tpJ~TLn><}TpZ9xnZh9XI+~Kvp9;~7Yc7E?r3{8~c@mP+p zm${<@ORe%65YO$_L#=we1>$e7=X2l;cl>*oeyMz)H#-}1a&*%rNk@e{mJR;fpC8xv zlyevPR8Mpw#LpWk0leSFd*yU~x<7ccW=^FR#6uvwD*@do0gF`{eG`KoVZ3gv_U`&d ztjNZm#QtXOFmN6mQ7hS^Ll+{8W6J5j1 zS%x3CIz{4Zk!k$l-JV59UxP|9JE3$#%GV=EfD!sCg$f;dz=vLPg zEb)$(OuYh%B?5B1FzJYc+V_Nt;B*XW6Tw5peOi%OMdyXsb4w`^2KajA=i~aA8q^`d z0&=0};SzI{kYndgy4+r+Z6%-8TL3B=I#7OIHGvet;CsM{IVtTnHX%(lgpAOau1suh^kPMdw++?-4w z-9H=q`-$%6-W)8Ld&f+WP5QR6ZxL8IKmV@c#f}GHF#Wi2xm~*3?PR2L%^UxQQn5ad|Hmq1Kj!sPhN!gQ zrgdFCBW^v3YDwW(dCG~og{cYWSejP4tQ%dGw3}%?V|J{PM!IZVxeXz^PjS5yE}-p& zwqco=-$g!;2&K84QyLyuHBrk$Ub@S-z?jrTcgphAL=VdLR7H2n;#5Ts%I4Hx?vz?| z2eN*z+Lym4i__GFGlMQ2I-gZl<`vh`s^?0VB7dS(|F4prbeazSo*m{Ql(w}lqQT7t z3?gU6B)XEP#m2glm&GW$k|)K=x{_aW)*V}vYMj6zFNf-gWVZYyy4IrskuuZmN8 zN1&w<{Y~5Lxi~`l(JvaGPA|zM&MIt3v`Bfq&x2ZX?iZp`V>Qt!eCB8pn zFe}}g=B&YtfsgVgNs*88Hp!8%{5naJul#qCC13eXQj2OLjcPq5k54>qd3QBW0Ebmx zGg*UY0im&1YIg9VFMahOCtW|CNoti z<*43^#^fsAi+;+jYD&0*ySDlZvZvh!hCxQhR=3g|?(`3CU`GNIDpaqM`h6~440-xS ziJG_GY@Reo&^>1HZSe7S&52oM&Yrt|i+Urd{U+j(`Fs&dO;>-)HvUQ_)$6*>wIhp} zHt{)S?HdIr=xug6cXX?DhZp!m|5}-m9u-)Li5?cX5bMW1hJ80(X*?5Pc?hu%K1wsaqaDWSdA=F)--Ni%85{YA`Vgp^Hf3{*$iy|^X3wa2EU6f4G%q?y!^q*4<|TRlijasAtAnf*m5Wcdu3 zArcnSvIdM;O7dx0Ka){P@)%G0x8q=P;xUoa2~Le@eYX zfi|I*yFR`%C8=a{EdAB81&rkzF-Or^O40 z2{vf#3fUz?CRss-shcmVc^gTjOkukQGh5st+Uz=GhL(uanYk_UJ&mbM6@PLs!f@GEm#*UkQlQVg45D9yyLP1Co#}-04veclL9L- z(6a!~(bHGxjYPrPeBP<#{+hAzTv{};OuM|$`SqoBwZVIqBeWHk+`zihWo+33ebs$F z!Y;lgvaA6sGg&@8WQ+u*w4@OuRMKo(R{uV8Ngh3D3?-$sgb@{#(rj8rf3IzXe# zGqb6bK@0O?%}`oL{%d(TwrOHD_Pe$U1QWg-+j?bWtoX60$77HMT>7xK6-nytlE7#Pu0cDW`)dS{dA^ zm};u#f`=1*gB`X+U^$X%F@2;IuIk{dmOm;9xCY@tRzg-dp%1mRX_p13nLZR)iLpKv zScxkZUuPMvMHFzSe^Y{YR+CDhBC@&qs#aWQ^*}cJA+&wi{$5+Bl@((cadqgSy)oI! zfCAs6$6l|`=6v_`S$I})_-|rtxqN{FoV(qK`MgC(m1tZc4yuS*OWznjwA6q;2`me# zM0s25{ndJWbrOkNL%Bkv-&QD}eP>i)WJdaqU?uwcl3*o<`j%j#E~Jun12!Gar-(*v z<*gmyP`KSc`4aFb#AD9_+-xRG@Xll{PV3W+&+5GbCOT8m(MT^F3{Q1Y+=LByayC>G zK|EoEj0U*1k8eD=J9OiQrt6;~diFt?G~#CHE%Nba#x}q~m^q>)Fz=0Fq^EjOV5CQT z%fJeoNU2*_E5w=_IlzsAykayw{fD}~1eLkiZa#vovuwjnMJlI_{VbzbP%?L|CT4-H z0S4vXicb|nX@+l&+BBQ~tQ1SZ$v{L-WW!2gq{jqK zVxk8HR{G0VX<^9e%}&i61KRf1I+T<9lCNsGpjFMrnjclGwKfxpZp=&p+78t^%zibU z{;(QnxL0uFQo^4k(Zb(=wmal5zk;FZcA{$;&Jb@m=Rg+%JofWa>P<#?__)M2aR&TD)e;3X-_<$y;UE;dfgjzKzg4=z(rhw{^87dFBRNzi zz`I`J)`4dgzch)1vH!}Di>P{b2^n}+_!Tjc4OUQA6RPNo!X4?0{P#bN$RA*zNh96L zS%W{h@H2krW8!Dr=7ZvE{?13m*SyV##joJxNnW3e>h3SRKC{=dYJ>#S%dbJly z8e*4CQd&}$MONtG#oS~~6i!Pckc?@SdAXd_yGq=j^xG+M>>7kJV`)o|w?b`Tw74LM z;eBTkf}=R_XN03T@`r*e+xMq}D?9X8X3K8^_91T)UD0HD7JJdT>WA?H24(hIl5yYv z+IcV(s(dXb{ra0+p>7wjEQsam^iWA8g7r-*%MbUT9r11-Njm@ndZNVV@u8_|{Wd1d zFLS-)czT-@4Y&*J{4BG|d$+w!s*NfVt}MSuIZsPK;(!d9LDm4>U{V9yS`X(;*>xf&cWD^HPw`Phwm6|GC*Lq zN=w1Y$TD1UAu+khPZH(1$@%W`OE_Z_#3b_@bHAU%vrDi?%Xu6M=e*}d zKPWM@wQNx(4whDs_$d14vv_wom1Yt*Nj6C@*jo&7eM3L;*)%@PaIhr0`>o&f2^i8$(ZF{=f;`{OW=6!o;d;1a=wIuOd=jHto{irP$Kbj8Q ztkn#d*e1GZTnZ;~QW%1Nf{Xk+qx!`Mfg(ZV#i~9pJc#fb4l%IA`e<)+Lr`p1KTlSx zS3IC(y$J!5>pML~&^`vH_^vEi&B;~-qqt|nKU#Jf#p4nJNG^@HS8;MEfS;BgXW-={ zX@CmxfH;{(Px5S{!feVKU%utTgDPB_mca`r5fRhc!`AG&% z*lo`Hy)SMSZ^v76vcICkz>~E}qKTP8X!_-35!XHGbC2kfrH~34B;8goRoP& ziGHbhU%m_WZ2aOKAeVPRtUZ8QI03bC{cU7>$KY_!Z#%rgIOnr#=!+ zs4zR}$OVtmsOM59iM*ckTR#jXKt|h<86i&MkW&`+g@{{DhTCTZJ{_mB{c3#iX6@k7 zo3?kZi7l;b?9r67-voi@T%MJel&~joy~G7)UtOC9j6LfM>;rsQP|NheAxVZA{L#J# zKpqk1Sa)LVuXCZ&5EqoZ%YUxP1!r&XK>;o?4ET_&h7qt^9j|~B&SNwTI3)ppKybm{ z8+!@0r=As*^#xw$gtMpiq5#+U0xPLu!0zFopnx0p%scoiZ3}UXaKY7Ow)dxD9px3V zBXp+30^@Mvj|t2=_;(MYiXO6qqYecue{;s!j((Mg8;b?5+OMVmG0ag$2{$O&y$2w6 z8s;ePf}@Y?6QV&hB_QNLusHfPl|H`YppHF&%H?kpEDmrjU*n~$I{0G;2yvKm!qtU# z^+%%~ZOc04nUbSAiIYz*jLFKO>fd7xZZ6xVkIGW(!G8po2!Ry%>zwzcp9PVgB{C!l z)#n$qOE9y=9GcFqb7yD?JB^#$qE1#9w#zWPVNZ6?wdWs19Z2-r+2C$XS=uZGftPGJ zJ|o3~p#5(tZ1xmvbBhVkwA}^&dTaU_Tj1$^NP2i-T~6zm@fw%( zM1r0k998rnll|VG9^ALJ`O1O9+|rot}p43>x4o1AG%qx6D(v>;7d5y zUu!^G)O|ZA>Tn}oej$%?fN?de>8l~jxYU(y+6XBfSgR&M?AU#Uu8bE zngtA@mermnQa}WH38f0;4HHC9KqcKLA5WsOP`vP(u;)*Il(@8E*#!vWg{1PGCqKZs z{Lh&5<>OuKajv{L5l#@?I!by^5fAPPeh@b5Kb9#iq8jR~$jXSdHj^NpR%V4UM2w9Z z^5FLW@FnVoh&UkAXiJt__G91iSA?Tz*ROf3g;jM>_Ny!*3BE`mSg8J2^4504GLvg(?{iew~m>3Sq;{>>+Or@84w08yBSS6vBk(wcw2AZ$C{FOXBhN!?#g=sJJPbRxNCs2 zi{5>ou=29b$*+|&V{!bvo5fItg2Fu)76b&E{C;4`80S$t^oVnvYBj({e?yM`RuDs9 zL%`X>QG;{TbR}iACuNn|G_2N>i{^}pza1@rrDJZ2_o9i;r>Ub{6%2?Y-fun61JUx8iyQ?Njyt~?ngvsJ5 z_9+x7xUwTZD!8%}KP-6BPYyTr4ny7N??@A_1<%UC>9AKQ*djq7?n~ct~69Tc?LrQ@lp*(c_lIv13PJDRJ?hN zya{AnhO%@${{(~#-1opNl9QjE=7M%D~mE^19t&hc&mnM8{294-S z_Zo>(vVynrQV1bRiQvFqHihND3!IYDxiUVNOx(v(+w$Fyc^cFUIiBo?b2%AF(T7ia znDby@Nsd2{{~{2PaFMq2+`RL=tl*-dkZ27Q1-x0xi>Pa9n&(Koa@$`Lu58!e60U5= zKbbLUFL9~z!l4{rDc7QR-s}?hn2Km@-kWo{t!SW?2+RA3$wpKT9azPivL|OsyXS;; z^g2^C@gWoM(jnWH5_jo@);{;alGi%GE^Lx3eBlWS|Gd3sj`PHR=_JOMx5s3^O?)us z=pWXi(^F@g_3!{4U1Qs0o)qmHPz3xcBJ~V@7d4>1(RzFP0vgFPRm^+Tq}m6Vf+If! zxPT)+09f8fy|0P9Nadw{IZR=PML(A5iOnKHV*9@7n+RW9uI~C67x(zd?PVH9jX;WJ z3%+v}#9M$at;*wWTuuIs+!GMVd<%@GCWxfIo>U-h<&FRn1MusTygNuAEhc3~pJr!YtkRwjpE_ecAWKib#VA%FVE2X`-=3=^ARU5lrCitR;) za|iY#12o_`FxTY1L0#ED2y>^=eq9LYS}DK|auVFXN&9g+cnh_4ez{XnMw#N^hyuhM zGFm)pIFH>?4r%m%+Qh5-0l`ah)-)Hx4yIV)6?|?!f-JL}nMSxR0B8qiCyHDnKV2VJ zzY*e8`7woDMOSH1_|3bCE_{o=WdonjRL|YR96-2a!jV6OjBR66#V3%y$)EKCO?7hr z=a^ACa)w#^OjDhCzeh_P6|g&i;dT_kq@ zleEu$M6d8q-V;6FZ9MiYrdGFe4Qeq?Xk41338FDkwolvb_`7%pj2Bb@J-a+avJpsG zzm%zcW~f_PI53KHCfRtC6lCZKCt#F01C@2!3tG2`fOghdvLWh@4iJL*Fw1VSFCCLX?A=k91&5cpyl~o z3z5u;Bt4W7lYb97&Y6$?3ldMU-F5E4=!ru~;c@WWW+&*+Wf%7)Vdc5gUY|Y%rU=71 zb6d2@$-;IK=GN##)!B8Hy-NR~tu7DkATi4?5=G98mTSe03ub{U|y-=KG;sbof6XsUpav@&Y9XxgXF z$P{ymGPlK?tS)GGVQ!5$v@O@}w0Pwx6b*VBr+6fj_=hSP|BEUaXbRM?5Zpvj9v-a2 zx*o038Evko8R6!u=C(+a|CZ@2XtyiV?$o+-@+N-?a$kXVvRR^8*DC)GVZbM7$1^R~ zT26v^w33Htt8%vT|Sc~(aZ;ski9GC=I& z26K=>y$Nj;E2hE5NKi-%8Yx00{Ys1OH)bX)rRRvDq>vUcl7v$Fl@{5r%v@4J&k|$t zHyw^WxGdz$XEx!4JKcM_drA3&tf7-#kUfea8*BQ0rMVb&eCWcnJ#Uu*#0t<@NKg?f zF+M&zu!w}DlmHWwl0sZoP!dWpK0Y!qnWU(M02R`LLR?0$1*Hg5;M~?GyS5PdX7eE% zd}@h&Q&JRhCx2%~3$!EL{@*^vKYLyC=*)=2;lomswuEEyCfn%dM}WXrM1z1QS&fQHaDIl*}eJn%m+{(iF6tFtg3`T5o7Ow)qb9?;!BpbJNr61z_CO)E_7P!LEA znj#C7ta`HA@MN~vm@&mIB*ov4*ia)XMgI3k_$LJyD%-X>W}U(esb*T#0n;-Re0bQj zZzE2;YKlf&bc(I4x^9n>IMceHG7#oNClO?z@A^mak&s;E-+z<#l?3nkJHnOy*PKF` zjlIjEdb6jl#%sA-4{a}4Dzh!M2{*w5uMz2p;hSZvq=7x2kO2}@&@Bd3k|IXdu>99P z*yaxH-$wyEV)#0xpl6TErzZ{R^MDB{r+}FnU{(s8;Jn=Z>+aI6s>2Y+!fb&V0%?K8sy*)8!A~L@d zBx9~bs4u)g*MQbclSItu95h(--WETv`q#=t(;?sts|o%lMNPNoJpAv}lJXJ3LX^Ap z^x2td7vM&zJ!U{uv$!63U!Tv|qU9NDZ0!PISWvmuLVaG~RHa%d8gf~xuN46Nsbs6T z&_%smhc2CH(7hpr?=GE3h?pwLJ?S}UmOVcuc(Mk;$%>sf8612K0}EVT3ku&&>dQoy zlE;5!QSqlNMVldOUT4jLs7Jun{wt3DDy3hfb%1Ie>S)L9-nYabw@moL=^aa}QMd|c zyw)e97#$3l$Il9HvbNO|jzG7#{hi|#gWjbZp)rxHw>S4-s3(weZ?Zc0nlPQVg%q3^ zQ`x{{+_UO(8i7XUTL6#8OKsLDJxUKMS!2+DbAb=WMxtDS_aiJ#cK)yTR_nr%TaYWv zx*Voe1Q^NMLWJOLPCY;Gi=MJ6#O`UDVSm#cyy+TLYVo-xK>JLZmuGqnZ6bos!%n-= zls)dSqA}*_GflV$0_W?W>hO38v=c_jjuf~U7^JNF)HHI$_%q7NaDXfSM+h%kO+DY= zIl61njdNNI&bKwF6ytkrhxWm=Di8Bg+4}0)NDklz6i6iQCmpTmUgqtaw>MA6AhKGN z#v^}xf)k|79EX?g(=TJ$w&RO{E1+_Mz0SNnR}6DI&Kn$Ae1Udm>u6Dl|wKuJ8|9iNU%;T~+3=Z1*Gs>Dx@02;rm)#|Q3{ z`(c*!p-f9gHVeC?W`At!b30bkBtk%Fd}d4$B6whs2;a^K_rL^eMh?cXi#er#VR^|7 z2~Pe?Z4cs`an(PG^eY)0H;7pf?=vP10~~~siGRYfqlPaLu7VSv-i~f}@#07L?T5Sm zeW*Viv=%gbFy8ng9Svvz5ZZXw({1~aq3f-7I0WbHAwsk&a$v}v>}O3}2>VQ#;nB_w zWt4V9 zz<;xI0Xvj0?+KLbU^&K-_jie1!I2jVthf=ZSkRJf`(UoXC(0G&KRY$gvN|ig-vuFr zBSGYu-Xg!&{kui|>pcsr#=Y9EX5|~TO--tol-8k!BehNOQSlh)wRut;`7hQ2M}7(r z^}lAciXe)ud!g>*zS0TC46&5I=5@|2iKw{m9o;jZszQ5hRA2H$hP&DxES};$}--WP;P~PaU z&s{&Q-!iWn9`MAJB*wa9#aJV8q#?+OI`Wr;o~|x>bC7MeV8pJs*d#@_i2Ne7SxbBe zwDH2oy=f7_v|T$b8dzhEs>E)v%gpXbheyg-V!I*h)4$^3JaRLV$Nfb@vx0xKyDs2; z>ESSChh*=)ALZmQ37Hx8pYtq_6&tb?D_fhM<-?zhCMTdG%8)w!i3F zy;UZo`0)Q>@w=!LD2bt(?gMi! z0c*}76YGLc*DxFY;|<=H_j%WFSg_7r+%vbcxb^vZ;${{MT){>#y6w>?eC0 zJZfMRuG8-E_+KQjHTRFDS{Q`7VsOknl%t{UZbphfR4;4J#@QD)%s2Ok&b?jy>f|pb z85{ocPv3JV0$>zF9wv6RFcn6|1Q9W%4=*Wz1;F=T1oLiJZL1pC{U18|C$ZQ27{H9f#JMD z@p3Zc7`$d9Ck(?_soq0UzeTPaf?WO$V(kLd!V9>T<)4D9*_9mF zY5ZA%yTNt%tEXvQ^QohpTXnn4qKjpJgT$N&<2q38Re5{3l&>dvGM!yf4=#iOdTL25 z2s&XNi+gt-_mCl#jj?8kWB&ZXJI0T0;W^6J4IVH5>_D9lUo8E0JWlodAR4iu)h0s& zM{oj59|Y_C7grojfo|Z6Z z0-`nk4MyaqWRya5@Wyo*BDN3Sl*9$I&-IHi*8DnnHWcQL-(*O{?-M^{43t?!1si22 z1|14_&+Re-Ihq8QB5}j)lRbhWw#`aN{KCR-G6XW62Vqibi`H$l^-(E& zSQ1#et5cD0=nr7uHFXhM0AZ%3Hm%Fdj*+lUxk<@=gcE$UAd#FbAk`Em*x4Y*hB}iC z_L`Ham%h%7#3uuD0||Ay*gS-K%G?A~{(78M$ux`F{g|V=P8aA(xG2bANtsxN{n#lx z-z@^^%RXd)F?7pe23iC#Q0`5kF*eJi!3lLWFO9rGp@JK-+HI#BYEsSI_w*m9GGvMR zH$|NOUH!#Fp!p&D_5d?jo7KMAI=+D|r{m@M@7~pEPPDGqoC5c*2g`yttw}v_6r2AU zs!3-SF2POS%0<@u=qTvCTTQK`Zr|snabC+x;UM%qd}Q##ia8&be5YgrkdU)pA<6R! zuqvNR{$nId6CwNQ=}N$RcTXT(-kTeOAuT5G_{kMuL_MrGlCL%zF_8OM<7 zP`FwS72SsFs|~N@bl%tHQ|4bH07Zxn^*z7SZE$L3na8^ z6Hk~0eEg$8?|m{GOZXY?!@{hhOD#tIuuANjoT--b>j1>Ir@W%dG)2i>sqxe@x*9ES z)-vCR+Dg1^+t_7~cvas$r(2YOtZcDk@%Hhb|&6V2_@ia`$2f^1ZEcx3D$H z+3h+D4i(f(;J-@1IboVvZn2kgj9%({)hYL&&c3@HZvM6;DmL2uZq^+L8cKrbga2U| zI@Rvkf;Fm6>1DXwq+C+}9sp=P&vJ`_ygsCf-WbKOM}85fRyZ!-XZ}438wO8y9gCqL ze3@m&7PZF|++y@Xo-N$SNXXe9#S&f$N^hz!*bL)e7X;2RIILZG2JqPL7-3b(8{ncK zMwYI2)6BDgfEcQKK7bfi4R65H3m`)A+Y&GWR88(&@2(DbtYxNh$Dj>ZI`-}z3!?PP zaZlJ(_Q>2>JM;g&;NXNf$hl}us%Cp%=d7I%X{cta=_@2F5TxxDb@(PBtT${?&c41C zqODGdf_RQ0R%aHxQ&2~M74yM--LOZ9zACs&h^|S+x!(^JsvDSo5}Ue`YphNjoQ|w? z_jTOOy&+N{9Vmj)46*chV;}OBLoG{7cbn7^ zaZa>rCYz@zgw^uDUN`E}dR^o8DKzYv#RL=b_}~*p#Lz(Q?zX_&o#~R)JCG70iA#`l z?BUFllQ=Aqhp+A9kpy&};k|%JI(XL@{e!{1Bnc?3U-NQd@|1y3?Dg@2PvobJJ#vvo zT46~1I$kExSDKUThryZ)B5BYi2~f0_2nSK*`fWr)7{Mj6O?az{)L+%p+8jKRl{uTu8QbnpE1+GCD>nwVs-FUOV+3NB0 z{(OEuoZL9PhexkX+V8x3y`r0Hy*3h(zHMA<3`hXWot1iO=-bc#Yb36(kq-YJiOQOE zCqprd0&-dVFUtGLe3+!3=Zo@gO9GK5)sj%ZBJArp5SHkrt@=w=Df1sBajXYwol%Y_ zdFmHQeBiIqKrNP~T_KjTI__~)U?11{09BhKw$VHaEF3A|L4eTWD0^3mECwHJ!3>`4 z!2e&%u0Q8ae2JwH4!6fDe223Q7>@w6_eHb9KTR~BtogZQ1|V#^HO=EXmcMi3EqjAn znQAo7sJ}}pEeC5ZMCn{D9Qu1ApE+@Ney_7}{8vf(DZ_rg_d1m4LL6!nwP+|EYXQ3Z zTuMve&k%-(a@+LHAI$`Qsyi7%KT$z7SSN4@%s$#Q)PJ z{-H*CX5`?lwXICR*(}w$-yAgqw^SM(|7F}%6o?H8QHXLn3=Z?RzOY037U_B(cTSijp z4i*zcvT3`ez*h`shLiSzMLoe=*M+b2f<>`IfBlL1`r{5}U1w>0r?wFlom1CSF1!xV zXxi-*)N{v@e3kK#3b(gS?*seZxp_v}nd_?{vL*srU6^$R*~r7Zb%19b5xu!(8WBmBJDJ|6y-ubSW1Ic{B%ww$`cb(khNiGv~%fd&`hRA z7`wGATEvGez$wg(MXfmh>K2SWW(J^N5@k zXXd5Orj^*!fE?8y>trVpc8+_bukOhEz_E1?*5@(KPU$?S_#0$ua_G3<1uaa*V3CH+By+^uI&g!S6;f5>vE+JA|JWq zoOGcW0Vpq(Rgi&8_xq+frRCRq$hs4IW61k~h8<&Fvj`u%q01wzjBy^a+n__vUG95p z^J69ZKzhTxtKuk)dnWtM2`MrU=ghvBRyiLnXZFy22$6Th+4p zvov2Yhk{AFTz+rQ|BAzlWV&8%y(=_eT*O<17;KA6iq>5fQJ*!!BeLvM?2gkMghOt2 z)|s(QZEwypo;IKmt&u6St49lb(;O6puavDov$~(G6*+-sah#%&L-D1N)a#&Jv_Kzc z&C!d}JabMeRf;kR9j_I^@z4*>2YYIF0iCK9{l>?VI9Ywh@5^HO&Q>t*Hybw=ZrE`W zX_+s=mq8qPLWUz5fc8!iPf=1C{i{%f&yytbgsN~};-#gD{GOja^|U%F`joKI;`sTI zC2)CMCk|qr_`xc2%!7E7An}}!XY4)M<_e^sZ>8rNfZW%DhH6#v6P226C8d%KU6Pn) zDHD0$;37@j!<-I*Ca!%Q-1GM`BIfV-o)72Gr|Zk}beR4Q%swSECedj56HBTRTs7lH zRbwVSai zxJ=H2s}H3z21p^aQeZmIk{H=VT$=s_;%D$j@kGc2q$oD(2~)Ymu}iyxRaSwUnB>;P z2P(=0_a6x>{eK>enbk(>Z=g_8jD9}tgexU&pAWa@wVo-3Tb zC1g?4G6f?a5zsllIwN>l)sv8l=;Nxitc{C1Bk1cV0M{u@1b(x=U;xb!s-pe5n9Zm~ zUy-)KLBmkgq}&liZI%ybh=4mM5Ew$**{|8hp3xWy@l)G1n*iesAQL6J;8&mqXlYgb zk7^5eZ8#jozz_lYj-RUZ9Y)PY7wkkBPG2;We$}mmB7LMJG#Gm44sBy0XUTZ5RFJu| zMW;Z>Jg(EbXCTS>?ZY*dUDcJjYhqe3Y5o{zL)K7rSt>^w}kasxbTyE{>UT_BaW&nU`86}at z>YT_Cz08dVx^ijO2g9}lMF`exG#@GsiHRe1sGQA zizRI%wa}a`3$UtxDSmoRB9Jv57k0>T4SDJ~f~BgmSUwJuMEc?GW7ztOr$P=yDu_hc zBAX;UsPJZlyYf&<5UsIQ&b&gE>##4T&QN0Pj6woLN^1326A9X-ECx2OrEC2;i!8#V zz__YS_H3jns%mQNDaGs?-)>DaTkQJS2WlTSwJYChc=&&6pl8t5-Nh_8whIdV-7-S?pw9SA>`> zShm|8(iPD23ZBE`le#zg2sU5xHKfI(GgdvuDk4@?IM@nKXLq!|i9ObfG_L-t3 z5v^yYk375*nIOf~is06+VU;S8GBc6jd5~_`dBOwld@qYT#0|X(*`UBp+ zQ#yJ9F+BfBfwUG}2zo`?+FoZTD_tA0 zd*QK(O;OXYxuH`(r$t$?Ut!v~Fsh%G)WV5rha-EPqKlhzV&|V2L~7fA%b_`b6T~N8 zuX33a3|qTHv{jc*n0IDXoEu1P-;Vh?Lze~o9@wXuJLt^5ON5;1P)g2QSWuq)JB8-E z8oW|Ts2d(I3-bGQj@e0hv+Wc<9Oq9n@%1W|xKN`soY=AvkaD5G`4tF7c%@6UE?mqG zP`d#NZJcIssO|75=|oNviR>7XdwLUEccDb|bpvEtHT>wsj$I&bL*k@jxL71?jZ>Wg z6V_nrg-xIsrJoA@C_qT~>C7(T8Hw*dje%0ge${2CrVbpx4}}f@)zM{P@gSb0vt-5x zvSj|itXlC_*fzzd{iw?9E-kPo^B&QW9}J^Ul6A4l>nS{v%?--TzI=1M=<2!9A1_(T zEXvIO;8ASBQ7Y+J0mgJbF=gYz^-Y^OHuyyk%g>++w49lzdwTcukl2$JcaJF?v~%?hvHOTeFDidtiFl8VJP*snTN@)5To(oeXSC-m zM(BbKz-kn%?p_zpXGJ5)E^7nxD-kT#wIL2>d^qh5X@h3B27ws%{Bc|Gq22xERT#?j zwizxJMyxxpM;aTeQ7d-S5`K9C-lkdHk2AY_F{;nO=A__xD|y=SiJMQ169a1X6uok7 z%T50U8AXbI=D%LX+Me|2urM;)td)%Xm7eC)N;e3yQBV<~L~@br8%c*v>o_Lq}l z$!lH0KZE0b$xG69Nu+~3WlqUV?HbG0{v(YI&L`2wErRCsWyBR@!xIT{X4NbW{eO!i zJJgkE$j_Eqh|4!9BZ9}R9J<;W?8O4t1nGBbfU>)vEsude+jJ0XeSYr(4k|`v=&AAW z1g6u8oG_>G!v7C71UFy7&$ZL!R9jYEen4~5P-;m)M%HfvTq7{sEK2Soa_-hrE@#o4 z_+qtRtB+Adf{?4HW$LOq5O!{o^sP%}$uTlH@fo!d@MPM2n`RGKDodLDaa{G$psQM86zovL3LJf=GDoPiA<)6hRPk#Kj-%=;Qug+qSGH_-!hDR4$}DzC-KiFK=?Vr&LezA)<6_T@uJ_DX{FPd z??z>c0jg2|8r!cGCM z!C3GTqLleRgohR!yljdPE_D+&|3M^~n`8pm{MsOh10{6E^Jr19^=UkM4og+QGWH%$ zz8!GzmXAUdK=FVlp#>GV)x+Zxx_ab{0R#GfH|B48x%ZBx!!C+?;Wu-xm29xD_!jaR zqIn6w;tmcavszLoq1w7v| zd$JJLPLbwYnoBZvfKjVikqlz_v4`-w(6qQG9|19h{;VT5^i506TJ*R>W5iowet~Yh z5}+B(rqbo3TU8x3-OJBfhfZ0Ael+M7jBb{|Lq(?t2_lKgp^5 z1k;V(gZ+vs#b{Zcet`;1M7Oppk5ipg)1viekaZm-Z%tysYWeOzUTP4|{CA0fc<{Yj zm5ldFb81AZ0f>MHv}u?z#dq z`kvuPUqtUCNPjaVPa8PZ6_Ms3u?}m?TEuKKTmH|pb4eap0q?T&2!P7n5qR^1d1~fS zw7!M~>R=~^2mXe8R!)yaje?a9554#J8=6=s?^McSk7OFyg`tA{)gG)^G$_zb64ffm z*a_mYm}+`H0E}0mF-l+!t-@08+lSLO21w)^#VS%b!)uvct!k5=5xi2{&i3|e zo@S6IQ5yeo!%(_TooY&||7MxWRX*2?#RuPLq&GeFyG4VY=8x4eCEaqddHwFCNTjP} z!FRA^@K^7u2N0O**hP!t%!cpkpO7u@qWJ`g&ZOC+RB+ z4){FcxR|J=b6zSbp~(B}XfwG(;EIOjNxu9S%SDv_iCPX3x%<+hc8nn!!aGv0@pFE% z+%$c1Dgu5zho}WQeutw@>(mW8ZMvL5=PKkVvz$4*-pWW+Vnw_@UvKt_zc5KkDu9-q zjH4u@g42&eqo4y!3j{c8;0U42kt0Q*(>(oHhQpPc7nV&^hWVSM!Ki1FpO4P^(@ptc zCLSpb?cgyU-V!&foXWqKQQ(11w)92htn@tmp_S;^Piga4<%`G%@v+||_wVz;OEh-n z8u>QUAnc#AZh~~f0o;!JsGgWQ<*%~|)(H-B{iznolqgEflAlx!phZQ=Q!EgoSz(I({+Th6{eNixPb(W)aCVB9?`Fi@P)KY8 z&?5MM2&=(_>|_t(%(vxJ=#>eP3gHw?Y@v~|RD2mGU}^V7&m*Uq8p>r(^0TfXr;i-^ zB&cPT%S_CZ&ai%r=$gFbTSOp@X=_X?7}SH~CyRWQ;ad)yYmUEUvZ2@^I>;;jTgs;O zA%$j)x=JIU`7v-Gt9xmyWoUk$SY@F-+&VYy3~-Xt?dO!@$@wle=| zy9w6z6qoH0*l@bYmKB%Ka#)h^4B?UC;U3KJpAjFymZ~E&q2)@IY|@B987o3(xq?JF z&60;!3y|?Cy*R8gqE8_$CvkZzR37`bMGxt02_40MxUXWK^bWQarCORCg_>zNp0pB} z^b|h|Ra@0&I=6H(%%4**l5LicZnOx!{3qU=EhbSjTL53U! z9_Gvsuo|*Qw?8$_BpexcRx62_QZ)4Ebe{{A|N7*EyL_(=3GB5tPr}0fF+x;0piA(Z zz#EPwRkGgAxvUO876A-tR$wj_KMB@!38qwNMEHidek&^sNOpIt7&FCu9|M$Pf1}WD zZJu2!l>VG%L?d&hv>sRq9$AB)v!$6MAN!wRVqn2QrL3uvl^WD)701;1RMhbre}K_W zY-|X~smY)BUPA1^gH-_z_opLL(je>)`7%OEAym&XIl&*yjr?l`Z|(jmBXH!>P6n0# zBL@uuJ58>aQ9Wl)>a4n8>hRmV?y=2v{wHt@1{Z_yPv+lA5YCq3a#_%6a#`})YT#L` z{7>Zn!)0HEuHc8$Hfd7+rb8KPYM71{F;k2B&oH?xur}i2pVORD|C4#1DNkD+7$NIS zyF_5y60%DFdE$}w;pkWSCuhs1qJ1VvFf&`cFGPtuTu%xL`+RxbWH(vy&Gk&1wC70! zf5fF$`uOAkxxOSB!Zd@lfKcC@)QSi4&C!C+c{;lTEO5uzQ*-rZshx+1N2%(gi#dvZ zJs`j8yXMJJFvaU}DCz~dDQC=54K7I*`6-*@Rz>L%I0l~Pd!A!}CD~=-QQ!tcL#FQS++WmP!GS+EVxh!=SY~SfL zC%S-_WEiC?u-LJ~j)%%XL^A0-y?UlN+JjHa5;wxs7Px=lP4WxY3I3)cne>27FI_wp ziFlUp`4tl^bqE_ztRUSj68^|-G?*&Ya+dp-9!H#+(;?hCb=2@ML$N1|UCgN-E3PGh zRYG}aHMv?A1W@z&1@KVOM?e0GJ%il7e9_VAE%xEuP-3}39Y))QQ~^gBXh3a*aZ7;Z zN()#e!+XXGmGG!8<03{Lfp<89WZxw=6f5!{=`49^ zFmhq`wj=}AIPY=mR=RslD+XspI^%<3bN zt@wixj9rUh?Bc(~_uMpu=aoLn@~WO?%`m8@>7X3WQyDZ-D>LRgkR__~tej;92p<;# zIXgPLv>(`D(jDN$8U>_YEV(=tV^x28G>5UX|NSdWVf$hcnnmX2$iaKQZ?2%|? z>af(yz`rjfQW9{<2}>Qn*G-KWsgGJiKqR{E=FFmq)LK80H6Q(N@xzljILc_6CqZNX zh;#~N2U+mV<|vg?hr0Gz>6r)ak?FJF0>cuk$Eo4LAXho-hJfIM#cA~E6o*Ay8xRlM6HcCGr;^&N7!OY-M{6c| zfvIl49|vY$stBhg`*|+_wukz5czBXVIs`3GO;Lp=zdYVjAq1RqgF}}UC#AL_&nYd} z8`H+1$mv0nWo9i0%zhS(NrSlH?zbLL$8NE+-OkR8_7(*%^($@Bl2l$y_5K?vz+v;l z)p(d?zF7JU_2KYvF3k}1iO@M_l*4oieN{_g2|6a-GWEKakr5msqN1h4uP<23M>i!% z{y}|k@Vt;z1;^7lPDU;qt_b(+$Jbfc|HVN*V}_q3G~hD$X=rXTNijto6+}QAEy7^NxeoRK7lQvlXli&^ zoMs5Tp=7Vt;vgWBqE#S@c@9~`r}7NMDv=LJN`h2~htKI<$hoJSiB&%_w#F8Tg!Q?UHNc(YwBoj_4arPl4Sm9Ykj$2d7IgI zwR{kG_3-|1sPoqqvPAPs;(UDD=vY6!u)FZT`>4^pSoc9*XnVRXIeD4jMtx!)Cw`}C zn!uU)iyH1*`*N~jx889XLz`>}jkZ!_Hv7KG+7WaxJGP83(uQ}5z{p-ZYJ*B z_~D9W#NO|{q0{$uWq*I`;O%T-kZ7s+!{g~-4E1(nY_GH3|IMr2@9p_6hx@~?sZp~) z=mFBUz14qlZEd=ddv^aa?NuAU&z}~>;qqeox)6vb> zz44`^rM2VZ?kuX;*LTVHrfGJ2fz#Q69yA&A_jfs`%ZV zJ%ec>W9-$Zwds3~qQ=U(&u{JKnk@WC0Om;1 z0lkUpmOZmu%``xdR4)E&&aGzdk=-L4Zd8TepEdo=hq{Nlo8j%Z?{OZJY@U>4czLRu292GgB?C@_ODhJ>ek-x_usFcf1S4bUb!~< zuNrJj!>t++n+3ha<-F2i=2r6$v~ss9<~*+Wx4)mAoCx{6(hK~u6MQCV@xLPl{Zhd{}9PCmBO!zK<_frY&*I{mr)dnKJ;C6j*V z6Q$u{GMtR(GJN?09VGcth@vI$1vKhlfeO8_%9cwczM>4xcXa&KwyP_tg{$mH8Bw&M z5+_vmpzme<`ppb9EYW+M9i+5A+&kpm2*mwu{Me63x73xo6>8wemu5JGX%&Re+Lxjg z;L6p@UDbm8{}2#Z-*v~_iHpI+l_h%p=;=$y4m(Zea+*mhm;C$lls!;^^#q#bBRMB# z7!#W`tSE??SETL{wpmoX~0${rn zHfCtNxOm78!4K*xXV=lq8q*dU{f+bW<#0;C0_9j8*8Zr*erYx4wkDW67vSF%N)q$D zDFJIQj4!y{Bh~I^zj@4^i?p%W!%e>?vgmS4s5ENfRU29@9^>WTzKm=#u+)LO9^0c_ zmqO316px$%m{A?|C}&D^hxZ%phwIXgFqBX&)IenG#T>d7G`sz-@IL{TN?qn^!61FQo8F~!eC}RCXL|@SVeS1lfIs5IUAJ?GG zw*2oD`&w;Tve8+lXx1qnK*&F;>TzMMap5c8r&3VK) z;c|0U7b+3QFtN(CjpWarmRX`;JK#Iz(aoaB##X>&aI@?{RCZUvRp4R%xek76gU};Y z6bSdU?K&a=&s(`md_L_ZG%&tYlv5;RpxFc5khsS?{X1yi!YmTj_8ST9CDfOFU=)-q zoyHpWsaL9zbUUOIZR(&LUB_$B?7Wz<2SOrI^@5Va*Y?V}`V{ysWE$5Q&>RvpSo5eS zo28dr>tEfq6#4>{!U@#|xWN1-)4+35Ufx`86(r=VUT4j0Fvq6(XH=bW$NR;L`42$3 zL)cW+#Ru>~)2#GjE{V^lLNAF@cmyHi(F%(FFwefZ4xGb@-uVB)=TN{ZoFb0ip8Ju)+`Vol}5h@)pE4h zpseYkWZmJh?QDkN(W;Bl0*u8HH)~J&C};jTYko6vNW^P4x|wOJk;ICzgj|I|xD`=< z41HI*SQj21+64cB0Zu^xtLofb2dJx+*N&ZVf3Sh2G@h@ z+v8fbR^dV-ib@yfm|xQ@VX52vd}kW3rUNZ{A+xNCtw!zO*fPE90o_{u>p@ds5tr&O ztSnb>b>$$PGziu!7;;+$7uSt4T7si zSZkS%5C@X}8FrV^lx@f`tf+MLvo6}74nU=`Hl88}u?DhQ@{e&M0MFU7rW$p4t>Y*T z>$oCT#wdFe*;EYAzU2Q12=Zi6pT#* z4P6cT7N$1BGQ41g_}VXnsOKVCdV-W5ukW;f^6lDZWAeRR@zm*q_0MR(FwiNKE4#IW zzn4d97-4=Idz_c>ukHBV%(W#0%7}WcWnI<1)wITKJ(R+NJFJtmovm*4j!|h{C;HhV zQloD5K-fN=J7td|8SFP}6_*WjI2+Jw*G|F}R;5y~e39BCDZyMc$HdvIT}8;%T4lKC zo1V(C85%9?)EQ6_6T|%ucDetzGiGOdD&GdT_OmMXV~8^t`ai8 zVOMd9M3*Vfgh5sKgKBlj2y2+QilQG+=hM-X&^%w4(X{vvv=17cCs0R$P)ZB6R@$rD z^H8BWurd_8$0B@8wV#A;&6QzyBj0Vp7+%n-$q4c-&nN?Ty%J62latjp7DVJ_%JCKz z;bsQoVAe;7T9t>i-bbRs+8WyXruZ6X$ZFg?V-ubB6h-7wYy9@c{rLEjcoT0gpOFRM zUg?@ILHdt-8$U_f(GNSL5U&?k!WY`oIEIH(U0rJ+$?P^oJZ?ZdegQklKbk&B+kDZS z`mA`2SnVX)1O(7fN~8KuEU(m{%vDg*^gA%vXTQn}V$%-n#=x*|tT=v!Vlt{bh|B)y zC*1MgEbU_Ov*Cd2bW$va(lm*ly(SXcB)=5?k)nGASSVK11)-!v!wGS*>+RYcntnDQ zRUi~PAa@}1Q7&Z6Y{-I_bj}wfYWOoVpC#~Mdp^VXKs<)4Ts)0%>-@E(JcOJY(`L|g z-xAYP)!SB#;}G*@eOcOb82ejoy9KsQ)5e=cU}a<)(6!h_M4%RC+bZSGhY0p&IG!Yq zN+#iUITw?YknduA=UkC5Ej?RnVv+zdWBQWJ+54&#TZcZvalQvhR9e%bsgmTbp*(Su z=1kC4i~&eV)@Qo=ZDneuihL zJ9>oYAJm>rV>Fc#a|i^B&JFI_>dNvZxCkfhsi0Y9wcf)!lh+w2-ygm?i7;gpP!I(* z_iAM&R*}_cadspw&6JrFc_mqOrtsFF}*E;K%J@lfVV_R)_L+4Zki)N0mBX~>><){YXw;p4tE zMKNl&1<4kN64OA*ykv|pP6GP+*&Fe@A`TK&h!E|DomE4k56+d^<l3 zU!>Qe26C0Vr1(GKA+rvw7(SV6!EU(`m62sV_fS`G7cWhx%AMOH6vtH$|u z7;o1WgX(UT9N!ocB_g0(@X3?d!@lWWfe!@m+{N(==;(=Rx~7l>46NTVY#`xdsQLyM zV~qW>G24+rI+3{hqG%v7)KmVeYrs~!HDy*JG9$eu*X!?IIUE0%&qg!D#dKr`y(3wR z-D#u0wwyW<0;PmW2)@g3mIQr2%AT}v8$Dd^84U^G@4J-tdp2Jnp|)Qn;a_rdSarTO`ZS9 zLW~P(Eh4M#s|%2@iOd{}ze%e3w)5@FmuBX`=C;O#c<11cuqX)6xQBPV1m|BFdt8tQ zUpBn@oPWC<=YDnUu0T1X`Z*z^rjWQZFC+5ft@gXA>2$G*r$;sICPGJqy4j(vNX$>87L&ZyTScjD~JM=894N|!Zq@F z#tKkz6*r>>-O~&N7-V)3)J_=RX~h_}daCF^(6X~jD9yJ)82mt=BMHo?__ACTmjS{Q zioBwPtlE7h&N2y0Q!zY#V987xOO(RF>~63$RyX6jStu}R|08+DmAPgmhaLixoXMPh zz9aD6m?>LiXqeq8g?))yR#AnYruAa`l|!GKSx-j5xgkzZgOS_mS3R4RC0nmCn@Y0_ z=5Xb62Nau!PR&T&!kA)KLATjqqU$+)N#qf)Zw7lm4_RKiVS|CACdtKcEZQPe&uNDY zc;97*w{OPTKRSKl2`Aeg8uKmoNofLXA;TEdakbZ%!ec<0R z0d`FVm(F&cUH3u%Mb&yINT1hPsoPyC@xJ&Cq9lQ{OH)V(WO}R%u|b_6@O1oenY;1YC2eRX83oE39j!@Pqh-}ZYBd2?Sh)> zSRY&0{*X;%Dm|L~MeH*BO&H14K4T(-=T4=i$v@`!NCP>!MQCwlc^2@3Ja7fnVP+`izvx?FlfngjYC*U}{s%I^e z+cs&a`}5&P1JBqW8z}J1_t4skd%_SZ1fa`~%64~Iyb1tH5IZN_w!Iv#l(3^FOQIX6 zBhKG&0X%Wv;z(L#ElIWVyw+7twpSnKjE33N!acj8m5H4|dBr6FA9d-l-`Z@&b+ba} zE*+2fPFvmN%%Rk+B{amzK_5&@rooxMch7qLFM4a8z^%5E6TgL;bz~eFtm8nL3=GL6 zv{=A99~OKtNU}_RT3w^?DU214#kiq^^c7zNKc0ZtBzp--Rc)^T8CB~OJ3!fv^oRBV zielV~Ks&~78PWsSGJuN_ZG;1IFvr+O{`dMm=Rqt*@zzd54_}{Mr}|<2Ea(}a2-+&D zoxMNELmd&(*r=C-NiL94Uz~qH3eR!w7k~aQx*Tf&nMvY-qnyW#?qLz_4Uy*RwI}$u zBA3C%A~(?>584R7Ck+V~l|$AhNu?1zxPV1IRoSd*2V6kycAZY;3U&a0b0) z3_`E|Hfqf^oXBQ6J!@F&c=Y3offSSK&ZGo9sFW)*3P4DnXA;?WHW%9;>oDXP%rd|; zZN$boYQ0@f7Q|kz4k~I2(X5QHvZ|sk?b4dbtg6@a=C;9y)x}3fbwB%zXgyA|viVh` z&yt2Kv$vc_d;b6w9A?oSGSGK?S5?*0O`0o;;%vyAL#R2?wko2~V7QiRAD=S~32WE! z=C^1DgR_uIN#JLQ z|9yUpiIbuGzZJs#zb^jEVr8nW#AdLd`PR((3;UUOL&J%XRS4&5msx45zd-66b7??Q ztTb0@yj&0wO4r+Jw)3+-de|L|diZ*uYaa|1Zs$*+bzCL%deK>}3z;3RER4v<7jPU| zPe*2(9s+4yT0KAPe5*~pRQQgB1N4h7GxFN!#g08A&$XmxWAMJCT2PosalDR5)uik~ z=e5ow`Uu_6lH!-mWwW*uqI2|9e4tlRv`G=_fCPa-f6f|8yvc|(oZHp$gHT3~TuKu1 z3GG#<>|XzvgI8M&#K1xE5_A+a?9^v=DV&sTj#fNpLH2JRkE$F2mEp*~Vt7|BWI zP2>`L*P)9>!Y@uv0nD_s5|UmZq6yNuLo&>0I>jJau{fz+$<*{g@NXNr-umWIrh zwua`WtV|Ae=0GI{DP#nKe>BK4(&8!*5Rfk5>i{?y@Rbc(z90Avq_c{YC`9%6mm}~S zXmb&H5eSIiafq*m;H3ir?;x$^3_gx_?%xX%IWI%r`PTKvPv#OVYLaxo!DeOo9o!CR}e(7!}K+zv=2KW zjg&NquQVd}l=Sag$42LVResBJ)p7ZGd3N*ayU@f3=*W4!ZdPl(E~kzhwcYKd8@+XG zzkTeR`1E+R5#Lr_X0b42P z4Lq+;5xe(8nIHD^8~P%k;8n_u?6=#+j}nC=tvxX;%@ z4M}<>g6{4N7uB-hARtUc;g^kFe&0Fd-g|KXQJIpfa0t@yk!f8;D%nkUIwxR_Z?Ah_ z_^#~QJC_f=(OEKGC(x*{cII#~i#{EJXz*beD3O)bb^h74dg09P>(h$3DCJnxWM;qg zvN9@){QEr~uSyi>k2K+FCV5QwttF||UxvLzLqI|dGw!rHJowf;wal@lUEv+4ko5bd zrKR>!i@^XDh9`F|k%W8&pC$4}ZU1mqC)~FfJlD_D;;nkcoryJAM*)xV&NKUmCkrR` z%b7V_gZJrU3e&m(ZjJm^O&GOp`&~=zpOJ+|i%^*v87s_eNrZK+p!>}X8FspzoD*j4 zi=Q&i=!`CUCMk#nRjx-JE*KJLJdW>GIdT+nA7{oOBsn~GOciyH+>s{VO5GAH~VA$IByfF#J1$V zZ0NM!andWHJz@2pudOr9)V4H~W3x}}#w0R&R+7JA#a|KL=HuzP5QFR`K1%S1r7lgx zmlB?@r0KC>EJ3;+J(hKC8!{tILFUZ)mOYWOL3$)0*Zl);OYY|^VBrs(8K-BW`V~dF zbgDCQk9)0MifT!0SQ+*kGc=RAKQi46vRwbBl|-R*)+tD{JM`(v8wvUg0cCuB@Rdcl z{zf$Lb}lBLqlC<>eVu|I#1Iyn2;(B*4v&l}y-Z^{&&rRV(DC4*wsBsUvOtJ_|{$7ka+o(4eZb~U62HsTg6gw>8%!MiDUD3(U&u`-JW5_qr zB4zCAg7h1oB`(Z&bPb8@eEAMcPht?2Kh1mf?J0$1@KL>|i3a>Ghydk{YTJI=K;8nr%W)9MgGgIEwNezE)Ez(Qc|Nk4332FCpuVA(~U4)H3}PqO@X5nmOuZzUl=N z^DLM~CuLuqguj_%IJp@35D4vCM#DTJ_*~&f>#s45j^e$8bl6eQ87eIEDzbdTke&qT zQEQoQfutS4?d?Q9UlDCRG}+4!ih#UI^+)H&kMnuhoc!!jYlH?pkstuNZbTYH`6V95 zPWH@)r4|*)V=8#PeE#~HQr{fGl*w7b%EHXgY^xv&Ue3>R)YGtv8ihBv-b*D?ujn_c zBTVqJXII&o7UZl-_U^*{K0tySUUmYM31g=#J+rgrq!k*FYlx^7tqc!&t?!s$VqOgj zzm}t&om~aSdL4U--Ina~zZqpE@hy=MUoFPmlei#>_y;)|KmJrD7(tA(iAF2&At~A? zcd$x*YwmV$EEL2jUm|5x9NpOyu(3)VaHa}?(Jsu1cRQoiU&|u~1r%3wuNa-d5`{ zdn1xV8`)S;f;CPYIf%nO+>Uv*fB9aTq#{;C7(6DRTNq9}gotAfha5;2%7zv2#_i9F z@mCW4)FYJe66Zdswz6AqTRK~ca3MyJhc-)CexyJ&i=?F!u640pu0K+k`an|Y$$P@W z&!r2O+e60<+Eg_wYEJ9~EKfE0itNkO8O~zmt#WYNG&!3TZ+r@_#uYbP)3DaL^|zTn z%O>OLdDAkH4D*xlKT6R1vC_3&=Gn1VJh`)nK>k+twU(nn>N4#A;Yxt| zNhep&TWZ;cXNODt@%C$vOd?#ktZ-d;0zQe4$F0!$c(U)U&a6F|C3VC8{yFwXgMiZG zM~%s)J%zKEp`v0dRhz>}2%Z;G{)WjaDz{QF9^^*VoYj~F$M3!HuiG;D+C4GRufF#I zdL_>K77Ir{oNP1pWVE;-)$NU(SH{hY4VqC7PwJ1vjLAY3K?3j&Ztc)t-a^C`N@2H zS*T*sY^UsgD7t6-ph7)WPasWBA@D9R>l z4`cXBDyloruW_S)(vpjW$TL$4;|R-n8rH>pCP@+FcMrG{%ye7S*02Z16$e`h{V&8h z`tsY>=L{CqgjfHs}cb( z+0jzqF|EdhJZkfSeL!n}&AgEgp~ekAcZ&6`>oujxTqK*gT?%$?aZsJ0hZC_pe;5r# zx+_V9`KrIdj5v7pn}}~nbLaCM1=HY8iECJ}*G!N3SyfC6B;7aX{$*#uiv8lfZ*<7= zS#g|#b6ad*9QYWQ9c@8wd23rJGkv;7#e?yhZdTW9zpNWwjFfn}+sAmaP{WykXO)C! zAH~Zh}SQR7o>2`8}2IvppNv-o+2`JnoZc zJACPqmp^WI#WC@Tb`SOV0$^xXBloA;h#*4y3|5K)sn@Hs zc#yZ2c9>RM(7Th+hoc3X6E-~=(p}*u^Z}^x{P^ei5` zV9VF4IUE~X8>_>WH!;B?DBDZ0dUv{^zYyyY384<=e__}*iCHa5SO_Ib#R=(o3^r#@ zAF{aQXOJW3c2SJd3coz915bxvgT&se>q%9SH~=$#WmOW6gzMZ9k^@g(ggpcAllhoz zqSFj>l;tyzY;QQ&{gOX=;|UeXIJ1-5d>H(wQbn&~ANN(RlCwOMEM3rGucNG^Il04= zz;#eDTP(*9;w@!d)e7!ot5T_2&o97Cca! zlN%lPaOU$wmh^bX2)oqY>u4rwR}!#T{*7(rWHibk+L zv;M`hJ+?K{j8kH65eM40F29}xSFyrB zx*Hr!*f<5tG=*b9ZYz4kl&=+uohx;9b;BFvdUKB&a;!_WeDE`( z>xt+~SB{-?7rR^scP9%Qtj`7{^xn6g8`O#|Kken;W8WV}T{0@pFOKiL6ytd!VC{zc zusDU2g{K)@AoDl+c-q=*w1QBb!k44ddKoSAfmXd{K15%Ic0OBrF>GD+9&-DIrM9Yk zTS>qNqRoG0BNwz%pbph3d3sSDrnos9Vv;#C2YxgnQO_(;n@&?^HpQmbhhQxbyZ4YJ;B@swXL4buQ`9)E9@8Kb zCy1y;lP?|hsvG0y>vx+mjhmCHF&y)bn?TgmDNaRiId92v&U{m-RgaLuAyz~bv7#x} zNtnTHFWG-vg_AIO^+(l-nG%)PJ$-DDyTJxnlhD)If!LLTExqWRA0e6C!g>NZmsB4$ zO75#jdt&WkZf16Wn1mQac&8IFnhbibk{3+?fi|Ms_#}wDe#Gl8X9HeSw3y-Nqj@d+ z24A~hzs7`WoD65@c=mx>zkG+z_E%53Z3+>+>@ZA9Fcl6YvD7AV$IZYkVi^IMj!ZC* zc}6T@)4LTZ`S&D+9>4jK+rxV9S?pzoGs?DUwIAy6D1CiyS&;b37&tS%HFfsUa$vAl zIB6g=$RLQS33JOj@9CF9ww}E1DVoRV zzZ$;M#YXfALeAEc zYKlt)Y))p~1CgGF|09|9R$$B5m3whSr#-5`GUAC!K$r=CU8E9zFqnxhp~; z!n86PmWvwvwLiX*-HNFJTvw=~HLfxrA9DX*-JYX)ID?=h57A#lT> zjV6{Sde}T+%zg(0^}V1Ls)qdRqA*FurFXm0&&9aNPj%5guBTPa5N8Qm1rO`G2Nrrb zI`t?>Xo4vUTG?WR!SL0WRrNJhrc5w@2jiyMkeZK0V$-n$) z$;Iiqwk+lV$_>Y|+ob}ZHkzTJQOhThO5tNA?rG{S-uj2bU}w zvotK_xqlrlSS_ zV_uS1#U2hKgt+(B96-)%fNAm+li=-1n4miH{>5WJU~#KmT$~iYB_OMHxw@-NVd=aK zi?Q#R?Su0aW&l!EU!LIy(E;xlvSU?RqrZ}#Bjc$jv>oZFD2h4WubL_&a_w^f)lJ{F zTIJ-aG-Gq87B`~4RB9TfD-V|8h!?W@?lQI0%P&abb`}{*qyA2#2 zcygE%Yos2fV4MC+={)5`wXJlyVJ(-02-BJhHvjBjbKmg&p`^>lWwYo2%giPxAEh5r zE6*RwHB~2auo``xhHSnPFp-xC=!#elL@&Ax6!NX1LKv zY+n`k*Er;k9c35rPjke%+R&zDc&8oM6SuBa>sv5orA3k9zye)s4<-uBSTUf^J8w>} zBU)D_G7ZymayrAo9hIqt@>Z0Va5j~twJlr2I@d*2cRJ3~r4U4aXUsdtyZf5s40l!h zD6>8aa(ijUF`c@q{zgi+ zamF)50YdNOBqyL03w#bZ9(mZ*h7kmGbwkAR+Ga$BJDZ?)Kj zbFH#ZI*oQ~rJgQDG5WY%JJXZH>@N$S?_>1qJR{oS`L2ohRd0=vgIjxOjK6jd2W}F; zWOZKdPOB}5%8T$hJsBt+>t!Tx%yWkQ3@_=JgWc-(R6O zMaZIF8Wzbfj~mylyw$J_s3wcmEv+Uij4G7eDAdj89X>YDBJ^q@p53>4x34Q}``#gh z54ShWSRD!e>I57UiqS=N(2wwb3= zetyB2K1#K8rdv?NJzb7+a4)*arF++8t!i-&1i0nc0#rVWL{VZvwDVT%xSm&;996;c z+U^Yx$08AZn-#^o;w<7=$*OOUP;ZkK#of36@-lbyf-XSbDC@00o&Nt(_mx3$HEon6 zSdgHD2MMl0LV~-yd$8aThG4-VxVzin?ko?fc8^j#AesFK&1fN>~>W;yM4ClA>Gb8-^R}S7D*9b%;VgZa{L3@1+y)q%f=u zWu;rY96gdrt0SBmup*io=JUVcYNga+Nob8R=a(Rwc&7J)uR>*2)G8rATC|82{YB-q z&w`rleHJc2v?!51wR2~0HMKpQL=ShFNhedt#KIEw_`#&sPqkW>6etr{8F{uvMtF}? z=`lnOR^29mRtSQ5vT~URLW1KK*Tt#cQ}d_o%X7S1rkO&b@Y|)7klMmUXN6pkoxFp= zTnBy>xRD~-hGPCNc5!QtiA#^784m{OH08%X`shHwX*4>k*O2q=QC=jkbGm}ve0JDT zpcQKe_r$%Y(u+7un z@%%7^%-KCNl$AB+0(mmn{ilw*7CQhj)YEW$oEf;%Y`>G-92UD zsrTY%Bx`7)kH8y#94Hi#w>DWRyA4h5Z+X6R+}~?x#KKUF)v;{2xlnAIW3yNGms(pN zMgCy4mVmTWFcZXFKj5}k&p81UD;n#gFcsM zOn)OwX6O(6h+7DvE}t%F-L-aSyDEHzO!G_MQi#%b!Ja1uM7z7}clNsXQJ2*Gokz5z zE@z0vh-`CueNHSGUwmZ$Tr))<30CUYP?9;Rs&s?DvvsD0Eor^Ep@+jWx-5->3AC7h z6PhSBStV5zA-7uRd+wPkN*2GZ7!B`ESv85C(sEIkn1aCLM^0sTK4yWkBxH0GTN4=k z_O-}R=)6;MOprzI&!%tm(2SQHvKvB`yM)UiHOf&U9YnLpsE0lA*kpmGebRE^nTOa< zv~fvntf>Cg7}rl}0IRv4CXHbVMz-ykPluO#`o@;o$I!(7K!fun#5y2^@`~Pb2MxU( zz8EHa(%q_RezSbsUY^jQdI*M}rmm$DI4;~9yRLfog~ogh@RsNWZ#+vTj$547311Y? zO{@&6e_hEo@wdQG)T-Q&d~Du@=;?^-`8-c_yga+C+iIRRkl&Lr>9vOhu0E}7op>U6 z%2{|?7&hEZOEzPhYyK^I!FOHs4lwr@QpwGHQy$(0$vkLgRk+edlPwVD%D?zBhdo*{ zN8J6mby`%dJwecfUB2Rw3_byN_Q2oxdFL@30)@&DzZ`*3vl-Kx0)4-pdwlT#EIF+y zGZ%#i^F~U#cDj+dFU_$@+9Vgc>AlNrX{msX?eY=tJ9rFhSl&g!M$Q1F)=vOyTaBoB zTvN&6&sW^E1R9*-GO*}WBqXOkfWEKo`q@h#E`L9!O#4x+SwxXASM0`Vhvr+233(cz zf?miwR@NvlbT(y)YELi>(3RjRE(WssZ0Q#z`bPLvxfu{$Hjao65|2mM&ipPKcuW{b z?4WD^RO+W!|4n&ftADR7*G>Oj-pzRWsCjda{K42*WwOrqUhcEjiMQj2j};`iqMtcx zi-Os&bR)tU$;NLNEIC{8|0-v{A=`wqqsnCGaDm;=g0L;p%9I?wELkU~rlvYu7Wzgi zm=T_4%82n%m02E6{XOoUH}*KYmd|mku z^-FY3P23-uOh{a8-!)r|fLL4w8D@y!QRfp_h~Ou~artF=`^XIMMc{+U{@1zD4;xCXc52j2>-Y%Hrl!G{ef|EnHTiaJw}OpI(_6MoD3}?YHUScN?uU%BQ z;|9JSO=QAlCz6xlDJ^(P30RM{v*7slV~pjVK$FBlG(q2wyBm`XtyeO3faHKA-@ zJ86)qn&RYeTE?Mvl8e9X1H*@EUrygz`Zy+`?I1;*LQZuk5XMuBbEe; z^d+{MsOCp|8#-3LSa16B*n$iYF%z7iRfR)3?8$p_6vCuCj$u&10A6PKLO5_L0 zCMiR!B)DY5{g)1Pv2U9QdpoS`C}oddQ>Nb()7-4=)FkP|*JcML>AqKw{95Fdo!vPV zKTe7G`Fb|&ZmxnvfQv^Rw=)QxMZhbV4uYizZ~0C5Tw-8Gjsz{7aD+y#t~_TK?=HtlWi1 zpJNDkqN$(mb~1p(mJbeG@NBMMir|0lIxZ}>K|k#|d7occ#YT+PVS|W z?9w+E;IGkVczw3Ure`f+70exxf4cD|ua{5_oDa{CN@Lb%PDQDgEZKjiXWpFcf&Jff zcFcI>QI7r6aU!+8oq9g4HN^t|@nb^RQg^Kyq|YZ8y2xkjXscLB2`aIP6ZV18Zo2Vu%&_p}&Vy(aPAi2^Rrx5WDFuc-Qn@snaL}M~HE?T89nO21Jei6U-Lex`{jh3~UPxBCChNDkUJB%7TY$V3_fd6D(Nq4~+{T@^N=n(LdZ z!)%#u5vaVY4c7vXD5{qsO|GsBfdffXaZWUn-l!56Pah;l^exD@2qe|@8cmXhfqnZtd*(Q`H!nu--faWmKi&v)TT0qKehS`sY zoCws}Lx}KaRp6R`EFVPA>Q`DsEM*KcZrIxe*nA=E%rMDf=iv7Vjzxu%j(f)a_?muw zhvJ{zGUzF=kXPTrLyC3;>m>JMn}b}A@CtexE+kU0_avK#!KjQ1_kH&;+0-pnlCcwy zRd_rz3LpO0WImP$*y9Y&`3lrjFI7C`Xz1T$HP~V!Se>D?SH1X{A1*Qjt42wJ0~~2s zW`nIH&TK`3C#>^e2CF9rL(@-as1LO*7+`wn9U3emdOgQWd4jo!3U&lkq%z2(Oxi^zuORA3l>U`q`SecvdS6YRT}8Q% zd=JmGX8zRR_p{ja-d92vfkfV3fE?l~QW8%jF=skv9@^)h*u{7^4228q6NfOe;itcp zUKjVVhPLmem+6$C^6b6Z3(fZ1L1!x~zK%a(kfKYH6p8HPM22z*@m^HrU{`_cDh2mw zxz@;8P5(Sszv_%lLBQm zbYIX_o?MKF7|Uvi&7mUtx7li2^S$vJk1tE}tCK~(=CC4;2u(JiU=%~6MAFam4eoQ# z9K#*qmDCO5USG-2wU!V#22QCiTAlN98=Qj?>GA3P18r0Zlb|1IQyUK3iyFObszEv)zC5GZv+j+d z0ofPtS8B8Ea<2?hzm8MAJn0`8Ha*_elX`n4b$VIJx4w#>$HmHGEa^p`GjR(G)Z{yHdLEskkBkRC^^RTW{~q7Imhdk z83Lpw&Wo>UNcP@yl&3~`$=Dq_8R+9JigbQ@vxHK3+G9dZ8j(N}s6&@;T61P zOQ{A3OpdXN^VO2Bi@(_XylVQ=mFN)rRn8Bc$TbQ}!DCUhY*(<>Y)lkutxJv&5YRDS z0nMCi+f)%<0cCQWQw4JVc(H#S%x`&$cO(AC;wAluUvfQUo%eP(%A%M$x(Lkm6YuNG zT7qYJiW3hr3l&nNBvHQfJQnmVTpZHGfsmo7tZ7IL>CA)}k!R-jQ(wokPy**jJ{nh+D3QDSkNNbOi z_PmaTC5n(Fq`D}P-FuBG(7K_t3(9?C4@s1He)nxVJEH=#P)UH&2Fa=p^;1V`*o`r2 zrALU=^)`qHkIMiKmp?Uk$HUuXO`aWN#xySWT!TCzf`xxQa$1l@%$Jbx$L9L1c8iiO z4$x%MY(eP!STzLj7j1;MuW=^aXKH3}o$)4axy|ibKW@*e6i!Yt{9DxOukN{e%Qn5Z z${wqzm3^Xl{#ADJ=G2cYR8yp$l|X`t(Lr5#)V;ksDnUmpYu>50)ZBOVk4ocX%G?GL ze;zV8a21v~kFioPS6HO`TGZ?ZQ;9itgw1~H7Eb>VOuN8PNFP?p=*2^<^F@{1L?tJa zqjcP`F#QQ06nAW(uaLPfr%ZgLv*H_h_;lLzNhvErTKlmTFZ-2j6w8W;z5Q(|kQjBJ z;eNRWS9eR~%Ul_UGc@2bx4kd!3z4aqwaDxgmD9m~&2s9cDbQBF3sYy7GiD@lwM z{0K^liRJT_KQwo#KR|4Cr-ZJp#uzv@XiUj8x(*BiSm~xmlHARMx}S+M0*OOMhzU1T zatx?paJoloE4K$w%%!O~ss*IV+;ksK>c-3UPRylO#$P~QBztx-jR#DgZAxvQxw+gQvRo> z{LsJ)Q~K)u6lZj$sAf{LxsNyB8|esQ?Op^BfwoH-bKA%PhqhGrHEsfMb`LXDR`wbm zz)G{RLPEsR_atZX=54<6#LZD+f$5^oZ=bc=6?POYj3L?zhXt#-e>%f5zuMmlsEB<; zp=W+gFVyLOm%N+#hQVF{lNIA@4VG1B%%onnztU{cuHOcj5YnsE^}1i|N82Ou@~w|x z=^cKftmpM&{`h#0-M&vNS5{wVRY6ZzR!3Mu&{Wbr2!t-DEah?MHpp66WlrDuwo3Yhlu z54s3aXRuwxtp*`<9hAlTD-Ll)3j>V9qE-E z$K;E-*z_LMpTF<}foo?O66mCaC;llCj;ck!QasYgKWVqY#x zvLU}pQcm?M`>mg4)QJxe&-2NM#=F|_Ec)^fiLHb6jI!)1c254s;z^H|;)dtFd9sWw zR@LojY+5eLQ#&r)GIp|1=B=A~Gvd3krze5NhGoM(uQtv|@_sAxZt-8PC!&F_$S4i@ z=L8%a6ZmbRCz$V*K36Crd!uMSnJ3PjR@%g%5ZZ0U_4f!&#bA`EDm>?FfdZ7_^`AWJXY*Q z9Zl)2<$m@pTfV<3U{cuU&%Y@k+l>N2six@)D6u3x9sLu)M67lPrd;Hj2Y&}uWA8KV zCMyupNevGI!2ofdrw;{{Vwj1)>q(j~tcKV!o8r&C2s)B{e+fDHZ#;t5hXenz@-yjw z$%j*^Z)q~r5&;~sIxYGlE+Z4~AXDH*VGIlpXF50fG7zMjyWyc1sY+)#UVWuj7JQ-F z?1*#8vI)E^wU)+}aOZJc@6{t}$wXHnnvVKcvwy}&{PDerL4xr}2{yZUgC9+~kjS0Xf#=8&O1ad( zWk95yzkb(U8n-hVxTA2!V1HYw(KpLo_92%_QJ#LS8Yo8;eBxvP-213*Zo((6jhWPf zFUAmk-pj0hfBzb;n5VKB(&BKyt1I{fpb_Q8?*I6rpW6aNeL5F-v=8m9s#PjRPcx+5 zPCBhlwe!xBXy6>!E>gX;hM}WTw#D#w7AS=7KCaP~Ik+2ZC=_4{fQxi4da~m~=(Z}S z+{MTYWj+W>e*1KB(of?Z&0n$e#tfp)no%mlL+|tji8Oh2f{#36H5Sw?03v(h#brQn zb{I5tcG!+hj!cki2XL!!5=Z{VtbB^XN!U_yCjFecnrAdu8!7W>efqhyn{mq{f?&>p z5{lR9q7Fy{=^DK|f@;yAY+FxNpO4)$3I{Sf?`{--JGgavxPapSfGv#O^oxEzQK=@I zzj985&^?%UL{+Ze@BA371N=}7DQ!IqRAT_I3SoUKsiVsg#qfh{zg!j9l}5dheV|Jr zUrx*NDV`z%0%lY5YCJfg4~V8)Vn#Df$I3fJR*Z9P21yeo<9SeFNKTD?v1Y_yl=D=<#%ik(<>a<}O+#XGjWalP*9}y+!&ZFciKOGaY zp-e*-WHhKJeX#Y3^${%_+sc7)Lm9V! z`)!Ng30<#5%BfL$RS`#r=*y(rxf(OOz*sGkPoIOw>7SNoV)w0bJwJ)E=Zqi!Ss96V zWRyvy&TNba(P4j6g-KmH;e~Zv+2E9~was+Ph(Y2qwpvJ%QIntCn*u-M9JH+ly6eBr z8Av8Me;ckA>-jp~nI@_O{YLO&Floi<5WMJY&M&)T;#9mP=OpJ%(=L}_(l zEJ$^nnBL?8EZq|YKbxb~;@{|2#HFVw-TzB!qlY; zs@xmvU3BZm{YfTZ{rR-Prjx@8ncJKo2}BBT4J zM^~Tgww={YLyh!NKgx7_l_Kh)Rxe7Ne3uLh|os-7XC9Bw>vY1mn zA`uVkf?P~zJXPpa42FK!Lx1mE%u;&~LoUVwav)D2t$skbw8*PQs)hvzZAV@+67c-i zJ-RVlRs>B2aH887B#|%LVbIhVOR$Lu1<7krmk;ayLXDBU@eY7EDpktnj&!P;8vjB+ z3404wUA2;W@ig>hsn?CBD%T~(?4GE3?56XAo)K(UCP_$nQBgs19#5wyiZPoSss@!S zqnapO+{;CL$&_+uK11eCy~EgKPfdj<+x}jcF;2O*!+hH3l@pBZ`-U1jVAhDnpB$~T zVnhu;*5y?NsxBNFC|r{*3~3n9w4BpM%UQe=e;d>RepD>uT^f$p3A{%v5~HsiNtq#K zH zmgB|?LXXD4m;%buh@E@-d}EGEApb_FL>!vwzy8zZ^J&JC!~u7iN>JV^EDXuxif9bex+$f^^xqxLk}f&02w_^HseX zj~TJTMPiXYUZ|Oq@OjyybRK$jb+$V;?J9A+Zn(Qgi3m-OWR$2)E3<9IQsHh$p-*G> zA)>Ew`!0zm^}6HLrh~m8k25#EpfvJgxg1%P%~!fF1tuaG+2CKNS$Db>O>;a#x}Tfo z7sHQ6#v#Qk4d>P+@iXb?Hepj`n1Lnpj=Y(=hUjRz73J{0UdrG2AsTUus#oHqKSGSQG=kl}Ew6YWjZ zcN(oK+UnZn)42{(;E;%H^2kYp)}A8G)$WCiybm4^k4Kqk-sC&Nuot7!k-Ejfjyv@U zu&;&NSt(`vE0?Zzfv{Mef&k6<_^voF?z(N$avlC@4Li)^Mu58AfX3HFdedYcS9GSo z{f|K_eGS^g&!wSijI}aWhqVObfJ9MNJv+@sDJEBPNmp#Grn@$?kg@HNPIi zp6$Vy{slY(KCN27h;njOiznID=aDBpe!Y6khLPReu}J4GpiZ?<0)u&mI@l2+IvLXE zo-OfFnv)Q->l}mv9M66j3$QBFQ+$8I{C2lbf_ki)uGez^vHvxRdO+ zyO`9mEpLMPB8N)0HrYX6*>}~kG28Lc2I=YO;y2f(zwqPmvu5#TU_T{{R!%Z&EpRJ? zP$ilwH178eXTp;7ptr)>x>#J>@n^b)Azp!H2^^$;yzKoc6be5>=wZLGSnNJ^{HyaH ze}l*i8MWE*poUBWw2YZ42_b@m#GtK|*6f5J;n0WD%f@ZOOX{QhmFm#E8Z$JU%Cos; z`I&2cIt(hM0f+W$Fgqs$w`oYG;8i2L2;!H0mK>s(l9`z~fR%C1lfdROWPlsC=aPTB zoWF2ld0&~Z&!vzZ3-$%ga=Th3DbfZoI*a znFLvS@P|bDa;+~ZsLmYgP*J-a9Xf)+<=2Z9Et#Xyz|qO|l8O(wmTiRr2N9>li+Z&L z{)O-dsq?P@ogzoxK!-IyeTRvV)xa?KRDyZZh^4JtZ-C+^mqyudz=xjKs*Omg3Q1O>++)>J#t^9IxFVG=Lnrb&f-7< zHTVdB;$wtt2^+4(Zv!&wD~sKk|9Z-D8E@*r1=G;dgo>hoYianO8BblO?DRJ|5O*eYV&6^_c+h|D7N_n1T__CX606p@40BsIrh)%K@`i;BS%n%>bVV$ob?wS@16s-XR-V5`sZ*`1(sapp6D5f@d zl4N<4MKlln9=gaJ-0zW@-y!ZF{d+FIv34^u7MdFvqpTAF24ceOHcctk+j*9alr^OINdn-}ud#L4BLjyyVEtH|`c!e2%sOcI|J3 z)@_iXnv-3EaeW8d`G>s$pXWlYRt%%p4XGp6zTXt3e0NYjzg1=st@=q@;#Ua@(=ME# zdUWjO9_U76|NOZQxjhZjL~Fj+XtM{EzBI|CNsLV@r%J@wa|(v0x?*1We&`$2rqH9e zvs+OE&?n0MCN$qrR!VTtlJ`LgvLl%@#ASIGCVK&zd%54mcZXmPEGUKlw zbpvQU9pNdkY?Y!Wjp~JVIVGG?P0$ZHer62KD(qII8u2NN_)xj#%$;`Nv=*XMGnrL5 zb<#mUbl~_c6+49T^^@$K+h6+wOL6K)sQxrfX;+vvE|fGdMq0|oHu(&POY z5Pa}}ugvgD_SlUYiTd{`krVNBoe;x`bZ5Xu(^B}r*4+heH%ZiD{Uatf;1P#bi}NHLWA*3jLbr_xbm14bGTZY zx^w2gs=I}R$=LwUy4W0evK?05NIVmbM{^#QWD>PbI@%!O&jOGcz--xopRd{{RaYwo z3^e9rNdGXD6=}vZ9YjmMnQxV;y!3xhqN#$HBj{N{!Vs$Ls#4CmCXLQze~qN0V|>ra zQs^Q~b1o8zcX-1LI4H`86Y!CwvAQ0HDx~AvMX_7}?FiQ??yV+%tc~Y#!6iU_+<8CT&tO4?A1@sUm`bvS=S$ zD~GycbFoN9*He+zn~2H=2Xtq!FBrj6pl&J^74lXvRHa*gV-w_+7T`lOH0DK&QE+iWx$Z~KWu?@> zGmLS;&$m`?j+vL0w7g6`-c3dIXY>`}+!G-i&|BJ>A9-X%%==M#%PV(^uRR3LTMpeC%1KgHpdYDe9U)4e_DwJJ#K@)6bBP_gwn=$t!~)6=&l z{d<_ZaIXN3&hOyZZjXnq48>J{qFFJ@ek`m9oM;^$+EOfNru)> z4!7w!=9nxt`iIarRTp5IqA*+6V7q~>xA%{VAH{6 zrVx!-)Sn%S7C4#?pFHvKUM5SaLFx9PZ|{ZOOU1=3x*sXvX z)Pj9S$^?zHwBO{>SIz?BVAyA3f{raXi5GAr30LdocX4~4tC|VXUvCWRD!M*$Y+Z{1 zHow|>vBX)(VpiB_Cj{p%{s5NNov2Ax(wr%Msx21k1!#zZ@z4A@czk8~?tXo~^;D*Y zc^GzW9m9oLPkdEP)dz;d7?-K-ivox3u=bd4>3I!Wr$s%f9&$W7_NCGWFuYD{XfC&bg-?v%a@=7>#|ibP8PQ5A|sYw9mwO z@5jm>=PG)W&E<`LN~%WdE&K$b8y~wkiC1s=_^Om@*!Hdr;FL-Pj4nby%(pnaqQiL^ z<-{Ps-jBa;Bxqbd-2rR@)R;+~eoMKSS*nx?2>A$eLyl`!y`|GZO+n3!U5&1sV~0%_ zl2WAilb4Q9p3=PXjB8Zy9DPCK`m^xkd_NM9!i))A=}57N`p&}eBrv_ok66iEV_vOhfCGZiQFD-xjnZ{OS3J<^}Je zzi*mCANH~*H`eF$@hZKx5$+PIv@c)Zd`|HQJ)Z!r!lb<5WF!>DD@Bd`l;z+N0B}ff z?_k$H!tt;-#4f{*Cf>loQNi}EKUiJt9Bdpd&CHw}Sq&Y`emJuJd-AVd-lu=g%VS5a zd)Ux_pZj>LJWiWHp&MUK03i!4#@PNg}5=-<0J-&sG1p-Di!T}bWQqQW zZPCwbmcMkUmYO+Jk50??dWOWAUf9C5%LU(xsCMcfSc^%b-0xVK{%T&-5lxmA_nH#B z(}#*1#y306IF#zJH7M2zJ3hc`|3j2H>)rqMa=(H4O=rOV^d=%bO;zNSNZ7GDw6=t_JM7v% z0z~V<^lx{?Y1HYDW^4LGH9Ac_tTZ)VJuKP-khmAOiF~dF($f9E>>z%hT5E6;EMs^> z*ER#3pCVUEx0aC^T9E;^`6pq-94NTl71YGE?4;r%{46PW%;*WLp}sS6OM}8;@C``~ ze@?RRR}JYhTY@*WS#Z-g4&b1RU3)>aTk~ zmm><8#pLPuTNv1vNq&=Vc_xe=GB=%e?QSbJUuL`EqSFV{I1I^v?F}1O0ljKdVD?yh z9&z?fRJJ`5Vxf+@O5=X4u!%|)uQRF$c$!qmTcsZhFGs}OJC6y&G7rUi4;D;K%LOsH zs-76Ly|*;#p3vJ;-H<@tnOwn6{P0doN~8w&FP;L#FIem+DPMFe&I&0|?E=%@W1^VK z9g7mUP#$iZf1NTq=W!WT`L1%|>j*IAxY!NRrcmt6NSXTY3%e+h)9#7GiqsSV4vy|W zMfww_8|!4|pl0Ue^shYT|8dU#>AMy1M0HrPhYc%(`W@q^8+do&l~|aOAPk;S0aZli z8;{kjjitV6NCv$E4cXQhxx*&fJ;JAI42s*|mgm)T?tRC%+6Hz6s4mqX+4|rAVi(4c zsqv%a(M+!~w(Zy$tW|{b?>qjbn~HO0^dl|6WIA_|s^Poss74d9AqugAL*%)s1#m%I z&<6bHC#D8fjWxG-hxemGQ*C{VhC{`ZolnWzb+3l8^sbhf0QQpy0OmKv3KMZ@!#U zH$ukL@D_9CWKlf(k7|K$wz1D!v71wh26H277oS~^XLY{bK0KUm z?Q;3UYTp zGV*g5B`%L;eJ{68@T$I-fZL)zs}J|nD<;9j;NfdCDX*X)4(}ql4kY00U8a15P}@f* zYNyJcK-OjjyquE`v6-CRmTU-=jFivRyFd8FY* zs|~@|-F1}=%Im( z$VT$E;Uhd#%v)-B8DYv8xWDkJzTAbJF(}evMw%3e(nxW?#VOu|AalUmM>tX7#sqz8 z&Y3S4MvXzIeiJJE77No9Y3u95t-Yy`@ASlRqu?jq&V9TQ#uUU6$`qmz<`k5Xchqo~ z!sZk}Dwn>RM}#qI&mj8_wp;!WyeY8@4{6)iX2=a)W!}eD3_cCwhukL!TWnXE3%7SK zZnhqMb^_enD`}q?Bx5aSoilFk=h?AIgVlBLD3VZwC-yvD4ci;r!;Lh&7=f z6C>(Fw;$u?aof#r>g4G0U@^#8ST=4iG?)}58h|C)N*FbZplCTIUdc<4r~KIj#A7_f zd+T;OL7TCHx)%Vwo}pXZuHNPsBpL2L3RZSK`uxx!dK@1qq`P6gO3vb=?<`%xO7!E! z8hYGHa!l3Z%%FtUu?Mjlyl{FzH@A$8Z0K_o`pm{|m&%5iCeDB>oBE*sLqAN6;WHK{ zHC(7LP7HcJT#4`p>r?Rx3pR^CH%9J%(2bD(L>Ni_kvF3KGh$@^huH}KPq~riA1BxF z4A*e;|My+D8o72^zOEE+q!HqNo5BhVaVv&Gk$M1qk2!^G1djTxj4(&c+k7C!{w>5A z^RVXY2UvNcNF%BK=FYFc)j1T`98*pS>}Ce=1KN}?L_Z3Dd>2RopakS9hlqX@`G^uo z4d4LODi4U}2b)F=IL(To>%H8rEZ_Ry*b~6Yn~2c zg$l@z<7+SFpIcCa4W>X8w0^bq4HEa44i(-pZ7xNgOq-fCG`@sgah*5dcogY3s=k~Q z(?|{mPGm>R5S9Li@9nvE-57_zYrn1Qkhwdv?RS{*u>~?=BC^6VF+@4Ui|-K=^B6Ds zvTEOvjfhvL0rReo?uR=c#q^f-s{eanGJMs39%oNjJI;se6#EBLN{gPs1s8>juPAo$S3x~AinnYRC%%6p&q*z2 zU3Dd0y>UQUU&jWoReK~_7*Q;Vx0F=QL)&%0GZ=Vn*rSvz-1R1lNP+Y-p zLF!%efCIqNPCb7vf1o8C5 z6d0D!llap2o1FE@b<^T-KY6m-?WXzcvX+qO6N-bTw3m8|rnIQ>U}2po9;yaCbBT*4 ztnx7;vL_ih+?9U_a8RbQ$Z4Z~)LBF`V};cfgFYOKT}oJuFpy-h2-Kk;kri=u33B4iY@gtBBi zj7(W3#9)d+Q`VSlg$`*jA+nS;hhxdof^f!hk`AVjWO*OzoZ)#ppZ5=V&pp4)^O@(m z?)#qSdw=ic`h1>CM=~$RbU842IS__@F85}F!KKnLHYeU;An{b28lOPv8RHq7KCD3H z!~7wEo`?BdobPQkwcHdqd>l}=yb57aWQWJO&c*{Jxm*OVX7M*=!*+Iy59L)6&f$}vH8uuG3uvFw zgqISLTB1Ai$X+7*LH!erimDGXqNXFej8^GU9KhLN!Qd}DBsk=CMO&D&)T@R!n ztM&32yz5+|JN2Q@>!N;|i)B`(vQ(@MBeP9#27C&{7=tJSD?cQ^92rG>pSoPi*w20e zLSbNk%xoKF-Z@>dc)0PM8%)vFSoAjAFI?jxqH4srMqCBeMn#m%#KS%$DLDitO-o3t z`t!ik;>z^7;QA6=XUs|DSzv(K&%^6_nwa{V4SBkl=O|Xs5_-*XHRIvgiW!JZV`bpS zC(INB$`YsCOm^K;XP$3ZQ*4^4YW2(g*&*%Lz`=}mNpq$GKM56Ja2}6i<o&tZzV*CB)Cu@u28B^N!PFJ`e&DI&KiS_k%Aw3Ohuz@R2;F>=*J zZ=Zj37ci?bNiuFe`D@w=n~(2%N@noZ+c(>$MYGkue2|613Td?Hq?h-T=$uM}(mCu15{e6rA|Ek&<}%+PA)aAM>g&P*>EnzXb7O9jjWT@o zLk;^BbAygq45k27QFqO;Z&}}36e{C2+WOorB(=SDz~1Vpb$$LZ6pVL~T6b|h7;%t` z{PtagyxYC={*b}q`!@~AFm-4>UtXPxo}()!oXB_P3kR+wGWl7@I8&yUKJ_YoJZ~~@ zGSUdDSY3Q1iBw_|U933Wbhi0=ZXB5<*Q5TKBRZ3jnXLRUBNhF`r+L$P)3Jr{%qYur&zC5F*g>Z(J24--0-rV8p0YwE z|9zDajjogE9E9I{d~pVWrS9{toO7o!&+$Kx$HhO%f4SBuv^MVotoQ%>u;Fl!YFak1 zSC9i<_Rb4o+vSdSLj|G(FiLLdK#$EWfoML{E@UnQxaI%5gq?ckKdijFp8J)qV6FBo zzX4=S3-g=&A^|}jxNuOvq^6Cp8Lnkuam+FeCWD9{HTp=S(c)&ZdKx-)p0fJ=iswIY zOjY@XoL`X8#{r$mmy(>w1im{OWnvOcf&F0{RcT3PGZRJC&?7!<_U@`tX4PWk>q)2g zP99hge<0q}@*82X{;A-K@MVJrf^hGx^30`319tvP;$5RnCeV}bp6ON~a`B0pOn*7bQPpTLU}d4eZ{YIKogj%X zutwcek7{SL4o@o+7ZqVMHgbHI+TcUNYBb_T?Xomq{5Z$``et(yv88f-)%4G3w2BcS zr7ZTYxwT4_kZgzbD@dtb(1R2mb)G|9$EQ1A?5(3s)#$^2_sDcM{UA}xKp+HZ@+VUZ z2^+o+A!_q|ZV^5RyOKFSM@Pn+MFZNtI2o6=Nl$c{ZJ?}Np>}R4R5SZh@uaE^E2D=0 zYrAw5Ozw*_s+z6&#nL%O@4;4!Ytk!3V)#U*Q~V@14^->+J5jrUo8|Z_Sp>DVKyU%Q zKl{Ex!ad1@=B+AcmlWN;+K}5>pxr{O(d;p?n3j>Yz0(aNhSxVlot5H_9t+w>tfCZFTX$4rS^?o`Ds*DwiogmR0?7}byL3})rF(EtcDAv~A^;b0zx1{k_BaZ1Y`8-6D*`*J z^V@Fb=;XqbL=9y_Pr=@`)wL(C{lie~nP#34cD1hFXElo4&HDRuQ=9LMX+{)Pgde!0 z^flb8qDH9rDj0;xif3Fb9|*{<_0R}Wi8uP%9@OadfG?k zkrRg>=ef5C7_VkVj(D5FxDearamRZ+L`A zmlG4RZ@zif?^msX%q%Q!iaExC5fkXX*PCHhAm&=};!>u*WqW2wZ@1@&kdO+2Pv0A_ zk$8RMIYU|esn~uacl(Z*c&0m(3SkeD%u)+9y086_AC&I?M4J7p(1j3-_g^*-`oAqs zb}=Xb7Gr00*Dj%6njP_$H@pNhn~Ta~GP&3AiN~5Ue&^M=!!aTfXa6u4uIHk7`Z0*jok% zYHbGy_R^8Sw<5q52LOMocvJHFi{gik=yR&LVtN_^32z`3}90&I)e`25d{2~ ze~V+=WwCE8|7=@CXAuwDVgZ{NftvwVEa?PZOdG#NM C&6Y3# literal 0 HcmV?d00001 diff --git a/excel_pajak_test/DatabaseServiceIntegrationTests.cs b/excel_pajak_test/DatabaseServiceIntegrationTests.cs new file mode 100644 index 0000000..1d98a8b --- /dev/null +++ b/excel_pajak_test/DatabaseServiceIntegrationTests.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using excel_pajak.Services; +using Shouldly; +using System; + +namespace excel_pajak_test; + +[TestClass] +public sealed class DatabaseServiceIntegrationTests +{ + private static string? _connectionString; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + // Load configuration from appsettings.json + var builder = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false); + + var configuration = builder.Build(); + + _connectionString = configuration["ConnectionStrings:DefaultConnection"]; + if (string.IsNullOrWhiteSpace(_connectionString)) + { + throw new InvalidOperationException("Connection string is missing in test appsettings.json"); + } + } + + [TestMethod] + public void Integration_ValidQuery_ReturnsResult() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 42", _connectionString!); + + // Assert + result.ShouldBe("42"); + } + + [TestMethod] + public void Integration_QueryReturnsNull_ReturnsNull() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT NULL::text", _connectionString!); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + public void Integration_EmptyResult_ReturnsNull() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 1 WHERE 1=0", _connectionString!); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + public void Integration_QueryReturnsString_ReturnsString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 'test value'", _connectionString!); + + // Assert + result.ShouldBe("test value"); + } + + [TestMethod] + public void Integration_QueryReturnsInteger_ReturnsString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 12345", _connectionString!); + + // Assert + result.ShouldBe("12345"); + } + + [TestMethod] + public void Integration_QueryReturnsTimestamp_ReturnsString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT NOW()", _connectionString!); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeOfType(); + Console.WriteLine($"Database timestamp: {result}"); + } + + [TestMethod] + public void Integration_QueryVersion_ReturnsVersionString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT version()", _connectionString!); + + // Assert + result.ShouldNotBeNull(); + result.ShouldStartWith("PostgreSQL"); + Console.WriteLine($"PostgreSQL version: {result}"); + } + + [TestMethod] + public void Integration_InvalidSQL_ThrowsInvalidOperationException() + { + // Act & Assert + var exception = Should.Throw(() => DatabaseService.ExecuteScalar("INVALID SQL SYNTAX", _connectionString!)); + + exception.Message.ShouldMatch(".*(PostgreSQL error|Database error).*"); + Console.WriteLine($"Expected error caught: {exception.Message}"); + } + + [TestMethod] + public void Integration_QueryToNonExistentTable_ThrowsInvalidOperationException() + { + // Act & Assert + var exception = Should.Throw(() => DatabaseService.ExecuteScalar("SELECT * FROM nonexistent_table_xyz LIMIT 1", _connectionString!)); + + exception.Message.ShouldMatch(".*(Database error|PostgreSQL error).*"); + exception.InnerException.ShouldNotBeNull(); + Console.WriteLine($"Expected error caught: {exception.Message}"); + } + + [TestMethod] + public void Integration_QueryCurrentDatabase_ReturnsDatabaseName() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT current_database()", _connectionString!); + + // Assert + result.ShouldNotBeNull(); + Console.WriteLine($"Current database: {result}"); + } + + [TestMethod] + public void Integration_QueryCurrentUser_ReturnsUserName() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT current_user", _connectionString!); + + // Assert + result.ShouldNotBeNull(); + Console.WriteLine($"Current user: {result}"); + } +} diff --git a/excel_pajak_test/DatabaseServiceTests.cs b/excel_pajak_test/DatabaseServiceTests.cs new file mode 100644 index 0000000..d6fecd1 --- /dev/null +++ b/excel_pajak_test/DatabaseServiceTests.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using excel_pajak.Services; +using Shouldly; +using System; + +namespace excel_pajak_test; + +[TestClass] +public sealed class DatabaseServiceTests +{ + private const string ValidConnectionString = "Server=localhost;Port=5432;Database=testdb;User Id=testuser;Password=testpass;"; + + #region Connection String Validation Tests + + [TestMethod] + public void ExecuteScalar_NullConnectionString_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => DatabaseService.ExecuteScalar("SELECT 1", null!)); + } + + [TestMethod] + public void ExecuteScalar_EmptyConnectionString_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => DatabaseService.ExecuteScalar("SELECT 1", string.Empty)); + } + + #endregion + + #region Query Validation Tests + + [TestMethod] + public void ExecuteScalar_NullQuery_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => DatabaseService.ExecuteScalar(null!, ValidConnectionString)); + } + + [TestMethod] + public void ExecuteScalar_EmptyQuery_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => DatabaseService.ExecuteScalar(string.Empty, ValidConnectionString)); + } + + [TestMethod] + public void ExecuteScalar_WhitespaceQuery_ThrowsArgumentException() + { + // Act & Assert + Should.Throw(() => DatabaseService.ExecuteScalar(" ", ValidConnectionString)); + } + + #endregion + + #region Database Connection Tests + + [TestMethod] + public void ExecuteScalar_InvalidConnection_ThrowsInvalidOperationException() + { + // Arrange + var invalidConnection = "Server=invalidhost;Port=9999;Database=nonexistent;User Id=invalid;Password=invalid;"; + + // Act & Assert + var exception = Should.Throw(() => DatabaseService.ExecuteScalar("SELECT 1", invalidConnection)); + + exception.Message.ShouldContain("Database error"); + exception.InnerException.ShouldNotBeNull(); + } + + #endregion + + #region Query Execution Tests (Integration - requires PostgreSQL) + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_ValidQuery_ReturnsResult() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 42", ValidConnectionString); + + // Assert + result.ShouldBe("42"); + } + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_QueryReturnsNull_ReturnsNull() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT NULL::text", ValidConnectionString); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_EmptyResult_ReturnsNull() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 1 WHERE 1=0", ValidConnectionString); + + // Assert + result.ShouldBeNull(); + } + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_QueryReturnsString_ReturnsString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 'test value'", ValidConnectionString); + + // Assert + result.ShouldBe("test value"); + } + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_QueryReturnsInteger_ReturnsString() + { + // Act + var result = DatabaseService.ExecuteScalar("SELECT 12345", ValidConnectionString); + + // Assert + result.ShouldBe("12345"); + } + + #endregion + + #region Error Handling Tests + + [TestMethod] + [Ignore] // Requires database connection + public void ExecuteScalar_InvalidSQL_ThrowsInvalidOperationException() + { + // Act & Assert + var exception = Should.Throw(() => DatabaseService.ExecuteScalar("INVALID SQL SYNTAX", ValidConnectionString)); + + exception.Message.ShouldMatch(".*(PostgreSQL error|Database error).*"); + } + + #endregion +} diff --git a/excel_pajak_test/ExcelServiceTests.cs b/excel_pajak_test/ExcelServiceTests.cs new file mode 100644 index 0000000..9712928 --- /dev/null +++ b/excel_pajak_test/ExcelServiceTests.cs @@ -0,0 +1,372 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using excel_pajak.Services; +using Shouldly; +using System; +using System.IO; + +namespace excel_pajak_test; + +[TestClass] +public sealed class ExcelServiceTests +{ + #region WriteExcel Parameter Validation Tests + + [TestMethod] + public void WriteExcel_EmptyFileName_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("", "value", 1, 1); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_NullFileName_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel(null!, "value", 1, 1); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_NonExistentFile_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("nonexistent.xlsx", "value", 1, 1); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_InvalidRowZero_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("test.xlsx", "value", 0, 1); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_InvalidRowNegative_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("test.xlsx", "value", -1, 1); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_InvalidColumnZero_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("test.xlsx", "value", 1, 0); + + // Assert + result.ShouldBeFalse(); + } + + [TestMethod] + public void WriteExcel_InvalidColumnNegative_ReturnsFailure() + { + // Act + var result = ExcelService.WriteExcel("test.xlsx", "value", 1, -1); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region InitExcel Parameter Validation Tests + + [TestMethod] + public void InitExcel_EmptyOutputName_ReturnsFailure() + { + // Act - This test validates the empty string check happens before initialization check + // by using a file that doesn't exist (which would fail if initialization check was first) + var result = ExcelService.InitExcel("", "template.xlsx", "output"); + + // Assert - Should fail due to empty output name + result.success.ShouldBeFalse(); + result.error.ShouldNotBeNull(); + } + + [TestMethod] + public void InitExcel_NullOutputName_ReturnsFailure() + { + // Act + var result = ExcelService.InitExcel(null!, null!, null!); + + // Assert + result.success.ShouldBeFalse(); + result.error.ShouldNotBeNull(); + } + + #endregion + + #region InitExcel Overwrite Control Tests + + [TestMethod] + public void InitExcel_OverwriteFalse_ExistingFile_ThrowsIOException() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var templatePath = Path.Combine(tempDir, "template.xlsx"); + var outputPath = Path.Combine(tempDir, "output.xlsx"); + + // Create a minimal template file + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test"); + using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + // Create existing output file + File.WriteAllText(outputPath, "existing content"); + + try + { + // Act + var result = ExcelService.InitExcel("output", templatePath, tempDir, overwrite: false); + + // Assert + result.success.ShouldBeFalse(); + result.error.ShouldBeOfType(); + result.error!.Message.ShouldContain("already exists"); + } + finally + { + // Cleanup + Directory.Delete(tempDir, true); + } + } + + [TestMethod] + public void InitExcel_OverwriteTrue_ExistingFile_Succeeds() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var templatePath = Path.Combine(tempDir, "template.xlsx"); + var outputPath = Path.Combine(tempDir, "output.xlsx"); + + // Create a minimal template file + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test"); + using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + // Create existing output file + File.WriteAllText(outputPath, "existing content"); + + try + { + // Act + var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir, overwrite: true); + + // Assert + result.success.ShouldBeTrue(); + result.result.ShouldNotBeNull(); + File.Exists(result.result!).ShouldBeTrue(); + } + finally + { + // Cleanup + Directory.Delete(tempDir, true); + } + } + + [TestMethod] + public void InitExcel_OverwriteFalse_NewFile_Succeeds() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var templatePath = Path.Combine(tempDir, "template.xlsx"); + + // Create a minimal template file + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test"); + using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + try + { + // Act + var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir, overwrite: false); + + // Assert + result.success.ShouldBeTrue(); + result.result.ShouldNotBeNull(); + File.Exists(result.result!).ShouldBeTrue(); + } + finally + { + // Cleanup + Directory.Delete(tempDir, true); + } + } + + [TestMethod] + public void InitExcel_DefaultOverwrite_ExistingFile_Overwrites() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "excel_pajak_test_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var templatePath = Path.Combine(tempDir, "template.xlsx"); + var outputPath = Path.Combine(tempDir, "output.xlsx"); + + // Create a minimal template file + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test"); + using (var fs = new FileStream(templatePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + // Create existing output file + File.WriteAllText(outputPath, "existing content"); + + try + { + // Act - Default behavior should overwrite + var result = ExcelService.InitExcel("output.xlsx", templatePath, tempDir); + + // Assert + result.success.ShouldBeTrue(); + result.result.ShouldNotBeNull(); + File.Exists(result.result!).ShouldBeTrue(); + } + finally + { + // Cleanup + Directory.Delete(tempDir, true); + } + } + + #endregion + + #region Integration Tests (manual execution) + + [TestMethod] + [Ignore] // Requires proper initialization sequence - run manually + public void IntegrationTest_InitializeAndInitExcel() + { + // This test requires manual setup - skip in CI + Assert.Inconclusive("Requires manual configuration setup"); + } + + [TestMethod] + [Ignore] // File locking issues in parallel tests + public void WriteExcel_WithValidFile_WritesValue_Integration() + { + // Arrange - Create a simple test Excel file using absolute path + var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_integration.xlsx"); + + // Create a minimal workbook using NPOI + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + var sheet = workbook.CreateSheet("Test"); + var row = sheet.CreateRow(0); + var cell = row.CreateCell(0); + cell.SetCellValue((string?)"Initial"); + + using (var fs = new FileStream(testFilePath, FileMode.Create)) + { + workbook.Write(fs, false); + } + workbook.Close(); + + try + { + // Act - Write to cell + var result = ExcelService.WriteExcel(testFilePath, "NewValue", 1, 1); + + // Assert + result.ShouldBeTrue(); + } + finally + { + // Clean up + if (File.Exists(testFilePath)) + { + try { File.Delete(testFilePath); } catch { } + } + } + } + + [TestMethod] + [Ignore] // File locking issues in parallel tests + public void WriteExcel_LongOverload_WritesNumericValue() + { + // Arrange + var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_long.xlsx"); + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test").CreateRow(0).CreateCell(0).SetCellValue(0); + using (var fs = new FileStream(testFilePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + try + { + // Act + long testValue = 1234567890L; + var result = ExcelService.WriteExcel(testFilePath, testValue, 1, 1); + + // Assert + result.ShouldBeTrue(); + + // Verify + using (var fs = new FileStream(testFilePath, FileMode.Open, FileAccess.Read)) + { + var wb = new NPOI.XSSF.UserModel.XSSFWorkbook(fs); + var cellValue = wb.GetSheetAt(0).GetRow(0).GetCell(0).NumericCellValue; + cellValue.ShouldBe(1234567890.0); + wb.Close(); + } + } + finally + { + if (File.Exists(testFilePath)) { try { File.Delete(testFilePath); } catch { } } + } + } + + [TestMethod] + [Ignore] // File locking issues in parallel tests + public void WriteExcel_LargeLongValue_MaintainsPrecision() + { + // Arrange + var testFilePath = Path.Combine(Path.GetTempPath(), "test_excel_large_long.xlsx"); + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + workbook.CreateSheet("Test").CreateRow(0).CreateCell(0).SetCellValue(0); + using (var fs = new FileStream(testFilePath, FileMode.Create)) { workbook.Write(fs, false); } + workbook.Close(); + + try + { + // Act - A large number that still fits within double's exact integer representation (53 bits) + long testValue = 9007199254740991L; // 2^53 - 1 + var result = ExcelService.WriteExcel(testFilePath, testValue, 1, 1); + + // Assert + result.ShouldBeTrue(); + + // Verify + using (var fs = new FileStream(testFilePath, FileMode.Open, FileAccess.Read)) + { + var wb = new NPOI.XSSF.UserModel.XSSFWorkbook(fs); + var cellValue = wb.GetSheetAt(0).GetRow(0).GetCell(0).NumericCellValue; + cellValue.ShouldBe((double)testValue); + wb.Close(); + } + } + finally + { + if (File.Exists(testFilePath)) { try { File.Delete(testFilePath); } catch { } } + } + } + + #endregion +} diff --git a/excel_pajak_test/MSTestSettings.cs b/excel_pajak_test/MSTestSettings.cs new file mode 100644 index 0000000..aaf278c --- /dev/null +++ b/excel_pajak_test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/excel_pajak_test/Test1.cs b/excel_pajak_test/Test1.cs new file mode 100644 index 0000000..cf25e29 --- /dev/null +++ b/excel_pajak_test/Test1.cs @@ -0,0 +1,11 @@ +namespace excel_pajak_test +{ + [TestClass] + public sealed class Test1 + { + [TestMethod] + public void TestMethod1() + { + } + } +} diff --git a/excel_pajak_test/appsettings.json b/excel_pajak_test/appsettings.json new file mode 100644 index 0000000..f398050 --- /dev/null +++ b/excel_pajak_test/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port=55432;Database=andal_kharisma;User Id=postgres;Password=Release@2024;Timeout=60;CommandTimeout=120;ServerCompatibilityMode=NoTypeLoading;" + }, + + "SchemaList": ["_onx4pzkwkeortehfjthgyfkb7c"], + "Year": "2025" +} diff --git a/excel_pajak_test/excel_pajak_test.csproj b/excel_pajak_test/excel_pajak_test.csproj new file mode 100644 index 0000000..6f9d741 --- /dev/null +++ b/excel_pajak_test/excel_pajak_test.csproj @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + net10.0 + latest + enable + enable + true + + + + + + + + + + + + + + + diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/excel-service-write-long/spec.md b/openspec/specs/excel-service-write-long/spec.md new file mode 100644 index 0000000..cab613d --- /dev/null +++ b/openspec/specs/excel-service-write-long/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: ExcelService shall support writing long values +The `ExcelService` class SHALL provide a `WriteExcel` overload that accepts a `long` value. + +#### Scenario: WriteExcel with valid long value +- **WHEN** `WriteExcel(string fileName, long value, int row, int column)` is called with valid parameters +- **THEN** the system SHALL write the numeric `long` value to the specified cell +- **AND** the cell type in Excel SHALL be numeric +- **AND** the file SHALL be saved successfully diff --git a/openspec/specs/file-overwrite-control/spec.md b/openspec/specs/file-overwrite-control/spec.md new file mode 100644 index 0000000..bffb4a0 --- /dev/null +++ b/openspec/specs/file-overwrite-control/spec.md @@ -0,0 +1,39 @@ +# file-overwrite-control Specification + +## Purpose +TBD - created by archiving change add-file-overwrite-control. Update Purpose after archive. +## Requirements +### Requirement: ExcelService provides file overwrite control +The ExcelService SHALL provide explicit control over whether existing files should be overwritten during initialization. + +#### Scenario: Create new file when file does not exist +- **WHEN** `InitExcel` is called with `overwrite=true` and file does not exist +- **THEN** method creates new file and returns success with file path + +#### Scenario: Overwrite existing file when overwrite is true +- **WHEN** `InitExcel` is called with `overwrite=true` and file exists +- **THEN** method overwrites existing file and returns success with file path + +#### Scenario: Throw exception when file exists and overwrite is false +- **WHEN** `InitExcel` is called with `overwrite=false` and file exists +- **THEN** method throws `IOException` with message indicating file already exists + +#### Scenario: Create file when overwrite is false and file does not exist +- **WHEN** `InitExcel` is called with `overwrite=false` and file does not exist +- **THEN** method creates new file and returns success with file path + +#### Scenario: Preserve existing file behavior when overwrite parameter is not provided +- **WHEN** `InitExcel` is called without the `overwrite` parameter +- **THEN** method behaves as current implementation (overwrites file) + +### Requirement: ExcelService maintains backward compatibility +The ExcelService SHALL maintain existing behavior for all current method signatures and parameters. + +#### Scenario: Existing code continues to work without changes +- **WHEN** existing code calls `InitExcel` without the `overwrite` parameter +- **THEN** method behaves identically to current implementation (overwrites file) + +#### Scenario: Existing error handling patterns remain valid +- **WHEN** existing code checks the result tuple for success/failure +- **THEN** method returns same tuple structure and error types as current implementation + diff --git a/openspec/specs/formula-refresh/spec.md b/openspec/specs/formula-refresh/spec.md new file mode 100644 index 0000000..5bc1463 --- /dev/null +++ b/openspec/specs/formula-refresh/spec.md @@ -0,0 +1,56 @@ +# Capability: Formula Refresh + +## Purpose +The Formula Refresh capability allows the system to recalculate all formulas in an Excel workbook and update their cached values. This is essential when data has been written to the workbook externally and the calculated results need to be persisted within the file itself. + +## Requirements + +### Requirement: Refresh all formulas in workbook +The ExcelService SHALL provide a public method `RefreshFormulas` that evaluates and recalculates all formulas in an Excel workbook. + +#### Scenario: Successful formula refresh +- **WHEN** a valid Excel file containing formulas is provided to `RefreshFormulas` +- **THEN** all formulas in the workbook are evaluated +- **AND** cached formula values are updated with recalculated results +- **AND** the workbook is saved back to the file +- **AND** the method returns `(success: true, result: , error: null)` + +#### Scenario: File not found +- **WHEN** `RefreshFormulas` is called with a file path that does not exist +- **THEN** the method returns `(success: false, result: null, error: FileNotFoundException)` + +#### Scenario: Invalid or corrupted workbook +- **WHEN** `RefreshFormulas` is called with a file that is not a valid Excel workbook +- **THEN** the method returns `(success: false, result: null, error: )` +- **AND** the original file remains unmodified + +#### Scenario: Empty file path +- **WHEN** `RefreshFormulas` is called with null or empty file path +- **THEN** the method returns `(success: false, result: null, error: ArgumentException)` + +### Requirement: Formula evaluation completeness +The `RefreshFormulas` method SHALL evaluate all formula types across all sheets in the workbook. + +#### Scenario: Multiple sheets with formulas +- **WHEN** a workbook contains multiple sheets with formulas +- **THEN** formulas in all sheets are evaluated +- **AND** cross-sheet references are properly resolved + +#### Scenario: Different formula types +- **WHEN** a workbook contains various formula types (SUM, AVERAGE, IF, VLOOKUP, etc.) +- **THEN** all formula types are evaluated correctly +- **AND** formulas dependent on other formulas are evaluated in correct order + +### Requirement: Resource management +The `RefreshFormulas` method SHALL properly dispose of all resources including file streams and workbook objects. + +#### Scenario: Proper disposal after successful refresh +- **WHEN** formula refresh completes successfully +- **THEN** all file streams are closed and disposed +- **AND** the workbook object is disposed +- **AND** no file locks remain on the Excel file + +#### Scenario: Proper disposal after error +- **WHEN** an exception occurs during formula refresh +- **THEN** all allocated resources are still disposed +- **AND** no file locks remain on the Excel file diff --git a/openspec/specs/postgresql-query/spec.md b/openspec/specs/postgresql-query/spec.md new file mode 100644 index 0000000..3a3a91b --- /dev/null +++ b/openspec/specs/postgresql-query/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Configuration loading from appsettings.json +The system SHALL load the PostgreSQL connection string from `appsettings.json` using the `ConnectionStrings:DefaultConnection` configuration key. + +#### Scenario: Successful configuration loading +- **WHEN** the application starts and `appsettings.json` exists with a valid `ConnectionStrings:DefaultConnection` entry +- **THEN** the system shall successfully load the connection string +- **AND** the connection string shall be available for database connections + +#### Scenario: Missing connection string +- **WHEN** the application starts and `appsettings.json` is missing the `ConnectionStrings:DefaultConnection` entry +- **THEN** the system shall throw an `InvalidOperationException` with a clear error message indicating the missing configuration + +#### Scenario: Invalid appsettings.json format +- **WHEN** the application starts and `appsettings.json` contains invalid JSON +- **THEN** the system shall throw a configuration exception indicating the JSON parsing error + +### Requirement: Database query execution +The system SHALL provide a function that accepts a SQL query string and returns the first column of the first row as a string value. + +#### Scenario: Successful query execution with single result +- **WHEN** a valid SQL query is executed that returns a single row with a single column +- **THEN** the system shall return the value as a string +- **AND** the database connection shall be properly opened and closed + +#### Scenario: Query returning NULL value +- **WHEN** a SQL query is executed that returns a NULL value in the first column of the first row +- **THEN** the system shall return `null` instead of throwing an exception + +#### Scenario: Query returning no rows +- **WHEN** a SQL query is executed that returns zero rows +- **THEN** the system shall return `null` + +#### Scenario: Invalid SQL syntax +- **WHEN** a SQL query with invalid syntax is executed +- **THEN** the system shall throw a `PostgresException` with the PostgreSQL error details + +#### Scenario: Database connection failure +- **WHEN** the database server is unreachable or credentials are invalid +- **THEN** the system shall throw a `PostgresException` or `NpgsqlException` with connection error details + +### Requirement: Connection management +The system SHALL properly manage database connections using proper disposal patterns to prevent connection leaks. + +#### Scenario: Connection properly disposed after query +- **WHEN** a query is executed successfully +- **THEN** the database connection shall be disposed after the query completes +- **AND** the connection shall be returned to the connection pool + +#### Scenario: Connection disposed on exception +- **WHEN** a query execution throws an exception +- **THEN** the database connection shall still be properly disposed +- **AND** no connection leak shall occur + +### Requirement: Static Service Initialization +The system SHALL provide a static method to initialize the database service with a connection string. + +#### Scenario: Successful static initialization +- **WHEN** the `Initialize` method is called with a valid connection string +- **THEN** the static internal state shall be updated +- **AND** subsequent query calls shall use this connection string + +### Requirement: Synchronous Database Query Execution +The system SHALL provide static functions that accept SQL query strings and return results synchronously. + +#### Scenario: Successful synchronous query execution +- **WHEN** a valid SQL query is executed via a static method +- **THEN** the system shall return the result string synchronously +- **AND** the database connection shall be properly opened and closed synchronously + +### Requirement: Static Configuration Access +The system SHALL provide static access to configuration properties through a dedicated service. + +#### Scenario: Accessing connection string via static method +- **WHEN** the static `GetConnectionString()` method is called +- **THEN** it shall return the connection string from the internal static configuration +- **AND** it shall throw an `InvalidOperationException` if the configuration is missing the required key + +### Requirement: Static Configuration Initialization +The system SHALL provide a way to initialize the static configuration state. + +#### Scenario: Default static initialization +- **WHEN** the configuration service is first accessed without explicit initialization +- **THEN** it shall automatically load configuration from `appsettings.json` + +#### Scenario: Explicit static initialization +- **WHEN** the `Initialize` method is called with a custom `IConfiguration` object +- **THEN** the static internal state shall be updated with the provided configuration diff --git a/query_employee_data.sql b/query_employee_data.sql new file mode 100644 index 0000000..d9d6638 --- /dev/null +++ b/query_employee_data.sql @@ -0,0 +1,176 @@ +with months as ( + select '202501' as payment_period + union all select '202502' + union all select '202503' + union all select '202504' + union all select '202505' + union all select '202506' + union all select '202507' + union all select '202508' + union all select '202509' + union all select '202510' + union all select '202511' + union all select '202512' +), +tax_calculation_basal as ( + select erp."TaxCalculationType" + from _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp + inner join _onx4pzkwkeortehfjthgyfkb7c."Employees" e on erp."EmployeeId" = e."Id" + where erp."PaymentPeriod" between '202501' and '202512' + and e."NIK" = 'PS2MA001' + and erp."BasicSalary" > 0 + order by erp."PaymentPeriod" desc + limit 1 +), +matched_records as ( + select e."NIK" as employee_number, + e."Name" as employee_name, + e."NPWP" as employee_npwp, + (case + when erp."TaxStatus"=1 then 'TK0' + when erp."TaxStatus"=2 then 'TK1' + when erp."TaxStatus"=3 then 'TK2' + when erp."TaxStatus"=4 then 'TK3' + when erp."TaxStatus"=5 then 'K0' + when erp."TaxStatus"=6 then 'K1' + when erp."TaxStatus"=7 then 'K2' + when erp."TaxStatus"=8 then 'K3' + end) as tax_marital_status, + ets."EmployeeTaxStatusCode" as employee_tax_status, + m.payment_period, + erp."NPWPPemotong" as npwp_pemotong, + (case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type, + erp."BasicSalary" as basic_salary, + erp."Overtime" as overtime, + erp."AllowanceRegular" as allowance, + erp."Deductions" as deduction, + erp."Natura" as benefit_in_kind, + erp."Severance" as severance, + erp."JKKCompany" as jkk, + erp."JKMCompany" as jkm, + erp."BPJSKesCompany" + erp."OtherInsuranceCompany" as bpjs_kesehatan_company, + erp."JHTEmployee" as jht_emp, + erp."JPEmployee" as jp_emp, + 0 as pension_emp, + erp."EmployeeCondition" as employee_condition, + erp."ResignType" as resign_type, + erp."AllowanceTaxRegular" as allowance_tax_regular, + erp."TaxRegular" as tax_regular, + erp."AllowanceIrregular" as allowance_irregular, + 0 as benefit_in_kind_irregular, + erp."AllowanceTaxIrregular" as allowance_tax_irregular, + erp."TaxIrregular" as tax_irregular, + 0 as severance_tax, + 0 as allowance_severance_tax, + erp."BegSalaryNetto" as beginning_salary_netto, + erp."BegPPh21" as beginning_pph21 + from months m + left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp + on m.payment_period = erp."PaymentPeriod" + left join _onx4pzkwkeortehfjthgyfkb7c."Employees" e + on erp."EmployeeId" = e."Id" + and e."NIK" = 'PS2MA001' + left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeTaxStatuses" ets + on e."EmployeeTaxStatusRefId" = ets."Id" + where erp."TaxCalculationType" = (select "TaxCalculationType" from tax_calculation_basal) +), +unmatched_records as ( + select e."NIK" as employee_number, + e."Name" as employee_name, + e."NPWP" as employee_npwp, + (case + when erp."TaxStatus"=1 then 'TK0' + when erp."TaxStatus"=2 then 'TK1' + when erp."TaxStatus"=3 then 'TK2' + when erp."TaxStatus"=4 then 'TK3' + when erp."TaxStatus"=5 then 'K0' + when erp."TaxStatus"=6 then 'K1' + when erp."TaxStatus"=7 then 'K2' + when erp."TaxStatus"=8 then 'K3' + end) as tax_marital_status, + ets."EmployeeTaxStatusCode" as employee_tax_status, + m.payment_period, + erp."NPWPPemotong" as npwp_pemotong, + (case erp."TaxCalculationType" when 2 then 'PaidByEmployee' when 3 then 'PaidAsAllowance' else 'PaidByCompany' end) as tax_type, + erp."BasicSalary" as basic_salary, + erp."Overtime" as overtime, + erp."AllowanceRegular" as allowance, + erp."Deductions" as deduction, + erp."Natura" as benefit_in_kind, + erp."Severance" as severance, + erp."JKKCompany" as jkk, + erp."JKMCompany" as jkm, + erp."BPJSKesCompany" + erp."OtherInsuranceCompany" as bpjs_kesehatan_company, + erp."JHTEmployee" as jht_emp, + erp."JPEmployee" as jp_emp, + 0 as pension_emp, + erp."EmployeeCondition" as employee_condition, + erp."ResignType" as resign_type, + erp."AllowanceTaxRegular" as allowance_tax_regular, + erp."TaxRegular" as tax_regular, + erp."AllowanceIrregular" as allowance_irregular, + 0 as benefit_in_kind_irregular, + erp."AllowanceTaxIrregular" as allowance_tax_irregular, + erp."TaxIrregular" as tax_irregular, + 0 as severance_tax, + 0 as allowance_severance_tax, + erp."BegSalaryNetto" as beginning_salary_netto, + erp."BegPPh21" as beginning_pph21 + from months m + left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeReportPeriodics" erp + on m.payment_period = erp."PaymentPeriod" + left join _onx4pzkwkeortehfjthgyfkb7c."Employees" e + on erp."EmployeeId" = e."Id" + and e."NIK" = 'PS2MA001' + left join _onx4pzkwkeortehfjthgyfkb7c."EmployeeTaxStatuses" ets + on e."EmployeeTaxStatusRefId" = ets."Id" + where erp."TaxCalculationType" <> (select "TaxCalculationType" from tax_calculation_basal) + and erp."TaxCalculationType" = 0 +), +empty_records as ( + select null::text as employee_number, + ''::text as employee_name, + ''::text as employee_npwp, + null::text as tax_marital_status, + null::integer as employee_tax_status, + m.payment_period, + ''::text as npwp_pemotong, + ''::text as tax_type, + 0::numeric as basic_salary, + 0::numeric as overtime, + 0::numeric as allowance, + 0::numeric as deduction, + 0::numeric as benefit_in_kind, + 0::numeric as severance, + 0::numeric as jkk, + 0::numeric as jkm, + 0::numeric as bpjs_kesehatan_company, + 0::numeric as jht_emp, + 0::numeric as jp_emp, + 0::numeric as pension_emp, + null::integer as employee_condition, + null::integer as resign_type, + 0::numeric as allowance_tax_regular, + 0::numeric as tax_regular, + 0::numeric as allowance_irregular, + 0::numeric as benefit_in_kind_irregular, + 0::numeric as allowance_tax_irregular, + 0::numeric as tax_irregular, + 0::numeric as severance_tax, + 0::numeric as allowance_severance_tax, + 0::numeric as beginning_salary_netto, + 0::numeric as beginning_pph21 + from months m + where not exists ( + select 1 from matched_records mr where mr.payment_period = m.payment_period + ) + and not exists ( + select 1 from unmatched_records ur where ur.payment_period = m.payment_period + ) +) +select * from matched_records +union all +select * from unmatched_records +union all +select * from empty_records +order by payment_period