feat: beta invoice match marks matching assigned items collected
After an invoice photo, exact matches are marked collected (with undo); uncertain ones are listed. Still off unless enabled in Settings > Beta. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
54005e8d10
commit
1c48c74bd2
4 changed files with 271 additions and 162 deletions
10
app/page.tsx
10
app/page.tsx
|
|
@ -11,6 +11,7 @@ import AreaGroup from '@/components/AreaGroup';
|
|||
import CollectModal from '@/components/CollectModal';
|
||||
import ProblemModal from '@/components/ProblemModal';
|
||||
import ItemDetailModal from '@/components/ItemDetailModal';
|
||||
import InvoiceMatchHost from '@/components/InvoiceMatchHost';
|
||||
import {
|
||||
getMyAssignedItems,
|
||||
getMyAssignedStockItems,
|
||||
|
|
@ -910,6 +911,15 @@ const loadItems = useCallback(async (showLoading = true) => {
|
|||
onClose={() => { setShowDetailModal(false); setSelectedItem(null); }}
|
||||
/>
|
||||
|
||||
{/* Beta: invoice match helper (renders nothing unless it is run) */}
|
||||
<InvoiceMatchHost
|
||||
onCollected={(ids) => {
|
||||
setItems((prev) => prev.filter((i) => !ids.includes(i.id)));
|
||||
showToast(`Invoice matched · ${ids.length} item${ids.length === 1 ? '' : 's'} marked collected`);
|
||||
}}
|
||||
onRestored={() => { void loadItems(false); }}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ScanSearch, Loader2, CheckCircle2, AlertTriangle, XCircle, HelpCircle } from 'lucide-react';
|
||||
import BottomSheet from '@/components/BottomSheet';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ScanSearch } from 'lucide-react';
|
||||
import { RUN_EVENT, invoiceCheckBase } from '@/lib/invoiceCheck';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
checkInvoice,
|
||||
invoiceCheckBase,
|
||||
readSeen,
|
||||
rememberSeen,
|
||||
type InvoiceCheckResult,
|
||||
type Verdict,
|
||||
} from '@/lib/invoiceCheck';
|
||||
|
||||
interface InvoiceCheckProps {
|
||||
poNumber: string;
|
||||
|
|
@ -20,136 +12,45 @@ interface InvoiceCheckProps {
|
|||
size?: 'sm' | 'lg';
|
||||
}
|
||||
|
||||
const STYLE: Record<Verdict, { icon: typeof CheckCircle2; row: string; label: string }> = {
|
||||
green: { icon: CheckCircle2, row: 'text-[#125a3e]', label: 'Matches' },
|
||||
amber: { icon: AlertTriangle, row: 'text-[#8a5a02]', label: 'Check' },
|
||||
red: { icon: XCircle, row: 'text-[#cf2d56]', label: 'Mismatch' },
|
||||
unchecked: { icon: HelpCircle, row: 'text-[rgba(38,37,30,0.55)]', label: 'Not checked' },
|
||||
};
|
||||
|
||||
// Beta helper. Renders nothing unless the driver switched it on in
|
||||
// Settings → Beta, so the normal PO buttons are untouched. It only reads:
|
||||
// tapping "Check invoice" asks WFDASH to compare the photo with the lines
|
||||
// collected on this PO and shows the answer. It never blocks anything.
|
||||
// Beta helper trigger. Renders nothing unless the driver switched it on in
|
||||
// Settings → Beta, so the normal PO buttons are untouched. It asks
|
||||
// InvoiceMatchHost (on the page) to match the invoice, either when the driver
|
||||
// taps the button or automatically right after a new invoice page uploads.
|
||||
export default function InvoiceCheck({ poNumber, pages, size = 'sm' }: InvoiceCheckProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<InvoiceCheckResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [seen, setSeen] = useState<'ok' | 'override' | undefined>();
|
||||
const prevPages = useRef<number | null>(null);
|
||||
|
||||
const trigger = () => window.dispatchEvent(new CustomEvent(RUN_EVENT, { detail: { poNumber } }));
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(Boolean(invoiceCheckBase()));
|
||||
setSeen(readSeen()[poNumber]);
|
||||
}, [poNumber]);
|
||||
}, []);
|
||||
|
||||
// A new invoice page just went up: check it straight away.
|
||||
useEffect(() => {
|
||||
const before = prevPages.current;
|
||||
prevPages.current = pages;
|
||||
if (before !== null && pages > before && poNumber && invoiceCheckBase()) trigger();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pages]);
|
||||
|
||||
if (!enabled || !poNumber || pages === 0) return null;
|
||||
|
||||
const run = async () => {
|
||||
if (busy) return;
|
||||
setOpen(true);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await checkInvoice(poNumber));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Check failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (result) {
|
||||
const how = result.all_green ? 'ok' : 'override';
|
||||
rememberSeen(poNumber, how);
|
||||
setSeen(how);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const lg = size === 'lg';
|
||||
const btn = lg
|
||||
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 touch-manipulation'
|
||||
: 'h-9 px-3 rounded-lg text-[13px] font-semibold flex items-center gap-1.5 touch-manipulation';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={run}
|
||||
aria-label={`Check the invoice for PO ${poNumber} (beta)`}
|
||||
className={cn(btn, 'bg-[#efeaf7] text-[#6c5a99] active:bg-[#ded5ef]')}
|
||||
>
|
||||
<ScanSearch size={lg ? 18 : 14} aria-hidden="true" />
|
||||
<span>{lg ? `Check invoice (beta)${seen ? ' · seen' : ''}` : 'Check'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={() => setOpen(false)}>
|
||||
{busy && (
|
||||
<p className="flex items-center gap-2 text-[15px] text-[#26251e] py-4">
|
||||
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
|
||||
Reading the invoice… this can take up to a minute.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-[15px] text-[#cf2d56] py-3">
|
||||
Couldn’t check: {error}. You can carry on as normal.
|
||||
</p>
|
||||
)}
|
||||
{result && (
|
||||
<>
|
||||
{result.lines.length === 0 ? (
|
||||
<p className="text-[15px] text-[#26251e] py-3">No collected lines on this PO to check yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
|
||||
{result.lines.map((l) => {
|
||||
const st = STYLE[l.verdict];
|
||||
const Icon = st.icon;
|
||||
return (
|
||||
<li key={l.id} className="py-3 flex gap-3">
|
||||
<Icon size={20} className={cn('flex-shrink-0 mt-0.5', st.row)} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] text-[#26251e] leading-snug break-words">
|
||||
{l.description || 'Item'} <span className="text-[rgba(38,37,30,0.55)]">× {l.qty_collected}</span>
|
||||
</p>
|
||||
<p className={cn('text-[12px] font-semibold', st.row)}>{st.label}</p>
|
||||
{l.reason && <p className="text-[12px] text-[rgba(38,37,30,0.7)] break-words">{l.reason}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{result.error && (
|
||||
<p className="text-[12px] text-[#8a5a02] mt-2">Note: {result.error}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className={cn(
|
||||
'mt-3 h-12 w-full rounded-xl text-[15px] font-semibold touch-manipulation',
|
||||
result.all_green ? 'bg-[#18714f] text-white' : 'bg-[#26251e] text-white'
|
||||
)}
|
||||
>
|
||||
{result.all_green ? 'All good — continue' : 'I’ve checked — continue anyway'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-2 h-11 w-full rounded-xl text-[14px] font-semibold text-[#6c5a99] touch-manipulation"
|
||||
>
|
||||
{result ? 'Close' : 'Close — carry on as normal'}
|
||||
</button>
|
||||
)}
|
||||
</BottomSheet>
|
||||
<button
|
||||
type="button"
|
||||
onClick={trigger}
|
||||
aria-label={`Match the invoice for PO ${poNumber} to your items (beta)`}
|
||||
className={cn(
|
||||
lg
|
||||
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 touch-manipulation'
|
||||
: 'h-9 px-3 rounded-lg text-[13px] font-semibold flex items-center gap-1.5 touch-manipulation',
|
||||
'bg-[#efeaf7] text-[#6c5a99] active:bg-[#ded5ef]',
|
||||
)}
|
||||
</>
|
||||
>
|
||||
<ScanSearch size={lg ? 18 : 14} aria-hidden="true" />
|
||||
<span>{lg ? 'Match invoice (beta)' : 'Match'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
185
components/InvoiceMatchHost.tsx
Normal file
185
components/InvoiceMatchHost.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, CheckCircle2, AlertTriangle, Undo2 } from 'lucide-react';
|
||||
import BottomSheet from '@/components/BottomSheet';
|
||||
import {
|
||||
RUN_EVENT,
|
||||
collectMatched,
|
||||
matchInvoice,
|
||||
undoMatched,
|
||||
type InvoiceMatchResult,
|
||||
type MatchedLine,
|
||||
} from '@/lib/invoiceCheck';
|
||||
|
||||
interface InvoiceMatchHostProps {
|
||||
/** Lines just marked collected: drop them from the driver's list. */
|
||||
onCollected: (ids: string[]) => void;
|
||||
/** Lines put back: reload the driver's list. */
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
// Beta helper, mounted once on the driver page. Renders nothing until a PO's
|
||||
// "Check invoice" runs. It marks only exact matches collected (same product,
|
||||
// invoice quantity = ordered quantity); everything uncertain is listed for the
|
||||
// driver and left alone. Every auto-collected line can be undone.
|
||||
export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMatchHostProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [poNumber, setPoNumber] = useState('');
|
||||
const [result, setResult] = useState<InvoiceMatchResult | null>(null);
|
||||
const [collected, setCollected] = useState<MatchedLine[]>([]);
|
||||
const [failedIds, setFailedIds] = useState<string[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const running = useRef(false);
|
||||
|
||||
const run = useCallback(
|
||||
async (po: string) => {
|
||||
if (running.current) return;
|
||||
running.current = true;
|
||||
setPoNumber(po);
|
||||
setOpen(true);
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setCollected([]);
|
||||
setFailedIds([]);
|
||||
try {
|
||||
const r = await matchInvoice(po);
|
||||
setResult(r);
|
||||
const done: MatchedLine[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const line of r.matched) {
|
||||
try {
|
||||
await collectMatched(line);
|
||||
done.push(line);
|
||||
} catch (e) {
|
||||
console.warn('[invoice-match] collect failed for', line.id, e);
|
||||
failed.push(line.id);
|
||||
}
|
||||
}
|
||||
setCollected(done);
|
||||
setFailedIds(failed);
|
||||
if (done.length > 0) onCollected(done.map((l) => l.id));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Check failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
running.current = false;
|
||||
}
|
||||
},
|
||||
[onCollected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const po = (e as CustomEvent<{ poNumber?: string }>).detail?.poNumber;
|
||||
if (po) void run(po);
|
||||
};
|
||||
window.addEventListener(RUN_EVENT, handler);
|
||||
return () => window.removeEventListener(RUN_EVENT, handler);
|
||||
}, [run]);
|
||||
|
||||
const undo = async (lines: MatchedLine[]) => {
|
||||
const kept: MatchedLine[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
await undoMatched(line);
|
||||
} catch (e) {
|
||||
console.warn('[invoice-match] undo failed for', line.id, e);
|
||||
kept.push(line);
|
||||
}
|
||||
}
|
||||
setCollected((prev) => prev.filter((l) => kept.some((k) => k.id === l.id) || !lines.some((x) => x.id === l.id)));
|
||||
onRestored();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<BottomSheet title="Invoice check" subtitle={`PO ${poNumber} · beta`} onClose={() => setOpen(false)}>
|
||||
{busy && (
|
||||
<p className="flex items-center gap-2 text-[15px] text-[#26251e] py-4">
|
||||
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
|
||||
Reading the invoice… this can take up to a minute.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-[15px] text-[#cf2d56] py-3">
|
||||
Couldn’t check: {error}. Nothing was changed — carry on as normal.
|
||||
</p>
|
||||
)}
|
||||
{result && !busy && (
|
||||
<>
|
||||
{collected.length > 0 && (
|
||||
<section className="mb-3">
|
||||
<p className="text-[13px] font-bold text-[#125a3e] mb-1">
|
||||
Marked collected ({collected.length})
|
||||
</p>
|
||||
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
|
||||
{collected.map((l) => (
|
||||
<li key={l.id} className="py-2.5 flex items-start gap-3">
|
||||
<CheckCircle2 size={20} className="text-[#125a3e] flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<p className="flex-1 min-w-0 text-[14px] text-[#26251e] leading-snug break-words">
|
||||
{l.description} <span className="text-[rgba(38,37,30,0.55)]">× {l.quantity}</span>
|
||||
{l.po_number !== poNumber && (
|
||||
<span className="block text-[12px] text-[rgba(38,37,30,0.55)]">PO {l.po_number}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void undo([l])}
|
||||
className="h-9 px-3 rounded-lg text-[12px] font-semibold text-[#6c5a99] bg-[#efeaf7] flex items-center gap-1 touch-manipulation"
|
||||
>
|
||||
<Undo2 size={13} aria-hidden="true" /> Undo
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{failedIds.length > 0 && (
|
||||
<p className="text-[13px] text-[#8a5a02] mb-3">
|
||||
{failedIds.length} matching item{failedIds.length === 1 ? '' : 's'} couldn’t be marked collected — collect {failedIds.length === 1 ? 'it' : 'them'} as normal.
|
||||
</p>
|
||||
)}
|
||||
{result.review.length > 0 && (
|
||||
<section className="mb-3">
|
||||
<p className="text-[13px] font-bold text-[#8a5a02] mb-1">Check these yourself ({result.review.length})</p>
|
||||
<ul className="divide-y divide-[rgba(38,37,30,0.08)]">
|
||||
{result.review.map((l) => (
|
||||
<li key={l.id} className="py-2.5 flex gap-3">
|
||||
<AlertTriangle size={20} className="text-[#8a5a02] flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] text-[#26251e] leading-snug break-words">{l.description}</p>
|
||||
<p className="text-[12px] text-[rgba(38,37,30,0.7)] break-words">{l.reason}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{result.matched.length === 0 && result.review.length === 0 && (
|
||||
<p className="text-[15px] text-[#26251e] py-3">
|
||||
{result.error
|
||||
? `Couldn’t read the invoice: ${result.error}`
|
||||
: 'None of your assigned items for this supplier were found on the invoice.'}
|
||||
</p>
|
||||
)}
|
||||
{result.error && (result.matched.length > 0 || result.review.length > 0) && (
|
||||
<p className="text-[12px] text-[#8a5a02] mb-2">Note: {result.error}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 h-12 w-full rounded-xl text-[15px] font-semibold bg-[#26251e] text-white touch-manipulation"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,38 @@
|
|||
// Beta helper: ask WFDASH whether the invoice photo matches what the driver
|
||||
// collected on a PO. Advisory only — nothing here writes to PocketBase or
|
||||
// changes how collecting works. Off unless the driver switches it on in
|
||||
// Settings.
|
||||
// Beta helper: photograph the invoice and let WFDASH match it against
|
||||
// everything assigned to this driver at that supplier. Exact matches (same
|
||||
// product, invoice quantity = ordered quantity) are marked collected through
|
||||
// the same writes the bulk-collect button uses, and can be undone.
|
||||
// Off unless the driver switches it on in Settings → Beta.
|
||||
|
||||
import { pb, getSettings } from './pocketbase';
|
||||
import { pb, getSettings, updateStockCollectionStatus } from './pocketbase';
|
||||
|
||||
export type Verdict = 'green' | 'amber' | 'red' | 'unchecked';
|
||||
|
||||
export interface CheckedLine {
|
||||
export interface MatchedLine {
|
||||
id: string;
|
||||
kind: 'customer' | 'stock';
|
||||
po_number: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
previous: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReviewLine {
|
||||
id: string;
|
||||
kind: 'customer' | 'stock';
|
||||
po_number: string;
|
||||
description: string;
|
||||
qty_collected: number;
|
||||
verdict: Verdict;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface InvoiceCheckResult {
|
||||
export interface InvoiceMatchResult {
|
||||
po_number: string;
|
||||
lines: CheckedLine[];
|
||||
all_green: boolean;
|
||||
matched: MatchedLine[];
|
||||
review: ReviewLine[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Fired by the PO buttons; InvoiceMatchHost (once per page) does the work. */
|
||||
export const RUN_EVENT = 'invoice-match:run';
|
||||
|
||||
/** The WFDASH address to call, or '' when the helper is off or not set up. */
|
||||
export function invoiceCheckBase(): string {
|
||||
const s = getSettings();
|
||||
|
|
@ -31,10 +41,10 @@ export function invoiceCheckBase(): string {
|
|||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export async function checkInvoice(poNumber: string): Promise<InvoiceCheckResult> {
|
||||
export async function matchInvoice(poNumber: string): Promise<InvoiceMatchResult> {
|
||||
const base = invoiceCheckBase();
|
||||
if (!base) throw new Error('Invoice check is not set up (Settings → Beta).');
|
||||
const res = await fetch(`${base}/api/v1/driver/collection-check`, {
|
||||
const res = await fetch(`${base}/api/v1/driver/invoice-match`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pb.authStore.token}` },
|
||||
body: JSON.stringify({ po_number: poNumber }),
|
||||
|
|
@ -42,25 +52,28 @@ export async function checkInvoice(poNumber: string): Promise<InvoiceCheckResult
|
|||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `Check failed (${res.status})`);
|
||||
return body as InvoiceCheckResult;
|
||||
return body as InvoiceMatchResult;
|
||||
}
|
||||
|
||||
// Remembered per PO on this device: the driver looked at the result and
|
||||
// carried on. Purely a note for the driver; it blocks and unlocks nothing.
|
||||
const SEEN_KEY = 'wfdash-driver:invoice-check-seen';
|
||||
|
||||
export function readSeen(): Record<string, 'ok' | 'override'> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
/** Mark one matched line collected, exactly as bulkCollect / the stock modal do. */
|
||||
export async function collectMatched(line: MatchedLine): Promise<void> {
|
||||
if (line.kind === 'stock') {
|
||||
await updateStockCollectionStatus(line.id, { qty_collected: line.quantity, expectedQty: line.quantity });
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await pb.collection('order_items').update(line.id, {
|
||||
item_status: 'collected',
|
||||
drv_status: 'collected',
|
||||
drv_collected_at: now,
|
||||
qty_collected: line.quantity,
|
||||
collected_date: now,
|
||||
});
|
||||
}
|
||||
|
||||
export function rememberSeen(poNumber: string, how: 'ok' | 'override') {
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...readSeen(), [poNumber]: how }));
|
||||
} catch {
|
||||
// Storage unavailable: the note is optional.
|
||||
}
|
||||
/** Put a matched line back to how it was before the auto-collect. */
|
||||
export async function undoMatched(line: MatchedLine): Promise<void> {
|
||||
await pb
|
||||
.collection(line.kind === 'stock' ? 'stock_purchase_items' : 'order_items')
|
||||
.update(line.id, line.previous);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue