@grapadigital/shared-schemas 1.0.228 → 1.0.231

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 CHANGED
@@ -38,5 +38,3 @@ export * from './schemas/snapshot.schema';
38
38
  export * from './schemas/lead.schema';
39
39
  export * from './utils/normalize-content-link.util';
40
40
  export * from './utils/merge-metrics-breakdown.util';
41
- export * from './utils/influencer-contract.util';
42
- export * from './utils/cost-per-impact.util';
package/dist/index.js CHANGED
@@ -59,5 +59,3 @@ __exportStar(require("./schemas/snapshot.schema"), exports);
59
59
  __exportStar(require("./schemas/lead.schema"), exports);
60
60
  __exportStar(require("./utils/normalize-content-link.util"), exports);
61
61
  __exportStar(require("./utils/merge-metrics-breakdown.util"), exports);
62
- __exportStar(require("./utils/influencer-contract.util"), exports);
63
- __exportStar(require("./utils/cost-per-impact.util"), exports);
@@ -40,7 +40,7 @@ export type ClientCustomization = (typeof CLIENT_CUSTOMIZATION_VALUES)[number];
40
40
  * `viralReachEngagementRate` é o limite de viral e só vale no Tier.
41
41
  * Ver GD-687.
42
42
  */
43
- export declare const CLIENT_GOAL_METRIC_VALUES: readonly ["potentialReach", "reachCount", "impressionCount", "engagementCount", "engagementRate", "reachEngagementRate", "qualifiedEngagementRate", "effectiveReach", "earnedMediaValue", "score", "costPerInfluency", "costPerImpact", "costPerEngagement", "savedCount", "shareCount", "videoCompletionRate", "viralReachEngagementRate"];
43
+ export declare const CLIENT_GOAL_METRIC_VALUES: readonly ["potentialReach", "reachCount", "impressionCount", "engagementCount", "engagementRate", "reachEngagementRate", "followersEngagementRate", "qualifiedEngagementRate", "effectiveReach", "earnedMediaValue", "score", "costPerInfluency", "costPerImpact", "costPerEngagement", "savedCount", "shareCount", "videoCompletionRate", "viralReachEngagementRate"];
44
44
  export type ClientGoalMetric = (typeof CLIENT_GOAL_METRIC_VALUES)[number];
45
45
  export type ClientGoalMetricUnit = 'count' | 'rate' | 'currency';
46
46
  /** Unidade de cada métrica. `rate` é guardado como fração (0,10 = 10%). */
