원가 기반 가격: 카테고리/공급사/상품별 마진 규칙, .99 반올림, 마진 경고, EU 30일 가격 이력
설치
genpm add @core/pricing포함 내용
- src/lib/pricing/에 소스 코드, 파일 6개. (16.4kB)
- src/lib/pricing/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- @core/catalog, @core/contracts, @core/db, @core/money을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/pricing에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/pricing — rules for AI agents
Purpose
Turns costs into selling prices and keeps price changes honest. Rules (scope all / category / supplier / product,
cost range, markup in basis points, fixed add-on, charm rounding, minimum margin, priority) pick the most specific
match; repriceProduct applies them to variants with a cost. Every price change goes to price_history, which gives
the EU "prior price" (lowest of the 30 days before the current price) for any sale badge. Tables: price_rules, price_history.
Map
index.ts— public API:upsertPriceRule,priceFromCost,applyRule,repriceProduct,setVariantPrice,priorPrice,honestCompareAt,lowMarginVariants,priceRulesAdminResource.pricing.ts— logic.admin.ts— admin resource.schema.ts— tables.
Integration
- Migrations as in
src/lib/db/AGENTS.md. - Create rules in the admin (
priceRulesAdminResourceinsrc/genpm/admin.ts, permissionspricing:read|update), e.g.markupBps: 15000(+150 %), rounding.99. - Change prices only with
setVariantPrice(variantId, amount)(also from the admin and supplier syncs) so history is kept. - Product pages: show a crossed-out price only with
honestCompareAt(variantId); never with a typed "compare at". - Simulate before applying:
repriceProduct(productId, { apply: false })returns old/new price and margin per variant. - Verify: lower a price,
honestCompareAtreturns the lowest price of the previous 30 days.
Conventions
- Amounts in minor units; markup and margins in basis points (10000 = 100 %).
- Review
lowMarginVariants()after cost changes; items below their rule's minimum must not be sold silently.
Don't
- Don't update
priceAmountdirectly or invent "was" prices; that breaks the 30-day rule (Omnibus Directive). - Don't raise a price shortly before a sale to inflate the discount.
- Don't expose costs or margins in public pages or APIs.
# @core/pricing — rules for AI agents
## Purpose
Turns costs into selling prices and keeps price changes honest. Rules (scope all / category / supplier / product,
cost range, markup in basis points, fixed add-on, charm rounding, minimum margin, priority) pick the most specific
match; `repriceProduct` applies them to variants with a cost. Every price change goes to `price_history`, which gives
the EU "prior price" (lowest of the 30 days before the current price) for any sale badge. Tables: `price_rules`, `price_history`.
## Map
- `index.ts` — public API: `upsertPriceRule`, `priceFromCost`, `applyRule`, `repriceProduct`, `setVariantPrice`, `priorPrice`, `honestCompareAt`, `lowMarginVariants`, `priceRulesAdminResource`.
- `pricing.ts` — logic. `admin.ts` — admin resource. `schema.ts` — tables.
## Integration
1. Migrations as in `src/lib/db/AGENTS.md`.
2. Create rules in the admin (`priceRulesAdminResource` in `src/genpm/admin.ts`, permissions `pricing:read|update`), e.g. `markupBps: 15000` (+150 %), rounding `.99`.
3. Change prices only with `setVariantPrice(variantId, amount)` (also from the admin and supplier syncs) so history is kept.
4. Product pages: show a crossed-out price only with `honestCompareAt(variantId)`; never with a typed "compare at".
5. Simulate before applying: `repriceProduct(productId, { apply: false })` returns old/new price and margin per variant.
6. Verify: lower a price, `honestCompareAt` returns the lowest price of the previous 30 days.
## Conventions
- Amounts in minor units; markup and margins in basis points (10000 = 100 %).
- Review `lowMarginVariants()` after cost changes; items below their rule's minimum must not be sold silently.
## Don't
- Don't update `priceAmount` directly or invent "was" prices; that breaks the 30-day rule (Omnibus Directive).
- Don't raise a price shortly before a sale to inflate the discount.
- Don't expose costs or margins in public pages or APIs.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Precio de venta desde el coste con reglas, cambios de precio con historial, "precio anterior" honesto y márgenes.
import { and, asc, desc, eq, gt, isNull, lt, or } from 'drizzle-orm';
import { z } from 'zod';
import { categories, productCategories, productVariants, products } from '../catalog/index.ts';
import type { Money } from '../contracts/index.ts';
import { type Executor, getDb, withTransaction } from '../db/index.ts';
import { charmPrice, money } from '../money/index.ts';
import { type PriceRule, priceHistory, priceRules } from './schema.ts';
export const PriceRuleInput = z
.object({
name: z.string().min(1).max(80),
scope: z.enum(['all', 'category', 'supplier', 'product']).default('all'),
scopeValue: z.string().max(120).nullish(),
minCost: z.number().int().min(0).nullish(),
maxCost: z.number().int().min(0).nullish(),
markupBps: z.number().int().min(0).max(100_000),
fixedAdd: z.number().int().min(0).default(0),
rounding: z.enum(['none', '.99', '.95', '.90', '.00']).default('.99'),
minMarginBps: z.number().int().min(0).max(9_900).default(1000),
priority: z.number().int().default(0),
active: z.boolean().default(true),
})
.refine((r) => r.scope === 'all' || !!r.scopeValue, 'scopeValue is required for this scope');
export async function upsertPriceRule(input: z.input<typeof PriceRuleInput>, id?: string, db: Executor = getDb()): Promise<PriceRule> {
const r = PriceRuleInput.parse(input);
const values = { ...r, scopeValue: r.scopeValue ?? null, minCost: r.minCost ?? null, maxCost: r.maxCost ?? null };
const [row] = id ? await db.update(priceRules).set(values).where(eq(priceRules.id, id)).returning() : await db.insert(priceRules).values(values).returning();
return row!;
}
export type PricingContext = { productId?: string; categorySlugs?: string[]; supplierId?: string };
const SPECIFICITY = { product: 3, supplier: 2, category: 1, all: 0 } as const;
/** Regla aplicable: la más específica (producto > proveedor > categoría > todo) y, a igualdad, la de mayor prioridad. */
export async function ruleFor(cost: Money, ctx: PricingContext, db: Executor = getDb()): Promise<PriceRule | null> {
const rules = await db.select().from(priceRules).where(eq(priceRules.active, true));
const fits = rules.filter(
(r) =>
(r.minCost == null || cost.amount >= r.minCost) &&
(r.maxCost == null || cost.amount <= r.maxCost) &&
(r.scope === 'all' ||
(r.scope === 'product' && r.scopeValue === ctx.productId) ||
(r.scope === 'supplier' && r.scopeValue === ctx.supplierId) ||
(r.scope === 'category' && !!ctx.categorySlugs?.includes(r.scopeValue ?? ''))),
);
fits.sort((a, b) => SPECIFICITY[b.scope] - SPECIFICITY[a.scope] || b.priority - a.priority || a.id.localeCompare(b.id));
return fits[0] ?? null;
}
/** Precio = coste × (1 + margen) + fijo, redondeado. */
export function applyRule(cost: Money, rule: Pick<PriceRule, 'markupBps' | 'fixedAdd' | 'rounding'>): Money {
const raw = money(Math.round((cost.amount * (10_000 + rule.markupBps)) / 10_000) + rule.fixedAdd, cost.currency);
return rule.rounding === 'none' ? raw : charmPrice(raw, rule.rounding);
}
/** Margen sobre el precio en puntos básicos (negativo = pérdida). */
export const marginBps = (price: number, cost: number) => (price <= 0 ? -10_000 : Math.round(((price - cost) * 10_000) / price));
export type PriceQuote = { price: Money; rule: PriceRule; marginBps: number; belowMinimum: boolean };
export async function priceFromCost(cost: Money, ctx: PricingContext = {}, db: Executor = getDb()): Promise<PriceQuote | null> {
const rule = await ruleFor(cost, ctx, db);
if (!rule) return null;
const price = applyRule(cost, rule);
const m = marginBps(price.amount, cost.amount);
return { price, rule, marginBps: m, belowMinimum: m < rule.minMarginBps };
}
/**
* Cambia el precio de una variante cerrando el periodo anterior del historial. No hace nada si no cambia.
* Úsalo en lugar de actualizar `priceAmount` directamente.
*/
export async function setVariantPrice(variantId: string, amount: number, db = getDb(), at = new Date()): Promise<boolean> {
return withTransaction(async (tx) => {
const [v] = await tx.select().from(productVariants).where(eq(productVariants.id, variantId)).for('update');
if (!v) throw new Error(`variant ${variantId} not found`);
const [open] = await tx.select().from(priceHistory).where(and(eq(priceHistory.variantId, variantId), isNull(priceHistory.validTo)));
if (v.priceAmount === amount && open) return false;
if (open) await tx.update(priceHistory).set({ validTo: at }).where(eq(priceHistory.id, open.id));
else if (v.priceAmount !== amount) await tx.insert(priceHistory).values({ variantId, amount: v.priceAmount, currency: v.currency, validFrom: v.createdAt, validTo: at });
await tx.insert(priceHistory).values({ variantId, amount, currency: v.currency, validFrom: at });
if (v.priceAmount !== amount) await tx.update(productVariants).set({ priceAmount: amount }).where(eq(productVariants.id, variantId));
return true;
}, db);
}
/**
* Precio anterior a efectos de anunciar una rebaja: el más bajo de los 30 días previos al precio actual
* (Directiva Ómnibus, art. 6 bis). Null si no hay historial suficiente.
*/
export async function priorPrice(variantId: string, db: Executor = getDb()): Promise<Money | null> {
const [current] = await db.select().from(priceHistory).where(and(eq(priceHistory.variantId, variantId), isNull(priceHistory.validTo))).orderBy(desc(priceHistory.validFrom)).limit(1);
if (!current) return null;
const since = new Date(current.validFrom.getTime() - 30 * 86_400_000);
const rows = await db
.select()
.from(priceHistory)
.where(and(eq(priceHistory.variantId, variantId), lt(priceHistory.validFrom, current.validFrom), or(isNull(priceHistory.validTo), gt(priceHistory.validTo, since))))
.orderBy(asc(priceHistory.amount))
.limit(1);
return rows[0] ? money(rows[0].amount, rows[0].currency) : null;
}
/** "Antes X" que se puede mostrar: el precio anterior honesto si es mayor que el actual; si no, nada. */
export async function honestCompareAt(variantId: string, db: Executor = getDb()): Promise<Money | null> {
const [v] = await db.select().from(productVariants).where(eq(productVariants.id, variantId));
const prior = await priorPrice(variantId, db);
return v && prior && prior.amount > v.priceAmount ? prior : null;
}
/**
* Recalcula con las reglas el precio de las variantes con coste de un producto. Devuelve los cambios y las alertas
* de margen. Con `apply: false` solo simula.
*/
export async function repriceProduct(productId: string, opts: { apply?: boolean; supplierId?: string } = {}, db = getDb()) {
const cats = await db.select({ slug: categories.slug }).from(productCategories).innerJoin(categories, eq(categories.id, productCategories.categoryId)).where(eq(productCategories.productId, productId));
const [p] = await db.select({ id: products.id }).from(products).where(eq(products.id, productId));
if (!p) throw new Error(`product ${productId} not found`);
const variants = await db.select().from(productVariants).where(and(eq(productVariants.productId, productId), eq(productVariants.active, true)));
const changes: Array<{ variantId: string; from: number; to: number; marginBps: number; belowMinimum: boolean }> = [];
for (const v of variants) {
if (v.costAmount == null) continue;
const q = await priceFromCost(money(v.costAmount, v.currency), { productId, categorySlugs: cats.map((c) => c.slug), supplierId: opts.supplierId }, db);
if (!q) continue;
changes.push({ variantId: v.id, from: v.priceAmount, to: q.price.amount, marginBps: q.marginBps, belowMinimum: q.belowMinimum });
if (opts.apply !== false && q.price.amount !== v.priceAmount) await setVariantPrice(v.id, q.price.amount, db);
}
return changes;
}
/** Variantes activas que se venden con margen por debajo de `minBps` (o con pérdida). */
export async function lowMarginVariants(minBps = 0, db: Executor = getDb()) {
const rows = await db
.select({ id: productVariants.id, sku: productVariants.sku, price: productVariants.priceAmount, cost: productVariants.costAmount, product: products.name, status: products.status })
.from(productVariants)
.innerJoin(products, eq(products.id, productVariants.productId))
.where(eq(productVariants.active, true));
return rows.filter((r) => r.cost != null && marginBps(r.price, r.cost) < minBps).map((r) => ({ ...r, marginBps: marginBps(r.price, r.cost!) }));
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.0 | 6aa4077 | 2시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.0 → 6aa40774c5407480451f9b9f74d4e4e0577c06df · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?