@factorypure/client-helpers 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -0
- package/dist/repricing.d.ts +156 -0
- package/dist/repricing.js +411 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MatchResult } from '@factorypure/matching-engine';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
export * from './repricing.js';
|
|
3
4
|
/**
|
|
4
5
|
* @packageDocumentation
|
|
5
6
|
* @module @factorypure/client-helpers
|
|
@@ -932,4 +933,3 @@ export declare function getUndercutResults(shownResults: (ScrapeResultsType | Im
|
|
|
932
933
|
* Results are sorted by price ascending.
|
|
933
934
|
*/
|
|
934
935
|
export declare function getNeedsReviewResults(sortedResults: (ScrapeResultsType | ImmersiveScrapeResultsType)[], shownResults: (ScrapeResultsType | ImmersiveScrapeResultsType)[], variantPrice: number): (ScrapeResultsType | ImmersiveScrapeResultsType)[];
|
|
935
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { startOfDay, subDays } from 'date-fns';
|
|
|
3
3
|
import { Index } from 'flexsearch';
|
|
4
4
|
import EnglishPreset from 'flexsearch/lang/en';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
export * from './repricing.js';
|
|
6
7
|
// GOTCHA must restart client
|
|
7
8
|
// npm install /Users/kokkoalex/Documents/GitHub/npm-packages/client-helpers
|
|
8
9
|
// npm uninstall @factorypure/client-helpers
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated repricing decision engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only (no I/O) so the same logic powers the fpjobs repricing job,
|
|
5
|
+
* the fpdash-server simulate endpoint, and client-side previews.
|
|
6
|
+
*
|
|
7
|
+
* Margin/profit/shipping math intentionally mirrors fpdash-client `getMargin`,
|
|
8
|
+
* `getProfit`, `getShippingFeeDetails` and `getShouldUseShippingInProfitMargin`.
|
|
9
|
+
*/
|
|
10
|
+
export type RepricingUndercutType = 'FIXED' | 'PERCENT';
|
|
11
|
+
export type RepricingFloorBehavior = 'skip' | 'match' | 'clamp_to_floor';
|
|
12
|
+
export type RepricingCeilingSource = 'baseline' | 'msrp' | 'lowest_of_both';
|
|
13
|
+
export type RepricingRounding = 'none' | '99' | 'whole';
|
|
14
|
+
export type RepricingCompareAtBehavior = 'leave' | 'set_old_price' | 'clear';
|
|
15
|
+
export type RepricingLowStockAction = 'none' | 'skip_decrease';
|
|
16
|
+
export type RepricingStrategy = 'beat_lowest' | 'raise_recovery';
|
|
17
|
+
export type RepricingAction = 'auto' | 'approval' | 'skip' | 'none';
|
|
18
|
+
export type RepricingRuleScope = 'global' | 'company' | 'variant';
|
|
19
|
+
export interface RepricingRules {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Off switch / live mode. When false, decisions are only logged (observe-only) and
|
|
23
|
+
* nothing is applied or queued. Only true when the global layer also enables it.
|
|
24
|
+
*/
|
|
25
|
+
live_enabled: boolean;
|
|
26
|
+
undercut_type: RepricingUndercutType;
|
|
27
|
+
undercut_value: number;
|
|
28
|
+
floor_behavior: RepricingFloorBehavior;
|
|
29
|
+
min_margin_pct: number | null;
|
|
30
|
+
min_profit: number | null;
|
|
31
|
+
respect_map: boolean;
|
|
32
|
+
allow_raise: boolean;
|
|
33
|
+
raise_requires_approval: boolean;
|
|
34
|
+
ceiling_source: RepricingCeilingSource;
|
|
35
|
+
rounding: RepricingRounding;
|
|
36
|
+
compare_at_behavior: RepricingCompareAtBehavior;
|
|
37
|
+
/** Ignore changes smaller than this many dollars. */
|
|
38
|
+
min_change_amt: number;
|
|
39
|
+
/** Auto-apply only when |change| <= this percent of current price. null = no limit. */
|
|
40
|
+
auto_max_change_pct: number | null;
|
|
41
|
+
/** Auto-apply only when |change| <= this many dollars. null = no limit. */
|
|
42
|
+
auto_max_change_amt: number | null;
|
|
43
|
+
min_hours_between_changes: number;
|
|
44
|
+
stale_hours: number;
|
|
45
|
+
excluded_sources: string[];
|
|
46
|
+
min_variant_price: number;
|
|
47
|
+
low_stock_threshold: number | null;
|
|
48
|
+
low_stock_action: RepricingLowStockAction;
|
|
49
|
+
max_auto_changes_per_day: number | null;
|
|
50
|
+
approval_expiry_hours: number | null;
|
|
51
|
+
}
|
|
52
|
+
/** Conservative placeholders until management sets real values. */
|
|
53
|
+
export declare const DEFAULT_REPRICING_RULES: Readonly<RepricingRules>;
|
|
54
|
+
export declare const REPRICING_RULE_KEYS: (keyof RepricingRules)[];
|
|
55
|
+
export type RepricingRuleLayer = {
|
|
56
|
+
[K in keyof RepricingRules]?: RepricingRules[K] | null;
|
|
57
|
+
};
|
|
58
|
+
/** Coerces a DB row (TINYINT/DECIMAL strings/JSON text) into a typed rule layer. */
|
|
59
|
+
export declare function normalizeRepricingRuleLayer(row: Record<string, unknown> | null | undefined): RepricingRuleLayer;
|
|
60
|
+
/**
|
|
61
|
+
* Merges layers broadest-first (global, company, variant). NULL/undefined values inherit.
|
|
62
|
+
* `live_enabled` additionally requires the global layer to be explicitly enabled so
|
|
63
|
+
* a single global switch can stop all automatic changes.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveRepricingRules(layers: {
|
|
66
|
+
global?: RepricingRuleLayer | null;
|
|
67
|
+
company?: RepricingRuleLayer | null;
|
|
68
|
+
variant?: RepricingRuleLayer | null;
|
|
69
|
+
}): RepricingRules;
|
|
70
|
+
export interface RepricingVariantInput {
|
|
71
|
+
id: number;
|
|
72
|
+
price: number;
|
|
73
|
+
compare_at_price?: number | null;
|
|
74
|
+
unit_cost: number | null;
|
|
75
|
+
custom_map_price?: number | null;
|
|
76
|
+
custom_msrp?: number | null;
|
|
77
|
+
median_shipping_cost?: number | null;
|
|
78
|
+
custom_projected_shipping_fee?: number | null;
|
|
79
|
+
shipping_included_in_cost?: 0 | 1 | null;
|
|
80
|
+
inventory_quantity?: number | null;
|
|
81
|
+
tracks_inventory?: 0 | 1 | boolean | null;
|
|
82
|
+
inventory_policy?: string | null;
|
|
83
|
+
product_status?: string | null;
|
|
84
|
+
ignore_auto_price_updates?: boolean;
|
|
85
|
+
has_active_price_cut?: boolean;
|
|
86
|
+
/** When the variant was last scraped (any result). Used to tell "competitor gone" from "no data". */
|
|
87
|
+
last_scraped_at?: Date | string | null;
|
|
88
|
+
}
|
|
89
|
+
export interface RepricingVariantState {
|
|
90
|
+
baseline_price?: number | null;
|
|
91
|
+
last_changed_at?: Date | string | null;
|
|
92
|
+
}
|
|
93
|
+
export interface CompetitorObservation {
|
|
94
|
+
listing_hash: string;
|
|
95
|
+
source: string | null;
|
|
96
|
+
title?: string | null;
|
|
97
|
+
/** Delivered total when known, otherwise listed price. */
|
|
98
|
+
price: number;
|
|
99
|
+
seen_at: Date | string;
|
|
100
|
+
out_of_stock?: boolean;
|
|
101
|
+
}
|
|
102
|
+
export interface RepricingReason {
|
|
103
|
+
code: string;
|
|
104
|
+
message: string;
|
|
105
|
+
}
|
|
106
|
+
export interface RepricingDecision {
|
|
107
|
+
action: RepricingAction;
|
|
108
|
+
strategy: RepricingStrategy | null;
|
|
109
|
+
old_price: number;
|
|
110
|
+
target_price: number | null;
|
|
111
|
+
old_compare_at_price: number | null;
|
|
112
|
+
/** undefined = leave unchanged. */
|
|
113
|
+
new_compare_at_price: number | null | undefined;
|
|
114
|
+
floor_price: number | null;
|
|
115
|
+
ceiling_price: number | null;
|
|
116
|
+
competitor: CompetitorObservation | null;
|
|
117
|
+
usable_competitor_count: number;
|
|
118
|
+
unit_cost: number | null;
|
|
119
|
+
shipping_cost: number;
|
|
120
|
+
margin_before: number | null;
|
|
121
|
+
margin_after: number | null;
|
|
122
|
+
profit_after: number | null;
|
|
123
|
+
reasons: RepricingReason[];
|
|
124
|
+
}
|
|
125
|
+
export declare const round2: (n: number) => number;
|
|
126
|
+
/** Largest "nice" price <= value (used when lowering toward a competitor). */
|
|
127
|
+
export declare function roundPriceDown(value: number, rounding: RepricingRounding): number;
|
|
128
|
+
/** Smallest "nice" price >= value (used when respecting a floor). */
|
|
129
|
+
export declare function roundPriceUp(value: number, rounding: RepricingRounding): number;
|
|
130
|
+
export declare function getRepricingShippingCost(variant: RepricingVariantInput): number;
|
|
131
|
+
export declare function computeRepricingMargin(price: number, variant: RepricingVariantInput): number | null;
|
|
132
|
+
export declare function computeRepricingProfit(price: number, variant: RepricingVariantInput): number | null;
|
|
133
|
+
/** Lowest price that satisfies margin, profit and MAP guardrails. */
|
|
134
|
+
export declare function computeRepricingFloor(variant: RepricingVariantInput, rules: RepricingRules): number;
|
|
135
|
+
export declare function computeRepricingCeiling(variant: RepricingVariantInput, state: RepricingVariantState | null | undefined, rules: RepricingRules): number;
|
|
136
|
+
/**
|
|
137
|
+
* Filters competitor observations down to the ones allowed to drive a price change.
|
|
138
|
+
* Returns usable observations (cheapest first) plus the reasons others were dropped.
|
|
139
|
+
*/
|
|
140
|
+
export declare function filterRepricingCompetitors({ variant, competitors, rules, now, }: {
|
|
141
|
+
variant: RepricingVariantInput;
|
|
142
|
+
competitors: CompetitorObservation[];
|
|
143
|
+
rules: RepricingRules;
|
|
144
|
+
now: Date;
|
|
145
|
+
}): {
|
|
146
|
+
usable: CompetitorObservation[];
|
|
147
|
+
dropped: Record<string, number>;
|
|
148
|
+
freshCount: number;
|
|
149
|
+
};
|
|
150
|
+
export declare function computeRepricingDecision({ variant, competitors, rules, state, now, }: {
|
|
151
|
+
variant: RepricingVariantInput;
|
|
152
|
+
competitors: CompetitorObservation[];
|
|
153
|
+
rules: RepricingRules;
|
|
154
|
+
state?: RepricingVariantState | null;
|
|
155
|
+
now?: Date;
|
|
156
|
+
}): RepricingDecision;
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated repricing decision engine.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only (no I/O) so the same logic powers the fpjobs repricing job,
|
|
5
|
+
* the fpdash-server simulate endpoint, and client-side previews.
|
|
6
|
+
*
|
|
7
|
+
* Margin/profit/shipping math intentionally mirrors fpdash-client `getMargin`,
|
|
8
|
+
* `getProfit`, `getShippingFeeDetails` and `getShouldUseShippingInProfitMargin`.
|
|
9
|
+
*/
|
|
10
|
+
/** Conservative placeholders until management sets real values. */
|
|
11
|
+
export const DEFAULT_REPRICING_RULES = Object.freeze({
|
|
12
|
+
enabled: false,
|
|
13
|
+
live_enabled: false,
|
|
14
|
+
undercut_type: 'FIXED',
|
|
15
|
+
undercut_value: 1,
|
|
16
|
+
floor_behavior: 'skip',
|
|
17
|
+
min_margin_pct: 11,
|
|
18
|
+
min_profit: null,
|
|
19
|
+
respect_map: true,
|
|
20
|
+
allow_raise: true,
|
|
21
|
+
raise_requires_approval: true,
|
|
22
|
+
ceiling_source: 'baseline',
|
|
23
|
+
rounding: 'none',
|
|
24
|
+
compare_at_behavior: 'leave',
|
|
25
|
+
min_change_amt: 1,
|
|
26
|
+
auto_max_change_pct: 5,
|
|
27
|
+
auto_max_change_amt: 25,
|
|
28
|
+
min_hours_between_changes: 24,
|
|
29
|
+
stale_hours: 48,
|
|
30
|
+
excluded_sources: [],
|
|
31
|
+
min_variant_price: 100,
|
|
32
|
+
low_stock_threshold: null,
|
|
33
|
+
low_stock_action: 'none',
|
|
34
|
+
max_auto_changes_per_day: 50,
|
|
35
|
+
approval_expiry_hours: 72,
|
|
36
|
+
});
|
|
37
|
+
export const REPRICING_RULE_KEYS = Object.keys(DEFAULT_REPRICING_RULES);
|
|
38
|
+
const BOOLEAN_RULE_KEYS = [
|
|
39
|
+
'enabled',
|
|
40
|
+
'live_enabled',
|
|
41
|
+
'respect_map',
|
|
42
|
+
'allow_raise',
|
|
43
|
+
'raise_requires_approval',
|
|
44
|
+
];
|
|
45
|
+
/** Coerces a DB row (TINYINT/DECIMAL strings/JSON text) into a typed rule layer. */
|
|
46
|
+
export function normalizeRepricingRuleLayer(row) {
|
|
47
|
+
const layer = {};
|
|
48
|
+
if (!row)
|
|
49
|
+
return layer;
|
|
50
|
+
for (const key of REPRICING_RULE_KEYS) {
|
|
51
|
+
const raw = row[key];
|
|
52
|
+
if (raw === null || raw === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
const defaultValue = DEFAULT_REPRICING_RULES[key];
|
|
55
|
+
if (BOOLEAN_RULE_KEYS.includes(key)) {
|
|
56
|
+
layer[key] = raw === true || raw === 1 || raw === '1';
|
|
57
|
+
}
|
|
58
|
+
else if (key === 'excluded_sources') {
|
|
59
|
+
const parsed = typeof raw === 'string' ? safeJsonParse(raw) : raw;
|
|
60
|
+
layer[key] = Array.isArray(parsed) ? parsed.map(String) : [];
|
|
61
|
+
}
|
|
62
|
+
else if (typeof defaultValue === 'number' || defaultValue === null) {
|
|
63
|
+
const num = Number(raw);
|
|
64
|
+
if (Number.isFinite(num))
|
|
65
|
+
layer[key] = num;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
layer[key] = raw;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return layer;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Merges layers broadest-first (global, company, variant). NULL/undefined values inherit.
|
|
75
|
+
* `live_enabled` additionally requires the global layer to be explicitly enabled so
|
|
76
|
+
* a single global switch can stop all automatic changes.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveRepricingRules(layers) {
|
|
79
|
+
const resolved = { ...DEFAULT_REPRICING_RULES };
|
|
80
|
+
for (const layer of [layers.global, layers.company, layers.variant]) {
|
|
81
|
+
if (!layer)
|
|
82
|
+
continue;
|
|
83
|
+
for (const key of REPRICING_RULE_KEYS) {
|
|
84
|
+
const value = layer[key];
|
|
85
|
+
if (value !== null && value !== undefined)
|
|
86
|
+
resolved[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const globalLive = layers.global?.live_enabled === true;
|
|
90
|
+
resolved.live_enabled = globalLive && resolved.live_enabled === true;
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
const OUR_SOURCE_NAMES = ['factorypure', 'factory pure'];
|
|
94
|
+
const OUTLIER_LOW_RATIO = 0.5;
|
|
95
|
+
const OUTLIER_HIGH_RATIO = 2;
|
|
96
|
+
export const round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
97
|
+
/** Largest "nice" price <= value (used when lowering toward a competitor). */
|
|
98
|
+
export function roundPriceDown(value, rounding) {
|
|
99
|
+
if (rounding === 'whole')
|
|
100
|
+
return Math.floor(value + 1e-9);
|
|
101
|
+
if (rounding === '99')
|
|
102
|
+
return round2(Math.floor(value + 0.01 + 1e-9) - 0.01);
|
|
103
|
+
return round2(Math.floor(value * 100 + 1e-6) / 100);
|
|
104
|
+
}
|
|
105
|
+
/** Smallest "nice" price >= value (used when respecting a floor). */
|
|
106
|
+
export function roundPriceUp(value, rounding) {
|
|
107
|
+
if (rounding === 'whole')
|
|
108
|
+
return Math.ceil(value - 1e-9);
|
|
109
|
+
if (rounding === '99')
|
|
110
|
+
return round2(Math.ceil(value + 0.01 - 1e-9) - 0.01);
|
|
111
|
+
return round2(Math.ceil(value * 100 - 1e-6) / 100);
|
|
112
|
+
}
|
|
113
|
+
export function getRepricingShippingCost(variant) {
|
|
114
|
+
const median = Number(variant.median_shipping_cost ?? 0);
|
|
115
|
+
if (median > 0)
|
|
116
|
+
return median;
|
|
117
|
+
return Number(variant.custom_projected_shipping_fee ?? 0);
|
|
118
|
+
}
|
|
119
|
+
function usesShippingInMargin(variant) {
|
|
120
|
+
return variant.shipping_included_in_cost === undefined ? true : !variant.shipping_included_in_cost;
|
|
121
|
+
}
|
|
122
|
+
export function computeRepricingMargin(price, variant) {
|
|
123
|
+
if (!price || !isFinite(price) || variant.unit_cost === null || variant.unit_cost === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
const shipping = usesShippingInMargin(variant) ? getRepricingShippingCost(variant) : 0;
|
|
126
|
+
return round2(((price - Number(variant.unit_cost) - shipping) / price) * 100);
|
|
127
|
+
}
|
|
128
|
+
export function computeRepricingProfit(price, variant) {
|
|
129
|
+
if (variant.unit_cost === null || variant.unit_cost === undefined)
|
|
130
|
+
return null;
|
|
131
|
+
const shipping = usesShippingInMargin(variant) ? getRepricingShippingCost(variant) : 0;
|
|
132
|
+
return round2(price - Number(variant.unit_cost) - shipping);
|
|
133
|
+
}
|
|
134
|
+
/** Lowest price that satisfies margin, profit and MAP guardrails. */
|
|
135
|
+
export function computeRepricingFloor(variant, rules) {
|
|
136
|
+
const cost = Number(variant.unit_cost ?? 0);
|
|
137
|
+
const shipping = usesShippingInMargin(variant) ? getRepricingShippingCost(variant) : 0;
|
|
138
|
+
const floors = [0.01];
|
|
139
|
+
if (rules.min_margin_pct !== null && rules.min_margin_pct < 100) {
|
|
140
|
+
floors.push((cost + shipping) / (1 - rules.min_margin_pct / 100));
|
|
141
|
+
}
|
|
142
|
+
if (rules.min_profit !== null) {
|
|
143
|
+
floors.push(cost + shipping + rules.min_profit);
|
|
144
|
+
}
|
|
145
|
+
if (rules.respect_map && variant.custom_map_price && Number(variant.custom_map_price) > 0) {
|
|
146
|
+
floors.push(Number(variant.custom_map_price));
|
|
147
|
+
}
|
|
148
|
+
return roundPriceUp(Math.max(...floors), 'none');
|
|
149
|
+
}
|
|
150
|
+
export function computeRepricingCeiling(variant, state, rules) {
|
|
151
|
+
const baseline = Number(state?.baseline_price ?? variant.price);
|
|
152
|
+
const msrp = variant.custom_msrp && Number(variant.custom_msrp) > 0 ? Number(variant.custom_msrp) : null;
|
|
153
|
+
if (rules.ceiling_source === 'msrp')
|
|
154
|
+
return msrp ?? baseline;
|
|
155
|
+
if (rules.ceiling_source === 'lowest_of_both')
|
|
156
|
+
return msrp !== null ? Math.min(msrp, baseline) : baseline;
|
|
157
|
+
return baseline;
|
|
158
|
+
}
|
|
159
|
+
function toTime(value) {
|
|
160
|
+
if (value === null || value === undefined)
|
|
161
|
+
return null;
|
|
162
|
+
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
163
|
+
return Number.isFinite(t) ? t : null;
|
|
164
|
+
}
|
|
165
|
+
function isOurOutOfStock(variant) {
|
|
166
|
+
const tracks = variant.tracks_inventory === 1 || variant.tracks_inventory === true;
|
|
167
|
+
if (!tracks)
|
|
168
|
+
return false;
|
|
169
|
+
if (variant.inventory_policy === 'continue')
|
|
170
|
+
return false;
|
|
171
|
+
return Number(variant.inventory_quantity ?? 0) <= 0;
|
|
172
|
+
}
|
|
173
|
+
function isLowStock(variant, rules) {
|
|
174
|
+
if (rules.low_stock_threshold === null)
|
|
175
|
+
return false;
|
|
176
|
+
const tracks = variant.tracks_inventory === 1 || variant.tracks_inventory === true;
|
|
177
|
+
if (!tracks)
|
|
178
|
+
return false;
|
|
179
|
+
return Number(variant.inventory_quantity ?? 0) <= rules.low_stock_threshold;
|
|
180
|
+
}
|
|
181
|
+
function applyUndercut(competitorPrice, rules) {
|
|
182
|
+
if (rules.undercut_type === 'PERCENT')
|
|
183
|
+
return competitorPrice * (1 - rules.undercut_value / 100);
|
|
184
|
+
return competitorPrice - rules.undercut_value;
|
|
185
|
+
}
|
|
186
|
+
function computeCompareAt(variant, newPrice, rules) {
|
|
187
|
+
const current = variant.compare_at_price !== null && variant.compare_at_price !== undefined
|
|
188
|
+
? Number(variant.compare_at_price)
|
|
189
|
+
: null;
|
|
190
|
+
if (rules.compare_at_behavior === 'clear')
|
|
191
|
+
return current === null ? undefined : null;
|
|
192
|
+
if (rules.compare_at_behavior === 'set_old_price') {
|
|
193
|
+
if (newPrice < variant.price) {
|
|
194
|
+
const target = current !== null && current > variant.price ? current : variant.price;
|
|
195
|
+
return target === current ? undefined : round2(target);
|
|
196
|
+
}
|
|
197
|
+
if (current !== null && current <= newPrice)
|
|
198
|
+
return null;
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Filters competitor observations down to the ones allowed to drive a price change.
|
|
205
|
+
* Returns usable observations (cheapest first) plus the reasons others were dropped.
|
|
206
|
+
*/
|
|
207
|
+
export function filterRepricingCompetitors({ variant, competitors, rules, now, }) {
|
|
208
|
+
const excluded = new Set(rules.excluded_sources.map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
209
|
+
const staleCutoff = now.getTime() - rules.stale_hours * 3_600_000;
|
|
210
|
+
const dropped = {};
|
|
211
|
+
const drop = (code) => (dropped[code] = (dropped[code] ?? 0) + 1);
|
|
212
|
+
const usable = [];
|
|
213
|
+
let freshCount = 0;
|
|
214
|
+
for (const c of competitors) {
|
|
215
|
+
const source = (c.source ?? '').trim().toLowerCase();
|
|
216
|
+
const seen = toTime(c.seen_at);
|
|
217
|
+
if (seen === null || seen < staleCutoff) {
|
|
218
|
+
drop('stale');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
freshCount++;
|
|
222
|
+
if (OUR_SOURCE_NAMES.includes(source)) {
|
|
223
|
+
drop('our_listing');
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (excluded.has(source)) {
|
|
227
|
+
drop('excluded_source');
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (c.out_of_stock) {
|
|
231
|
+
drop('out_of_stock');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!(c.price > 0)) {
|
|
235
|
+
drop('no_price');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (c.price < variant.price * OUTLIER_LOW_RATIO || c.price > variant.price * OUTLIER_HIGH_RATIO) {
|
|
239
|
+
drop('price_outlier');
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
usable.push(c);
|
|
243
|
+
}
|
|
244
|
+
usable.sort((a, b) => a.price - b.price);
|
|
245
|
+
return { usable, dropped, freshCount };
|
|
246
|
+
}
|
|
247
|
+
export function computeRepricingDecision({ variant, competitors, rules, state, now = new Date(), }) {
|
|
248
|
+
const price = Number(variant.price);
|
|
249
|
+
const reasons = [];
|
|
250
|
+
const shipping = usesShippingInMargin(variant) ? getRepricingShippingCost(variant) : 0;
|
|
251
|
+
const base = {
|
|
252
|
+
action: 'none',
|
|
253
|
+
strategy: null,
|
|
254
|
+
old_price: price,
|
|
255
|
+
target_price: null,
|
|
256
|
+
old_compare_at_price: variant.compare_at_price !== null && variant.compare_at_price !== undefined
|
|
257
|
+
? Number(variant.compare_at_price)
|
|
258
|
+
: null,
|
|
259
|
+
new_compare_at_price: undefined,
|
|
260
|
+
floor_price: null,
|
|
261
|
+
ceiling_price: null,
|
|
262
|
+
competitor: null,
|
|
263
|
+
usable_competitor_count: 0,
|
|
264
|
+
unit_cost: variant.unit_cost === null || variant.unit_cost === undefined ? null : Number(variant.unit_cost),
|
|
265
|
+
shipping_cost: round2(shipping),
|
|
266
|
+
margin_before: computeRepricingMargin(price, variant),
|
|
267
|
+
margin_after: null,
|
|
268
|
+
profit_after: null,
|
|
269
|
+
reasons,
|
|
270
|
+
};
|
|
271
|
+
const skip = (code, message) => {
|
|
272
|
+
reasons.push({ code, message });
|
|
273
|
+
return { ...base, action: 'skip' };
|
|
274
|
+
};
|
|
275
|
+
const none = (code, message) => {
|
|
276
|
+
reasons.push({ code, message });
|
|
277
|
+
return { ...base, action: 'none' };
|
|
278
|
+
};
|
|
279
|
+
if (!rules.enabled)
|
|
280
|
+
return skip('disabled', 'Repricing is not enabled for this product');
|
|
281
|
+
if (variant.product_status && variant.product_status !== 'active') {
|
|
282
|
+
return skip('inactive_product', `Product status is ${variant.product_status}`);
|
|
283
|
+
}
|
|
284
|
+
if (variant.ignore_auto_price_updates)
|
|
285
|
+
return skip('ignore_auto_updates', 'Product is marked "don\'t auto-update price"');
|
|
286
|
+
if (variant.has_active_price_cut)
|
|
287
|
+
return skip('active_price_cut', 'Product is in an active price cut');
|
|
288
|
+
if (!(price > 0))
|
|
289
|
+
return skip('no_price', 'Product has no current price');
|
|
290
|
+
if (price < rules.min_variant_price) {
|
|
291
|
+
return skip('below_min_variant_price', `Price $${price.toFixed(2)} is below the $${rules.min_variant_price} minimum`);
|
|
292
|
+
}
|
|
293
|
+
if (base.unit_cost === null || base.unit_cost <= 0)
|
|
294
|
+
return skip('missing_cost', 'Product cost is missing');
|
|
295
|
+
if (isOurOutOfStock(variant))
|
|
296
|
+
return skip('our_out_of_stock', 'We are out of stock');
|
|
297
|
+
const lastChanged = toTime(state?.last_changed_at);
|
|
298
|
+
if (lastChanged !== null && now.getTime() - lastChanged < rules.min_hours_between_changes * 3_600_000) {
|
|
299
|
+
return skip('cooldown', `Price changed within the last ${rules.min_hours_between_changes} hours`);
|
|
300
|
+
}
|
|
301
|
+
const floor = computeRepricingFloor(variant, rules);
|
|
302
|
+
const ceiling = Math.max(computeRepricingCeiling(variant, state, rules), floor);
|
|
303
|
+
base.floor_price = floor;
|
|
304
|
+
base.ceiling_price = round2(ceiling);
|
|
305
|
+
const { usable, dropped, freshCount } = filterRepricingCompetitors({ variant, competitors, rules, now });
|
|
306
|
+
base.usable_competitor_count = usable.length;
|
|
307
|
+
for (const [code, count] of Object.entries(dropped)) {
|
|
308
|
+
reasons.push({
|
|
309
|
+
code: `ignored_${code}`,
|
|
310
|
+
message: `${count} confirmed listing(s) ignored: ${code.replace(/_/g, ' ')}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const lowest = usable[0] ?? null;
|
|
314
|
+
base.competitor = lowest;
|
|
315
|
+
let strategy;
|
|
316
|
+
let target;
|
|
317
|
+
if (lowest && lowest.price <= price) {
|
|
318
|
+
strategy = 'beat_lowest';
|
|
319
|
+
const beat = roundPriceDown(applyUndercut(lowest.price, rules), rules.rounding);
|
|
320
|
+
if (beat >= floor) {
|
|
321
|
+
target = beat;
|
|
322
|
+
}
|
|
323
|
+
else if (rules.floor_behavior === 'match' && lowest.price >= floor) {
|
|
324
|
+
target = round2(lowest.price);
|
|
325
|
+
reasons.push({
|
|
326
|
+
code: 'matched_instead_of_beat',
|
|
327
|
+
message: 'Beating would break the floor, so matching instead',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
else if (rules.floor_behavior === 'clamp_to_floor') {
|
|
331
|
+
target = roundPriceUp(floor, rules.rounding);
|
|
332
|
+
reasons.push({ code: 'clamped_to_floor', message: `Held at floor $${floor.toFixed(2)}` });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
return {
|
|
336
|
+
...skip('below_floor', `Beating $${lowest.price.toFixed(2)} would go below the $${floor.toFixed(2)} floor`),
|
|
337
|
+
strategy,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
if (!rules.allow_raise)
|
|
343
|
+
return none('raise_disabled', 'Not undercut, and raising is disabled');
|
|
344
|
+
if (!lowest) {
|
|
345
|
+
const lastScraped = toTime(variant.last_scraped_at);
|
|
346
|
+
const staleCutoff = now.getTime() - rules.stale_hours * 3_600_000;
|
|
347
|
+
if (competitors.length === 0 && !state?.baseline_price) {
|
|
348
|
+
return none('no_trusted_competitors', 'No human-confirmed competitor listings');
|
|
349
|
+
}
|
|
350
|
+
if (freshCount === 0 && (lastScraped === null || lastScraped < staleCutoff)) {
|
|
351
|
+
return skip('stale_data', 'No fresh competitor data to act on');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
strategy = 'raise_recovery';
|
|
355
|
+
const desired = lowest ? roundPriceDown(applyUndercut(lowest.price, rules), rules.rounding) : ceiling;
|
|
356
|
+
target = Math.max(Math.min(desired, ceiling), floor);
|
|
357
|
+
if (target <= price)
|
|
358
|
+
return { ...none('already_competitive', 'Price is already competitive'), strategy };
|
|
359
|
+
reasons.push({
|
|
360
|
+
code: lowest ? 'competitor_raised' : 'competitor_gone',
|
|
361
|
+
message: lowest
|
|
362
|
+
? `Lowest trusted competitor is $${lowest.price.toFixed(2)}`
|
|
363
|
+
: 'No in-stock trusted competitor is currently cheaper',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
target = round2(target);
|
|
367
|
+
const delta = round2(target - price);
|
|
368
|
+
const result = {
|
|
369
|
+
...base,
|
|
370
|
+
strategy,
|
|
371
|
+
target_price: target,
|
|
372
|
+
new_compare_at_price: computeCompareAt(variant, target, rules),
|
|
373
|
+
margin_after: computeRepricingMargin(target, variant),
|
|
374
|
+
profit_after: computeRepricingProfit(target, variant),
|
|
375
|
+
};
|
|
376
|
+
if (Math.abs(delta) < rules.min_change_amt) {
|
|
377
|
+
reasons.push({
|
|
378
|
+
code: 'change_too_small',
|
|
379
|
+
message: `Change of $${Math.abs(delta).toFixed(2)} is below the minimum`,
|
|
380
|
+
});
|
|
381
|
+
return { ...result, action: 'none' };
|
|
382
|
+
}
|
|
383
|
+
if (delta < 0 && rules.low_stock_action === 'skip_decrease' && isLowStock(variant, rules)) {
|
|
384
|
+
reasons.push({ code: 'low_stock', message: 'Our stock is low, so not lowering the price' });
|
|
385
|
+
return { ...result, action: 'skip' };
|
|
386
|
+
}
|
|
387
|
+
const changePct = (Math.abs(delta) / price) * 100;
|
|
388
|
+
const withinPct = rules.auto_max_change_pct === null || changePct <= rules.auto_max_change_pct;
|
|
389
|
+
const withinAmt = rules.auto_max_change_amt === null || Math.abs(delta) <= rules.auto_max_change_amt;
|
|
390
|
+
const raiseBlocked = delta > 0 && rules.raise_requires_approval;
|
|
391
|
+
if (raiseBlocked) {
|
|
392
|
+
reasons.push({ code: 'raise_needs_approval', message: 'Price increases require approval' });
|
|
393
|
+
return { ...result, action: 'approval' };
|
|
394
|
+
}
|
|
395
|
+
if (!withinPct || !withinAmt) {
|
|
396
|
+
reasons.push({
|
|
397
|
+
code: 'change_too_large',
|
|
398
|
+
message: `Change of $${Math.abs(delta).toFixed(2)} (${changePct.toFixed(1)}%) exceeds the automatic limit`,
|
|
399
|
+
});
|
|
400
|
+
return { ...result, action: 'approval' };
|
|
401
|
+
}
|
|
402
|
+
return { ...result, action: 'auto' };
|
|
403
|
+
}
|
|
404
|
+
function safeJsonParse(value) {
|
|
405
|
+
try {
|
|
406
|
+
return JSON.parse(value);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factorypure/client-helpers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"watch": "tsc --watch",
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "npm run build && node --test test/*.test.js"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [],
|
|
17
17
|
"author": "",
|