first commit

This commit is contained in:
mdares
2026-04-07 08:54:41 -06:00
commit 3d1a8ba07e
92 changed files with 15392 additions and 0 deletions

View File

@@ -0,0 +1,307 @@
import "server-only";
import { addMinutes } from "@/lib/time";
import { prisma } from "@/lib/db";
import { TRANSACTION_STATUS, type PaymentMethodValue, type ServiceTypeValue } from "@/server/domain/constants";
import { relayManager } from "@/server/relay/relayManager";
import { calculateAddonTotalCents, calculateLoyaltyDiscountCents } from "@/server/services/calculations";
import { timerService } from "@/server/services/timerService";
type ActivateMachineAddonsInput = {
detergentQty: number;
softenerQty: number;
bleachQty: number;
};
export type ActivateMachineInput = {
machineId: string;
employeeId: string;
customerId: string;
baseAmountCents: number;
durationMinutes: number;
serviceType: ServiceTypeValue;
paymentMethod: PaymentMethodValue;
addons: ActivateMachineAddonsInput;
};
export async function activateMachine(input: ActivateMachineInput) {
const startedAt = new Date();
const expectedEndAt = addMinutes(startedAt, input.durationMinutes);
const { machineRelayChannel, transaction } = await prisma.$transaction(async (tx) => {
const machine = await tx.machine.findUnique({
where: { id: input.machineId },
include: {
transactions: {
where: {
status: {
in: [TRANSACTION_STATUS.running, TRANSACTION_STATUS.pendingRelay]
}
},
take: 1
}
}
});
if (!machine || !machine.isActive) {
throw new Error("Maquina no disponible");
}
if (machine.outOfService) {
throw new Error("Maquina fuera de servicio");
}
if (machine.transactions.length > 0) {
throw new Error("Maquina actualmente en uso");
}
const customer = await tx.customer.findUnique({
where: { id: input.customerId },
select: { id: true, isActive: true }
});
if (!customer || !customer.isActive) {
throw new Error("Cliente no disponible");
}
const config = await tx.appConfig.findUnique({
where: { id: 1 },
select: {
loyaltyEveryNTransactions: true,
loyaltyDiscountPct: true,
detergentAddonCents: true,
softenerAddonCents: true,
bleachAddonCents: true
}
});
if (!config) {
throw new Error("Configuracion no disponible");
}
const priorEligibleTransactions = await tx.transaction.count({
where: {
customerId: input.customerId,
status: {
in: [TRANSACTION_STATUS.pendingRelay, TRANSACTION_STATUS.running, TRANSACTION_STATUS.completed]
}
}
});
const customerTransactionNumber = priorEligibleTransactions + 1;
const loyaltyEveryNTransactions = Math.max(1, config.loyaltyEveryNTransactions);
const loyaltyDiscountPct = Math.max(0, Math.min(100, config.loyaltyDiscountPct));
const loyaltyDiscountApplied =
loyaltyDiscountPct > 0 && customerTransactionNumber % loyaltyEveryNTransactions === 0;
const discountCents = loyaltyDiscountApplied
? calculateLoyaltyDiscountCents(input.baseAmountCents, loyaltyDiscountPct)
: 0;
const addonAmountCents = calculateAddonTotalCents({
detergentQty: input.addons.detergentQty,
softenerQty: input.addons.softenerQty,
bleachQty: input.addons.bleachQty,
detergentAddonCents: config.detergentAddonCents,
softenerAddonCents: config.softenerAddonCents,
bleachAddonCents: config.bleachAddonCents
});
const amountCents = Math.max(0, input.baseAmountCents - discountCents + addonAmountCents);
const ticketAgg = await tx.transaction.aggregate({
_max: { ticketNumber: true }
});
const ticketNumber = (ticketAgg._max.ticketNumber ?? 0) + 1;
const transaction = await tx.transaction.create({
data: {
ticketNumber,
machineId: input.machineId,
employeeId: input.employeeId,
customerId: input.customerId,
baseAmountCents: input.baseAmountCents,
discountCents,
loyaltyDiscountApplied,
addonDetergentQty: input.addons.detergentQty,
addonSoftenerQty: input.addons.softenerQty,
addonBleachQty: input.addons.bleachQty,
addonAmountCents,
serviceType: input.serviceType,
amountCents,
paymentMethod: input.paymentMethod,
startedAt,
expectedEndAt,
status: TRANSACTION_STATUS.pendingRelay
}
});
return {
machineRelayChannel: machine.relayChannel,
transaction
};
});
await prisma.transaction.update({
where: { id: transaction.id },
data: { relayOnAttemptedAt: new Date() }
});
try {
await relayManager.turnOn(machineRelayChannel);
const updated = await prisma.transaction.update({
where: { id: transaction.id },
data: {
status: TRANSACTION_STATUS.running,
relayTurnedOnAt: new Date(),
relayFailureReason: null
},
include: {
customer: {
select: { firstName: true, lastName: true, phone: true }
},
employee: {
select: { name: true }
},
machine: {
select: { name: true }
}
}
});
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
return {
transaction: updated,
relayOk: true
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const updated = await prisma.transaction.update({
where: { id: transaction.id },
data: {
status: TRANSACTION_STATUS.relayFailed,
relayFailureReason: message
},
include: {
customer: {
select: { firstName: true, lastName: true, phone: true }
},
employee: {
select: { name: true }
},
machine: {
select: { name: true }
}
}
});
return {
transaction: updated,
relayOk: false,
relayError: message
};
}
}
export async function retryRelayOn(transactionId: string) {
const transaction = await prisma.transaction.findUnique({
where: { id: transactionId },
include: { machine: true }
});
if (!transaction) {
throw new Error("Transaccion no encontrada");
}
const retryableStatuses = new Set<string>([TRANSACTION_STATUS.relayFailed, TRANSACTION_STATUS.pendingRelay]);
if (!retryableStatuses.has(transaction.status)) {
throw new Error("Transaccion no elegible para reintento");
}
await prisma.transaction.update({
where: { id: transaction.id },
data: { relayOnAttemptedAt: new Date() }
});
await relayManager.turnOn(transaction.machine.relayChannel);
const updated = await prisma.transaction.update({
where: { id: transaction.id },
data: {
status: TRANSACTION_STATUS.running,
relayTurnedOnAt: new Date(),
relayFailureReason: null
}
});
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
return updated;
}
export async function addTimeToTransaction(input: {
transactionId: string;
employeeId: string;
extraMinutes: number;
extraAmountCents: number;
reason?: string;
}) {
const transaction = await prisma.transaction.findUnique({
where: { id: input.transactionId }
});
if (!transaction) {
throw new Error("Transaccion no encontrada");
}
if (transaction.status !== TRANSACTION_STATUS.running) {
throw new Error("Solo se puede agregar tiempo a transacciones activas");
}
const nextEnd = addMinutes(transaction.expectedEndAt, input.extraMinutes);
const updated = await prisma.transaction.update({
where: { id: transaction.id },
data: {
expectedEndAt: nextEnd,
amountCents: transaction.amountCents + input.extraAmountCents,
baseAmountCents: transaction.baseAmountCents + input.extraAmountCents
}
});
await prisma.transactionExtension.create({
data: {
transactionId: transaction.id,
employeeId: input.employeeId,
extraMinutes: input.extraMinutes,
extraAmountCents: input.extraAmountCents,
reason: input.reason
}
});
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
return updated;
}
export async function voidTransaction(input: { transactionId: string; reason: string }) {
const transaction = await prisma.transaction.findUnique({
where: { id: input.transactionId },
include: { machine: true }
});
if (!transaction) {
throw new Error("Transaccion no encontrada");
}
if (transaction.status === TRANSACTION_STATUS.voided) {
return transaction;
}
if (transaction.status === TRANSACTION_STATUS.running) {
await relayManager.turnOff(transaction.machine.relayChannel);
}
timerService.unschedule(transaction.id);
return prisma.transaction.update({
where: { id: transaction.id },
data: {
status: TRANSACTION_STATUS.voided,
voidReason: input.reason,
endedAt: new Date(),
relayTurnedOffAt: new Date()
}
});
}