Files
efrilmandClaude Opus 5 992bb04816 feat(order): support weight-based products
Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the
order line could not represent: quantity is INTEGER and prices are always
computed as quantity * unit_price.

Model one weighing as one order line. quantity stays INTEGER and keeps
meaning "how many items"; the measured amount goes into a new nullable
order_items.weight, and the line is priced weight * unit_price. Two
weighings of the same product are two lines, never merged into one.

Keeping quantity integral avoids float comparisons in void, refund and
split bill, where accumulated rounding error would silently misbehave —
"1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid
split-bill item marked unpaid.

BillableQuantity() is now the single place that decides between weight
and count; every price and cost calculation goes through it. Missing one
would bill a 4.2 ons fish as a single ons — wrong money, no error.

Two database constraints back the design: a weighed line always carries a
positive weight, and its quantity is pinned to 1. The latter also makes
void all-or-nothing for weighed lines, so the row-splitting branch can
never produce a zero-weight remainder row.

Also wires product.unit_id through the API, which was previously not
settable at all, and corrects the misleading comment on the request's
unit_price field — that value has never been used; price always comes
from the database.

Design notes and the audit of every price multiplication site are in
docs/rfc-weight-based-products.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:14:42 +07:00

608 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laporan Transaksi Harian - APSKEL</title>
<style>
@page {
size: A4;
margin: 20mm 15mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
color: #2d3748;
line-height: 1.6;
font-size: 12px;
font-weight: 400;
}
.report-container {
max-width: 210mm;
margin: 0 auto;
background: #ffffff;
padding: 0;
}
/* Header Section */
.report-header {
border-bottom: 4px solid #2b6cb0;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.company-section {
display: flex;
align-items: center;
gap: 15px;
}
.logo-container {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #e2e8f0;
background: #f7fafc;
display: flex;
align-items: center;
justify-content: center;
}
.company-details h1 {
font-size: 28px;
font-weight: 800;
color: #2b6cb0;
margin-bottom: 2px;
letter-spacing: -0.5px;
}
.company-subtitle {
font-size: 14px;
color: #4a5568;
font-weight: 500;
}
.report-info {
text-align: right;
}
.report-title {
font-size: 20px;
font-weight: 700;
color: #2d3748;
margin-bottom: 4px;
}
.report-subtitle {
font-size: 12px;
color: #718096;
font-weight: 500;
}
.report-meta {
background: #f7fafc;
border-radius: 6px;
padding: 15px;
border: 1px solid #e2e8f0;
}
.meta-grid {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 3px;
}
.meta-label {
font-size: 10px;
font-weight: 600;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-value {
font-size: 13px;
font-weight: 600;
color: #2d3748;
}
/* Section Headers */
.section {
margin-bottom: 30px;
}
.section-header {
margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 2px solid #e2e8f0;
}
.section-number {
font-size: 11px;
font-weight: 700;
color: #2b6cb0;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #2d3748;
}
/* Compact Summary Section */
.summary-container {
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.summary-header {
background: #2b6cb0;
color: #fff;
padding: 8px 12px;
}
.summary-header h3 {
font-size: 13px;
font-weight: 600;
margin: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 12px;
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
text-align: center;
min-height: 80px;
justify-content: space-between;
}
.summary-label {
font-size: 8px;
font-weight: 700;
color: #4a5568;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
line-height: 1.2;
}
.summary-value {
font-size: 14px;
font-weight: 800;
color: #2d3748;
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
margin-bottom: 4px;
}
.summary-description {
font-size: 8px;
color: #718096;
line-height: 1.2;
text-align: center;
}
/* Data Tables */
.table-container {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.table-header {
background: #f7fafc;
padding: 12px 20px;
border-bottom: 1px solid #e2e8f0;
}
.table-title {
font-size: 14px;
font-weight: 700;
color: #2d3748;
margin-bottom: 2px;
}
.table-subtitle {
font-size: 11px;
color: #718096;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.data-table thead {
background: #edf2f7;
}
.data-table th {
padding: 10px 12px;
text-align: left;
font-size: 10px;
font-weight: 700;
color: #4a5568;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e2e8f0;
}
.data-table th.align-center { text-align: center; }
.data-table th.align-right { text-align: right; }
.data-table tbody tr {
border-bottom: 1px solid #f7fafc;
}
.data-table tbody tr:nth-child(even) {
background-color: #f7fafc;
}
.data-table tbody tr:last-child {
border-bottom: none;
}
.data-table td {
padding: 10px 12px;
font-size: 11px;
color: #4a5568;
font-weight: 500;
}
.data-table td.align-center {
text-align: center;
}
.data-table td.align-right {
text-align: right;
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
font-weight: 600;
}
.data-table td.number-cell {
font-weight: 600;
color: #2d3748;
}
.item-name {
font-weight: 700;
color: #2d3748;
}
/* Financial Summary */
.financial-summary {
max-width: 400px;
margin-left: 0;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.financial-header {
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
color: #ffffff;
padding: 12px 20px;
text-align: center;
}
.financial-header h3 {
font-size: 14px;
font-weight: 700;
margin: 0;
}
.financial-content {
padding: 0;
}
.financial-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
border-bottom: 1px solid #f7fafc;
font-size: 11px;
}
.financial-row:last-child {
border-bottom: none;
}
.financial-row.subtotal {
background: #f7fafc;
border-bottom: 2px solid #e2e8f0;
font-weight: 600;
}
.financial-row.final {
background: linear-gradient(135deg, #2b6cb0 0%, #3182ce 100%);
color: #ffffff;
font-weight: 700;
font-size: 12px;
}
.financial-label {
font-weight: 600;
color: inherit;
}
.financial-amount {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-family: 'Courier New', monospace;
color: inherit;
}
/* Footer Notes */
.report-footer {
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #e2e8f0;
background: #f7fafc;
border-radius: 6px;
padding: 15px;
}
.footer-title {
font-size: 12px;
font-weight: 700;
color: #2d3748;
margin-bottom: 8px;
}
.footer-notes {
list-style: none;
padding: 0;
}
.footer-notes li {
font-size: 10px;
color: #718096;
line-height: 1.4;
margin-bottom: 6px;
padding-left: 12px;
position: relative;
}
.footer-notes li:before {
content: "•";
color: #2b6cb0;
font-weight: bold;
position: absolute;
left: 0;
}
/* Fixed Layout for PDF */
body {
font-size: 10pt;
line-height: 1.4;
}
.report-container {
padding: 0;
margin: 0;
max-width: none;
width: 100%;
}
.data-table tbody tr:nth-child(even) {
background-color: #f7fafc !important;
}
.section {
page-break-inside: avoid;
}
.summary-grid {
page-break-inside: avoid;
}
.data-table {
page-break-inside: avoid;
}
.page-break {
page-break-before: always;
}
.no-break {
page-break-inside: avoid;
}
</style>
</head>
<body>
<div class="report-container">
<!-- HEADER -->
<div class="report-header">
<div class="header-main">
<div class="company-section">
<div class="logo-container">
<!-- SVG Logo -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="40" height="40">
<rect fill="rgb(241,234,249)" x="0" y="0" width="500" height="500" />
<g transform="translate(248 204) scale(1.97)">
<g transform="translate(18 17)">
<path fill="rgb(54,23,94)" d="M 71 60 v 6 a 4 4 0 0 1 -4 4 H 61 a 4 4 0 0 0 0 8 h 6 A 12 12 0 0 0 79 66 V 60 a 4 4 0 0 0 -8 0" />
</g>
<g transform="translate(-17 -18)">
<path fill="rgb(54,23,94)" d="M 30 39 V 33 a 4 4 0 0 1 4 -4 h 6 a 4 4 0 0 0 0 -8 H 34 A 12 12 0 0 0 22 33 v 6 a 4 4 0 0 0 8 0" />
</g>
<g transform="translate(17 -17)">
<path fill="rgb(54,23,94)" d="M 81 40 V 27 a 8 8 0 0 0 -8 -8 H 60.17 A 4.12 4.12 0 0 0 56 22.61 A 4 4 0 0 0 60 27 h 7.34 L 54.17 40.17 a 4 4 0 0 0 5.66 5.66 L 73 32.66 v 7.17 A 4.12 4.12 0 0 0 76.61 44 A 4 4 0 0 0 81 40" />
</g>
<g transform="translate(-16 16)">
<path fill="rgb(54,23,94)" d="M 19 61 V 71 A 10 10 0 0 0 29 81 H 39 A 10 10 0 0 0 49 71 V 61 A 10 10 0 0 0 39 51 H 29 A 10 10 0 0 0 19 61 m 8 0 a 2 2 0 0 1 2 -2 H 39 a 2 2 0 0 1 2 2 V 71 a 2 2 0 0 1 -2 2 H 29 a 2 2 0 0 1 -2 -2 Z" />
</g>
</g>
</svg>
</div>
<div class="company-details">
<h1>APSKEL</h1>
<div class="company-subtitle">
{{.OrganizationName}}
{{if .OutletName}}– {{.OutletName}}{{end}}
</div>
</div>
</div>
<div class="report-info">
<div class="report-title">Laporan Transaksi Harian</div>
<div class="report-subtitle">Daily Transaction Report</div>
</div>
</div>
<div class="report-meta">
<div class="meta-grid">
<div class="meta-item">
<div class="meta-label">Tanggal Laporan</div>
<div class="meta-value">{{.ReportDate}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Periode</div>
<div class="meta-value">{{.StartDate}} – {{.EndDate}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Dicetak Oleh</div>
<div class="meta-value">{{.GeneratedBy}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Waktu Cetak</div>
<div class="meta-value">{{.PrintTime}}</div>
</div>
</div>
</div>
</div>
<!-- 01. RINGKASAN KINERJA -->
<div class="section no-break">
<div class="section-header">
<div class="section-number">01. Ringkasan Kinerja</div>
<div class="section-title">Metrik Kinerja Harian</div>
</div>
<div class="summary-container">
<div class="summary-header">
<h3>Ringkasan Keuangan & Operasional</h3>
</div>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">Total Transaksi</div>
<div class="summary-value">{{.Summary.TotalTransactions}}</div>
<div class="summary-description">Jumlah transaksi yang diproses</div>
</div>
<div class="summary-item">
<div class="summary-label">Item Terjual</div>
<div class="summary-value">{{.Summary.TotalItems}}</div>
<div class="summary-description">Total unit produk yang terjual</div>
</div>
<div class="summary-item">
<div class="summary-label">Pendapatan Kotor</div>
<div class="summary-value">{{.Summary.GrossSales}}</div>
<div class="summary-description">Total penjualan sebelum potongan</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Diskon</div>
<div class="summary-value">{{.Summary.Discount}}</div>
<div class="summary-description">Potongan harga yang diberikan</div>
</div>
<div class="summary-item">
<div class="summary-label">Pajak</div>
<div class="summary-value">{{.Summary.Tax}}</div>
<div class="summary-description">Total pajak yang dikumpulkan</div>
</div>
<div class="summary-item">
<div class="summary-label">Pendapatan Bersih</div>
<div class="summary-value">{{.Summary.NetSales}}</div>
<div class="summary-description">Pendapatan setelah diskon & pajak</div>
</div>
<div class="summary-item">
<div class="summary-label">HPP (COGS)</div>
<div class="summary-value">{{.Summary.COGS}}</div>
<div class="summary-description">Harga Pokok Penjualan</div>
</div>
<div class="summary-item">
<div class="summary-label">Laba Kotor</div>
<div class="summary-value">{{.Summary.GrossProfit}}</div>
<div class="summary-description">Keuntungan sebelum biaya operasional</div>
</div>
<div class="summary-item">
<div class="summary-label">Margin Laba Kotor</div>
<div class="summary-value">{{.Summary.GrossMarginPercent}}%</div>
<div class="summary-description">Persentase keuntungan terhadap penjualan</div>
</div>
</div>
</div>
</div>
<!-- 02. RINCIAN TRANSAKSI -->
<div class="section no-break">
<div class="section-header">
<div class="section-number">02. Rincian Transaksi</div>
<div class="section-title">Detail Penjualan Per Item</div>
</div>
<div class="table-container">
<div class="table-header">
<div class="table-title">Breakdown Penjualan Harian</div>
<div class="table-subtitle">Rincian transaksi berdasarkan item produk yang terjual</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>No.</th>
<th>Nama Item</th>
<th class="align-center">Qty</th>
<th class="align-center">Berat</th>
<th class="align-right">Penjualan Kotor</th>
<th class="align-right">Diskon</th>
<th class="align-right">Penjualan Bersih</th>
<th class="align-right">HPP</th>
<th class="align-right">Laba Kotor</th>
</tr>
</thead>
<tbody>
{{range $i, $item := .Items}}
<tr>
<td class="align-center number-cell">{{add $i 1}}</td>
<td><span class="item-name">{{$item.Name}}</span></td>
<td class="align-center number-cell">{{$item.Quantity}}</td>
<td class="align-center number-cell">{{$item.Weight}}</td>
<td class="align-right number-cell">{{$item.GrossSales}}</td>
<td class="align-right number-cell">{{$item.Discount}}</td>
<td class="align-right number-cell">{{$item.NetSales}}</td>
<td class="align-right number-cell">{{$item.COGS}}</td>
<td class="align-right number-cell">{{$item.GrossProfit}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- 03. RINGKASAN FINANSIAL -->
<div class="section no-break">
<div class="section-header">
<div class="section-number">03. Ringkasan Finansial</div>
<div class="section-title">Perhitungan Laba Rugi</div>
</div>
<div class="financial-summary">
<div class="financial-header">
<h3>Laporan Laba Kotor Harian</h3>
</div>
<div class="financial-content">
<div class="financial-row subtotal">
<span class="financial-label">Pendapatan Kotor</span>
<span class="financial-amount">{{.Summary.GrossSales}}</span>
</div>
<div class="financial-row">
<span class="financial-label">Dikurangi: Diskon</span>
<span class="financial-amount">({{.Summary.Discount}})</span>
</div>
<div class="financial-row">
<span class="financial-label">Ditambah: Pajak</span>
<span class="financial-amount">{{.Summary.Tax}}</span>
</div>
<div class="financial-row subtotal">
<span class="financial-label">Pendapatan Bersih</span>
<span class="financial-amount">{{.Summary.NetSales}}</span>
</div>
<div class="financial-row">
<span class="financial-label">Dikurangi: HPP (COGS)</span>
<span class="financial-amount">({{.Summary.COGS}})</span>
</div>
<div class="financial-row final">
<span class="financial-label">Laba Kotor</span>
<span class="financial-amount">{{.Summary.GrossProfit}}</span>
</div>
</div>
</div>
</div>
<!-- FOOTER NOTES -->
<div class="report-footer">
<div class="footer-title">Catatan Laporan</div>
<ul class="footer-notes">
<li>Laporan ini dibuat berdasarkan data transaksi yang tercatat dalam sistem APSKEL pada periode yang ditentukan</li>
<li>Semua nilai dalam mata uang Rupiah (IDR) kecuali dinyatakan lain dalam dokumen</li>
<li>Margin Laba Kotor dihitung sebagai (Laba Kotor ÷ Pendapatan Bersih) × 100%</li>
<li>HPP (Harga Pokok Penjualan) mencakup biaya langsung untuk memproduksi atau memperoleh barang yang terjual</li>
<li>Data ini dapat digunakan untuk analisis kinerja harian dan perencanaan strategis bisnis</li>
</ul>
</div>
</div>
</body>
</html>