using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Application.Abstractions.Data;
using Application.Abstractions.Messaging;
using Microsoft.EntityFrameworkCore;
using SharedKernel.Helpers;
using SharedKernel.Results;
using CouponCodeEntity = Domain.Entities.Store.CouponCode;
namespace Application.Features.Admin.Store.Coupon.ImportCodes;
///
/// CSV 양식 (DB 컬럼명 기준, 9 컬럼):
/// ID, Code, Status, IssuedToMemberID, IssuedToMemberName, IssuedToMemberEmail, IssuedAt, UsedAt, CreatedAt
///
/// Upload 규칙:
/// - 헤더 행 필수 (대소문자 무관, 영어 컬럼명)
/// - Status = 0 (Available 신규 등록) 또는 1 (Used; 회원/일시 필수)
/// - IssuedToMemberID 가 Member 테이블에 존재해야 함 (Status=1 일 때)
/// - IssuedToMemberName / IssuedToMemberEmail / CreatedAt / ID 는 import 시 무시 (참고용)
/// - 일시는 KST 입력 → UTC 변환 후 저장
/// - 기존 코드 hash 중복은 skip
///
public sealed class Handler(IAppDbContext db) : ICommandHandler>
{
private const int BatchSize = 500;
private const int LookupBatchSize = 1000;
private static readonly string[] ExpectedHeader =
[
"ID",
"Code",
"Status",
"IssuedToMemberID",
"IssuedToMemberName",
"IssuedToMemberEmail",
"IssuedAt",
"UsedAt",
"CreatedAt"
];
public async Task> Handle(Command request, CancellationToken ct)
{
var coupon = await db.Coupon.FirstOrDefaultAsync(c => c.ID == request.CouponID, ct);
if (coupon is null)
{
return Result.Failure(Error.NotFound("Coupon.NotFound", "쿠폰을 찾을 수 없습니다."));
}
if (request.CsvFile is null || request.CsvFile.Length == 0)
{
return Result.Failure(Error.Problem("Coupon.EmptyFile", "업로드할 CSV 파일이 비어있습니다."));
}
if (request.CsvFile.Length > 10 * 1024 * 1024)
{
return Result.Failure(Error.Problem("Coupon.FileTooLarge", "CSV 파일은 10MB 이하만 허용합니다."));
}
List rows;
using (var stream = request.CsvFile.OpenReadStream())
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
rows = [.. CsvHelper.Read(reader)];
}
if (rows.Count == 0)
{
return Result.Failure(Error.Problem("Coupon.EmptyCsv", "CSV 에 데이터가 없습니다."));
}
var header = rows[0];
if (header.Length < ExpectedHeader.Length)
{
return Result.Failure(Error.Problem("Coupon.InvalidHeader",
$"CSV 헤더 컬럼 수 부족. {ExpectedHeader.Length}개 필요, {header.Length}개 발견."));
}
for (var i = 0; i < ExpectedHeader.Length; i++)
{
if (!string.Equals(header[i].Trim(), ExpectedHeader[i], StringComparison.OrdinalIgnoreCase))
{
return Result.Failure(Error.Problem("Coupon.InvalidHeader",
$"CSV 헤더 {i + 1}번째 컬럼이 다릅니다. 기대: {ExpectedHeader[i]}, 실제: {header[i]}"));
}
}
var dataRows = rows.Skip(1).ToList();
var totalLines = dataRows.Count;
// 회원 ID 사전 lookup (Status=1 행 후보)
var memberIDsInCsv = new HashSet();
foreach (var row in dataRows)
{
if (row.Length < 4)
{
continue;
}
if (int.TryParse(row[3].Trim(), out var mid) && mid > 0)
{
memberIDsInCsv.Add(mid);
}
}
var existingMemberIDs = new HashSet();
foreach (var batch in memberIDsInCsv.Chunk(LookupBatchSize))
{
var found = await db.Member.AsNoTracking()
.Where(m => batch.Contains(m.ID))
.Select(m => m.ID)
.ToListAsync(ct);
foreach (var id in found)
{
existingMemberIDs.Add(id);
}
}
// 기존 코드 hash 사전 조회
var hashesInCsv = new HashSet();
var rowHashes = new string?[dataRows.Count];
for (var i = 0; i < dataRows.Count; i++)
{
var row = dataRows[i];
if (row.Length < 2)
{
continue;
}
var code = row[1].Trim();
if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
{
continue;
}
var hash = ComputeHash(code);
rowHashes[i] = hash;
hashesInCsv.Add(hash);
}
var existingHashes = new HashSet();
foreach (var batch in hashesInCsv.Chunk(LookupBatchSize))
{
var found = await db.CouponCode.AsNoTracking()
.Where(c => batch.Contains(c.CodeHash))
.Select(c => c.CodeHash)
.ToListAsync(ct);
foreach (var h in found)
{
existingHashes.Add(h);
}
}
var added = 0;
var addedAvailable = 0;
var addedUsed = 0;
var skippedDuplicate = 0;
var skippedInvalid = 0;
var skippedMissingMember = 0;
var batchAddedSinceSave = 0;
var seenInCsv = new HashSet();
for (var i = 0; i < dataRows.Count; i++)
{
var row = dataRows[i];
if (row.Length < ExpectedHeader.Length)
{
skippedInvalid++;
continue;
}
var code = row[1].Trim();
if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
{
skippedInvalid++;
continue;
}
var hash = rowHashes[i];
if (hash is null)
{
skippedInvalid++;
continue;
}
if (existingHashes.Contains(hash))
{
skippedDuplicate++;
continue;
}
if (!seenInCsv.Add(hash))
{
skippedDuplicate++;
continue;
}
var statusStr = row[2].Trim();
if (statusStr != "0" && statusStr != "1")
{
skippedInvalid++;
continue;
}
CouponCodeEntity entity;
if (statusStr == "0")
{
entity = CouponCodeEntity.Create(request.CouponID, code);
addedAvailable++;
}
else
{
if (!int.TryParse(row[3].Trim(), out var memberID) || memberID <= 0)
{
skippedInvalid++;
continue;
}
if (!existingMemberIDs.Contains(memberID))
{
skippedMissingMember++;
continue;
}
if (!TryParseLocalDateTime(row[6].Trim(), out var issuedAtLocal) ||
!TryParseLocalDateTime(row[7].Trim(), out var usedAtLocal))
{
skippedInvalid++;
continue;
}
var issuedAtUtc = DateTime.SpecifyKind(issuedAtLocal, DateTimeKind.Local).ToUniversalTime();
var usedAtUtc = DateTime.SpecifyKind(usedAtLocal, DateTimeKind.Local).ToUniversalTime();
if (issuedAtUtc > usedAtUtc)
{
skippedInvalid++;
continue;
}
try
{
entity = CouponCodeEntity.CreateUsedForImport(request.CouponID, code, memberID, issuedAtUtc, usedAtUtc);
}
catch
{
skippedInvalid++;
continue;
}
addedUsed++;
}
db.CouponCode.Add(entity);
added++;
batchAddedSinceSave++;
if (batchAddedSinceSave >= BatchSize)
{
await db.SaveChangesAsync(ct);
batchAddedSinceSave = 0;
}
}
if (batchAddedSinceSave > 0)
{
await db.SaveChangesAsync(ct);
}
if (added > 0)
{
coupon.IncreaseGeneratedCount(added);
if (addedUsed > 0)
{
coupon.IncreaseUsedCount(addedUsed);
}
await db.SaveChangesAsync(ct);
}
return Result.Success(new Response(
TotalLines: totalLines,
Added: added,
AddedAvailable: addedAvailable,
AddedUsed: addedUsed,
SkippedDuplicate: skippedDuplicate,
SkippedInvalid: skippedInvalid,
SkippedMissingMember: skippedMissingMember
));
}
private static bool TryParseLocalDateTime(string input, out DateTime result)
{
var formats = new[]
{
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy/MM/dd HH:mm"
};
if (DateTime.TryParseExact(input, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
{
return true;
}
return DateTime.TryParse(input, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
}
private static string ComputeHash(string code)
{
var bytes = Encoding.UTF8.GetBytes(code);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
}