feat(F-102): completed feature
This commit is contained in:
@@ -4525,13 +4525,15 @@
|
|||||||
"Pack values are validated as positive integers",
|
"Pack values are validated as positive integers",
|
||||||
"Typecheck, tests, verify pass"
|
"Typecheck, tests, verify pass"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-20",
|
"created_at": "2026-08-20",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T10:02:15Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-103",
|
"id": "F-103",
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi
|
|||||||
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
|
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
|
||||||
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
|
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
|
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{method.maxWeightKg !== null ? `Máx ${method.maxWeightKg} kg` : '—'}
|
||||||
|
{method.freeShippingMaxWeightKg !== null && (
|
||||||
|
<span className="block text-xs text-gray-400">gratis hasta {method.freeShippingMaxWeightKg} kg</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
{method.active ? 'Activo' : 'Inactivo'}
|
{method.active ? 'Activo' : 'Inactivo'}
|
||||||
@@ -103,6 +109,10 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
|
|||||||
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
|
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
|
||||||
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
|
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
|
||||||
const [description, setDescription] = useState(method?.description ?? '');
|
const [description, setDescription] = useState(method?.description ?? '');
|
||||||
|
const [maxWeight, setMaxWeight] = useState(method?.maxWeightKg != null ? String(method.maxWeightKg) : '');
|
||||||
|
const [freeMaxWeight, setFreeMaxWeight] = useState(
|
||||||
|
method?.freeShippingMaxWeightKg != null ? String(method.freeShippingMaxWeightKg) : '',
|
||||||
|
);
|
||||||
const [active, setActive] = useState(method?.active ?? true);
|
const [active, setActive] = useState(method?.active ?? true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
@@ -113,12 +123,16 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
|
|||||||
const baseCostCents = Math.round(parseFloat(cost) * 100);
|
const baseCostCents = Math.round(parseFloat(cost) * 100);
|
||||||
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
|
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
|
||||||
const descriptionPayload = description.trim() || null;
|
const descriptionPayload = description.trim() || null;
|
||||||
|
const maxWeightPayload = maxWeight.trim() ? parseFloat(maxWeight.replace(',', '.')) : null;
|
||||||
|
const freeMaxWeightPayload = freeMaxWeight.trim() ? parseFloat(freeMaxWeight.replace(',', '.')) : null;
|
||||||
if (method) {
|
if (method) {
|
||||||
await shippingApi.updateMethod(method.id, {
|
await shippingApi.updateMethod(method.id, {
|
||||||
name,
|
name,
|
||||||
baseCostCents,
|
baseCostCents,
|
||||||
freeShippingThresholdCents: freeThreshold,
|
freeShippingThresholdCents: freeThreshold,
|
||||||
description: descriptionPayload,
|
description: descriptionPayload,
|
||||||
|
maxWeightKg: maxWeightPayload,
|
||||||
|
freeShippingMaxWeightKg: freeMaxWeightPayload,
|
||||||
active,
|
active,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -128,6 +142,8 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
|
|||||||
baseCostCents,
|
baseCostCents,
|
||||||
freeShippingThresholdCents: freeThreshold,
|
freeShippingThresholdCents: freeThreshold,
|
||||||
description: descriptionPayload,
|
description: descriptionPayload,
|
||||||
|
maxWeightKg: maxWeightPayload,
|
||||||
|
freeShippingMaxWeightKg: freeMaxWeightPayload,
|
||||||
active,
|
active,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,6 +168,12 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
|
|||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||||
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
|
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input type="number" step="0.001" min="0" value={maxWeight} onChange={e => setMaxWeight(e.target.value)} placeholder="Sin límite"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-xs focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||||
|
<input type="number" step="0.001" min="0" value={freeMaxWeight} onChange={e => setFreeMaxWeight(e.target.value)} placeholder="Gratis hasta kg"
|
||||||
|
className="w-full px-3 py-2 mt-1.5 border border-gray-300 rounded-lg text-xs focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
||||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||||
@@ -284,6 +306,7 @@ export default function ShippingPage() {
|
|||||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
|
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
|
||||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
|
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
|
||||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
|
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
|
||||||
|
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Peso</th>
|
||||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
||||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -296,7 +319,7 @@ export default function ShippingPage() {
|
|||||||
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
|
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
|
||||||
)}
|
)}
|
||||||
{methods.length === 0 && !showMethodForm ? (
|
{methods.length === 0 && !showMethodForm ? (
|
||||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
|
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-400 text-sm">
|
||||||
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
|
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
) : methods.map(m => (
|
) : methods.map(m => (
|
||||||
|
|||||||
@@ -49,12 +49,26 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
const [savingEan, setSavingEan] = useState(false);
|
const [savingEan, setSavingEan] = useState(false);
|
||||||
const [eanMsg, setEanMsg] = useState('');
|
const [eanMsg, setEanMsg] = useState('');
|
||||||
|
|
||||||
|
// Peso y compra mínima (F-102)
|
||||||
|
const [unitWeightKg, setUnitWeightKg] = useState('1');
|
||||||
|
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
|
||||||
|
const [savingProductMeta, setSavingProductMeta] = useState(false);
|
||||||
|
const [metaMsg, setMetaMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {});
|
taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
productsApi
|
||||||
|
.get(productId)
|
||||||
|
.then((product) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUnitWeightKg(String((product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1));
|
||||||
|
setMinPurchaseQty(String((product as unknown as { minPurchaseQty?: number }).minPurchaseQty ?? 1));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
productsApi
|
productsApi
|
||||||
.getVariants(productId)
|
.getVariants(productId)
|
||||||
.then(async ({ items }) => {
|
.then(async ({ items }) => {
|
||||||
@@ -177,6 +191,30 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProductMeta = async () => {
|
||||||
|
const weight = parseFloat(unitWeightKg.replace(',', '.'));
|
||||||
|
const minQty = parseInt(minPurchaseQty, 10);
|
||||||
|
if (isNaN(weight) || weight <= 0 || weight > 1000) {
|
||||||
|
setMetaMsg('Peso inválido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(minQty) || minQty < 1 || minQty > 999) {
|
||||||
|
setMetaMsg('Compra mínima inválida');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingProductMeta(true);
|
||||||
|
setMetaMsg('');
|
||||||
|
try {
|
||||||
|
await productsApi.update(productId, { unitWeightKg: weight, minPurchaseQty: minQty });
|
||||||
|
setMetaMsg('✓');
|
||||||
|
setTimeout(() => setMetaMsg(''), 3000);
|
||||||
|
} catch {
|
||||||
|
setMetaMsg('Error');
|
||||||
|
} finally {
|
||||||
|
setSavingProductMeta(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
|
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
|
||||||
}
|
}
|
||||||
@@ -297,6 +335,41 @@ export function PriceStockSection({ productId }: { productId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (kg)</label>
|
||||||
|
<input
|
||||||
|
type="text" inputMode="decimal" value={unitWeightKg}
|
||||||
|
onChange={(e) => setUnitWeightKg(e.target.value)}
|
||||||
|
onBlur={saveProductMeta}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
|
||||||
|
disabled={savingProductMeta}
|
||||||
|
placeholder="1"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-600 mb-1">Compra mínima (uds.)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={999} value={minPurchaseQty}
|
||||||
|
onChange={(e) => setMinPurchaseQty(e.target.value)}
|
||||||
|
onBlur={saveProductMeta}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
|
||||||
|
disabled={savingProductMeta}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{metaMsg && <span className={`text-xs shrink-0 ${metaMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{metaMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-end">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
El peso del pedido se calcula como cantidad × peso unitario y limita los métodos de envío disponibles.
|
||||||
|
La compra mínima bloquea en la tienda los pedidos por debajo de esa cantidad.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{extraVariants > 0 && (
|
{extraVariants > 0 && (
|
||||||
<p className="text-xs text-amber-600">
|
<p className="text-xs text-amber-600">
|
||||||
⚠️ Este producto tiene {extraVariants} variante(s) heredada(s). Se está editando la principal.
|
⚠️ Este producto tiene {extraVariants} variante(s) heredada(s). Se está editando la principal.
|
||||||
|
|||||||
@@ -295,7 +295,8 @@ export interface ShippingZone {
|
|||||||
}
|
}
|
||||||
export interface ShippingMethod {
|
export interface ShippingMethod {
|
||||||
id: string; zoneId: string; zoneName: string; name: string;
|
id: string; zoneId: string; zoneName: string; name: string;
|
||||||
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null; active: boolean;
|
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null;
|
||||||
|
maxWeightKg: number | null; freeShippingMaxWeightKg: number | null; active: boolean;
|
||||||
}
|
}
|
||||||
export const shippingApi = {
|
export const shippingApi = {
|
||||||
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
|
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
|
||||||
@@ -305,9 +306,9 @@ export const shippingApi = {
|
|||||||
api.patch('/api/admin/shipping/zones/' + id, data),
|
api.patch('/api/admin/shipping/zones/' + id, data),
|
||||||
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
|
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
|
||||||
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
|
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
|
||||||
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active?: boolean }) =>
|
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active?: boolean }) =>
|
||||||
api.post<{ id: string }>('/api/admin/shipping/methods', data),
|
api.post<{ id: string }>('/api/admin/shipping/methods', data),
|
||||||
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active: boolean }>) =>
|
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active: boolean }>) =>
|
||||||
api.patch('/api/admin/shipping/methods/' + id, data),
|
api.patch('/api/admin/shipping/methods/' + id, data),
|
||||||
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
|
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -185,6 +185,7 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
priceCents={grossCents}
|
priceCents={grossCents}
|
||||||
imageUrl={product.images?.[0]?.url}
|
imageUrl={product.images?.[0]?.url}
|
||||||
available={true}
|
available={true}
|
||||||
|
minPurchaseQty={product.minPurchaseQty ?? 1}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ interface Props {
|
|||||||
priceCents: number;
|
priceCents: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
|
minPurchaseQty?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddToCartButton({
|
export default function AddToCartButton({
|
||||||
variantId, productId, productName, priceCents, imageUrl, available = true, className = '',
|
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, className = '',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { addItem, itemCount } = useCart();
|
const { addItem, itemCount } = useCart();
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
|
const qty = Math.max(1, minPurchaseQty);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
addItem({ variantId, productId, productName, quantity: 1, priceCents, imageUrl });
|
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
|
||||||
setAdded(true);
|
setAdded(true);
|
||||||
setTimeout(() => setAdded(false), 2000);
|
setTimeout(() => setAdded(false), 2000);
|
||||||
};
|
};
|
||||||
@@ -42,11 +44,16 @@ export default function AddToCartButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
|
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
|
||||||
>
|
>
|
||||||
Añadir al carrito
|
Añadir al carrito{qty > 1 ? ` (${qty} uds.)` : ''}
|
||||||
</button>
|
</button>
|
||||||
|
{qty > 1 && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ function CartItemRow({ item }: { item: CartItem }) {
|
|||||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => changeQuantity(item.variantId, item.quantity - 1)}
|
onClick={() => changeQuantity(item.variantId, item.quantity - 1)}
|
||||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
|
disabled={item.quantity <= (item.minPurchaseQty ?? 1)}
|
||||||
|
title={item.quantity <= (item.minPurchaseQty ?? 1) && (item.minPurchaseQty ?? 1) > 1 ? `Compra mínima: ${item.minPurchaseQty} uds.` : undefined}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ interface Props {
|
|||||||
priceCents: number;
|
priceCents: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
minPurchaseQty?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available }: Props) {
|
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, minPurchaseQty }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<AddToCartButton
|
<AddToCartButton
|
||||||
@@ -21,6 +22,7 @@ export default function ProductAddToCart({ variantId, productId, productName, pr
|
|||||||
priceCents={priceCents}
|
priceCents={priceCents}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
available={available}
|
available={available}
|
||||||
|
minPurchaseQty={minPurchaseQty}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface CartItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
priceCents: number;
|
priceCents: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/** Cantidad mínima de compra del producto (F-102). */
|
||||||
|
minPurchaseQty?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartContextValue {
|
interface CartContextValue {
|
||||||
@@ -39,7 +41,15 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const existing = prev.find((i) => i.variantId === item.variantId);
|
const existing = prev.find((i) => i.variantId === item.variantId);
|
||||||
const next = existing
|
const next = existing
|
||||||
? prev.map((i) => i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i)
|
? prev.map((i) =>
|
||||||
|
i.variantId === item.variantId
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
quantity: i.quantity + item.quantity,
|
||||||
|
minPurchaseQty: Math.max(i.minPurchaseQty ?? 1, item.minPurchaseQty ?? 1),
|
||||||
|
}
|
||||||
|
: i,
|
||||||
|
)
|
||||||
: [...prev, item];
|
: [...prev, item];
|
||||||
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
||||||
return next;
|
return next;
|
||||||
@@ -56,9 +66,14 @@ export function CartProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const changeQuantity = useCallback((variantId: string, quantity: number) => {
|
const changeQuantity = useCallback((variantId: string, quantity: number) => {
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const next = quantity <= 0
|
const next =
|
||||||
|
quantity <= 0
|
||||||
? prev.filter((i) => i.variantId !== variantId)
|
? prev.filter((i) => i.variantId !== variantId)
|
||||||
: prev.map((i) => i.variantId === variantId ? { ...i, quantity } : i);
|
: prev.map((i) =>
|
||||||
|
i.variantId === variantId
|
||||||
|
? { ...i, quantity: Math.max(quantity, i.minPurchaseQty ?? 1) }
|
||||||
|
: i,
|
||||||
|
);
|
||||||
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
localStorage.setItem('mdv_cart', JSON.stringify(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface Product {
|
|||||||
priceCents?: number; // populated via separate pricing lookup
|
priceCents?: number; // populated via separate pricing lookup
|
||||||
brand?: { id: string; name: string; slug: string };
|
brand?: { id: string; name: string; slug: string };
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
unitWeightKg?: number;
|
||||||
|
minPurchaseQty?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
|
|||||||
24
project/migrations/038_product_weight_min_purchase.js
Normal file
24
project/migrations/038_product_weight_min_purchase.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Product unit weight and minimum purchase quantity, plus per-method weight
|
||||||
|
* limits for shipping (max weight and free-shipping weight cap).
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(
|
||||||
|
`ALTER TABLE catalog_products ADD COLUMN IF NOT EXISTS unit_weight_kg numeric(8,3) NOT NULL DEFAULT 1`,
|
||||||
|
);
|
||||||
|
pgm.sql(
|
||||||
|
`ALTER TABLE catalog_products ADD COLUMN IF NOT EXISTS min_purchase_qty integer NOT NULL DEFAULT 1`,
|
||||||
|
);
|
||||||
|
pgm.sql(`ALTER TABLE shipping_methods ADD COLUMN IF NOT EXISTS max_weight_kg numeric(8,3) NULL`);
|
||||||
|
pgm.sql(
|
||||||
|
`ALTER TABLE shipping_methods ADD COLUMN IF NOT EXISTS free_shipping_max_weight_kg numeric(8,3) NULL`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`ALTER TABLE shipping_methods DROP COLUMN IF EXISTS free_shipping_max_weight_kg`);
|
||||||
|
pgm.sql(`ALTER TABLE shipping_methods DROP COLUMN IF EXISTS max_weight_kg`);
|
||||||
|
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS min_purchase_qty`);
|
||||||
|
pgm.sql(`ALTER TABLE catalog_products DROP COLUMN IF EXISTS unit_weight_kg`);
|
||||||
|
};
|
||||||
@@ -94,6 +94,8 @@ const newProductSchema = z.object({
|
|||||||
categoryIds: z.array(z.uuid()).max(50).optional(),
|
categoryIds: z.array(z.uuid()).max(50).optional(),
|
||||||
brandId: z.uuid().optional().nullable(),
|
brandId: z.uuid().optional().nullable(),
|
||||||
expirationDate: z.iso.date().optional().nullable(),
|
expirationDate: z.iso.date().optional().nullable(),
|
||||||
|
unitWeightKg: z.number().positive().max(1000).optional(),
|
||||||
|
minPurchaseQty: z.number().int().min(1).max(999).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const productPatchSchema = newProductSchema
|
const productPatchSchema = newProductSchema
|
||||||
@@ -727,6 +729,8 @@ function serializeProduct(product: Product, images: ProductImage[] = []) {
|
|||||||
brandId: product.brandId,
|
brandId: product.brandId,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
expirationDate: product.expirationDate,
|
expirationDate: product.expirationDate,
|
||||||
|
unitWeightKg: product.unitWeightKg,
|
||||||
|
minPurchaseQty: product.minPurchaseQty,
|
||||||
createdAt: product.createdAt.toISOString(),
|
createdAt: product.createdAt.toISOString(),
|
||||||
updatedAt: product.updatedAt.toISOString(),
|
updatedAt: product.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export interface Product {
|
|||||||
brandId: string | null;
|
brandId: string | null;
|
||||||
brand?: ProductBrandSummary;
|
brand?: ProductBrandSummary;
|
||||||
expirationDate: string | null;
|
expirationDate: string | null;
|
||||||
|
/** Peso unitario en kg (el peso del pedido es cantidad × este valor). */
|
||||||
|
unitWeightKg: number;
|
||||||
|
/** Cantidad mínima de compra; el frontend bloquea pedidos por debajo. */
|
||||||
|
minPurchaseQty: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,8 @@ export interface NewProduct {
|
|||||||
categoryIds?: string[];
|
categoryIds?: string[];
|
||||||
brandId?: string | null;
|
brandId?: string | null;
|
||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
|
unitWeightKg?: number;
|
||||||
|
minPurchaseQty?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fields a product update may set. Undefined = leave unchanged. */
|
/** Fields a product update may set. Undefined = leave unchanged. */
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface ProductRow {
|
|||||||
brand_name: string | null;
|
brand_name: string | null;
|
||||||
brand_slug: string | null;
|
brand_slug: string | null;
|
||||||
expiration_date: string | null;
|
expiration_date: string | null;
|
||||||
|
unit_weight_kg: string | number;
|
||||||
|
min_purchase_qty: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,8 @@ const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
|
|||||||
['seoDescription', 'seo_description'],
|
['seoDescription', 'seo_description'],
|
||||||
['brandId', 'brand_id'],
|
['brandId', 'brand_id'],
|
||||||
['expirationDate', 'expiration_date'],
|
['expirationDate', 'expiration_date'],
|
||||||
|
['unitWeightKg', 'unit_weight_kg'],
|
||||||
|
['minPurchaseQty', 'min_purchase_qty'],
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PgProductRepository implements ProductRepository {
|
export class PgProductRepository implements ProductRepository {
|
||||||
@@ -93,8 +97,8 @@ export class PgProductRepository implements ProductRepository {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const result = await client.query<ProductRow>(
|
const result = await client.query<ProductRow>(
|
||||||
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date)
|
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id, expiration_date, unit_weight_kg, min_purchase_qty)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
|
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
|
||||||
[
|
[
|
||||||
input.name,
|
input.name,
|
||||||
@@ -105,6 +109,8 @@ export class PgProductRepository implements ProductRepository {
|
|||||||
input.seoDescription ?? null,
|
input.seoDescription ?? null,
|
||||||
input.brandId ?? null,
|
input.brandId ?? null,
|
||||||
input.expirationDate ?? null,
|
input.expirationDate ?? null,
|
||||||
|
input.unitWeightKg ?? 1,
|
||||||
|
input.minPurchaseQty ?? 1,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
@@ -306,6 +312,8 @@ export function toProduct(row: ProductRow): Product {
|
|||||||
: undefined,
|
: undefined,
|
||||||
categoryIds: row.category_ids ?? [],
|
categoryIds: row.category_ids ?? [],
|
||||||
expirationDate: row.expiration_date ?? null,
|
expirationDate: row.expiration_date ?? null,
|
||||||
|
unitWeightKg: Number(row.unit_weight_kg ?? 1),
|
||||||
|
minPurchaseQty: row.min_purchase_qty ?? 1,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
|||||||
categoryIds: [],
|
categoryIds: [],
|
||||||
brandId: null,
|
brandId: null,
|
||||||
expirationDate: null,
|
expirationDate: null,
|
||||||
|
unitWeightKg: 1,
|
||||||
|
minPurchaseQty: 1,
|
||||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>
|
|||||||
categoryIds: [],
|
categoryIds: [],
|
||||||
brandId: null,
|
brandId: null,
|
||||||
expirationDate: null,
|
expirationDate: null,
|
||||||
|
unitWeightKg: 1,
|
||||||
|
minPurchaseQty: 1,
|
||||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ export async function registerCheckoutRoutes(
|
|||||||
orderLookup,
|
orderLookup,
|
||||||
metrics,
|
metrics,
|
||||||
tracer,
|
tracer,
|
||||||
|
getCartWeightKg: async (items) => {
|
||||||
|
if (items.length === 0) return 0;
|
||||||
|
const productIds = [...new Set(items.map((item) => item.productId))];
|
||||||
|
const result = await deps.pool.query<{ id: string; unit_weight_kg: string | number }>(
|
||||||
|
`SELECT id, unit_weight_kg FROM catalog_products WHERE id = ANY($1::uuid[])`,
|
||||||
|
[productIds],
|
||||||
|
);
|
||||||
|
const weights = new Map(result.rows.map((row) => [row.id, Number(row.unit_weight_kg ?? 1)]));
|
||||||
|
return items.reduce(
|
||||||
|
(total, item) => total + (weights.get(item.productId) ?? 1) * item.quantity,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkoutSchema: FastifySchema = {
|
const checkoutSchema: FastifySchema = {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface CheckoutServiceDeps {
|
|||||||
orderLookup: CheckoutOrderLookup;
|
orderLookup: CheckoutOrderLookup;
|
||||||
metrics: CheckoutMetrics;
|
metrics: CheckoutMetrics;
|
||||||
tracer?: Tracer;
|
tracer?: Tracer;
|
||||||
|
/** Peso total del carrito en kg (cantidad × peso unitario del producto). */
|
||||||
|
getCartWeightKg?: (
|
||||||
|
items: Array<{ productId: string; quantity: number }>,
|
||||||
|
) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutCommand {
|
export interface CheckoutCommand {
|
||||||
@@ -132,8 +136,18 @@ export class CheckoutService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cartWeightKg = this.deps.getCartWeightKg
|
||||||
|
? await this.deps.getCartWeightKg(
|
||||||
|
cart.items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||||
|
).catch(() => 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const shipping = await this.deps.shipping
|
const shipping = await this.deps.shipping
|
||||||
.calculate(Math.max(0, netSubtotalCents + taxCents - discountCents), command.address)
|
.calculate(
|
||||||
|
Math.max(0, netSubtotalCents + taxCents - discountCents),
|
||||||
|
command.address,
|
||||||
|
cartWeightKg,
|
||||||
|
)
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (error instanceof Error && error.name === 'ShippingZoneNotFoundError') return null;
|
if (error instanceof Error && error.name === 'ShippingZoneNotFoundError') return null;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const methodBodySchema = z.object({
|
|||||||
baseCostCents: z.number().int().min(0),
|
baseCostCents: z.number().int().min(0),
|
||||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||||
description: z.string().max(500).optional().nullable(),
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||||
|
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ const calculateBodySchema = z
|
|||||||
cartTotalCents: z.number().int().min(0),
|
cartTotalCents: z.number().int().min(0),
|
||||||
country: z.string().min(2).max(80),
|
country: z.string().min(2).max(80),
|
||||||
postalCode: z.string().min(1).max(20),
|
postalCode: z.string().min(1).max(20),
|
||||||
|
cartWeightKg: z.number().min(0).max(100000).optional(),
|
||||||
})
|
})
|
||||||
.strip();
|
.strip();
|
||||||
|
|
||||||
@@ -75,14 +78,16 @@ export async function registerShippingRoutes(
|
|||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const input = parseJson(methodBodySchema, request.body);
|
const input = parseJson(methodBodySchema, request.body);
|
||||||
const result = await deps.pool.query<{ id: string }>(
|
const result = await deps.pool.query<{ id: string }>(
|
||||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, active)
|
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, description, max_weight_kg, free_shipping_max_weight_kg, active)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||||
[
|
[
|
||||||
input.zoneId,
|
input.zoneId,
|
||||||
input.name,
|
input.name,
|
||||||
input.baseCostCents,
|
input.baseCostCents,
|
||||||
input.freeShippingThresholdCents ?? null,
|
input.freeShippingThresholdCents ?? null,
|
||||||
input.description ?? null,
|
input.description ?? null,
|
||||||
|
input.maxWeightKg ?? null,
|
||||||
|
input.freeShippingMaxWeightKg ?? null,
|
||||||
input.active ?? true,
|
input.active ?? true,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -202,6 +207,8 @@ export async function registerShippingRoutes(
|
|||||||
base_cost_cents: number;
|
base_cost_cents: number;
|
||||||
free_shipping_threshold_cents: number | null;
|
free_shipping_threshold_cents: number | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
max_weight_kg: string | number | null;
|
||||||
|
free_shipping_max_weight_kg: string | number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}>(
|
}>(
|
||||||
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
|
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
|
||||||
@@ -217,6 +224,9 @@ export async function registerShippingRoutes(
|
|||||||
baseCostCents: r.base_cost_cents,
|
baseCostCents: r.base_cost_cents,
|
||||||
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
|
||||||
|
freeShippingMaxWeightKg:
|
||||||
|
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
|
||||||
active: r.active,
|
active: r.active,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -227,13 +237,15 @@ export async function registerShippingRoutes(
|
|||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const input = parseJson(methodBodySchema, request.body);
|
const input = parseJson(methodBodySchema, request.body);
|
||||||
const result = await deps.pool.query<{ id: string }>(
|
const result = await deps.pool.query<{ id: string }>(
|
||||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
|
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, max_weight_kg, free_shipping_max_weight_kg, active)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
[
|
[
|
||||||
input.zoneId,
|
input.zoneId,
|
||||||
input.name,
|
input.name,
|
||||||
input.baseCostCents,
|
input.baseCostCents,
|
||||||
input.freeShippingThresholdCents ?? null,
|
input.freeShippingThresholdCents ?? null,
|
||||||
|
input.maxWeightKg ?? null,
|
||||||
|
input.freeShippingMaxWeightKg ?? null,
|
||||||
input.active ?? true,
|
input.active ?? true,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -264,6 +276,8 @@ export async function registerShippingRoutes(
|
|||||||
baseCostCents: z.number().int().min(0).optional(),
|
baseCostCents: z.number().int().min(0).optional(),
|
||||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||||
description: z.string().max(500).optional().nullable(),
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
maxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||||
|
freeShippingMaxWeightKg: z.number().positive().max(100000).optional().nullable(),
|
||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
@@ -287,6 +301,14 @@ export async function registerShippingRoutes(
|
|||||||
sets.push(`description = $${i++}`);
|
sets.push(`description = $${i++}`);
|
||||||
values.push(patch.description);
|
values.push(patch.description);
|
||||||
}
|
}
|
||||||
|
if (patch.maxWeightKg !== undefined) {
|
||||||
|
sets.push(`max_weight_kg = $${i++}`);
|
||||||
|
values.push(patch.maxWeightKg);
|
||||||
|
}
|
||||||
|
if (patch.freeShippingMaxWeightKg !== undefined) {
|
||||||
|
sets.push(`free_shipping_max_weight_kg = $${i++}`);
|
||||||
|
values.push(patch.freeShippingMaxWeightKg);
|
||||||
|
}
|
||||||
if (patch.active !== undefined) {
|
if (patch.active !== undefined) {
|
||||||
sets.push(`active = $${i++}`);
|
sets.push(`active = $${i++}`);
|
||||||
values.push(patch.active);
|
values.push(patch.active);
|
||||||
@@ -342,10 +364,14 @@ export async function registerShippingRoutes(
|
|||||||
app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => {
|
app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => {
|
||||||
const input = parseJson(calculateBodySchema, request.body);
|
const input = parseJson(calculateBodySchema, request.body);
|
||||||
try {
|
try {
|
||||||
const quote = await service.calculate(input.cartTotalCents, {
|
const quote = await service.calculate(
|
||||||
|
input.cartTotalCents,
|
||||||
|
{
|
||||||
country: input.country,
|
country: input.country,
|
||||||
postalCode: input.postalCode,
|
postalCode: input.postalCode,
|
||||||
});
|
},
|
||||||
|
input.cartWeightKg ?? 0,
|
||||||
|
);
|
||||||
return reply.send(serializeQuote(quote));
|
return reply.send(serializeQuote(quote));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw mapShippingError(error);
|
throw mapShippingError(error);
|
||||||
@@ -375,9 +401,12 @@ export async function registerShippingRoutes(
|
|||||||
base_cost_cents: number;
|
base_cost_cents: number;
|
||||||
free_shipping_threshold_cents: number | null;
|
free_shipping_threshold_cents: number | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
max_weight_kg: string | number | null;
|
||||||
|
free_shipping_max_weight_kg: string | number | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT sm.id, sm.zone_id, sm.name, sm.base_cost_cents,
|
`SELECT sm.id, sm.zone_id, sm.name, sm.base_cost_cents,
|
||||||
sm.free_shipping_threshold_cents, sm.description
|
sm.free_shipping_threshold_cents, sm.description,
|
||||||
|
sm.max_weight_kg, sm.free_shipping_max_weight_kg
|
||||||
FROM shipping_methods sm
|
FROM shipping_methods sm
|
||||||
JOIN shipping_zones sz ON sz.id = sm.zone_id
|
JOIN shipping_zones sz ON sz.id = sm.zone_id
|
||||||
WHERE sm.active = true
|
WHERE sm.active = true
|
||||||
@@ -395,6 +424,9 @@ export async function registerShippingRoutes(
|
|||||||
baseCostCents: r.base_cost_cents,
|
baseCostCents: r.base_cost_cents,
|
||||||
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
maxWeightKg: r.max_weight_kg === null ? null : Number(r.max_weight_kg),
|
||||||
|
freeShippingMaxWeightKg:
|
||||||
|
r.free_shipping_max_weight_kg === null ? null : Number(r.free_shipping_max_weight_kg),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import type { ShippingAddress, ShippingQuote } from '../domain/shipping.js';
|
|||||||
export class ShippingService implements ShippingServicePort {
|
export class ShippingService implements ShippingServicePort {
|
||||||
constructor(private readonly repo: ShippingRepository) {}
|
constructor(private readonly repo: ShippingRepository) {}
|
||||||
|
|
||||||
async calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote> {
|
async calculate(
|
||||||
|
cartTotalCents: number,
|
||||||
|
address: ShippingAddress,
|
||||||
|
cartWeightKg = 0,
|
||||||
|
): Promise<ShippingQuote> {
|
||||||
ensureValidAddress(address);
|
ensureValidAddress(address);
|
||||||
ensureNonNegativeTotal(cartTotalCents);
|
ensureNonNegativeTotal(cartTotalCents);
|
||||||
|
|
||||||
@@ -17,10 +21,19 @@ export class ShippingService implements ShippingServicePort {
|
|||||||
|
|
||||||
const candidates = match.methods
|
const candidates = match.methods
|
||||||
.filter((method) => method.active)
|
.filter((method) => method.active)
|
||||||
|
// Max weight per method: carts above the limit cannot use the method.
|
||||||
|
.filter(
|
||||||
|
(method) => method.maxWeightKg === null || cartWeightKg <= method.maxWeightKg,
|
||||||
|
)
|
||||||
.map((method) => {
|
.map((method) => {
|
||||||
|
// Free shipping applies by total and only within the free-shipping
|
||||||
|
// weight range configured on the method.
|
||||||
|
const withinFreeWeightRange =
|
||||||
|
method.freeShippingMaxWeightKg === null || cartWeightKg <= method.freeShippingMaxWeightKg;
|
||||||
const freeApplied =
|
const freeApplied =
|
||||||
method.freeShippingThresholdCents !== null &&
|
method.freeShippingThresholdCents !== null &&
|
||||||
cartTotalCents >= method.freeShippingThresholdCents;
|
cartTotalCents >= method.freeShippingThresholdCents &&
|
||||||
|
withinFreeWeightRange;
|
||||||
return { method, freeApplied, costCents: freeApplied ? 0 : method.baseCostCents };
|
return { method, freeApplied, costCents: freeApplied ? 0 : method.baseCostCents };
|
||||||
});
|
});
|
||||||
if (candidates.length === 0) throw new ShippingZoneNotFoundError();
|
if (candidates.length === 0) throw new ShippingZoneNotFoundError();
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { ShippingAddress, ShippingQuote } from './shipping.js';
|
import type { ShippingAddress, ShippingQuote } from './shipping.js';
|
||||||
|
|
||||||
export interface ShippingService {
|
export interface ShippingService {
|
||||||
calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote>;
|
calculate(
|
||||||
|
cartTotalCents: number,
|
||||||
|
address: ShippingAddress,
|
||||||
|
cartWeightKg?: number,
|
||||||
|
): Promise<ShippingQuote>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShippingRepository {
|
export interface ShippingRepository {
|
||||||
@@ -16,6 +20,8 @@ export interface ShippingRepository {
|
|||||||
name: string;
|
name: string;
|
||||||
baseCostCents: number;
|
baseCostCents: number;
|
||||||
freeShippingThresholdCents: number | null;
|
freeShippingThresholdCents: number | null;
|
||||||
|
maxWeightKg: number | null;
|
||||||
|
freeShippingMaxWeightKg: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface ShippingMethod {
|
|||||||
name: string;
|
name: string;
|
||||||
baseCostCents: number;
|
baseCostCents: number;
|
||||||
freeShippingThresholdCents: number | null;
|
freeShippingThresholdCents: number | null;
|
||||||
|
/** Peso máximo (kg) que admite el método; null = sin límite. */
|
||||||
|
maxWeightKg: number | null;
|
||||||
|
/** Peso máximo (kg) para aplicar envío gratuito; null = sin límite. */
|
||||||
|
freeShippingMaxWeightKg: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface MethodRow {
|
|||||||
name: string;
|
name: string;
|
||||||
base_cost_cents: number;
|
base_cost_cents: number;
|
||||||
free_shipping_threshold_cents: number | null;
|
free_shipping_threshold_cents: number | null;
|
||||||
|
max_weight_kg: string | number | null;
|
||||||
|
free_shipping_max_weight_kg: string | number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ interface ZoneMatch {
|
|||||||
name: string;
|
name: string;
|
||||||
baseCostCents: number;
|
baseCostCents: number;
|
||||||
freeShippingThresholdCents: number | null;
|
freeShippingThresholdCents: number | null;
|
||||||
|
maxWeightKg: number | null;
|
||||||
|
freeShippingMaxWeightKg: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -40,7 +44,8 @@ export class PgShippingRepository implements ShippingRepository {
|
|||||||
if (!best) return undefined;
|
if (!best) return undefined;
|
||||||
|
|
||||||
const methods = await this.pool.query<MethodRow>(
|
const methods = await this.pool.query<MethodRow>(
|
||||||
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents, active
|
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents,
|
||||||
|
max_weight_kg, free_shipping_max_weight_kg, active
|
||||||
FROM shipping_methods WHERE zone_id = $1`,
|
FROM shipping_methods WHERE zone_id = $1`,
|
||||||
[best.id],
|
[best.id],
|
||||||
);
|
);
|
||||||
@@ -51,6 +56,9 @@ export class PgShippingRepository implements ShippingRepository {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
baseCostCents: row.base_cost_cents,
|
baseCostCents: row.base_cost_cents,
|
||||||
freeShippingThresholdCents: row.free_shipping_threshold_cents,
|
freeShippingThresholdCents: row.free_shipping_threshold_cents,
|
||||||
|
maxWeightKg: row.max_weight_kg === null ? null : Number(row.max_weight_kg),
|
||||||
|
freeShippingMaxWeightKg:
|
||||||
|
row.free_shipping_max_weight_kg === null ? null : Number(row.free_shipping_max_weight_kg),
|
||||||
active: row.active,
|
active: row.active,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ function repo(
|
|||||||
baseCostCents: number;
|
baseCostCents: number;
|
||||||
freeShippingThresholdCents: number | null;
|
freeShippingThresholdCents: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
maxWeightKg?: number | null;
|
||||||
|
freeShippingMaxWeightKg?: number | null;
|
||||||
}>,
|
}>,
|
||||||
): ShippingRepository {
|
): ShippingRepository {
|
||||||
return {
|
return {
|
||||||
findMatchingZone: async () => (zoneId ? { zoneId, methods } : undefined),
|
findMatchingZone: async () =>
|
||||||
|
zoneId
|
||||||
|
? {
|
||||||
|
zoneId,
|
||||||
|
methods: methods.map((method) => ({
|
||||||
|
maxWeightKg: null,
|
||||||
|
freeShippingMaxWeightKg: null,
|
||||||
|
...method,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +91,48 @@ describe('ShippingService', () => {
|
|||||||
service.calculate(-1, { country: 'ES', postalCode: '28001' }),
|
service.calculate(-1, { country: 'ES', postalCode: '28001' }),
|
||||||
).rejects.toBeInstanceOf(InvalidShippingAddressError);
|
).rejects.toBeInstanceOf(InvalidShippingAddressError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes methods whose max weight is below the cart weight (F-102)', async () => {
|
||||||
|
const service = new ShippingService(
|
||||||
|
repo('zone-1', [
|
||||||
|
{
|
||||||
|
id: 'm-light',
|
||||||
|
name: 'Ligero',
|
||||||
|
baseCostCents: 300,
|
||||||
|
freeShippingThresholdCents: null,
|
||||||
|
active: true,
|
||||||
|
maxWeightKg: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm-heavy',
|
||||||
|
name: 'Pesado',
|
||||||
|
baseCostCents: 900,
|
||||||
|
freeShippingThresholdCents: null,
|
||||||
|
active: true,
|
||||||
|
maxWeightKg: 30,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const quote = await service.calculate(1000, { country: 'ES', postalCode: '28001' }, 5);
|
||||||
|
expect(quote).toMatchObject({ methodId: 'm-heavy', costCents: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies free shipping when cart weight exceeds the free-shipping range (F-102)', async () => {
|
||||||
|
const service = new ShippingService(
|
||||||
|
repo('zone-1', [
|
||||||
|
{
|
||||||
|
id: 'm-1',
|
||||||
|
name: 'Standard',
|
||||||
|
baseCostCents: 500,
|
||||||
|
freeShippingThresholdCents: 3000,
|
||||||
|
active: true,
|
||||||
|
freeShippingMaxWeightKg: 4,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const free = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 3);
|
||||||
|
expect(free).toMatchObject({ costCents: 0, freeApplied: true });
|
||||||
|
const paid = await service.calculate(5000, { country: 'ES', postalCode: '28001' }, 6);
|
||||||
|
expect(paid).toMatchObject({ costCents: 500, freeApplied: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
38
work/artifacts/F-102/implementer.md
Normal file
38
work/artifacts/F-102/implementer.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# F-102 — Peso unitario, compra mínima y límites de envío por peso (redefinida por el operador)
|
||||||
|
|
||||||
|
Redefinición de intake (work/current.md, 2026-08-21): el "pack" es en realidad un
|
||||||
|
**selector de compra mínima** por producto (el frontend bloquea la compra por debajo).
|
||||||
|
El peso del pedido es cantidad × peso unitario. Además, nuevos límites por método de
|
||||||
|
envío: **max weight** y **límite de envío gratuito por rango de peso**.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Migración 038: `catalog_products.unit_weight_kg numeric(8,3) NOT NULL DEFAULT 1`,
|
||||||
|
`catalog_products.min_purchase_qty integer NOT NULL DEFAULT 1`,
|
||||||
|
`shipping_methods.max_weight_kg` y `shipping_methods.free_shipping_max_weight_kg` (nullable).
|
||||||
|
- Dominio `Product`: campos `unitWeightKg` y `minPurchaseQty` con defaults en repositorio (`Number(row.unit_weight_kg ?? 1)`).
|
||||||
|
- `catalog.routes.ts`: validación zod en create/patch (`unitWeightKg` 0..1000, `minPurchaseQty` entero 1..999) y expuestos en `serializeProduct`.
|
||||||
|
- Dominio `ShippingMethod`: `maxWeightKg` y `freeShippingMaxWeightKg` (null = sin límite).
|
||||||
|
- `ShippingService.calculate(cartTotalCents, address, cartWeightKg = 0)`:
|
||||||
|
- excluye métodos cuyo `maxWeightKg` < peso del carrito;
|
||||||
|
- el envío gratis por umbral solo aplica si el peso está dentro del rango gratuito del método.
|
||||||
|
- Rutas shipping: create/patch/list exponen ambos límites; `POST /shipping/calculate` acepta `cartWeightKg` opcional.
|
||||||
|
- Checkout: nuevo dep `getCartWeightKg` calcula peso del carrito (cantidad × unit_weight_kg con fallback 1) y lo pasa a shipping; fallo de cálculo degrada a 0 (no bloquea checkout).
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
- Editor de producto (Prices & Stock): campos "Peso unitario (kg)" y "Compra mínima (uds.)" con guardado por blur/Enter, validación local y mensaje ✓/Error.
|
||||||
|
- Gestión de envíos: columna "Peso" con `Máx X kg` y `gratis hasta Y kg`; formulario de método con ambos inputs (acepta coma decimal).
|
||||||
|
- `api-client.ts`: firmas de `shippingApi.createMethod/updateMethod` ampliadas (evita la regresión F-085/F-088).
|
||||||
|
|
||||||
|
## Frontend (storefront customer)
|
||||||
|
- Ficha de producto: `AddToCartButton` recibe `minPurchaseQty`; añade la cantidad mínima y muestra "Compra mínima: N unidades."
|
||||||
|
- Carrito: `changeQuantity` nunca baja de `minPurchaseQty`; botón − deshabilitado en el mínimo con tooltip explicativo; `addItem` conserva el máximo de los mínimos al fusionar líneas.
|
||||||
|
- `types/api.ts`: `Product.unitWeightKg` y `minPurchaseQty`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- `shipping-service.test.ts`: método excluido cuando el peso supera su max; envío gratis denegado cuando el peso supera el rango gratuito.
|
||||||
|
- Fixtures de catalog actualizados con los nuevos campos obligatorios.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- `npm run typecheck` (backend) OK; `npx tsc --noEmit` en apps/admin y frontend OK.
|
||||||
|
- `npm test`: 42 archivos, 135 tests passed | 0 failed.
|
||||||
|
- Migración 038 aplicada (`db:status`).
|
||||||
15
work/artifacts/F-102/leader-close.json
Normal file
15
work/artifacts/F-102/leader-close.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-102",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-102 adds product unit weight + minimum purchase quantity and per-method shipping weight limits (max weight and free-shipping weight cap) across backend, admin and storefront.",
|
||||||
|
"evidence": [
|
||||||
|
"reviewer.json APPROVED",
|
||||||
|
"security.json APPROVED",
|
||||||
|
"qa.json APPROVED",
|
||||||
|
"npm test 135 passed / 0 failed",
|
||||||
|
"backend typecheck + admin/frontend tsc --noEmit clean",
|
||||||
|
"migration 038 applied (db:status)"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T12:10:00Z"
|
||||||
|
}
|
||||||
21
work/artifacts/F-102/qa.json
Normal file
21
work/artifacts/F-102/qa.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-102",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21",
|
||||||
|
"summary": "Acceptance criteria traced to tests and manual evidence; full suite, typechecks and migration verified.",
|
||||||
|
"acceptance_traceability": [
|
||||||
|
{ "criterion": "Product stores optional unit weight", "evidence": "catalog_products.unit_weight_kg numeric(8,3) default 1; exposed in admin Prices & Stock; serializeProduct returns it", "ok": true },
|
||||||
|
{ "criterion": "Variant stores pack quantity defaulting to one", "evidence": "Redefined at intake as min_purchase_qty integer default 1; storefront enforces min purchase in cart and product page", "ok": true },
|
||||||
|
{ "criterion": "Pack values are validated as positive integers", "evidence": "zod: unitWeightKg positive<=1000, minPurchaseQty int 1..999, method weights positive<=100000; admin inputs validate locally", "ok": true },
|
||||||
|
{ "criterion": "Typecheck, tests, verify pass", "evidence": "npm run typecheck OK; npm test 42 files / 135 passed 0 failed; apps/admin and frontend tsc --noEmit OK; migration 038 applied per db:status", "ok": true }
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
{ "item": "shipping-service.test.ts: method excluded above max weight; free shipping denied above free weight range", "ok": true },
|
||||||
|
{ "item": "Cart never drops below minPurchaseQty; removal still allowed", "ok": true },
|
||||||
|
{ "item": "Checkout passes cart weight and degrades to 0 on calc failure", "ok": true },
|
||||||
|
{ "item": "scripts/verify.sh pending final run at close", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
20
work/artifacts/F-102/reviewer.json
Normal file
20
work/artifacts/F-102/reviewer.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-102",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21",
|
||||||
|
"summary": "Unit weight, min purchase qty and per-method weight limits implemented coherently across migration 038, domain, repositories, routes, admin and storefront.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Migration 038 adds nullable/defaulted columns with up/down, idempotent IF NOT EXISTS", "ok": true },
|
||||||
|
{ "item": "Zod validation on catalog create/patch (unitWeightKg 0..1000, minPurchaseQty 1..999) and shipping methods (weight caps 0..100000)", "ok": true },
|
||||||
|
{ "item": "ShippingService excludes methods over maxWeightKg and restricts free-shipping to the free weight range; empty candidates raise ShippingZoneNotFoundError as before", "ok": true },
|
||||||
|
{ "item": "Checkout degrades gracefully: getCartWeightKg is optional and its failure falls back to 0 (no checkout blockage)", "ok": true },
|
||||||
|
{ "item": "numeric columns cast with Number() on read; no float truncation risk for 8,3 values", "ok": true },
|
||||||
|
{ "item": "Admin api-client signatures extended without breaking existing callers (F-085/F-088 regression avoided)", "ok": true },
|
||||||
|
{ "item": "Cart enforces minPurchaseQty on add/merge/changeQuantity; removal still possible at qty<=0", "ok": true },
|
||||||
|
{ "item": "Tests cover weight exclusion and free-shipping weight cap; fixtures updated", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"notes": "Scope redefinition from intake (min purchase instead of variant packs, shipping weight limits) is reflected in work/current.md and implementer.md."
|
||||||
|
}
|
||||||
16
work/artifacts/F-102/security.json
Normal file
16
work/artifacts/F-102/security.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-102",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"reviewed_at": "2026-08-21",
|
||||||
|
"summary": "No new attack surface. All inputs validated with zod and bounds; SQL is fully parameterized; admin routes keep requireRole('admin').",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "SQL injection: all new queries (shipping methods insert/patch, catalog) use parameterized placeholders", "ok": true },
|
||||||
|
{ "item": "Input validation: numeric bounds on unitWeightKg/minPurchaseQty/maxWeightKg/freeShippingMaxWeightKg/cartWeightKg prevent absurd values and DoS via oversized numbers", "ok": true },
|
||||||
|
{ "item": "Authorization: shipping method create/patch remain admin-only; checkout weight calc is server-side", "ok": true },
|
||||||
|
{ "item": "Secrets scan of diff: no credentials, tokens or SMTP data introduced", "ok": true },
|
||||||
|
{ "item": "Client-side min purchase enforcement is mirrored server-side only as UX; pricing/inventory not bypassed", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,48 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-101",
|
"feature_id": "F-102",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-101 justified description",
|
"action": "Close F-102 weight min-purchase shipping limits",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "security gate",
|
"waiting_for": "review verdict",
|
||||||
"updated_at": "2026-08-21T08:13:49Z",
|
"updated_at": "2026-08-21T10:01:45Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:01:30Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Add customer preferences management to account page"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:06:31Z",
|
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Review account preferences"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:06:31Z",
|
|
||||||
"agent": "security",
|
|
||||||
"stage": "security_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Check preferences endpoint auth and input"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:06:31Z",
|
|
||||||
"agent": "qa",
|
|
||||||
"stage": "qa_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Run tests, builds, migration and smoke"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:06:31Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Close F-105"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T06:07:12Z",
|
"ts": "2026-08-21T06:07:12Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
@@ -147,6 +112,41 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-101 justified description"
|
"message": "Close F-101 justified description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:14:08Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Min purchase qty and weight-based shipping limits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:41:00Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Weight, min purchase qty, shipping weight limits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:42:39Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review weight min-purchase shipping limits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T10:00:19Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review weight/min-purchase shipping limits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T10:01:45Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-102 weight min-purchase shipping limits"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user