@grapadigital/shared-schemas 1.0.227 → 1.0.228
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 +2 -0
- package/dist/index.js +2 -0
- package/dist/schemas/influencer.schema.d.ts +45 -0
- package/dist/schemas/influencer.schema.js +75 -1
- package/dist/utils/cost-per-impact.util.d.ts +37 -0
- package/dist/utils/cost-per-impact.util.js +72 -0
- package/dist/utils/influencer-contract.util.d.ts +27 -0
- package/dist/utils/influencer-contract.util.js +67 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/schemas/influencer.schema.ts +70 -0
- package/src/utils/cost-per-impact.util.ts +99 -0
- package/src/utils/influencer-contract.util.ts +83 -0
package/dist/index.d.ts
CHANGED
|
@@ -38,3 +38,5 @@ 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,3 +59,5 @@ __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);
|
|
@@ -62,15 +62,60 @@ export declare const NoteSchema: MongooseSchema<Note, import("mongoose").Model<N
|
|
|
62
62
|
} & {
|
|
63
63
|
__v: number;
|
|
64
64
|
}>;
|
|
65
|
+
/** Status do Contrato do Influenciador. Ver GD-715. */
|
|
66
|
+
export declare const CONTRACT_STATUS_VALUES: readonly ["draft", "proposed", "signed", "rejected"];
|
|
67
|
+
export type ContractStatus = (typeof CONTRACT_STATUS_VALUES)[number];
|
|
68
|
+
/**
|
|
69
|
+
* Aditivo do Contrato: soma no valor total quando ativo e pode estender a
|
|
70
|
+
* vigência (o fim do Contrato vai até o fim do último Aditivo ativo). Não tem
|
|
71
|
+
* ligação com Ações. Ver GD-715.
|
|
72
|
+
*/
|
|
73
|
+
export declare class ContractAmendment {
|
|
74
|
+
_id?: Types.ObjectId;
|
|
75
|
+
value: number;
|
|
76
|
+
isActive?: boolean;
|
|
77
|
+
startDate: Date;
|
|
78
|
+
endDate: Date;
|
|
79
|
+
description?: string | null;
|
|
80
|
+
}
|
|
81
|
+
export declare const ContractAmendmentSchema: MongooseSchema<ContractAmendment, import("mongoose").Model<ContractAmendment, any, any, any, import("mongoose").Document<unknown, any, ContractAmendment, any, {}> & ContractAmendment & Required<{
|
|
82
|
+
_id: Types.ObjectId;
|
|
83
|
+
}> & {
|
|
84
|
+
__v: number;
|
|
85
|
+
}, any>, {}, {}, {}, {}, import("mongoose").DefaultSchemaOptions, ContractAmendment, import("mongoose").Document<unknown, {}, import("mongoose").FlatRecord<ContractAmendment>, {}, import("mongoose").ResolveSchemaOptions<import("mongoose").DefaultSchemaOptions>> & import("mongoose").FlatRecord<ContractAmendment> & Required<{
|
|
86
|
+
_id: Types.ObjectId;
|
|
87
|
+
}> & {
|
|
88
|
+
__v: number;
|
|
89
|
+
}>;
|
|
65
90
|
export declare class InfluencerContract {
|
|
66
91
|
_id?: Types.ObjectId;
|
|
67
92
|
client: Types.ObjectId | Client;
|
|
93
|
+
/** Cachê total do Contrato (o `valor_cache` do genc). Ver GD-715. */
|
|
68
94
|
value: number;
|
|
95
|
+
/** Valor em produtos; entra no total. Ver GD-715. */
|
|
96
|
+
productsValue?: number;
|
|
97
|
+
status?: ContractStatus;
|
|
98
|
+
/** Desligado = Contrato encerrado. */
|
|
99
|
+
isActive?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Rateio do custo: monthly = valor mensal (total ÷ meses) por mês-calendário;
|
|
102
|
+
* annual = valor total no período.
|
|
103
|
+
*/
|
|
69
104
|
periodType: 'monthly' | 'annual';
|
|
70
105
|
startDate: Date;
|
|
71
106
|
endDate?: Date;
|
|
72
107
|
deliveryScope?: Delivery;
|
|
73
108
|
documentUrl?: string;
|
|
109
|
+
/** CPI (Custo por Impacto) contratual, digitado no planejamento. Ver GD-715. */
|
|
110
|
+
contractedCostPerImpact?: number | null;
|
|
111
|
+
/** E/R contratual, em fração (0,02 = 2%). */
|
|
112
|
+
contractedEngagementRate?: number | null;
|
|
113
|
+
/** Ex.: "Team Galaxy", "Content Creator", "Tech". */
|
|
114
|
+
squad?: string | null;
|
|
115
|
+
/** Ex.: "Technology", "Camera", "Fashion". */
|
|
116
|
+
passionPoint?: string | null;
|
|
117
|
+
description?: string | null;
|
|
118
|
+
amendments?: ContractAmendment[];
|
|
74
119
|
}
|
|
75
120
|
export declare const InfluencerContractSchema: MongooseSchema<InfluencerContract, import("mongoose").Model<InfluencerContract, any, any, any, import("mongoose").Document<unknown, any, InfluencerContract, any, {}> & InfluencerContract & Required<{
|
|
76
121
|
_id: Types.ObjectId;
|
|
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
9
9
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.InfluencerModel = exports.InfluencerSchema = exports.Influencer = exports.InfluencerContractSchema = exports.InfluencerContract = exports.NoteSchema = exports.Note = exports.AdvisorSchema = exports.Advisor = void 0;
|
|
12
|
+
exports.InfluencerModel = exports.InfluencerSchema = exports.Influencer = exports.InfluencerContractSchema = exports.InfluencerContract = exports.ContractAmendmentSchema = exports.ContractAmendment = exports.CONTRACT_STATUS_VALUES = exports.NoteSchema = exports.Note = exports.AdvisorSchema = exports.Advisor = void 0;
|
|
13
13
|
const mongoose_1 = require("@nestjs/mongoose");
|
|
14
14
|
const mongoose_2 = require("mongoose");
|
|
15
15
|
const party_schema_1 = require("./party.schema");
|
|
@@ -72,6 +72,44 @@ Note = __decorate([
|
|
|
72
72
|
], Note);
|
|
73
73
|
exports.Note = Note;
|
|
74
74
|
exports.NoteSchema = mongoose_1.SchemaFactory.createForClass(Note);
|
|
75
|
+
/** Status do Contrato do Influenciador. Ver GD-715. */
|
|
76
|
+
exports.CONTRACT_STATUS_VALUES = ['draft', 'proposed', 'signed', 'rejected'];
|
|
77
|
+
/**
|
|
78
|
+
* Aditivo do Contrato: soma no valor total quando ativo e pode estender a
|
|
79
|
+
* vigência (o fim do Contrato vai até o fim do último Aditivo ativo). Não tem
|
|
80
|
+
* ligação com Ações. Ver GD-715.
|
|
81
|
+
*/
|
|
82
|
+
let ContractAmendment = class ContractAmendment {
|
|
83
|
+
};
|
|
84
|
+
__decorate([
|
|
85
|
+
(0, mongoose_1.Prop)({ type: mongoose_2.Schema.Types.ObjectId, auto: true, required: false }),
|
|
86
|
+
__metadata("design:type", mongoose_2.Types.ObjectId)
|
|
87
|
+
], ContractAmendment.prototype, "_id", void 0);
|
|
88
|
+
__decorate([
|
|
89
|
+
(0, mongoose_1.Prop)({ type: Number, required: true }),
|
|
90
|
+
__metadata("design:type", Number)
|
|
91
|
+
], ContractAmendment.prototype, "value", void 0);
|
|
92
|
+
__decorate([
|
|
93
|
+
(0, mongoose_1.Prop)({ type: Boolean, required: false, default: true }),
|
|
94
|
+
__metadata("design:type", Boolean)
|
|
95
|
+
], ContractAmendment.prototype, "isActive", void 0);
|
|
96
|
+
__decorate([
|
|
97
|
+
(0, mongoose_1.Prop)({ type: Date, required: true }),
|
|
98
|
+
__metadata("design:type", Date)
|
|
99
|
+
], ContractAmendment.prototype, "startDate", void 0);
|
|
100
|
+
__decorate([
|
|
101
|
+
(0, mongoose_1.Prop)({ type: Date, required: true }),
|
|
102
|
+
__metadata("design:type", Date)
|
|
103
|
+
], ContractAmendment.prototype, "endDate", void 0);
|
|
104
|
+
__decorate([
|
|
105
|
+
(0, mongoose_1.Prop)({ type: String, required: false, default: null }),
|
|
106
|
+
__metadata("design:type", Object)
|
|
107
|
+
], ContractAmendment.prototype, "description", void 0);
|
|
108
|
+
ContractAmendment = __decorate([
|
|
109
|
+
(0, mongoose_1.Schema)({ timestamps: true })
|
|
110
|
+
], ContractAmendment);
|
|
111
|
+
exports.ContractAmendment = ContractAmendment;
|
|
112
|
+
exports.ContractAmendmentSchema = mongoose_1.SchemaFactory.createForClass(ContractAmendment);
|
|
75
113
|
// Contrato de valor fixo recorrente (influenciador embaixador de marca) —
|
|
76
114
|
// alternativa ao pagamento avulso por Recruitment (scope.payment/advisorPayment).
|
|
77
115
|
// Escopo de entregas reaproveita a mesma classe Delivery do termo de aceite
|
|
@@ -90,6 +128,18 @@ __decorate([
|
|
|
90
128
|
(0, mongoose_1.Prop)({ type: Number, required: true }),
|
|
91
129
|
__metadata("design:type", Number)
|
|
92
130
|
], InfluencerContract.prototype, "value", void 0);
|
|
131
|
+
__decorate([
|
|
132
|
+
(0, mongoose_1.Prop)({ type: Number, required: false, default: 0 }),
|
|
133
|
+
__metadata("design:type", Number)
|
|
134
|
+
], InfluencerContract.prototype, "productsValue", void 0);
|
|
135
|
+
__decorate([
|
|
136
|
+
(0, mongoose_1.Prop)({ type: String, enum: exports.CONTRACT_STATUS_VALUES, required: false, default: 'signed' }),
|
|
137
|
+
__metadata("design:type", String)
|
|
138
|
+
], InfluencerContract.prototype, "status", void 0);
|
|
139
|
+
__decorate([
|
|
140
|
+
(0, mongoose_1.Prop)({ type: Boolean, required: false, default: true }),
|
|
141
|
+
__metadata("design:type", Boolean)
|
|
142
|
+
], InfluencerContract.prototype, "isActive", void 0);
|
|
93
143
|
__decorate([
|
|
94
144
|
(0, mongoose_1.Prop)({ type: String, enum: ['monthly', 'annual'], required: true }),
|
|
95
145
|
__metadata("design:type", String)
|
|
@@ -110,6 +160,30 @@ __decorate([
|
|
|
110
160
|
(0, mongoose_1.Prop)({ type: String, required: false }),
|
|
111
161
|
__metadata("design:type", String)
|
|
112
162
|
], InfluencerContract.prototype, "documentUrl", void 0);
|
|
163
|
+
__decorate([
|
|
164
|
+
(0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
|
|
165
|
+
__metadata("design:type", Object)
|
|
166
|
+
], InfluencerContract.prototype, "contractedCostPerImpact", void 0);
|
|
167
|
+
__decorate([
|
|
168
|
+
(0, mongoose_1.Prop)({ type: Number, required: false, default: null }),
|
|
169
|
+
__metadata("design:type", Object)
|
|
170
|
+
], InfluencerContract.prototype, "contractedEngagementRate", void 0);
|
|
171
|
+
__decorate([
|
|
172
|
+
(0, mongoose_1.Prop)({ type: String, required: false, default: null }),
|
|
173
|
+
__metadata("design:type", Object)
|
|
174
|
+
], InfluencerContract.prototype, "squad", void 0);
|
|
175
|
+
__decorate([
|
|
176
|
+
(0, mongoose_1.Prop)({ type: String, required: false, default: null }),
|
|
177
|
+
__metadata("design:type", Object)
|
|
178
|
+
], InfluencerContract.prototype, "passionPoint", void 0);
|
|
179
|
+
__decorate([
|
|
180
|
+
(0, mongoose_1.Prop)({ type: String, required: false, default: null }),
|
|
181
|
+
__metadata("design:type", Object)
|
|
182
|
+
], InfluencerContract.prototype, "description", void 0);
|
|
183
|
+
__decorate([
|
|
184
|
+
(0, mongoose_1.Prop)({ type: [exports.ContractAmendmentSchema], required: false, default: [] }),
|
|
185
|
+
__metadata("design:type", Array)
|
|
186
|
+
], InfluencerContract.prototype, "amendments", void 0);
|
|
113
187
|
InfluencerContract = __decorate([
|
|
114
188
|
(0, mongoose_1.Schema)({ timestamps: true })
|
|
115
189
|
], InfluencerContract);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { InfluencerContract } from '../schemas/influencer.schema';
|
|
2
|
+
export declare const getEngagementRatePercent: (totals: {
|
|
3
|
+
engagement: number;
|
|
4
|
+
potentialReach: number;
|
|
5
|
+
}) => number | null;
|
|
6
|
+
export declare const getCostPerImpact: (input: {
|
|
7
|
+
value: number;
|
|
8
|
+
engagementRatePercent: number | null;
|
|
9
|
+
impressions: number;
|
|
10
|
+
posts: number;
|
|
11
|
+
}) => number | null;
|
|
12
|
+
export interface ContractMonthTotals {
|
|
13
|
+
year: number;
|
|
14
|
+
month: number;
|
|
15
|
+
posts: number;
|
|
16
|
+
impressions: number;
|
|
17
|
+
engagement: number;
|
|
18
|
+
potentialReach: number;
|
|
19
|
+
}
|
|
20
|
+
export interface MonthlyCostPerImpact extends ContractMonthTotals {
|
|
21
|
+
monthlyValue: number;
|
|
22
|
+
monthlyCostPerImpact: number | null;
|
|
23
|
+
engagementRatePercent: number | null;
|
|
24
|
+
elapsedMonths: number;
|
|
25
|
+
accumulatedSpend: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Série mensal acumulada desde o mês de início até `untilMonth` (inclusive):
|
|
29
|
+
* gasto acumulado = valor mensal × meses decorridos; métricas somadas desde o
|
|
30
|
+
* início. `monthlyTotals` traz os totais de cada mês (não acumulados).
|
|
31
|
+
*/
|
|
32
|
+
export declare const getMonthlyCostPerImpactSeries: (contract: Pick<InfluencerContract, 'value' | 'productsValue' | 'startDate' | 'endDate' | 'amendments' | 'status' | 'isActive'>, monthlyTotals: ContractMonthTotals[], until: {
|
|
33
|
+
year: number;
|
|
34
|
+
month: number;
|
|
35
|
+
}) => MonthlyCostPerImpact[];
|
|
36
|
+
/** Média dos CPIs mensais não nulos. */
|
|
37
|
+
export declare const getAverageMonthlyCostPerImpact: (series: MonthlyCostPerImpact[]) => number | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getAverageMonthlyCostPerImpact = exports.getMonthlyCostPerImpactSeries = exports.getCostPerImpact = exports.getEngagementRatePercent = void 0;
|
|
4
|
+
const influencer_contract_util_1 = require("./influencer-contract.util");
|
|
5
|
+
/**
|
|
6
|
+
* CPI (Custo por Impacto), como no genc (`TB_Contratos_Influencer_Cliente` e
|
|
7
|
+
* `TB_CPI_Mensal_Contrato`). Ver GD-715.
|
|
8
|
+
* CPI = valor ÷ ((E/R% ÷ 100) × impressões × posts), E/R% = engajamento ÷ alcance potencial × 100.
|
|
9
|
+
*/
|
|
10
|
+
const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
|
|
11
|
+
const getEngagementRatePercent = (totals) => totals.potentialReach > 0 ? (totals.engagement / totals.potentialReach) * 100 : null;
|
|
12
|
+
exports.getEngagementRatePercent = getEngagementRatePercent;
|
|
13
|
+
const getCostPerImpact = (input) => {
|
|
14
|
+
const { value, engagementRatePercent, impressions, posts } = input;
|
|
15
|
+
if (!engagementRatePercent || engagementRatePercent <= 0 || value <= 0)
|
|
16
|
+
return null;
|
|
17
|
+
const divisor = (engagementRatePercent / 100) * impressions * posts;
|
|
18
|
+
return divisor > 0 ? round(value / divisor, 6) : null;
|
|
19
|
+
};
|
|
20
|
+
exports.getCostPerImpact = getCostPerImpact;
|
|
21
|
+
/**
|
|
22
|
+
* Série mensal acumulada desde o mês de início até `untilMonth` (inclusive):
|
|
23
|
+
* gasto acumulado = valor mensal × meses decorridos; métricas somadas desde o
|
|
24
|
+
* início. `monthlyTotals` traz os totais de cada mês (não acumulados).
|
|
25
|
+
*/
|
|
26
|
+
const getMonthlyCostPerImpactSeries = (contract, monthlyTotals, until) => {
|
|
27
|
+
const brt = new Date(new Date(contract.startDate).getTime() - 3 * 60 * 60 * 1000);
|
|
28
|
+
let year = brt.getUTCFullYear();
|
|
29
|
+
let month = brt.getUTCMonth() + 1;
|
|
30
|
+
const monthlyValue = (0, influencer_contract_util_1.getContractMonthlyValue)(contract);
|
|
31
|
+
const byMonth = new Map(monthlyTotals.map((t) => [`${t.year}-${t.month}`, t]));
|
|
32
|
+
const acc = { posts: 0, impressions: 0, engagement: 0, potentialReach: 0 };
|
|
33
|
+
const series = [];
|
|
34
|
+
let elapsedMonths = 0;
|
|
35
|
+
while (year < until.year || (year === until.year && month <= until.month)) {
|
|
36
|
+
elapsedMonths += 1;
|
|
37
|
+
const totals = byMonth.get(`${year}-${month}`);
|
|
38
|
+
if (totals) {
|
|
39
|
+
acc.posts += totals.posts;
|
|
40
|
+
acc.impressions += totals.impressions;
|
|
41
|
+
acc.engagement += totals.engagement;
|
|
42
|
+
acc.potentialReach += totals.potentialReach;
|
|
43
|
+
}
|
|
44
|
+
const accumulatedSpend = round(monthlyValue * elapsedMonths, 2);
|
|
45
|
+
const engagementRatePercent = acc.posts > 0 ? (0, exports.getEngagementRatePercent)(acc) : null;
|
|
46
|
+
series.push({
|
|
47
|
+
year,
|
|
48
|
+
month,
|
|
49
|
+
...acc,
|
|
50
|
+
monthlyValue,
|
|
51
|
+
elapsedMonths,
|
|
52
|
+
accumulatedSpend,
|
|
53
|
+
engagementRatePercent: engagementRatePercent == null ? null : round(engagementRatePercent, 4),
|
|
54
|
+
monthlyCostPerImpact: acc.posts > 0 && acc.engagement > 0
|
|
55
|
+
? (0, exports.getCostPerImpact)({ value: accumulatedSpend, engagementRatePercent, impressions: acc.impressions, posts: acc.posts })
|
|
56
|
+
: null,
|
|
57
|
+
});
|
|
58
|
+
month += 1;
|
|
59
|
+
if (month > 12) {
|
|
60
|
+
month = 1;
|
|
61
|
+
year += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return series;
|
|
65
|
+
};
|
|
66
|
+
exports.getMonthlyCostPerImpactSeries = getMonthlyCostPerImpactSeries;
|
|
67
|
+
/** Média dos CPIs mensais não nulos. */
|
|
68
|
+
const getAverageMonthlyCostPerImpact = (series) => {
|
|
69
|
+
const values = series.map((row) => row.monthlyCostPerImpact).filter((v) => v != null);
|
|
70
|
+
return values.length > 0 ? round(values.reduce((a, b) => a + b, 0) / values.length, 6) : null;
|
|
71
|
+
};
|
|
72
|
+
exports.getAverageMonthlyCostPerImpact = getAverageMonthlyCostPerImpact;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { InfluencerContract } from '../schemas/influencer.schema';
|
|
2
|
+
type ContractLike = Pick<InfluencerContract, 'value' | 'productsValue' | 'startDate' | 'endDate' | 'amendments' | 'status' | 'isActive'> & {
|
|
3
|
+
client?: unknown;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export declare const getContractEndDate: (contract: ContractLike) => Date | null;
|
|
11
|
+
/**
|
|
12
|
+
* Meses de vigência como no genc (`calcular_meses_vigencia`): anos × 12 + meses
|
|
13
|
+
* e mais 1 se sobrar dia; mínimo 1. Sem fim, conta até `now`.
|
|
14
|
+
* Ex.: 10/06/2025 a 11/02/2027 = 21.
|
|
15
|
+
*/
|
|
16
|
+
export declare const getContractMonths: (contract: ContractLike, now?: Date) => number;
|
|
17
|
+
/** Cachê + produtos + Aditivos ativos. */
|
|
18
|
+
export declare const getContractTotalValue: (contract: ContractLike) => number;
|
|
19
|
+
/** Total ÷ meses de vigência, 2 casas (428.392,93 ÷ 21 = 20.399,66). */
|
|
20
|
+
export declare const getContractMonthlyValue: (contract: ContractLike, now?: Date) => number;
|
|
21
|
+
/** Ativo e assinado (status ausente conta como assinado). */
|
|
22
|
+
export declare const isContractUsable: (contract: ContractLike) => boolean;
|
|
23
|
+
/** Contrato usável do cliente vigente em `at` (sem fim = em aberto). */
|
|
24
|
+
export declare const getActiveContract: <T extends ContractLike>(influencer: {
|
|
25
|
+
contracts?: T[] | null | undefined;
|
|
26
|
+
} | null | undefined, clientId: unknown, at: Date) => T | null;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getActiveContract = exports.isContractUsable = exports.getContractMonthlyValue = exports.getContractTotalValue = exports.getContractMonths = exports.getContractEndDate = void 0;
|
|
4
|
+
const BRT_OFFSET_MS = 3 * 60 * 60 * 1000;
|
|
5
|
+
/** Data no calendário de São Paulo (as datas do Flow são gravadas à meia-noite BRT = 03:00Z). */
|
|
6
|
+
const brtParts = (date) => {
|
|
7
|
+
const d = new Date(new Date(date).getTime() - BRT_OFFSET_MS);
|
|
8
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
|
9
|
+
};
|
|
10
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
11
|
+
const activeAmendments = (contract) => (contract.amendments ?? []).filter((amendment) => amendment.isActive !== false);
|
|
12
|
+
/**
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
const getContractEndDate = (contract) => {
|
|
18
|
+
const ends = [contract.endDate, ...activeAmendments(contract).map((a) => a.endDate)]
|
|
19
|
+
.filter((date) => !!date)
|
|
20
|
+
.map((date) => new Date(date).getTime());
|
|
21
|
+
return ends.length > 0 ? new Date(Math.max(...ends)) : null;
|
|
22
|
+
};
|
|
23
|
+
exports.getContractEndDate = getContractEndDate;
|
|
24
|
+
/**
|
|
25
|
+
* Meses de vigência como no genc (`calcular_meses_vigencia`): anos × 12 + meses
|
|
26
|
+
* e mais 1 se sobrar dia; mínimo 1. Sem fim, conta até `now`.
|
|
27
|
+
* Ex.: 10/06/2025 a 11/02/2027 = 21.
|
|
28
|
+
*/
|
|
29
|
+
const getContractMonths = (contract, now = new Date()) => {
|
|
30
|
+
const start = brtParts(contract.startDate);
|
|
31
|
+
const end = brtParts((0, exports.getContractEndDate)(contract) ?? now);
|
|
32
|
+
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)
|
|
39
|
+
months += 1;
|
|
40
|
+
return Math.max(1, months);
|
|
41
|
+
};
|
|
42
|
+
exports.getContractMonths = getContractMonths;
|
|
43
|
+
/** Cachê + produtos + Aditivos ativos. */
|
|
44
|
+
const getContractTotalValue = (contract) => round2((contract.value ?? 0) +
|
|
45
|
+
(contract.productsValue ?? 0) +
|
|
46
|
+
activeAmendments(contract).reduce((sum, a) => sum + (a.value ?? 0), 0));
|
|
47
|
+
exports.getContractTotalValue = getContractTotalValue;
|
|
48
|
+
/** Total ÷ meses de vigência, 2 casas (428.392,93 ÷ 21 = 20.399,66). */
|
|
49
|
+
const getContractMonthlyValue = (contract, now) => round2((0, exports.getContractTotalValue)(contract) / (0, exports.getContractMonths)(contract, now));
|
|
50
|
+
exports.getContractMonthlyValue = getContractMonthlyValue;
|
|
51
|
+
/** Ativo e assinado (status ausente conta como assinado). */
|
|
52
|
+
const isContractUsable = (contract) => contract.isActive !== false && (contract.status ?? 'signed') === 'signed';
|
|
53
|
+
exports.isContractUsable = isContractUsable;
|
|
54
|
+
/** Contrato usável do cliente vigente em `at` (sem fim = em aberto). */
|
|
55
|
+
const getActiveContract = (influencer, clientId, at) => {
|
|
56
|
+
const time = new Date(at).getTime();
|
|
57
|
+
return ((influencer?.contracts ?? []).find((contract) => {
|
|
58
|
+
if (!(0, exports.isContractUsable)(contract))
|
|
59
|
+
return false;
|
|
60
|
+
const client = contract.client?._id ?? contract.client;
|
|
61
|
+
if (String(client) !== String(clientId))
|
|
62
|
+
return false;
|
|
63
|
+
const end = (0, exports.getContractEndDate)(contract);
|
|
64
|
+
return new Date(contract.startDate).getTime() <= time && (!end || time <= end.getTime());
|
|
65
|
+
}) ?? null);
|
|
66
|
+
};
|
|
67
|
+
exports.getActiveContract = getActiveContract;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -38,3 +38,5 @@ 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';
|
|
@@ -47,6 +47,38 @@ export class Note {
|
|
|
47
47
|
|
|
48
48
|
export const NoteSchema = SchemaFactory.createForClass(Note);
|
|
49
49
|
|
|
50
|
+
/** Status do Contrato do Influenciador. Ver GD-715. */
|
|
51
|
+
export const CONTRACT_STATUS_VALUES = ['draft', 'proposed', 'signed', 'rejected'] as const;
|
|
52
|
+
export type ContractStatus = (typeof CONTRACT_STATUS_VALUES)[number];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Aditivo do Contrato: soma no valor total quando ativo e pode estender a
|
|
56
|
+
* vigência (o fim do Contrato vai até o fim do último Aditivo ativo). Não tem
|
|
57
|
+
* ligação com Ações. Ver GD-715.
|
|
58
|
+
*/
|
|
59
|
+
@Schema({ timestamps: true })
|
|
60
|
+
export class ContractAmendment {
|
|
61
|
+
@Prop({ type: MongooseSchema.Types.ObjectId, auto: true, required: false })
|
|
62
|
+
_id?: Types.ObjectId;
|
|
63
|
+
|
|
64
|
+
@Prop({ type: Number, required: true })
|
|
65
|
+
value: number;
|
|
66
|
+
|
|
67
|
+
@Prop({ type: Boolean, required: false, default: true })
|
|
68
|
+
isActive?: boolean;
|
|
69
|
+
|
|
70
|
+
@Prop({ type: Date, required: true })
|
|
71
|
+
startDate: Date;
|
|
72
|
+
|
|
73
|
+
@Prop({ type: Date, required: true })
|
|
74
|
+
endDate: Date;
|
|
75
|
+
|
|
76
|
+
@Prop({ type: String, required: false, default: null })
|
|
77
|
+
description?: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const ContractAmendmentSchema = SchemaFactory.createForClass(ContractAmendment);
|
|
81
|
+
|
|
50
82
|
// Contrato de valor fixo recorrente (influenciador embaixador de marca) —
|
|
51
83
|
// alternativa ao pagamento avulso por Recruitment (scope.payment/advisorPayment).
|
|
52
84
|
// Escopo de entregas reaproveita a mesma classe Delivery do termo de aceite
|
|
@@ -59,9 +91,25 @@ export class InfluencerContract {
|
|
|
59
91
|
@Prop({ type: MongooseSchema.Types.ObjectId, ref: 'Client', required: true })
|
|
60
92
|
client: Types.ObjectId | Client;
|
|
61
93
|
|
|
94
|
+
/** Cachê total do Contrato (o `valor_cache` do genc). Ver GD-715. */
|
|
62
95
|
@Prop({ type: Number, required: true })
|
|
63
96
|
value: number;
|
|
64
97
|
|
|
98
|
+
/** Valor em produtos; entra no total. Ver GD-715. */
|
|
99
|
+
@Prop({ type: Number, required: false, default: 0 })
|
|
100
|
+
productsValue?: number;
|
|
101
|
+
|
|
102
|
+
@Prop({ type: String, enum: CONTRACT_STATUS_VALUES, required: false, default: 'signed' })
|
|
103
|
+
status?: ContractStatus;
|
|
104
|
+
|
|
105
|
+
/** Desligado = Contrato encerrado. */
|
|
106
|
+
@Prop({ type: Boolean, required: false, default: true })
|
|
107
|
+
isActive?: boolean;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rateio do custo: monthly = valor mensal (total ÷ meses) por mês-calendário;
|
|
111
|
+
* annual = valor total no período.
|
|
112
|
+
*/
|
|
65
113
|
@Prop({ type: String, enum: ['monthly', 'annual'], required: true })
|
|
66
114
|
periodType: 'monthly' | 'annual';
|
|
67
115
|
|
|
@@ -83,6 +131,28 @@ export class InfluencerContract {
|
|
|
83
131
|
// fica salva no Mongo.
|
|
84
132
|
@Prop({ type: String, required: false })
|
|
85
133
|
documentUrl?: string;
|
|
134
|
+
|
|
135
|
+
/** CPI (Custo por Impacto) contratual, digitado no planejamento. Ver GD-715. */
|
|
136
|
+
@Prop({ type: Number, required: false, default: null })
|
|
137
|
+
contractedCostPerImpact?: number | null;
|
|
138
|
+
|
|
139
|
+
/** E/R contratual, em fração (0,02 = 2%). */
|
|
140
|
+
@Prop({ type: Number, required: false, default: null })
|
|
141
|
+
contractedEngagementRate?: number | null;
|
|
142
|
+
|
|
143
|
+
/** Ex.: "Team Galaxy", "Content Creator", "Tech". */
|
|
144
|
+
@Prop({ type: String, required: false, default: null })
|
|
145
|
+
squad?: string | null;
|
|
146
|
+
|
|
147
|
+
/** Ex.: "Technology", "Camera", "Fashion". */
|
|
148
|
+
@Prop({ type: String, required: false, default: null })
|
|
149
|
+
passionPoint?: string | null;
|
|
150
|
+
|
|
151
|
+
@Prop({ type: String, required: false, default: null })
|
|
152
|
+
description?: string | null;
|
|
153
|
+
|
|
154
|
+
@Prop({ type: [ContractAmendmentSchema], required: false, default: [] })
|
|
155
|
+
amendments?: ContractAmendment[];
|
|
86
156
|
}
|
|
87
157
|
|
|
88
158
|
export const InfluencerContractSchema = SchemaFactory.createForClass(InfluencerContract);
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
};
|