using Microsoft.AspNetCore.Mvc; using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using eSPJ.Models; using eSPJ.Services; namespace eSPJ.Controllers.SpjDriverUpstController { [Route("upst/detail-penjemputan")] public class DetailPenjemputanController : Controller { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly DetailPenjemputanService _detailService; private readonly ILogger _logger; private readonly IWebHostEnvironment _env; public DetailPenjemputanController( IHttpClientFactory httpClientFactory, IConfiguration configuration, DetailPenjemputanService detailService, ILogger logger, IWebHostEnvironment env) { _httpClientFactory = httpClientFactory; _configuration = configuration; _detailService = detailService; _logger = logger; _env = env; } private static string SanitizePathSegment(string? value, string fallback = "umum") { var safe = string.Concat((value ?? string.Empty).Trim().Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' ? c : '-')); safe = Regex.Replace(safe, "-+", "-").Trim('-'); return string.IsNullOrWhiteSpace(safe) ? fallback : safe; } private string GetUploadDirectory(string dateFolder, string? nomorSpj = null, string? namaTps = null) { var uploadDir = Path.Combine( _env.ContentRootPath, "uploads", "penjemputan", dateFolder, SanitizePathSegment(nomorSpj, "spj-umum"), SanitizePathSegment(namaTps, "tps-1")); Directory.CreateDirectory(uploadDir); return uploadDir; } private static string BuildUploadUrl(string dateFolder, string fileName, string? nomorSpj = null, string? namaTps = null) { var spjFolder = SanitizePathSegment(nomorSpj, "spj-umum"); var tpsFolder = SanitizePathSegment(namaTps, "tps-1"); return $"/uploads/penjemputan/{dateFolder}/{spjFolder}/{tpsFolder}/{fileName}"; } private async Task FindExistingRecordAsync(string? nomorSpj, string? spjDetailId, string? lokasiAngkutId, string? namaTps) { if (string.IsNullOrWhiteSpace(nomorSpj)) { return null; } return await _detailService.GetRecordDetailAsync(nomorSpj, spjDetailId, lokasiAngkutId, namaTps); } private static List MapRecordTimbangan(List? items) { return (items ?? new List()) .Select(item => new RecordTimbanganItem { Berat = item.Berat?.FirstOrDefault() ?? 0, JenisSampah = item.JenisSampah != null && item.JenisSampah.Count > 0 ? item.JenisSampah[0].ToString() : "Residu", FotoFileName = item.FotoFileName ?? string.Empty, Uploaded = item.IsUploaded, OcrInfo = item.IsUploaded ? "Foto dari server." : "OCR: belum diproses." }) .ToList(); } private static RecordSaveRequest BuildRecordSaveRequest(TpsData? existingRecord, string? nomorSpj, string? namaTps, string? spjDetailId, string? lokasiAngkutId) { return new RecordSaveRequest { NomorSpj = nomorSpj ?? existingRecord?.NomorSpj ?? string.Empty, NamaTps = namaTps ?? existingRecord?.Name ?? string.Empty, SpjDetailId = spjDetailId ?? existingRecord?.SpjDetailId ?? string.Empty, LokasiAngkutId = lokasiAngkutId ?? existingRecord?.LokasiAngkutId ?? string.Empty, Latitude = existingRecord?.Latitude ?? string.Empty, Longitude = existingRecord?.Longitude ?? string.Empty, AlamatJalan = existingRecord?.AlamatJalan ?? string.Empty, WaktuKedatangan = existingRecord?.WaktuKedatangan ?? string.Empty, FotoKedatanganFileNames = existingRecord?.FotoKedatangan != null ? new List(existingRecord.FotoKedatangan) : new List(), FotoKedatanganUploaded = existingRecord?.FotoKedatanganUploaded ?? false, Timbangan = MapRecordTimbangan(existingRecord?.Timbangan), TotalOrganik = existingRecord?.TotalOrganik ?? 0, TotalAnorganik = existingRecord?.TotalAnorganik ?? 0, TotalResidu = existingRecord?.TotalResidu ?? 0, TotalTimbangan = existingRecord?.TotalTimbangan ?? 0, FotoPetugasFileNames = existingRecord?.FotoPetugas != null ? new List(existingRecord.FotoPetugas) : new List(), FotoPetugasUploaded = existingRecord?.FotoPetugasUploaded ?? false, NamaPetugas = existingRecord?.NamaPetugas ?? string.Empty, IsSubmit = existingRecord?.IsSubmit ?? false, }; } private static void RecalculateTotals(RecordSaveRequest request) { var timbangan = request.Timbangan ?? new List(); request.TotalOrganik = timbangan .Where(item => string.Equals(item.JenisSampah, "Organik", StringComparison.OrdinalIgnoreCase)) .Sum(item => item.Berat); request.TotalAnorganik = timbangan .Where(item => string.Equals(item.JenisSampah, "Anorganik", StringComparison.OrdinalIgnoreCase)) .Sum(item => item.Berat); request.TotalResidu = timbangan .Where(item => string.Equals(item.JenisSampah, "Residu", StringComparison.OrdinalIgnoreCase)) .Sum(item => item.Berat); request.TotalTimbangan = timbangan.Sum(item => item.Berat); } private async Task SaveUploadedRecordAsync( bool isTps, string? nomorSpj, string? namaTps, string? spjDetailId, string? lokasiAngkutId, Action applyChanges) { var existingRecord = await FindExistingRecordAsync(nomorSpj, spjDetailId, lokasiAngkutId, namaTps); var request = BuildRecordSaveRequest(existingRecord, nomorSpj, namaTps, spjDetailId, lokasiAngkutId); applyChanges(request); RecalculateTotals(request); return isTps ? await _detailService.SaveRecordTpsAsync(request) : await _detailService.SaveRecordNonTpsAsync(request); } private void ApplyNoCacheHeaders() { Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"; Response.Headers["Pragma"] = "no-cache"; Response.Headers["Expires"] = "0"; } private static string ExtractOcrMessageContent(JsonElement messageContent) { if (messageContent.ValueKind == JsonValueKind.String) { return messageContent.GetString() ?? string.Empty; } if (messageContent.ValueKind == JsonValueKind.Array) { var parts = new List(); foreach (var item in messageContent.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String) { var text = item.GetString(); if (!string.IsNullOrWhiteSpace(text)) { parts.Add(text); } continue; } if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("text", out var textProp) && textProp.ValueKind == JsonValueKind.String) { var text = textProp.GetString(); if (!string.IsNullOrWhiteSpace(text)) { parts.Add(text); } } } return string.Join(" ", parts); } return string.Empty; } private static decimal? TryParseWeightFromOcrContent(string content) { if (string.IsNullOrWhiteSpace(content)) { return null; } foreach (var rawLine in content.Split('\n')) { var line = rawLine.Trim(); if (string.IsNullOrWhiteSpace(line)) { continue; } line = line.Trim('"', '\'', '`', '.', ';', ':'); if (Regex.IsMatch(line, @"^-?\d{1,6}[.,]\d{2}$")) { var normalized = line.Replace(',', '.'); if (decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) { return value; } } if (Regex.IsMatch(line, @"^\d{4}$") && int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out var int4Value)) { return int4Value / 100m; } } var looksExplanatory = Regex.IsMatch(content, @"[A-Za-z]") || content.Contains("contoh", StringComparison.OrdinalIgnoreCase) || content.Contains("misal", StringComparison.OrdinalIgnoreCase); if (looksExplanatory) { return null; } var decMatch = Regex.Match(content, @"-?\d{1,6}[.,]\d{2}"); if (decMatch.Success) { var normalized = decMatch.Value.Replace(',', '.'); if (decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var decValue)) { return decValue; } } var int4Match = Regex.Match(content, @"\b\d{4}\b"); if (int4Match.Success && int.TryParse(int4Match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fallbackInt4)) { return fallbackInt4 / 100m; } return null; } private void TryDeleteFile(string path) { try { System.IO.File.Delete(path); } catch { /* best-effort cleanup */ } } private async Task ResolveRecordSaveRequestAsync() { Request.EnableBuffering(); if (Request.Body.CanSeek) { Request.Body.Position = 0; } using var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); var rawBody = await reader.ReadToEndAsync(); if (Request.Body.CanSeek) { Request.Body.Position = 0; } if (string.IsNullOrWhiteSpace(rawBody)) { return null; } try { return JsonSerializer.Deserialize(rawBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (JsonException ex) { _logger.LogWarning(ex, "Gagal parse body save-record. Body: {RawBody}", rawBody); return null; } } [HttpGet("")] public IActionResult Index() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/Index.cshtml"); } [HttpGet("tanpa-tps")] public IActionResult TanpaTps() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/TanpaTps.cshtml"); } [HttpGet("batal")] public IActionResult Batal() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/Batal.cshtml"); } [HttpGet("detail-batal")] public IActionResult DetailBatal() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/DetailBatal.cshtml"); } [HttpGet("detail-selesai")] public IActionResult DetailSelesai() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/DetailSelesai.cshtml"); } [HttpGet("detail-selesai-tanpa-tps")] public IActionResult DetailSelesaiTanpaTps() { return View("~/Views/Admin/Transport/SpjDriverUpst/DetailPenjemputan/DetailSelesaiTanpaTps.cshtml"); } [HttpGet("api/submitted")] [IgnoreAntiforgeryToken] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public async Task GetSubmittedBySpj([FromQuery] string nomorSpj) { ApplyNoCacheHeaders(); if (string.IsNullOrWhiteSpace(nomorSpj)) { return BadRequest(new { success = false, message = "nomorSpj wajib diisi." }); } var items = await _detailService.GetSubmittedByNomorSpjAsync(nomorSpj); return Ok(new { success = true, items }); } [HttpGet("api/records")] [IgnoreAntiforgeryToken] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public async Task GetRecordsBySpj([FromQuery] string nomorSpj) { ApplyNoCacheHeaders(); if (string.IsNullOrWhiteSpace(nomorSpj)) { return BadRequest(new { success = false, message = "nomorSpj wajib diisi." }); } var items = await _detailService.GetRecordsByNomorSpjAsync(nomorSpj); return Ok(new { success = true, items }); } [HttpGet("api/submitted/detail")] [IgnoreAntiforgeryToken] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public async Task GetSubmittedDetail( [FromQuery] string nomorSpj, [FromQuery] string? spjDetailId = null, [FromQuery] string? lokasiAngkutId = null, [FromQuery] string? namaTps = null) { ApplyNoCacheHeaders(); if (string.IsNullOrWhiteSpace(nomorSpj)) { return BadRequest(new { success = false, message = "nomorSpj wajib diisi." }); } var item = await _detailService.GetSubmittedDetailAsync(nomorSpj, spjDetailId, lokasiAngkutId, namaTps); return Ok(new { success = true, hasData = item != null, item }); } [HttpGet("api/records/detail")] [IgnoreAntiforgeryToken] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public async Task GetRecordDetail( [FromQuery] string nomorSpj, [FromQuery] string? spjDetailId = null, [FromQuery] string? lokasiAngkutId = null, [FromQuery] string? namaTps = null) { ApplyNoCacheHeaders(); if (string.IsNullOrWhiteSpace(nomorSpj)) { return BadRequest(new { success = false, message = "nomorSpj wajib diisi." }); } var item = await _detailService.GetRecordDetailAsync(nomorSpj, spjDetailId, lokasiAngkutId, namaTps); return Ok(new { success = true, hasData = item != null, item }); } [HttpPost("")] [ValidateAntiForgeryToken] public async Task Submit([FromForm] DetailPenjemputanRequest request) { var isAjaxRequest = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase) || Request.Headers.Accept.Any(value => value?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true); try { var result = await _detailService.SubmitPenjemputanAsync(request); if (isAjaxRequest) { return result.Success ? Ok(result) : BadRequest(result); } if (result.Success) { TempData["Success"] = result.Message; } else { TempData["Error"] = result.Message; } return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error submitting penjemputan data"); if (isAjaxRequest) { return StatusCode(500, new DetailPenjemputanResponse { Success = false, Message = "Terjadi kesalahan saat menyimpan data." }); } TempData["Error"] = "Terjadi kesalahan saat menyimpan data."; return RedirectToAction(nameof(Index)); } } [HttpPost("save-record-non-tps")] [IgnoreAntiforgeryToken] public async Task SaveRecordNonTps() { var request = await ResolveRecordSaveRequestAsync(); if (request == null) return BadRequest(new RecordSaveResponse { Success = false, Message = "Request tidak valid." }); var result = await _detailService.SaveRecordNonTpsAsync(request); return result.Success ? Ok(result) : StatusCode(500, result); } [HttpPost("save-record")] [IgnoreAntiforgeryToken] public async Task SaveRecord() { var request = await ResolveRecordSaveRequestAsync(); if (request == null) return BadRequest(new RecordSaveResponse { Success = false, Message = "Request tidak valid." }); var result = await _detailService.SaveRecordTpsAsync(request); return result.Success ? Ok(result) : StatusCode(500, result); } [HttpPost("upload-foto-kedatangan-non-tps")] [IgnoreAntiforgeryToken] public async Task UploadFotoKedatanganNonTps( [FromForm] List? FotoKedatangan, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] string? WaktuKedatangan, [FromForm] string? Latitude, [FromForm] string? Longitude, [FromForm] string? AlamatJalan) { if (FotoKedatangan == null || FotoKedatangan.Count == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var fileNames = new List(); foreach (var file in FotoKedatangan) { if (file.Length == 0) continue; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var name = $"kedatangan_{Guid.NewGuid()}{ext}"; var path = Path.Combine(uploadDir, name); await using var stream = new FileStream(path, FileMode.Create); await file.CopyToAsync(stream); fileNames.Add(name); } var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n, NomorSpj, NamaTps)).ToList(); var saveResult = await SaveUploadedRecordAsync( isTps: false, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { request.WaktuKedatangan = string.IsNullOrWhiteSpace(WaktuKedatangan) ? request.WaktuKedatangan : WaktuKedatangan; request.Latitude = string.IsNullOrWhiteSpace(Latitude) ? request.Latitude : Latitude; request.Longitude = string.IsNullOrWhiteSpace(Longitude) ? request.Longitude : Longitude; request.AlamatJalan = string.IsNullOrWhiteSpace(AlamatJalan) ? request.AlamatJalan : AlamatJalan; request.FotoKedatanganFileNames = fileUrls; request.FotoKedatanganUploaded = true; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto kedatangan berhasil diupload." }); } [HttpPost("upload-foto-timbangan-non-tps")] [IgnoreAntiforgeryToken] public async Task UploadFotoTimbanganNonTps( [FromForm] IFormFile? FotoTimbangan, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] int ItemIndex, [FromForm] string? JenisSampah, [FromForm] decimal Berat) { if (FotoTimbangan == null || FotoTimbangan.Length == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var ext = Path.GetExtension(FotoTimbangan.FileName).ToLowerInvariant(); var jenisSafe = (JenisSampah ?? "residu").ToLowerInvariant(); var beratStr = Berat.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture).Replace('.', '_'); var name = $"timbangan{ItemIndex + 1}-{jenisSafe}-{beratStr}{ext}"; var filePath = Path.Combine(uploadDir, name); await using var stream = new FileStream(filePath, FileMode.Create); await FotoTimbangan.CopyToAsync(stream); var fileUrl = BuildUploadUrl(dateFolder, name, NomorSpj, NamaTps); var saveResult = await SaveUploadedRecordAsync( isTps: false, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { while (request.Timbangan.Count <= ItemIndex) { request.Timbangan.Add(new RecordTimbanganItem()); } request.Timbangan[ItemIndex] = new RecordTimbanganItem { FotoFileName = fileUrl, JenisSampah = JenisSampah ?? "Residu", Berat = Berat, Uploaded = true }; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileName = name, fileUrl, message = $"Foto timbangan #{ItemIndex + 1} berhasil diupload." }); } [HttpPost("upload-foto-petugas-non-tps")] [IgnoreAntiforgeryToken] public async Task UploadFotoPetugasNonTps( [FromForm] List? FotoPetugas, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] string? NamaPetugas) { if (FotoPetugas == null || FotoPetugas.Count == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var fileNames = new List(); foreach (var file in FotoPetugas) { if (file.Length == 0) continue; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var name = $"petugas_{Guid.NewGuid()}{ext}"; var path = Path.Combine(uploadDir, name); await using var stream = new FileStream(path, FileMode.Create); await file.CopyToAsync(stream); fileNames.Add(name); } var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n, NomorSpj, NamaTps)).ToList(); var saveResult = await SaveUploadedRecordAsync( isTps: false, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { request.FotoPetugasFileNames = fileUrls; request.FotoPetugasUploaded = true; request.NamaPetugas = string.IsNullOrWhiteSpace(NamaPetugas) ? request.NamaPetugas : NamaPetugas; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." }); } [HttpPost("upload-foto-kedatangan")] [IgnoreAntiforgeryToken] public async Task UploadFotoKedatangan( [FromForm] List? FotoKedatangan, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] string? WaktuKedatangan, [FromForm] string? Latitude, [FromForm] string? Longitude, [FromForm] string? AlamatJalan) { if (FotoKedatangan == null || FotoKedatangan.Count == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var fileNames = new List(); foreach (var file in FotoKedatangan) { if (file.Length == 0) continue; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var name = $"kedatangan_{Guid.NewGuid()}{ext}"; var path = Path.Combine(uploadDir, name); await using var stream = new FileStream(path, FileMode.Create); await file.CopyToAsync(stream); fileNames.Add(name); } var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n, NomorSpj, NamaTps)).ToList(); var saveResult = await SaveUploadedRecordAsync( isTps: true, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { request.WaktuKedatangan = string.IsNullOrWhiteSpace(WaktuKedatangan) ? request.WaktuKedatangan : WaktuKedatangan; request.Latitude = string.IsNullOrWhiteSpace(Latitude) ? request.Latitude : Latitude; request.Longitude = string.IsNullOrWhiteSpace(Longitude) ? request.Longitude : Longitude; request.AlamatJalan = string.IsNullOrWhiteSpace(AlamatJalan) ? request.AlamatJalan : AlamatJalan; request.FotoKedatanganFileNames = fileUrls; request.FotoKedatanganUploaded = true; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto kedatangan berhasil diupload." }); } [HttpPost("upload-foto-timbangan")] [IgnoreAntiforgeryToken] public async Task UploadFotoTimbangan( [FromForm] IFormFile? FotoTimbangan, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] int ItemIndex, [FromForm] string? JenisSampah, [FromForm] decimal Berat) { if (FotoTimbangan == null || FotoTimbangan.Length == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var ext = Path.GetExtension(FotoTimbangan.FileName).ToLowerInvariant(); var jenisSafe = (JenisSampah ?? "residu").ToLowerInvariant(); var beratStr = Berat.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture).Replace('.', '_'); var name = $"timbangan{ItemIndex + 1}-{jenisSafe}-{beratStr}{ext}"; var filePath = Path.Combine(uploadDir, name); await using var stream = new FileStream(filePath, FileMode.Create); await FotoTimbangan.CopyToAsync(stream); var fileUrl = BuildUploadUrl(dateFolder, name, NomorSpj, NamaTps); var saveResult = await SaveUploadedRecordAsync( isTps: true, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { while (request.Timbangan.Count <= ItemIndex) { request.Timbangan.Add(new RecordTimbanganItem()); } request.Timbangan[ItemIndex] = new RecordTimbanganItem { FotoFileName = fileUrl, JenisSampah = JenisSampah ?? "Residu", Berat = Berat, Uploaded = true }; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileName = name, fileUrl, message = $"Foto timbangan #{ItemIndex + 1} berhasil diupload." }); } [HttpPost("upload-foto-petugas")] [IgnoreAntiforgeryToken] public async Task UploadFotoPetugas( [FromForm] List? FotoPetugas, [FromForm] string? NomorSpj, [FromForm] string? NamaTps, [FromForm] string? SpjDetailId, [FromForm] string? LokasiAngkutId, [FromForm] string? NamaPetugas) { if (FotoPetugas == null || FotoPetugas.Count == 0) return BadRequest(new { success = false, message = "Tidak ada foto." }); var dateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadDir = GetUploadDirectory(dateFolder, NomorSpj, NamaTps); var fileNames = new List(); foreach (var file in FotoPetugas) { if (file.Length == 0) continue; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var name = $"petugas_{Guid.NewGuid()}{ext}"; var path = Path.Combine(uploadDir, name); await using var stream = new FileStream(path, FileMode.Create); await file.CopyToAsync(stream); fileNames.Add(name); } var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n, NomorSpj, NamaTps)).ToList(); var saveResult = await SaveUploadedRecordAsync( isTps: true, nomorSpj: NomorSpj, namaTps: NamaTps, spjDetailId: SpjDetailId, lokasiAngkutId: LokasiAngkutId, applyChanges: request => { request.FotoPetugasFileNames = fileUrls; request.FotoPetugasUploaded = true; request.NamaPetugas = string.IsNullOrWhiteSpace(NamaPetugas) ? request.NamaPetugas : NamaPetugas; }); if (!saveResult.Success) { return StatusCode(500, new { success = false, message = saveResult.Message }); } return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." }); } [HttpPost("ocr-timbangan")] [IgnoreAntiforgeryToken] public async Task OcrTimbangan(IFormFile? Foto) { if (Foto == null || Foto.Length == 0) { return BadRequest(new { success = false, message = "Foto tidak ditemukan." }); } if (Foto.Length > 5 * 1024 * 1024) { return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 5MB." }); } var apiKey = _configuration["9Router:OCRkey"]; if (string.IsNullOrWhiteSpace(apiKey)) { return StatusCode(500, new { success = false, message = "9Router API key belum diset." }); } byte[] fileBytes; await using (var ms = new MemoryStream()) { await Foto.CopyToAsync(ms); fileBytes = ms.ToArray(); } var mimeType = "image/jpeg"; var base64 = Convert.ToBase64String(fileBytes); var dataUrl = $"data:{mimeType};base64,{base64}"; var payload = new { model = "image-combo", messages = new object[] { new { role = "user", content = new object[] { new { type = "text", text = @"Baca angka pada display timbangan digital dari gambar ini. Fokus hanya pada digit display, abaikan refleksi, cahaya merah, dan teks lain. Keluarkan tepat satu baris angka saja. Format output wajib NNN,NN (gunakan koma sebagai desimal dua digit). Jika tidak yakin atau angka tidak terbaca, keluarkan 0,00. Jangan tambahkan kata, kalimat, atau simbol lain." }, new { type = "image_url", image_url = new { url = dataUrl } } } } } }; var json = JsonSerializer.Serialize(payload); var request = new HttpRequestMessage(HttpMethod.Post, "http://10.50.50.61:20128/v1/chat/completions"); request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); request.Headers.TryAddWithoutValidation("Accept", "application/json"); request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://pesapakawan.dinaslhdki.id"); request.Headers.TryAddWithoutValidation("X-Title", "eSPJ OCR Timbangan"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); var client = _httpClientFactory.CreateClient(); using var response = await client.SendAsync(request); var responseText = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { return StatusCode((int)response.StatusCode, new { success = false, message = "OpenRouter request gagal.", detail = responseText }); } string content; try { using var doc = JsonDocument.Parse(responseText); var messageContent = doc.RootElement .GetProperty("choices")[0] .GetProperty("message") .GetProperty("content"); content = ExtractOcrMessageContent(messageContent); } catch { return Ok(new { success = false, message = "Gagal membaca respons OCR.", raw = responseText }); } content = content.Trim(); if (content.Contains("UNREADABLE", StringComparison.OrdinalIgnoreCase)) { return Ok(new { success = false, message = "Angka tidak terbaca.", raw = content }); } var weight = TryParseWeightFromOcrContent(content); if (weight == null) { return Ok(new { success = false, message = "AI tidak menemukan angka valid.", raw = content }); } var weightStr = weight.Value.ToString("0.00", CultureInfo.GetCultureInfo("id-ID")); return Ok(new { success = true, weight = weightStr, raw = content }); } } }