update: submit struk bg

main
muamars 2026-03-17 13:46:41 +07:00
parent 5e8b735ea9
commit b95e93d3c2
5 changed files with 737 additions and 1307 deletions

View File

@ -1,10 +1,26 @@
using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace eSPJ.Controllers.SpjDriverUpstController
{
[Route("upst/submit")]
public class SubmitController : Controller
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _env;
public SubmitController(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IWebHostEnvironment env)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_env = env;
}
[HttpGet("")]
public IActionResult Index()
@ -12,70 +28,235 @@ namespace eSPJ.Controllers.SpjDriverUpstController
return View("~/Views/Admin/Transport/SpjDriverUpst/Submit/Index.cshtml");
}
[HttpGet("struk")]
public IActionResult Struk()
{
return View("~/Views/Admin/Transport/SpjDriverUpst/Submit/Struk.cshtml");
}
[HttpPost("ocr-struk")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> OcrStruk(IFormFile? Foto)
{
if (Foto == null || Foto.Length == 0)
return BadRequest(new { success = false, message = "Foto tidak ditemukan." });
if (Foto.Length > 10 * 1024 * 1024)
return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 10MB." });
var apiKey = _configuration["OpenRouter:OCRkey"];
if (string.IsNullOrWhiteSpace(apiKey))
return StatusCode(500, new { success = false, message = "OpenRouter API key belum diset." });
byte[] fileBytes;
await using (var ms = new MemoryStream())
{
await Foto.CopyToAsync(ms);
fileBytes = ms.ToArray();
}
var mimeType = string.IsNullOrWhiteSpace(Foto.ContentType) ? "image/jpeg" : Foto.ContentType;
var base64 = Convert.ToBase64String(fileBytes);
var dataUrl = $"data:{mimeType};base64,{base64}";
var payload = new
{
model = "google/gemini-2.5-flash-image",
temperature = 0,
messages = new object[]
{
new
{
role = "user",
content = new object[]
{
new
{
type = "text",
text = @"Baca data dari foto struk timbang kendaraan ini. Ekstrak semua data yang tersedia ke dalam format JSON berikut:
{
""nomorStruk"": ""<nomor struk tanpa prefix, contoh: 8001441>"",
""nomorPolisi"": ""<nomor polisi kendaraan, contoh: B 9125 PJA>"",
""penugasan"": ""<area penugasan, contoh: JAKARTA BARAT>"",
""waktuMasuk"": ""<waktu masuk format YYYY-MM-DD, HH:MM:SS>"",
""waktuKeluar"": ""<waktu keluar format YYYY-MM-DD, HH:MM:SS>"",
""beratMasuk"": <berat masuk dalam kg sebagai angka integer tanpa desimal>,
""beratKeluar"": <berat keluar dalam kg sebagai angka integer tanpa desimal>,
""beratNett"": <berat netto dalam kg sebagai angka integer tanpa desimal>
}
Catatan penting:
- nomorStruk: ambil angka utama saja, abaikan prefix bulan seperti '03_' atau '03 '
- waktu: konversi ke format YYYY-MM-DD, HH:MM:SS (contoh: 2025-08-04, 08:13:51)
- berat: hanya angka integer tanpa satuan 'kg'
- Jika data tidak ada atau tidak terbaca, isi dengan null
- Kembalikan HANYA JSON valid tanpa penjelasan atau markdown code block"
},
new
{
type = "image_url",
image_url = new { url = dataUrl }
}
}
}
}
};
var json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/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 Struk");
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
});
}
using var doc = JsonDocument.Parse(responseText);
var content = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "";
var jsonMatch = Regex.Match(content, @"\{[\s\S]*\}");
if (!jsonMatch.Success)
return Ok(new { success = false, message = "AI tidak mengembalikan data valid.", raw = content });
try
{
using var dataDoc = JsonDocument.Parse(jsonMatch.Value);
var root = dataDoc.RootElement;
return Ok(new
{
success = true,
data = new
{
nomorStruk = GetStringValue(root, "nomorStruk"),
nomorPolisi = GetStringValue(root, "nomorPolisi"),
penugasan = GetStringValue(root, "penugasan"),
waktuMasuk = GetStringValue(root, "waktuMasuk"),
waktuKeluar = GetStringValue(root, "waktuKeluar"),
beratMasuk = GetIntValue(root, "beratMasuk"),
beratKeluar = GetIntValue(root, "beratKeluar"),
beratNett = GetIntValue(root, "beratNett")
},
raw = content
});
}
catch
{
return Ok(new { success = false, message = "Gagal memproses respons AI.", raw = content });
}
}
[HttpPost("struk")]
public IActionResult ProcessStruk(string NomorStruk, string NomorPolisi, string Penugasan,
string WaktuMasuk, string WaktuKeluar, int? BeratMasuk, int? BeratKeluar, int BeratNett)
public async Task<IActionResult> ProcessStruk(
string? NomorStruk,
string? NomorPolisi,
string? Penugasan,
string? WaktuMasuk,
string? WaktuKeluar,
int? BeratMasuk,
int? BeratKeluar,
int? BeratNett,
string? Timbang,
IFormFile? FotoStruk)
{
try
{
// Validate required inputs
if (string.IsNullOrEmpty(NomorStruk) || BeratNett <= 0)
if (string.IsNullOrWhiteSpace(NomorStruk))
{
TempData["Error"] = "Nomor struk dan berat nett harus diisi.";
TempData["Error"] = "Nomor struk wajib diisi.";
return RedirectToAction("Struk");
}
// Validate receipt number format (numbers only, 7+ digits)
if (!System.Text.RegularExpressions.Regex.IsMatch(NomorStruk, @"^\d{7,}$"))
if (!Regex.IsMatch(NomorStruk, @"^\d{3,}$"))
{
TempData["Error"] = "Format nomor struk tidak valid. Harus berupa angka minimal 7 digit.";
TempData["Error"] = "Format nomor struk tidak valid. Harus berupa angka minimal 3 digit.";
return RedirectToAction("Struk");
}
// Validate weight range
if (BeratNett < 100 || BeratNett > 50000)
if (string.IsNullOrWhiteSpace(NomorPolisi))
{
TempData["Error"] = "Berat nett harus antara 100 kg - 50,000 kg.";
TempData["Error"] = "Nomor polisi wajib diisi.";
return RedirectToAction("Struk");
}
// Validate optional weights
if (BeratMasuk.HasValue && (BeratMasuk < 0 || BeratMasuk > 100000))
if (string.IsNullOrWhiteSpace(Penugasan))
{
TempData["Error"] = "Berat masuk tidak valid.";
TempData["Error"] = "Penugasan wajib diisi.";
return RedirectToAction("Struk");
}
if (BeratKeluar.HasValue && (BeratKeluar < 0 || BeratKeluar > 100000))
if (string.IsNullOrWhiteSpace(WaktuMasuk))
{
TempData["Error"] = "Berat keluar tidak valid.";
TempData["Error"] = "Waktu masuk wajib diisi.";
return RedirectToAction("Struk");
}
// Here you would normally save to database
// For now, just simulate success with all data
var submitData = new
if (string.IsNullOrWhiteSpace(WaktuKeluar))
{
NomorStruk,
NomorPolisi = NomorPolisi ?? "N/A",
Penugasan = Penugasan ?? "N/A",
WaktuMasuk = WaktuMasuk ?? "N/A",
WaktuKeluar = WaktuKeluar ?? "N/A",
BeratMasuk = BeratMasuk?.ToString() ?? "N/A",
BeratKeluar = BeratKeluar?.ToString() ?? "N/A",
BeratNett
};
TempData["Error"] = "Waktu keluar wajib diisi.";
return RedirectToAction("Struk");
}
TempData["Success"] = $"Struk berhasil disubmit! No: {NomorStruk}, Nett: {BeratNett} kg";
if (!BeratMasuk.HasValue || BeratMasuk <= 0)
{
TempData["Error"] = "Berat masuk wajib diisi.";
return RedirectToAction("Struk");
}
if (!BeratKeluar.HasValue || BeratKeluar <= 0)
{
TempData["Error"] = "Berat keluar wajib diisi.";
return RedirectToAction("Struk");
}
if (!BeratNett.HasValue || BeratNett <= 0)
{
TempData["Error"] = "Berat nett wajib diisi.";
return RedirectToAction("Struk");
}
if (FotoStruk == null || FotoStruk.Length == 0)
{
TempData["Error"] = "Foto struk wajib dilampirkan.";
return RedirectToAction("Struk");
}
string? fotoStrukUrl = null;
if (FotoStruk != null && FotoStruk.Length > 0)
{
var datePart = DateTime.Now.ToString("yyyy-MM-dd");
var uploadPath = Path.Combine(_env.ContentRootPath, "uploads", "struk", datePart);
if (!Directory.Exists(uploadPath))
Directory.CreateDirectory(uploadPath);
var ext = Path.GetExtension(FotoStruk.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
var fileName = $"struk_{NomorStruk}_{Guid.NewGuid():N}{ext}";
var filePath = Path.Combine(uploadPath, fileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await FotoStruk.CopyToAsync(stream);
fotoStrukUrl = $"/uploads/struk/{datePart}/{fileName}";
}
var timbangLabel = Timbang == "RDF" ? "Timbangan RDF" : "Timbangan TPA";
TempData["Success"] = $"Struk berhasil disubmit! No: {NomorStruk}, {timbangLabel}, Nett: {BeratNett!.Value} kg";
return RedirectToAction("Index", "Home");
}
catch (Exception)
{
@ -83,5 +264,24 @@ namespace eSPJ.Controllers.SpjDriverUpstController
return RedirectToAction("Struk");
}
}
private static string? GetStringValue(JsonElement root, string key)
{
if (root.TryGetProperty(key, out var prop) && prop.ValueKind == JsonValueKind.String)
return prop.GetString();
return null;
}
private static int? GetIntValue(JsonElement root, string key)
{
if (root.TryGetProperty(key, out var prop))
{
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var val))
return val;
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out var strVal))
return strVal;
}
return null;
}
}
}