@@ -33,6 +33,7 @@ exports.CLIENT_GOAL_METRIC_VALUES = [
33
33
  'engagementCount',
34
34
  'engagementRate',
35
35
  'reachEngagementRate',
36
+ 'followersEngagementRate',
36
37
  'qualifiedEngagementRate',
37
38
  'effectiveReach',
38
39
  'earnedMediaValue',
@@ -53,6 +54,7 @@ exports.CLIENT_GOAL_METRIC_UNITS = {
53
54
  engagementCount: 'count',
54
55
  engagementRate: 'rate',
55
56
  reachEngagementRate: 'rate',
57
+ followersEngagementRate: 'rate',
56
58
  qualifiedEngagementRate: 'rate',
57
59
  effectiveReach: 'count',
58
60
  earnedMediaValue: 'currency',
@@ -126,6 +126,15 @@ export declare class Metrics {
126
126
  reachEngagementRate?: number | null;
127
127
  /** Tx. de Engaj. por Seguidores: engagementCount ÷ potentialReach (fração). Ver GD-703. */
128
128
  followersEngagementRate?: number | null;
129
+ /**
130
+ * Engajamento ponderado do Real E/R (pesos do genc): curtidas×1 + comentários
131
+ * curados×2 + compartilhamentos×3 + salvos×3 + respostas×2 + cliques×6 +
132
+ * toques×1. Comentário curado = alvo marca ou conteúdo, fora de spam. null em
133
+ * story, sem GenIA ou sem comentários classificados. Ver GD-720.
134
+ */
135
+ curatedEngagementCount?: number | null;
136
+ /** Real E/R: curatedEngagementCount ÷ potentialReach (fração). Ver GD-720. */
137
+ curatedEngagementRate?: number | null;
129
138
  /** Nome do Client.tiers pelo potentialReach do post; null sem Tiers ou fora das faixas. Ver GD-703. */
130
139
  tier?: string | null;
131
140
  /**
@@ -219,6 +219,14 @@ __decorate([
219
219
  (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
220
220
  __metadata("design:type", Object)
221
221
  ], Metrics.prototype, "followersEngagementRate", void 0);
222
+ __decorate([
223
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
224
+ __metadata("design:type", Object)
225
+ ], Metrics.prototype, "curatedEngagementCount", void 0);
226
+ __decorate([
227
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
228
+ __metadata("design:type", Object)
229
+ ], Metrics.prototype, "curatedEngagementRate", void 0);
222
230
  __decorate([
223
231
  (0, mongoose_1.Prop)({ type: String, required: false, default: null }),
224
232
  __metadata("design:type", Object)
@@ -393,22 +401,23 @@ ContentMetricsHistoryEntry = __decorate([
393
401
  exports.ContentMetricsHistoryEntry = ContentMetricsHistoryEntry;
394
402
  exports.ContentMetricsHistoryEntrySchema = mongoose_1.SchemaFactory.createForClass(ContentMetricsHistoryEntry);
395
403
  /** Métricas estimadas pelo porte do perfil, para quando a rede não informa. Ver GD-751. */
404
+ // null é valor válido (sem estimativa): required recusaria null no create.
396
405
  let EstimatedMetrics = class EstimatedMetrics {
397
406
  };
398
407
  __decorate([
399
- (0, mongoose_1.Prop)({ type: Number, required: true, default: null }),
408
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
400
409
  __metadata("design:type", Object)
401
410
  ], EstimatedMetrics.prototype, "impressionCount", void 0);
402
411
  __decorate([
403
- (0, mongoose_1.Prop)({ type: Number, required: true, default: null }),
412
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
404
413
  __metadata("design:type", Object)
405
414
  ], EstimatedMetrics.prototype, "reachCount", void 0);
406
415
  __decorate([
407
- (0, mongoose_1.Prop)({ type: Number, required: true, default: null }),
416
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
408
417
  __metadata("design:type", Object)
409
418
  ], EstimatedMetrics.prototype, "engagementCount", void 0);
410
419
  __decorate([
411
- (0, mongoose_1.Prop)({ type: Number, required: true, default: null }),
420
+ (0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
412
421
  __metadata("design:type", Object)
413
422
  ], EstimatedMetrics.prototype, "engagementRate", void 0);
414
423
  EstimatedMetrics = __decorate([
@@ -4,14 +4,19 @@ type ContractLike = Pick<InfluencerContract, 'value' | 'productsValue' | 'startD
4
4
  };
5
5
  /**
6
6
  * Fim efetivo da vigência: o maior entre o fim do Contrato e o fim dos Aditivos
7
- * ativos (no genc o Aditivo estende a vigência). null = Contrato em aberto.
8
- * Ver GD-715.
7
+ * ativos (o Aditivo estende a vigência). null = Contrato em aberto, mesmo com
8
+ * Aditivo. Ver GD-715.
9
9
  */
10
10
  export declare const getContractEndDate: (contract: ContractLike) => Date | null;
11
+ /**
12
+ * Início do dia seguinte ao fim da vigência em São Paulo (limite exclusivo):
13
+ * o último dia conta inteiro. null = Contrato em aberto.
14
+ */
15
+ export declare const getContractEndExclusive: (contract: ContractLike) => Date | null;
11
16
  /**
12
17
  * Meses de vigência como no genc (`calcular_meses_vigencia`): anos × 12 + meses
13
18
  * e mais 1 se sobrar dia; mínimo 1. Sem fim, conta até `now`.
14
- * Ex.: 10/06/2025 a 11/02/2027 = 21.
19
+ * Ex.: 10/06/2025 a 11/02/2027 = 21; 15/03 a 14/09 = 6.
15
20
  */
16
21
  export declare const getContractMonths: (contract: ContractLike, now?: Date) => number;
17
22
  /** Cachê + produtos + Aditivos ativos. */
@@ -20,7 +25,7 @@ export declare const getContractTotalValue: (contract: ContractLike) => number;
20
25
  export declare const getContractMonthlyValue: (contract: ContractLike, now?: Date) => number;
21
26
  /** Ativo e assinado (status ausente conta como assinado). */
22
27
  export declare const isContractUsable: (contract: ContractLike) => boolean;
23
- /** Contrato usável do cliente vigente em `at` (sem fim = em aberto). */
28
+ /** Contrato usável do cliente vigente em `at`, com o último dia inteiro (sem fim = em aberto). */
24
29
  export declare const getActiveContract: <T extends ContractLike>(influencer: {
25
30
  contracts?: T[] | null | undefined;
26
31
  } | null | undefined, clientId: unknown, at: Date) => T | null;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getActiveContract = exports.isContractUsable = exports.getContractMonthlyValue = exports.getContractTotalValue = exports.getContractMonths = exports.getContractEndDate = void 0;
3
+ exports.getActiveContract = exports.isContractUsable = exports.getContractMonthlyValue = exports.getContractTotalValue = exports.getContractMonths = exports.getContractEndExclusive = exports.getContractEndDate = void 0;
4
4
  const BRT_OFFSET_MS = 3 * 60 * 60 * 1000;
5
5
  /** Data no calendário de São Paulo (as datas do Flow são gravadas à meia-noite BRT = 03:00Z). */
6
6
  const brtParts = (date) => {
@@ -11,31 +11,41 @@ const round2 = (value) => Math.round(value * 100) / 100;
11
11
  const activeAmendments = (contract) => (contract.amendments ?? []).filter((amendment) => amendment.isActive !== false);
12
12
  /**
13
13
  * Fim efetivo da vigência: o maior entre o fim do Contrato e o fim dos Aditivos
14
- * ativos (no genc o Aditivo estende a vigência). null = Contrato em aberto.
15
- * Ver GD-715.
14
+ * ativos (o Aditivo estende a vigência). null = Contrato em aberto, mesmo com
15
+ * Aditivo. Ver GD-715.
16
16
  */
17
17
  const getContractEndDate = (contract) => {
18
+ if (!contract.endDate)
19
+ return null;
18
20
  const ends = [contract.endDate, ...activeAmendments(contract).map((a) => a.endDate)]
19
21
  .filter((date) => !!date)
20
22
  .map((date) => new Date(date).getTime());
21
- return ends.length > 0 ? new Date(Math.max(...ends)) : null;
23
+ return new Date(Math.max(...ends));
22
24
  };
23
25
  exports.getContractEndDate = getContractEndDate;
26
+ /**
27
+ * Início do dia seguinte ao fim da vigência em São Paulo (limite exclusivo):
28
+ * o último dia conta inteiro. null = Contrato em aberto.
29
+ */
30
+ const getContractEndExclusive = (contract) => {
31
+ const end = (0, exports.getContractEndDate)(contract);
32
+ if (!end)
33
+ return null;
34
+ const { year, month, day } = brtParts(end);
35
+ return new Date(Date.UTC(year, month - 1, day + 1) + BRT_OFFSET_MS);
36
+ };
37
+ exports.getContractEndExclusive = getContractEndExclusive;
24
38
  /**
25
39
  * Meses de vigência como no genc (`calcular_meses_vigencia`): anos × 12 + meses
26
40
  * e mais 1 se sobrar dia; mínimo 1. Sem fim, conta até `now`.
27
- * Ex.: 10/06/2025 a 11/02/2027 = 21.
41
+ * Ex.: 10/06/2025 a 11/02/2027 = 21; 15/03 a 14/09 = 6.
28
42
  */
29
43
  const getContractMonths = (contract, now = new Date()) => {
30
44
  const start = brtParts(contract.startDate);
31
45
  const end = brtParts((0, exports.getContractEndDate)(contract) ?? now);
32
46
  let months = (end.year - start.year) * 12 + (end.month - start.month);
33
- let days = end.day - start.day;
34
- if (days < 0) {
35
- months -= 1;
36
- days += 1; // relativedelta: só importa se sobrou dia
37
- }
38
- if (days > 0)
47
+ // relativedelta: dia do fim menor tira um mês e sobra dia, que devolve o mês.
48
+ if (end.day > start.day)
39
49
  months += 1;
40
50
  return Math.max(1, months);
41
51
  };
@@ -51,7 +61,7 @@ exports.getContractMonthlyValue = getContractMonthlyValue;
51
61
  /** Ativo e assinado (status ausente conta como assinado). */
52
62
  const isContractUsable = (contract) => contract.isActive !== false && (contract.status ?? 'signed') === 'signed';
53
63
  exports.isContractUsable = isContractUsable;
54
- /** Contrato usável do cliente vigente em `at` (sem fim = em aberto). */
64
+ /** Contrato usável do cliente vigente em `at`, com o último dia inteiro (sem fim = em aberto). */
55
65
  const getActiveContract = (influencer, clientId, at) => {
56
66
  const time = new Date(at).getTime();
57
67
  return ((influencer?.contracts ?? []).find((contract) => {
@@ -60,8 +70,8 @@ const getActiveContract = (influencer, clientId, at) => {
60
70
  const client = contract.client?._id ?? contract.client;
61
71
  if (String(client) !== String(clientId))
62
72
  return false;
63
- const end = (0, exports.getContractEndDate)(contract);
64
- return new Date(contract.startDate).getTime() <= time && (!end || time <= end.getTime());
73
+ const end = (0, exports.getContractEndExclusive)(contract);
74
+ return new Date(contract.startDate).getTime() <= time && (!end || time < end.getTime());
65
75
  }) ?? null);
66
76
  };
67
77
  exports.getActiveContract = getActiveContract;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grapadigital/shared-schemas",
3
- "version": "1.0.228",
3
+ "version": "1.0.231",
4
4
  "description": "Shared Mongoose Schemas",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -38,5 +38,3 @@ export * from './schemas/snapshot.schema';
38
38
  export * from './schemas/lead.schema';
39
39
  export * from './utils/normalize-content-link.util';
40
40
  export * from './utils/merge-metrics-breakdown.util';
41
- export * from './utils/influencer-contract.util';
42
- export * from './utils/cost-per-impact.util';
@@ -32,6 +32,7 @@ export const CLIENT_GOAL_METRIC_VALUES = [
32
32
  'engagementCount',
33
33
  'engagementRate',
34
34
  'reachEngagementRate',
35
+ 'followersEngagementRate',
35
36
  'qualifiedEngagementRate',
36
37
  'effectiveReach',
37
38
  'earnedMediaValue',
@@ -56,6 +57,7 @@ export const CLIENT_GOAL_METRIC_UNITS: Record<ClientGoalMetric, ClientGoalMetric
56
57
  engagementCount: 'count',
57
58
  engagementRate: 'rate',
58
59
  reachEngagementRate: 'rate',
60
+ followersEngagementRate: 'rate',
59
61
  qualifiedEngagementRate: 'rate',
60
62
  effectiveReach: 'count',
61
63
  earnedMediaValue: 'currency',
@@ -177,6 +177,19 @@ export class Metrics {
177
177
  @Prop({ type: Number, required: false, default: null })
178
178
  followersEngagementRate?: number | null;
179
179
 
180
+ /**
181
+ * Engajamento ponderado do Real E/R (pesos do genc): curtidas×1 + comentários
182
+ * curados×2 + compartilhamentos×3 + salvos×3 + respostas×2 + cliques×6 +
183
+ * toques×1. Comentário curado = alvo marca ou conteúdo, fora de spam. null em
184
+ * story, sem GenIA ou sem comentários classificados. Ver GD-720.
185
+ */
186
+ @Prop({ type: Number, required: false, default: null })
187
+ curatedEngagementCount?: number | null;
188
+
189
+ /** Real E/R: curatedEngagementCount ÷ potentialReach (fração). Ver GD-720. */
190
+ @Prop({ type: Number, required: false, default: null })
191
+ curatedEngagementRate?: number | null;
192
+
180
193
  /** Nome do Client.tiers pelo potentialReach do post; null sem Tiers ou fora das faixas. Ver GD-703. */
181
194
  @Prop({ type: String, required: false, default: null })
182
195
  tier?: string | null;
@@ -325,18 +338,19 @@ export class ContentMetricsHistoryEntry {
325
338
  export const ContentMetricsHistoryEntrySchema = SchemaFactory.createForClass(ContentMetricsHistoryEntry);
326
339
 
327
340
  /** Métricas estimadas pelo porte do perfil, para quando a rede não informa. Ver GD-751. */
341
+ // null é valor válido (sem estimativa): required recusaria null no create.
328
342
  @Schema({ _id: false })
329
343
  export class EstimatedMetrics {
330
- @Prop({ type: Number, required: true, default: null })
344
+ @Prop({ type: Number, required: false, default: null })
331
345
  impressionCount?: number | null;
332
346
 
333
- @Prop({ type: Number, required: true, default: null })
347
+ @Prop({ type: Number, required: false, default: null })
334
348
  reachCount?: number | null;
335
349
 
336
- @Prop({ type: Number, required: true, default: null })
350
+ @Prop({ type: Number, required: false, default: null })
337
351
  engagementCount?: number | null;
338
352
 
339
- @Prop({ type: Number, required: true, default: null })
353
+ @Prop({ type: Number, required: false, default: null })
340
354
  engagementRate?: number | null;
341
355
  }
342
356
 
@@ -1,99 +0,0 @@
1
- import { getContractMonthlyValue } from './influencer-contract.util';
2
- import { InfluencerContract } from '../schemas/influencer.schema';
3
-
4
- /**
5
- * CPI (Custo por Impacto), como no genc (`TB_Contratos_Influencer_Cliente` e
6
- * `TB_CPI_Mensal_Contrato`). Ver GD-715.
7
- * CPI = valor ÷ ((E/R% ÷ 100) × impressões × posts), E/R% = engajamento ÷ alcance potencial × 100.
8
- */
9
-
10
- const round = (value: number, places: number) => Math.round(value * 10 ** places) / 10 ** places;
11
-
12
- export const getEngagementRatePercent = (totals: { engagement: number; potentialReach: number }): number | null =>
13
- totals.potentialReach > 0 ? (totals.engagement / totals.potentialReach) * 100 : null;
14
-
15
- export const getCostPerImpact = (input: {
16
- value: number;
17
- engagementRatePercent: number | null;
18
- impressions: number;
19
- posts: number;
20
- }): number | null => {
21
- const { value, engagementRatePercent, impressions, posts } = input;
22
- if (!engagementRatePercent || engagementRatePercent <= 0 || value <= 0) return null;
23
- const divisor = (engagementRatePercent / 100) * impressions * posts;
24
- return divisor > 0 ? round(value / divisor, 6) : null;
25
- };
26
-
27
- export interface ContractMonthTotals {
28
- year: number;
29
- month: number;
30
- posts: number;
31
- impressions: number;
32
- engagement: number;
33
- potentialReach: number;
34
- }
35
-
36
- export interface MonthlyCostPerImpact extends ContractMonthTotals {
37
- monthlyValue: number;
38
- monthlyCostPerImpact: number | null;
39
- engagementRatePercent: number | null;
40
- elapsedMonths: number;
41
- accumulatedSpend: number;
42
- }
43
-
44
- /**
45
- * Série mensal acumulada desde o mês de início até `untilMonth` (inclusive):
46
- * gasto acumulado = valor mensal × meses decorridos; métricas somadas desde o
47
- * início. `monthlyTotals` traz os totais de cada mês (não acumulados).
48
- */
49
- export const getMonthlyCostPerImpactSeries = (
50
- contract: Pick<InfluencerContract, 'value' | 'productsValue' | 'startDate' | 'endDate' | 'amendments' | 'status' | 'isActive'>,
51
- monthlyTotals: ContractMonthTotals[],
52
- until: { year: number; month: number },
53
- ): MonthlyCostPerImpact[] => {
54
- const brt = new Date(new Date(contract.startDate).getTime() - 3 * 60 * 60 * 1000);
55
- let year = brt.getUTCFullYear();
56
- let month = brt.getUTCMonth() + 1;
57
- const monthlyValue = getContractMonthlyValue(contract);
58
- const byMonth = new Map(monthlyTotals.map((t) => [`${t.year}-${t.month}`, t]));
59
- const acc = { posts: 0, impressions: 0, engagement: 0, potentialReach: 0 };
60
- const series: MonthlyCostPerImpact[] = [];
61
- let elapsedMonths = 0;
62
- while (year < until.year || (year === until.year && month <= until.month)) {
63
- elapsedMonths += 1;
64
- const totals = byMonth.get(`${year}-${month}`);
65
- if (totals) {
66
- acc.posts += totals.posts;
67
- acc.impressions += totals.impressions;
68
- acc.engagement += totals.engagement;
69
- acc.potentialReach += totals.potentialReach;
70
- }
71
- const accumulatedSpend = round(monthlyValue * elapsedMonths, 2);
72
- const engagementRatePercent = acc.posts > 0 ? getEngagementRatePercent(acc) : null;
73
- series.push({
74
- year,
75
- month,
76
- ...acc,
77
- monthlyValue,
78
- elapsedMonths,
79
- accumulatedSpend,
80
- engagementRatePercent: engagementRatePercent == null ? null : round(engagementRatePercent, 4),
81
- monthlyCostPerImpact:
82
- acc.posts > 0 && acc.engagement > 0
83
- ? getCostPerImpact({ value: accumulatedSpend, engagementRatePercent, impressions: acc.impressions, posts: acc.posts })
84
- : null,
85
- });
86
- month += 1;
87
- if (month > 12) {
88
- month = 1;
89
- year += 1;
90
- }
91
- }
92
- return series;
93
- };
94
-
95
- /** Média dos CPIs mensais não nulos. */
96
- export const getAverageMonthlyCostPerImpact = (series: MonthlyCostPerImpact[]): number | null => {
97
- const values = series.map((row) => row.monthlyCostPerImpact).filter((v): v is number => v != null);
98
- return values.length > 0 ? round(values.reduce((a, b) => a + b, 0) / values.length, 6) : null;
99
- };
@@ -1,83 +0,0 @@
1
- import { InfluencerContract } from '../schemas/influencer.schema';
2
-
3
- type ContractLike = Pick<
4
- InfluencerContract,
5
- 'value' | 'productsValue' | 'startDate' | 'endDate' | 'amendments' | 'status' | 'isActive'
6
- > & { client?: unknown };
7
-
8
- const BRT_OFFSET_MS = 3 * 60 * 60 * 1000;
9
-
10
- /** Data no calendário de São Paulo (as datas do Flow são gravadas à meia-noite BRT = 03:00Z). */
11
- const brtParts = (date: Date) => {
12
- const d = new Date(new Date(date).getTime() - BRT_OFFSET_MS);
13
- return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
14
- };
15
-
16
- const round2 = (value: number) => Math.round(value * 100) / 100;
17
-
18
- const activeAmendments = (contract: ContractLike) =>
19
- (contract.amendments ?? []).filter((amendment) => amendment.isActive !== false);
20
-
21
- /**
22
- * Fim efetivo da vigência: o maior entre o fim do Contrato e o fim dos Aditivos
23
- * ativos (no genc o Aditivo estende a vigência). null = Contrato em aberto.
24
- * Ver GD-715.
25
- */
26
- export const getContractEndDate = (contract: ContractLike): Date | null => {
27
- const ends = [contract.endDate, ...activeAmendments(contract).map((a) => a.endDate)]
28
- .filter((date): date is Date => !!date)
29
- .map((date) => new Date(date).getTime());
30
- return ends.length > 0 ? new Date(Math.max(...ends)) : null;
31
- };
32
-
33
- /**
34
- * Meses de vigência como no genc (`calcular_meses_vigencia`): anos × 12 + meses
35
- * e mais 1 se sobrar dia; mínimo 1. Sem fim, conta até `now`.
36
- * Ex.: 10/06/2025 a 11/02/2027 = 21.
37
- */
38
- export const getContractMonths = (contract: ContractLike, now: Date = new Date()): number => {
39
- const start = brtParts(contract.startDate);
40
- const end = brtParts(getContractEndDate(contract) ?? now);
41
- let months = (end.year - start.year) * 12 + (end.month - start.month);
42
- let days = end.day - start.day;
43
- if (days < 0) {
44
- months -= 1;
45
- days += 1; // relativedelta: só importa se sobrou dia
46
- }
47
- if (days > 0) months += 1;
48
- return Math.max(1, months);
49
- };
50
-
51
- /** Cachê + produtos + Aditivos ativos. */
52
- export const getContractTotalValue = (contract: ContractLike): number =>
53
- round2(
54
- (contract.value ?? 0) +
55
- (contract.productsValue ?? 0) +
56
- activeAmendments(contract).reduce((sum, a) => sum + (a.value ?? 0), 0),
57
- );
58
-
59
- /** Total ÷ meses de vigência, 2 casas (428.392,93 ÷ 21 = 20.399,66). */
60
- export const getContractMonthlyValue = (contract: ContractLike, now?: Date): number =>
61
- round2(getContractTotalValue(contract) / getContractMonths(contract, now));
62
-
63
- /** Ativo e assinado (status ausente conta como assinado). */
64
- export const isContractUsable = (contract: ContractLike): boolean =>
65
- contract.isActive !== false && (contract.status ?? 'signed') === 'signed';
66
-
67
- /** Contrato usável do cliente vigente em `at` (sem fim = em aberto). */
68
- export const getActiveContract = <T extends ContractLike>(
69
- influencer: { contracts?: T[] | null } | null | undefined,
70
- clientId: unknown,
71
- at: Date,
72
- ): T | null => {
73
- const time = new Date(at).getTime();
74
- return (
75
- (influencer?.contracts ?? []).find((contract) => {
76
- if (!isContractUsable(contract)) return false;
77
- const client = (contract.client as { _id?: unknown })?._id ?? contract.client;
78
- if (String(client) !== String(clientId)) return false;
79
- const end = getContractEndDate(contract);
80
- return new Date(contract.startDate).getTime() <= time && (!end || time <= end.getTime());
81
- }) ?? null
82
- );
83
- };