feat(dn): per-line readiness and price tolerance for inbox 3
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
c36fc5fb82
commit
2ccb6efadc
2 changed files with 403 additions and 0 deletions
196
src/lib/inbox-three/readiness.test.ts
Normal file
196
src/lib/inbox-three/readiness.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildPreview, type PreviewAllocation, type PreviewContext } from "@/lib/dn-pipeline/ai-preview";
|
||||
import { guardAgrees, lineReady, priceDiffers, readyIndexes, slipRateNet } from "./readiness";
|
||||
|
||||
const PIPE_ITEM = "pipeitem1234567";
|
||||
const PLIER_ITEM = "plieritem1234567";
|
||||
|
||||
const ROLL_RULE = { rule_key: "roll-to-metre", version: 1, status: "active", supplier_ids: [], instructions: ["One roll is 30 m"] };
|
||||
|
||||
const context = (over: Partial<PreviewContext> = {}): PreviewContext => ({
|
||||
note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "VDN1", items: [] },
|
||||
pages: [],
|
||||
supplier: { id: "supplierabcdefg", name: "Vula", reconciliation_rules: { rules: [ROLL_RULE] } },
|
||||
po: { id: "poabcdefghijklm", purchase_order_number: "PO1", total_value: 1150 },
|
||||
poItems: [
|
||||
{ id: PIPE_ITEM, description: "S&S Pipes-1050 X 2.5(100D)", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
|
||||
{ id: PLIER_ITEM, description: "Fencing Pliers", unit: "ea", qty: 10, rate: 250, disc_percent: 0, vat_percent: 15 },
|
||||
],
|
||||
lines: [
|
||||
{ sourceLineId: "pipe", description: "U PIPES-600 X 2.5(100D)", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null },
|
||||
{ sourceLineId: "plier", description: "PLIER FENCING 300MM", qty: 3, unit: "ea", unit_price: 250, line_amount: 750, product_code: null },
|
||||
],
|
||||
priorDeliveries: [],
|
||||
priorItems: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const allocation = (over: Partial<PreviewAllocation> = {}): PreviewAllocation => ({
|
||||
sourceLineIds: ["pipe"],
|
||||
purchaseOrderItemId: PIPE_ITEM,
|
||||
quantityBasis: "direct",
|
||||
factor: 1,
|
||||
amountBasis: "po_rate",
|
||||
sourceAmountBasis: "unknown",
|
||||
ruleKeys: [],
|
||||
explanation: "test",
|
||||
reviewQuestion: null,
|
||||
poDescription: null,
|
||||
quantity: null,
|
||||
unit: null,
|
||||
amount: null,
|
||||
amountTaxBasis: "unknown",
|
||||
netAmount: null,
|
||||
status: "proposed",
|
||||
reviewReasons: [],
|
||||
quantityExcess: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("priceDiffers", () => {
|
||||
it("flags a rate that is more than R0.50 or 1% away from the PO rate", () => {
|
||||
expect(priceDiffers(1124.35, 1128.7)).toBe(true);
|
||||
expect(priceDiffers(100, 100.4)).toBe(false);
|
||||
expect(priceDiffers(100, 101.01)).toBe(true);
|
||||
expect(priceDiffers(100, 99.5)).toBe(false);
|
||||
expect(priceDiffers(100, 98.9)).toBe(true);
|
||||
});
|
||||
|
||||
it("never flags a slip that printed no rate", () => {
|
||||
expect(priceDiffers(100, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slipRateNet", () => {
|
||||
it("reads a VAT-inclusive slip rate as an ex-VAT rate per PO unit", () => {
|
||||
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 1, 15, "gross")).toBe(1128.7);
|
||||
});
|
||||
|
||||
it("divides by the conversion factor to get a rate per PO unit", () => {
|
||||
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 2, 15, "net")).toBe(649);
|
||||
});
|
||||
|
||||
it("refuses to compare a rate whose tax basis nobody stated", () => {
|
||||
expect(slipRateNet({ sourceLineId: "mesh", description: "MESH", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }, 1, 15, "unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("guardAgrees", () => {
|
||||
it("refuses a line the rule matcher sends to a different PO line", () => {
|
||||
// 600 pipe against a 1050 line: the guard declines on the dimensions.
|
||||
expect(guardAgrees(context(), allocation({ purchaseOrderItemId: PIPE_ITEM }))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a line the rule matcher simply does not recognise", () => {
|
||||
// "PLIER FENCING 300MM" against "Fencing Pliers": no dimension on the PO
|
||||
// line to contradict the slip, so the guard has nothing to say.
|
||||
expect(guardAgrees(context(), allocation({ sourceLineIds: ["plier"], purchaseOrderItemId: PLIER_ITEM }))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a line the rule matcher sends to the same PO line", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
expect(guardAgrees(c, allocation())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineReady", () => {
|
||||
it("keeps a line open while it is over the ordered quantity", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 103, unit: "m", unit_price: 1124.35, line_amount: 115808.05, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
|
||||
expect(preview.allocations[0].quantityExcess).toBe(3);
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a line a person confirmed, even over the ordered quantity", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 103, unit: "m", unit_price: 1124.35, line_amount: 115808.05, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
|
||||
preview.allocations[0].operatorConfirmation = { confirmed: true, poItemId: PIPE_ITEM, quantity: 103, unit: "m", usePoRate: true, acceptExcess: true, reason: "Site confirmed the over-delivery" };
|
||||
expect(lineReady(c, preview, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an AI line open when its unit differs from the PO line and nothing remembers the conversion", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30 })] });
|
||||
expect(preview.allocations[0].status).toBe("proposed");
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an AI line whose conversion a supplier rule remembers and whose price agrees", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 33730.5, line_amount: 67461, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] });
|
||||
expect(lineReady(c, preview, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an AI line open when its price differs from the PO rate by more than the tolerance", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] });
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a VAT-inclusive slip price honest: R1 298 incl is R4.35 over the PO rate", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "MESH DIAMOND ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 1, ruleKeys: ["roll-to-metre"] })] });
|
||||
// 1298 incl is 1128.70 ex, which is R4.35 (0.39%) over the PO rate.
|
||||
expect(slipRateNet(c.lines[0], 1, 15, "gross")).toBe(1128.7);
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a line open while the rule guard contradicts the allocation", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "U PIPES-600 X 2.5(100D)", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
|
||||
expect(preview.allocations[0].status).toBe("proposed");
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("never calls a posted line ready", () => {
|
||||
const c = context({
|
||||
lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }],
|
||||
poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
|
||||
preview.allocations[0].posted = true;
|
||||
expect(lineReady(c, preview, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyIndexes", () => {
|
||||
it("lists only the lines that can be confirmed", () => {
|
||||
const c = context({
|
||||
lines: [
|
||||
{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null },
|
||||
{ sourceLineId: "plier", description: "Fencing Pliers", qty: 3, unit: "ea", unit_price: 250, line_amount: 750, product_code: null },
|
||||
],
|
||||
poItems: [
|
||||
{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 },
|
||||
{ id: PLIER_ITEM, description: "Fencing Pliers", unit: "ea", qty: 10, rate: 250, disc_percent: 0, vat_percent: 15 },
|
||||
],
|
||||
});
|
||||
const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] });
|
||||
// The plier line was never allocated to anything, so it stays open.
|
||||
expect(preview.allocations).toHaveLength(2);
|
||||
expect(readyIndexes(c, preview)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
207
src/lib/inbox-three/readiness.ts
Normal file
207
src/lib/inbox-three/readiness.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Per-line readiness for Inbox 3.
|
||||
*
|
||||
* A slip line is READY when a person can confirm it without changing
|
||||
* anything: the AI allocation and the rule guard agree on the PO line, the
|
||||
* unit needs no conversion nobody remembers, the price agrees with the PO
|
||||
* rate, and the line books without a fight.
|
||||
*
|
||||
* Pure. No I/O and no posting — a person still presses Confirm. The office
|
||||
* screen and the existing inbox read the same answer, so a line cannot be
|
||||
* ready in one and open in the other.
|
||||
*/
|
||||
import { allocationNeedsHelp } from "@/lib/dn-pipeline/delivery-ready";
|
||||
import { prepareLineBooking } from "@/lib/dn-pipeline/line-booking";
|
||||
import { dims, resolvePoItem, type PoItemRef } from "@/lib/dn-pipeline/match-po-item";
|
||||
import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults";
|
||||
import { finiteNumber, type PreviewAllocation, type PreviewContext, type PreviewLine, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview";
|
||||
|
||||
/** A line is flagged once its price is this far from the PO rate. */
|
||||
export const PRICE_TOLERANCE = { rand: 0.5, percent: 1 } as const;
|
||||
|
||||
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Does the slip's rate differ from the PO rate?
|
||||
*
|
||||
* More than R0.50 per PO unit, or more than 1% of it. A slip that printed
|
||||
* no rate is not a price problem — there is nothing to compare.
|
||||
*/
|
||||
export function priceDiffers(poRateNet: number, slipRateNet: number | null): boolean {
|
||||
if (slipRateNet === null || !Number.isFinite(slipRateNet)) return false;
|
||||
if (!Number.isFinite(poRateNet)) return false;
|
||||
const difference = Math.abs(slipRateNet - poRateNet);
|
||||
return difference > PRICE_TOLERANCE.rand || difference > (Math.abs(poRateNet) * PRICE_TOLERANCE.percent) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slip's own rate, per PO unit, ex VAT.
|
||||
*
|
||||
* `factor` is how many PO units one slip unit is (30 for a 30 m roll), so
|
||||
* the slip's per-unit price has to be divided by it before it can be
|
||||
* compared with a PO rate. A VAT-inclusive slip price is taken back to ex
|
||||
* VAT first: comparing R1 298 incl with R1 124.35 ex would show 15% on
|
||||
* every line instead of the R4.35 that is really there.
|
||||
*
|
||||
* An unknown tax basis is null: the two rates cannot be compared honestly,
|
||||
* and a comparison nobody can trust is worse than none.
|
||||
*/
|
||||
export function slipRateNet(
|
||||
line: PreviewLine,
|
||||
factor: number,
|
||||
vatPercent: number,
|
||||
amountTaxBasis: "net" | "gross" | "unknown",
|
||||
): number | null {
|
||||
const printed = finiteNumber(line.unit_price);
|
||||
if (printed === null || printed < 0) return null;
|
||||
if (!Number.isFinite(factor) || factor <= 0) return null;
|
||||
const perPoUnit = printed / factor;
|
||||
if (amountTaxBasis === "net") return round2(perPoUnit);
|
||||
if (amountTaxBasis === "gross") {
|
||||
const vat = Number.isFinite(vatPercent) && vatPercent > 0 ? vatPercent : 0;
|
||||
return round2(perPoUnit / (1 + vat / 100));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PO rate the line books at, after the line's own discount.
|
||||
*/
|
||||
function poRateNet(context: PreviewContext, a: PreviewAllocation): number | null {
|
||||
const item = context.poItems.find((i) => i.id === a.purchaseOrderItemId);
|
||||
if (!item) return null;
|
||||
const rate = finiteNumber(item.rate);
|
||||
const discount = finiteNumber(item.disc_percent) ?? 0;
|
||||
if (rate === null || rate < 0 || discount < 0 || discount > 100) return null;
|
||||
return rate * (1 - discount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule matcher declined because the PO line carries a different size.
|
||||
*
|
||||
* 600 pipe against a 1050 line is a contradiction: the PO line states its
|
||||
* own dimensions and they are not the slip's. A PO line with no dimensions
|
||||
* at all ("Fencing Pliers") is not — it simply never states a size.
|
||||
*/
|
||||
function dimensionConflict(description: string | null | undefined, chosen: PoItemRef | undefined): boolean {
|
||||
if (!chosen) return false;
|
||||
const slipDims = dims(description);
|
||||
if (slipDims.size === 0) return false;
|
||||
const chosenDims = dims(chosen.description);
|
||||
if (chosenDims.size === 0) return false;
|
||||
return [...slipDims].every((d) => chosenDims.has(d)) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the rule guard agree with this allocation?
|
||||
*
|
||||
* The guard is a second opinion, not a second decision: a line it sends to
|
||||
* a different PO line, or refuses because of a size conflict, is shown as
|
||||
* "check" and never auto-matches. A line it merely does not recognise is
|
||||
* no objection — the AI may well have seen something the wording lost.
|
||||
*/
|
||||
export function guardAgrees(context: PreviewContext, a: PreviewAllocation): boolean {
|
||||
const poItems = context.poItems as unknown as PoItemRef[];
|
||||
for (const id of a.sourceLineIds) {
|
||||
const line = context.lines.find((l) => l.sourceLineId === id);
|
||||
if (!line) continue;
|
||||
const match = resolvePoItem(line.description, poItems);
|
||||
if (match.kind === "matched") {
|
||||
if (a.purchaseOrderItemId && match.poItem.id !== a.purchaseOrderItemId) return false;
|
||||
continue;
|
||||
}
|
||||
if (match.kind === "none") {
|
||||
const chosen = poItems.find((p) => p.id === a.purchaseOrderItemId);
|
||||
if (dimensionConflict(line.description, chosen)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the slip's unit already agree with the PO line's?
|
||||
*/
|
||||
function unitAgrees(context: PreviewContext, a: PreviewAllocation): boolean {
|
||||
const item = context.poItems.find((i) => i.id === a.purchaseOrderItemId);
|
||||
if (!item) return false;
|
||||
const target = String(item.unit ?? "");
|
||||
if (!target.trim()) return false;
|
||||
return a.sourceLineIds.every((id) => {
|
||||
const line = context.lines.find((l) => l.sourceLineId === id);
|
||||
return line ? sameReceivingUnit(line.unit, target) : false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the slip's printed price differ from the PO rate?
|
||||
*
|
||||
* A PO rate is always ex VAT and a slip may print either, so the slip rate
|
||||
* is read twice — as printed, and with VAT removed — and the line is only
|
||||
* flagged when the difference survives BOTH. A slip printing exactly the
|
||||
* PO rate plus VAT is not a price problem, and a slip printing the PO rate
|
||||
* is not one either.
|
||||
*/
|
||||
function priceFlagged(context: PreviewContext, a: PreviewAllocation): boolean {
|
||||
const rate = poRateNet(context, a);
|
||||
if (rate === null) return false;
|
||||
const line = context.lines.find((l) => a.sourceLineIds.includes(l.sourceLineId));
|
||||
if (!line) return false;
|
||||
const factor = a.factor ?? 1;
|
||||
const vat = a.vatPercent ?? 15;
|
||||
const asPrinted = slipRateNet(line, factor, vat, "net");
|
||||
const exVat = slipRateNet(line, factor, vat, "gross");
|
||||
return priceDiffers(rate, asPrinted) && priceDiffers(rate, exVat);
|
||||
}
|
||||
|
||||
/** The source lines this note has already booked. */
|
||||
function bookedSourceKeys(context: PreviewContext): string[] {
|
||||
const state = context.note.line_posting as { bookings?: { sourceKeys?: string[] }[] } | undefined;
|
||||
return (state?.bookings ?? []).flatMap((b) => b.sourceKeys ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this line be confirmed as it stands?
|
||||
*
|
||||
* A person who has already confirmed the line owns it: their decision
|
||||
* stands even where the guard would have objected. An AI line has to earn
|
||||
* it — agreement from the guard, a unit nobody had to guess, a price
|
||||
* within tolerance, and a booking that passes the same validation a
|
||||
* person's edit passes.
|
||||
*/
|
||||
export function lineReady(context: PreviewContext, preview: ProcessingPreview, index: number): boolean {
|
||||
const a = preview.allocations[index];
|
||||
if (!a || a.posted) return false;
|
||||
if (allocationNeedsHelp(a, preview)) return false;
|
||||
if (a.netAmount === null || !Number.isFinite(a.netAmount)) return false;
|
||||
|
||||
if (!a.operatorConfirmation) {
|
||||
if (a.status !== "proposed") return false;
|
||||
if (!guardAgrees(context, a)) return false;
|
||||
// A unit that differs from the PO's is only allowed through on a
|
||||
// conversion a supplier rule remembers, so the factor is on the line.
|
||||
if (!unitAgrees(context, a) && a.ruleKeys.length === 0) return false;
|
||||
if (priceFlagged(context, a)) return false;
|
||||
}
|
||||
|
||||
try {
|
||||
prepareLineBooking({
|
||||
context,
|
||||
preview,
|
||||
index,
|
||||
posted: bookedSourceKeys(context),
|
||||
confirmation: a.operatorConfirmation ?? { confirmed: true },
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The indexes of the lines that can be confirmed without a change. */
|
||||
export function readyIndexes(context: PreviewContext, preview: ProcessingPreview): number[] {
|
||||
const ready: number[] = [];
|
||||
preview.allocations.forEach((_, index) => {
|
||||
if (lineReady(context, preview, index)) ready.push(index);
|
||||
});
|
||||
return ready;
|
||||
}
|
||||
Loading…
Reference in a new issue