View File

@ -6,7 +6,7 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-[10px] font-black uppercase tracking-[0.22em] text-gray-400">Install App</p>
<h3 class="mt-1 text-sm font-black tracking-tight">Pasang eSPJ di perangkat</h3>
<h3 class="mt-1 text-sm font-black tracking-tight">PKM UPST di perangkat</h3>
<p id="pwaInstallDescription" class="mt-1 text-[11px] leading-relaxed text-gray-500/80">Akses lebih cepat langsung dari home screen tanpa buka browser dulu.</p>
</div>
<button id="pwaInstallDismiss" type="button" class="flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-lg font-bold text-gray-500/80 transition active:scale-90">×</button>
@ -26,7 +26,7 @@
<div id="pwaInstallHelpModal" class="fixed inset-0 z-130 hidden items-end justify-center bg-slate-950/60 p-4 backdrop-blur-sm sm:items-center">
<div class="w-full max-w-sm rounded-4xl bg-white p-6 shadow-2xl">
<p class="text-[10px] font-black uppercase tracking-[0.24em] text-upst">Cara Install</p>
<h3 class="mt-2 text-lg font-black tracking-tight text-slate-900">Pasang eSPJ ke home screen</h3>
<h3 class="mt-2 text-lg font-black tracking-tight text-slate-900">Pasang PKM UPST ke home screen</h3>
<p id="pwaInstallHelpText" class="mt-3 text-sm leading-relaxed text-slate-600">Gunakan menu browser lalu pilih install aplikasi.</p>
<div id="pwaInstallHelpSteps" class="mt-5 grid gap-2 rounded-2xl bg-slate-50 p-4 text-xs leading-relaxed text-slate-600">

File diff suppressed because it is too large Load Diff

View File

@ -434,6 +434,9 @@
.right-full {
right: 100%;
}
.-bottom-0 {
bottom: calc(var(--spacing) * -0);
}
.-bottom-0\.5 {
bottom: calc(var(--spacing) * -0.5);
}
@ -479,6 +482,9 @@
.left-0 {
left: calc(var(--spacing) * 0);
}
.left-1 {
left: calc(var(--spacing) * 1);
}
.left-1\/2 {
left: calc(1/2 * 100%);
}
@ -794,6 +800,9 @@
.-mr-16 {
margin-right: calc(var(--spacing) * -16);
}
.mr-1 {
margin-right: calc(var(--spacing) * 1);
}
.mr-1\.5 {
margin-right: calc(var(--spacing) * 1.5);
}
@ -884,6 +893,9 @@
.aspect-square {
aspect-ratio: 1 / 1;
}
.h-0 {
height: calc(var(--spacing) * 0);
}
.h-0\.5 {
height: calc(var(--spacing) * 0.5);
}
@ -989,6 +1001,9 @@
.h-full {
height: 100%;
}
.max-h-48 {
max-height: calc(var(--spacing) * 48);
}
.min-h-screen {
min-height: 100vh;
}
@ -1166,6 +1181,10 @@
.border-collapse {
border-collapse: collapse;
}
.-translate-x-1 {
--tw-translate-x: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@ -1178,6 +1197,10 @@
--tw-translate-x: calc(var(--spacing) * 16);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@ -1304,6 +1327,13 @@
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-0 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-0\.5 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@ -1740,6 +1770,9 @@
.bg-blue-600 {
background-color: var(--color-blue-600);
}
.bg-cyan-400 {
background-color: var(--color-cyan-400);
}
.bg-cyan-400\/10 {
background-color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@ -1803,6 +1836,9 @@
.bg-indigo-300 {
background-color: var(--color-indigo-300);
}
.bg-lime-500 {
background-color: var(--color-lime-500);
}
.bg-lime-500\/15 {
background-color: color-mix(in srgb, oklch(76.8% 0.233 130.85) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@ -1824,6 +1860,9 @@
.bg-orange-500 {
background-color: var(--color-orange-500);
}
.bg-orange-700 {
background-color: var(--color-orange-700);
}
.bg-orange-700\/30 {
background-color: color-mix(in srgb, oklch(55.3% 0.195 38.402) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@ -1866,6 +1905,9 @@
.bg-slate-900 {
background-color: var(--color-slate-900);
}
.bg-slate-950 {
background-color: var(--color-slate-950);
}
.bg-slate-950\/60 {
background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 60%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@ -2837,6 +2879,12 @@
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-black {
--tw-shadow-color: #000;
@supports (color: color-mix(in lab, red, red)) {
--tw-shadow-color: color-mix(in oklab, var(--color-black) var(--tw-shadow-alpha), transparent);
}
}
.shadow-black\/20 {
--tw-shadow-color: color-mix(in srgb, #000 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@ -2849,6 +2897,9 @@
--tw-shadow-color: color-mix(in oklab, var(--color-gray-200) var(--tw-shadow-alpha), transparent);
}
}
.ring-black {
--tw-ring-color: var(--color-black);
}
.ring-black\/5 {
--tw-ring-color: color-mix(in srgb, #000 5%, transparent);
@supports (color: color-mix(in lab, red, red)) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB