diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs index ef420d6..05f1af9 100644 --- a/TaxBaik.Application/DependencyInjection.cs +++ b/TaxBaik.Application/DependencyInjection.cs @@ -20,6 +20,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/TaxBaik.Application/Services/ConsultingActivityService.cs b/TaxBaik.Application/Services/ConsultingActivityService.cs new file mode 100644 index 0000000..0a3d98d --- /dev/null +++ b/TaxBaik.Application/Services/ConsultingActivityService.cs @@ -0,0 +1,47 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ConsultingActivityService(IConsultingActivityRepository repository) +{ + public async Task CreateAsync(int clientId, string activityType, DateTime activityDate, + string description, int? consultantId = null, DateTime? nextFollowupDate = null, CancellationToken ct = default) + { + if (clientId <= 0) + throw new ValidationException("유효한 고객을 선택하세요."); + if (string.IsNullOrWhiteSpace(activityType)) + throw new ValidationException("활동 유형을 입력하세요."); + if (string.IsNullOrWhiteSpace(description)) + throw new ValidationException("활동 내용을 입력하세요."); + + var activity = new ConsultingActivity + { + ClientId = clientId, + ActivityType = activityType.Trim(), + ActivityDate = activityDate, + Description = description.Trim(), + AssignedConsultantId = consultantId, + NextFollowupDate = nextFollowupDate, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await repository.CreateAsync(activity, ct); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task> GetPendingFollowupsAsync(CancellationToken ct = default) => + await repository.GetPendingFollowupsAsync(ct); + + public async Task> GetConsultantActivityAsync(int consultantId, DateTime fromDate, CancellationToken ct = default) => + await repository.GetByConsultantAsync(consultantId, fromDate, ct); + + public async Task UpdateAsync(int id, string? outcome, DateTime? nextFollowupDate, CancellationToken ct = default) + { + var activity = new ConsultingActivity { Id = id, Outcome = outcome, NextFollowupDate = nextFollowupDate, UpdatedAt = DateTime.UtcNow }; + await repository.UpdateAsync(activity, ct); + } +} diff --git a/TaxBaik.Application/Services/ContractService.cs b/TaxBaik.Application/Services/ContractService.cs new file mode 100644 index 0000000..f902b8f --- /dev/null +++ b/TaxBaik.Application/Services/ContractService.cs @@ -0,0 +1,50 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ContractService(IContractRepository repository) +{ + public async Task CreateAsync(int clientId, string contractNumber, string serviceType, + DateTime startDate, decimal? monthlyFee = null, decimal? totalAmount = null, CancellationToken ct = default) + { + if (clientId <= 0) + throw new ValidationException("유효한 고객을 선택하세요."); + if (string.IsNullOrWhiteSpace(contractNumber)) + throw new ValidationException("계약 번호를 입력하세요."); + if (string.IsNullOrWhiteSpace(serviceType)) + throw new ValidationException("서비스 유형을 입력하세요."); + + var contract = new Contract + { + ClientId = clientId, + ContractNumber = contractNumber.Trim(), + ServiceType = serviceType.Trim(), + ContractDate = DateTime.Today, + StartDate = startDate, + MonthlyFee = monthlyFee, + TotalAmount = totalAmount, + Status = "active", + PaymentStatus = "pending", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await repository.CreateAsync(contract, ct); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task> GetActiveContractsAsync(CancellationToken ct = default) => + await repository.GetActiveContractsAsync(ct); + + public async Task> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken ct = default) => + await repository.GetExpiringContractsAsync(daysAhead, ct); + + public async Task GetMonthlyRecurringRevenueAsync(CancellationToken ct = default) => + await repository.GetMonthlyRecurringRevenueAsync(ct); +} diff --git a/TaxBaik.Application/Services/RevenueTrackingService.cs b/TaxBaik.Application/Services/RevenueTrackingService.cs new file mode 100644 index 0000000..d9a3b2e --- /dev/null +++ b/TaxBaik.Application/Services/RevenueTrackingService.cs @@ -0,0 +1,52 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class RevenueTrackingService(IRevenueTrackingRepository repository) +{ + public async Task CreateAsync(int clientId, string invoiceNumber, DateTime invoiceDate, + decimal amount, string? serviceType = null, DateTime? dueDate = null, CancellationToken ct = default) + { + if (clientId <= 0) + throw new ValidationException("유효한 고객을 선택하세요."); + if (string.IsNullOrWhiteSpace(invoiceNumber)) + throw new ValidationException("인보이스 번호를 입력하세요."); + if (amount <= 0) + throw new ValidationException("금액은 0보다 커야 합니다."); + + var revenue = new RevenueTracking + { + ClientId = clientId, + InvoiceNumber = invoiceNumber.Trim(), + InvoiceDate = invoiceDate, + Amount = amount, + ServiceType = string.IsNullOrWhiteSpace(serviceType) ? null : serviceType.Trim(), + DueDate = dueDate, + PaymentStatus = "pending", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await repository.CreateAsync(revenue, ct); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task> GetPendingPaymentsAsync(CancellationToken ct = default) => + await repository.GetPendingPaymentsAsync(ct); + + public async Task> GetMonthlyRevenueAsync(DateTime month, CancellationToken ct = default) + { + var startDate = new DateTime(month.Year, month.Month, 1); + var endDate = startDate.AddMonths(1).AddDays(-1); + return await repository.GetByDateRangeAsync(startDate, endDate, ct); + } + + public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken ct = default) => + await repository.MarkPaidAsync(id, paymentDate, ct); + + public async Task GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken ct = default) => + await repository.GetTotalRevenueAsync(startDate, endDate, ct); +} diff --git a/TaxBaik.Application/Services/TaxFilingScheduleService.cs b/TaxBaik.Application/Services/TaxFilingScheduleService.cs new file mode 100644 index 0000000..fcf1dbd --- /dev/null +++ b/TaxBaik.Application/Services/TaxFilingScheduleService.cs @@ -0,0 +1,50 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class TaxFilingScheduleService(ITaxFilingScheduleRepository repository) +{ + public async Task CreateAsync(int clientId, string filingType, DateTime dueDate, int filingYear, + int? assignedToId = null, CancellationToken ct = default) + { + if (clientId <= 0) + throw new ValidationException("유효한 고객을 선택하세요."); + if (string.IsNullOrWhiteSpace(filingType)) + throw new ValidationException("신고 유형을 입력하세요."); + if (dueDate < DateTime.Today) + throw new ValidationException("마감일은 오늘 이후여야 합니다."); + + var schedule = new TaxFilingSchedule + { + ClientId = clientId, + FilingType = filingType.Trim(), + DueDate = dueDate, + FilingYear = filingYear, + Status = "pending", + AssignedToId = assignedToId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await repository.CreateAsync(schedule, ct); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) => + await repository.GetByIdAsync(id, ct); + + public async Task> GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken ct = default) => + await repository.GetUpcomingDuesAsync(daysAhead, ct); + + public async Task MarkCompletedAsync(int id, CancellationToken ct = default) => + await repository.MarkCompletedAsync(id, ct); + + public async Task GetPendingCountAsync(CancellationToken ct = default) + { + var pending = await repository.GetByStatusAsync("pending", ct); + return pending.Count(); + } +} diff --git a/TaxBaik.Application/Services/TaxProfileService.cs b/TaxBaik.Application/Services/TaxProfileService.cs new file mode 100644 index 0000000..7dfe1d4 --- /dev/null +++ b/TaxBaik.Application/Services/TaxProfileService.cs @@ -0,0 +1,58 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class TaxProfileService(ITaxProfileRepository repository) +{ + public async Task CreateAsync(int clientId, string? businessType, string? businessRegistration = null, + string? accountingMethod = null, DateTime? establishmentDate = null, CancellationToken ct = default) + { + if (clientId <= 0) + throw new ValidationException("유효한 고객을 선택하세요."); + if (string.IsNullOrWhiteSpace(businessType)) + throw new ValidationException("사업 유형을 입력하세요."); + + var profile = new TaxProfile + { + ClientId = clientId, + BusinessType = businessType.Trim(), + BusinessRegistration = string.IsNullOrWhiteSpace(businessRegistration) ? null : businessRegistration.Trim(), + EstablishmentDate = establishmentDate, + AccountingMethod = string.IsNullOrWhiteSpace(accountingMethod) ? null : accountingMethod.Trim(), + TaxRiskLevel = "normal", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await repository.CreateAsync(profile, ct); + } + + public async Task GetByClientIdAsync(int clientId, CancellationToken ct = default) => + await repository.GetByClientIdAsync(clientId, ct); + + public async Task UpdateAsync(int profileId, string? businessType, string? accountingMethod, + DateTime? nextFilingDueDate, string taxRiskLevel = "normal", CancellationToken ct = default) + { + var profile = new TaxProfile { Id = profileId }; + if (!string.IsNullOrWhiteSpace(businessType)) + profile.BusinessType = businessType.Trim(); + if (!string.IsNullOrWhiteSpace(accountingMethod)) + profile.AccountingMethod = accountingMethod.Trim(); + profile.NextFilingDueDate = nextFilingDueDate; + profile.TaxRiskLevel = taxRiskLevel; + profile.UpdatedAt = DateTime.UtcNow; + + await repository.UpdateAsync(profile, ct); + } + + public async Task> GetHighRiskProfilesAsync(CancellationToken ct = default) => + await repository.GetByRiskLevelAsync("high", ct); + + public async Task> GetUpcomingFilingDuesAsync(int daysAhead = 30, CancellationToken ct = default) + { + var startDate = DateTime.Today; + var endDate = startDate.AddDays(daysAhead); + return await repository.GetUpcomingFilingDuesAsync(startDate, endDate, ct); + } +} diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs index 9c99c09..19610c1 100644 --- a/TaxBaik.Infrastructure/DependencyInjection.cs +++ b/TaxBaik.Infrastructure/DependencyInjection.cs @@ -21,6 +21,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/TaxBaik.Infrastructure/Repositories/ConsultingActivityRepository.cs b/TaxBaik.Infrastructure/Repositories/ConsultingActivityRepository.cs new file mode 100644 index 0000000..0883e19 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/ConsultingActivityRepository.cs @@ -0,0 +1,57 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ConsultingActivityRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IConsultingActivityRepository +{ + public async Task CreateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO consulting_activities (client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at) + VALUES (@ClientId, @ActivityType, @ActivityDate, @ActivityTime, @AssignedConsultantId, @Description, @Outcome, @NextFollowupDate, @Notes, NOW(), NOW()) + RETURNING id", + activity); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at + FROM consulting_activities WHERE client_id = @ClientId ORDER BY activity_date DESC", + new { ClientId = clientId }); + } + + public async Task> GetPendingFollowupsAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at + FROM consulting_activities WHERE next_followup_date IS NOT NULL AND next_followup_date <= CURRENT_DATE + ORDER BY next_followup_date ASC"); + } + + public async Task> GetByConsultantAsync(int consultantId, DateTime fromDate, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, activity_type, activity_date, activity_time, assigned_consultant, description, outcome, next_followup_date, notes, created_at, updated_at + FROM consulting_activities WHERE assigned_consultant = @ConsultantId AND activity_date >= @FromDate + ORDER BY activity_date DESC", + new { ConsultantId = consultantId, FromDate = fromDate }); + } + + public async Task UpdateAsync(ConsultingActivity activity, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE consulting_activities SET activity_type = @ActivityType, activity_date = @ActivityDate, + activity_time = @ActivityTime, assigned_consultant = @AssignedConsultantId, description = @Description, + outcome = @Outcome, next_followup_date = @NextFollowupDate, notes = @Notes, updated_at = NOW() + WHERE id = @Id", + activity); + } +} diff --git a/TaxBaik.Infrastructure/Repositories/ContractRepository.cs b/TaxBaik.Infrastructure/Repositories/ContractRepository.cs new file mode 100644 index 0000000..e68f535 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/ContractRepository.cs @@ -0,0 +1,74 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class ContractRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IContractRepository +{ + public async Task CreateAsync(Contract contract, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO contracts (client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at) + VALUES (@ClientId, @ContractNumber, @ServiceType, @ContractDate, @StartDate, @EndDate, @MonthlyFee, @TotalAmount, @PaymentStatus, @Status, @Notes, NOW(), NOW()) + RETURNING id", + contract); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at + FROM contracts WHERE id = @Id", + new { Id = id }); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at + FROM contracts WHERE client_id = @ClientId ORDER BY contract_date DESC", + new { ClientId = clientId }); + } + + public async Task> GetActiveContractsAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at + FROM contracts WHERE status = 'active' ORDER BY client_id"); + } + + public async Task> GetExpiringContractsAsync(int daysAhead = 30, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, contract_number, service_type, contract_date, start_date, end_date, monthly_fee, total_amount, payment_status, status, notes, created_at, updated_at + FROM contracts + WHERE status = 'active' AND end_date IS NOT NULL AND end_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead + ORDER BY end_date ASC", + new { DaysAhead = daysAhead }); + } + + public async Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE contracts SET contract_number = @ContractNumber, service_type = @ServiceType, contract_date = @ContractDate, + start_date = @StartDate, end_date = @EndDate, monthly_fee = @MonthlyFee, total_amount = @TotalAmount, + payment_status = @PaymentStatus, status = @Status, notes = @Notes, updated_at = NOW() + WHERE id = @Id", + contract); + } + + public async Task GetMonthlyRecurringRevenueAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + var result = await conn.QueryFirstAsync( + @"SELECT COALESCE(SUM(monthly_fee), 0) FROM contracts WHERE status = 'active' AND monthly_fee IS NOT NULL"); + return result; + } +} diff --git a/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs b/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs new file mode 100644 index 0000000..8a527a9 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/RevenueTrackingRepository.cs @@ -0,0 +1,72 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class RevenueTrackingRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IRevenueTrackingRepository +{ + public async Task CreateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO revenue_tracking (client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at) + VALUES (@ClientId, @InvoiceNumber, @InvoiceDate, @ServiceType, @Amount, @PaymentStatus, @PaymentDate, @DueDate, @Notes, NOW(), NOW()) + RETURNING id", + revenue); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at + FROM revenue_tracking WHERE client_id = @ClientId ORDER BY invoice_date DESC", + new { ClientId = clientId }); + } + + public async Task> GetPendingPaymentsAsync(CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at + FROM revenue_tracking WHERE payment_status = 'pending' ORDER BY due_date ASC"); + } + + public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, invoice_number, invoice_date, service_type, amount, payment_status, payment_date, due_date, notes, created_at, updated_at + FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate ORDER BY invoice_date DESC", + new { StartDate = startDate, EndDate = endDate }); + } + + public async Task UpdateAsync(RevenueTracking revenue, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE revenue_tracking SET invoice_number = @InvoiceNumber, invoice_date = @InvoiceDate, + service_type = @ServiceType, amount = @Amount, payment_status = @PaymentStatus, + payment_date = @PaymentDate, due_date = @DueDate, notes = @Notes, updated_at = NOW() + WHERE id = @Id", + revenue); + } + + public async Task MarkPaidAsync(int id, DateTime paymentDate, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE revenue_tracking SET payment_status = 'paid', payment_date = @PaymentDate, updated_at = NOW() WHERE id = @Id", + new { Id = id, PaymentDate = paymentDate }); + } + + public async Task GetTotalRevenueAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + var result = await conn.QueryFirstAsync( + @"SELECT COALESCE(SUM(amount), 0) FROM revenue_tracking WHERE invoice_date BETWEEN @StartDate AND @EndDate", + new { StartDate = startDate, EndDate = endDate }); + return result; + } +} diff --git a/TaxBaik.Infrastructure/Repositories/TaxFilingScheduleRepository.cs b/TaxBaik.Infrastructure/Repositories/TaxFilingScheduleRepository.cs new file mode 100644 index 0000000..6c2c4ab --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/TaxFilingScheduleRepository.cs @@ -0,0 +1,73 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class TaxFilingScheduleRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxFilingScheduleRepository +{ + public async Task CreateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO tax_filing_schedules (client_id, filing_type, due_date, filing_year, status, assigned_to, notes, created_at, updated_at) + VALUES (@ClientId, @FilingType, @DueDate, @FilingYear, @Status, @AssignedToId, @Notes, NOW(), NOW()) + RETURNING id", + schedule); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at + FROM tax_filing_schedules WHERE id = @Id", + new { Id = id }); + } + + public async Task> GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at + FROM tax_filing_schedules WHERE client_id = @ClientId ORDER BY due_date DESC", + new { ClientId = clientId }); + } + + public async Task> GetUpcomingDuesAsync(int daysAhead = 30, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at + FROM tax_filing_schedules + WHERE status = 'pending' AND due_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '1 day' * @DaysAhead + ORDER BY due_date ASC", + new { DaysAhead = daysAhead }); + } + + public async Task> GetByStatusAsync(string status, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, filing_type, due_date, filing_year, status, assigned_to, completed_date, notes, created_at, updated_at + FROM tax_filing_schedules WHERE status = @Status ORDER BY due_date", + new { Status = status }); + } + + public async Task UpdateAsync(TaxFilingSchedule schedule, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE tax_filing_schedules SET filing_type = @FilingType, due_date = @DueDate, status = @Status, + assigned_to = @AssignedToId, notes = @Notes, updated_at = NOW() WHERE id = @Id", + schedule); + } + + public async Task MarkCompletedAsync(int id, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE tax_filing_schedules SET status = 'completed', completed_date = NOW(), updated_at = NOW() WHERE id = @Id", + new { Id = id }); + } +} diff --git a/TaxBaik.Infrastructure/Repositories/TaxProfileRepository.cs b/TaxBaik.Infrastructure/Repositories/TaxProfileRepository.cs new file mode 100644 index 0000000..f1eedc2 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/TaxProfileRepository.cs @@ -0,0 +1,70 @@ +namespace TaxBaik.Infrastructure.Repositories; + +using Dapper; +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class TaxProfileRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), ITaxProfileRepository +{ + public async Task CreateAsync(TaxProfile profile, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"INSERT INTO tax_profiles (client_id, business_registration, business_type, establishment_date, + annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date, + next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at) + VALUES (@ClientId, @BusinessRegistration, @BusinessType, @EstablishmentDate, @AnnualRevenueRange, + @EmployeeCount, @AccountingMethod, @FiscalYearEnd, @LastFilingDate, @NextFilingDueDate, + @TaxRiskLevel, @PreviousAuditHistory, @SpecialNotes, NOW(), NOW()) + RETURNING id", + profile); + } + + public async Task GetByClientIdAsync(int clientId, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"SELECT id, client_id, business_registration, business_type, establishment_date, + annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date, + next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at + FROM tax_profiles WHERE client_id = @ClientId", + new { ClientId = clientId }); + } + + public async Task UpdateAsync(TaxProfile profile, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + await conn.ExecuteAsync( + @"UPDATE tax_profiles SET business_registration = @BusinessRegistration, business_type = @BusinessType, + establishment_date = @EstablishmentDate, annual_revenue_range = @AnnualRevenueRange, + employee_count = @EmployeeCount, accounting_method = @AccountingMethod, fiscal_year_end = @FiscalYearEnd, + last_filing_date = @LastFilingDate, next_filing_due_date = @NextFilingDueDate, + tax_risk_level = @TaxRiskLevel, previous_audit_history = @PreviousAuditHistory, + special_notes = @SpecialNotes, updated_at = NOW() + WHERE id = @Id", + profile); + } + + public async Task> GetByRiskLevelAsync(string riskLevel, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, business_registration, business_type, establishment_date, + annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date, + next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at + FROM tax_profiles WHERE tax_risk_level = @RiskLevel ORDER BY client_id", + new { RiskLevel = riskLevel }); + } + + public async Task> GetUpcomingFilingDuesAsync(DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"SELECT id, client_id, business_registration, business_type, establishment_date, + annual_revenue_range, employee_count, accounting_method, fiscal_year_end, last_filing_date, + next_filing_due_date, tax_risk_level, previous_audit_history, special_notes, created_at, updated_at + FROM tax_profiles WHERE next_filing_due_date BETWEEN @StartDate AND @EndDate + ORDER BY next_filing_due_date", + new { StartDate = startDate, EndDate = endDate }); + } +} diff --git a/TaxBaik.Web/Controllers/TaxProfileController.cs b/TaxBaik.Web/Controllers/TaxProfileController.cs new file mode 100644 index 0000000..d285356 --- /dev/null +++ b/TaxBaik.Web/Controllers/TaxProfileController.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TaxBaik.Application.Services; + +namespace TaxBaik.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class TaxProfileController(TaxProfileService taxProfileService) : ControllerBase +{ + [HttpPost] + public async Task Create([FromBody] CreateTaxProfileRequest request) + { + try + { + var id = await taxProfileService.CreateAsync(request.ClientId, request.BusinessType, + request.BusinessRegistration, request.AccountingMethod, request.EstablishmentDate); + return CreatedAtAction(nameof(GetByClientId), new { clientId = request.ClientId }, new { id }); + } + catch (ValidationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("client/{clientId:int}")] + public async Task GetByClientId(int clientId) + { + try + { + var profile = await taxProfileService.GetByClientIdAsync(clientId); + if (profile == null) + return NotFound(new { error = "세무 프로필을 찾을 수 없습니다." }); + return Ok(profile); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "조회 실패", message = ex.Message }); + } + } + + [HttpGet("high-risk")] + public async Task GetHighRiskProfiles() + { + try + { + var profiles = await taxProfileService.GetHighRiskProfilesAsync(); + return Ok(new { data = profiles }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "조회 실패", message = ex.Message }); + } + } + + [HttpGet("upcoming-filings")] + public async Task GetUpcomingFiliings([FromQuery] int daysAhead = 30) + { + try + { + var profiles = await taxProfileService.GetUpcomingFilingDuesAsync(daysAhead); + return Ok(new { data = profiles, daysAhead }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "조회 실패", message = ex.Message }); + } + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateTaxProfileRequest request) + { + try + { + await taxProfileService.UpdateAsync(id, request.BusinessType, request.AccountingMethod, + request.NextFilingDueDate, request.TaxRiskLevel); + return Ok(new { message = "세무 프로필이 수정되었습니다." }); + } + catch (ValidationException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "수정 실패", message = ex.Message }); + } + } + + public record CreateTaxProfileRequest( + int ClientId, string BusinessType, string? BusinessRegistration = null, + string? AccountingMethod = null, DateTime? EstablishmentDate = null); + + public record UpdateTaxProfileRequest( + string? BusinessType = null, string? AccountingMethod = null, + DateTime? NextFilingDueDate = null, string TaxRiskLevel = "normal"); +}