Prefer text fields in technical-form AI and add invoice payment methods.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f8b865b0aa
commit
503b70a690
@@ -0,0 +1,51 @@
|
||||
-- Meshkee CMS — invoice / template payment methods (نحوه ی پرداخت)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- template payment methods (duplicatable free-text)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE invoice_template_payment_methods (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
template_id BIGINT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT invoice_template_payment_methods_template_id_fkey
|
||||
FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE,
|
||||
CONSTRAINT invoice_template_payment_methods_text_nonempty
|
||||
CHECK (char_length(trim(text)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_template_payment_methods_template_id
|
||||
ON invoice_template_payment_methods (template_id, sort_order);
|
||||
|
||||
CREATE TRIGGER invoice_template_payment_methods_set_updated_at
|
||||
BEFORE UPDATE ON invoice_template_payment_methods
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- invoice payment methods (duplicatable free-text)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE invoice_payment_methods (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
invoice_id BIGINT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT invoice_payment_methods_invoice_id_fkey
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE,
|
||||
CONSTRAINT invoice_payment_methods_text_nonempty
|
||||
CHECK (char_length(trim(text)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invoice_payment_methods_invoice_id
|
||||
ON invoice_payment_methods (invoice_id, sort_order);
|
||||
|
||||
CREATE TRIGGER invoice_payment_methods_set_updated_at
|
||||
BEFORE UPDATE ON invoice_payment_methods
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
+33
-5
@@ -1380,11 +1380,12 @@ model InvoiceTemplate {
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
accounts InvoiceTemplateAccount[]
|
||||
items InvoiceTemplateItem[]
|
||||
keyPoints InvoiceTemplateKeyPoint[]
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoices Invoice[]
|
||||
accounts InvoiceTemplateAccount[]
|
||||
items InvoiceTemplateItem[]
|
||||
keyPoints InvoiceTemplateKeyPoint[]
|
||||
paymentMethods InvoiceTemplatePaymentMethod[]
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoices Invoice[]
|
||||
|
||||
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
|
||||
@@map("invoice_templates")
|
||||
@@ -1423,6 +1424,19 @@ model InvoiceTemplateKeyPoint {
|
||||
@@map("invoice_template_key_points")
|
||||
}
|
||||
|
||||
model InvoiceTemplatePaymentMethod {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateId BigInt @map("template_id")
|
||||
text String
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id")
|
||||
@@map("invoice_template_payment_methods")
|
||||
}
|
||||
|
||||
model InvoiceTemplateAccount {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateId BigInt @map("template_id")
|
||||
@@ -1458,6 +1472,7 @@ model Invoice {
|
||||
accounts InvoiceAccount[]
|
||||
items InvoiceItem[]
|
||||
keyPoints InvoiceKeyPoint[]
|
||||
paymentMethods InvoicePaymentMethod[]
|
||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||
@@ -1504,6 +1519,19 @@ model InvoiceKeyPoint {
|
||||
@@map("invoice_key_points")
|
||||
}
|
||||
|
||||
model InvoicePaymentMethod {
|
||||
id BigInt @id @default(autoincrement())
|
||||
invoiceId BigInt @map("invoice_id")
|
||||
text String
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id")
|
||||
@@map("invoice_payment_methods")
|
||||
}
|
||||
|
||||
model InvoiceAccount {
|
||||
id BigInt @id @default(autoincrement())
|
||||
invoiceId BigInt @map("invoice_id")
|
||||
|
||||
@@ -1379,7 +1379,7 @@ export class LegacyMigrateService {
|
||||
.filter((m) => Number(m.size) > 0)
|
||||
.slice(0, 12)
|
||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
||||
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||
oldBizId,
|
||||
oldSlug,
|
||||
probeMedia,
|
||||
@@ -1417,7 +1417,7 @@ export class LegacyMigrateService {
|
||||
|
||||
for (const media of articleMedia) {
|
||||
const size = Number(media.size) || 0;
|
||||
if (size <= 0) {
|
||||
if (size <= 0 || !storagePrefix) {
|
||||
imagesFailed += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1611,7 +1611,7 @@ export class LegacyMigrateService {
|
||||
.filter((m) => Number(m.size) > 0)
|
||||
.slice(0, 12)
|
||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
||||
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||
oldBizId,
|
||||
oldSlug,
|
||||
probeMedia,
|
||||
@@ -1648,7 +1648,7 @@ export class LegacyMigrateService {
|
||||
|
||||
for (const media of itemMedia) {
|
||||
const size = Number(media.size) || 0;
|
||||
if (size <= 0) {
|
||||
if (size <= 0 || !storagePrefix) {
|
||||
imagesFailed += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1978,6 +1978,27 @@ export class LegacyMigrateService {
|
||||
};
|
||||
}
|
||||
|
||||
private async tryResolveStoragePrefix(
|
||||
oldBizId: number,
|
||||
oldSlug: string | null,
|
||||
probeMedia: Array<{ id: number; fileName: string }>,
|
||||
): Promise<string | null> {
|
||||
if (!probeMedia.length) return null;
|
||||
try {
|
||||
return await this.legacySourceS3.resolveBusinessPrefix(
|
||||
oldBizId,
|
||||
oldSlug,
|
||||
probeMedia,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async copyLegacyMedia(input: {
|
||||
businessId: bigint;
|
||||
oldMedia: OldMediaRow;
|
||||
|
||||
@@ -126,10 +126,12 @@ export class CategoryTechnicalFormAiService {
|
||||
|
||||
قوانین:
|
||||
- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگیها» را اضافه میکند).
|
||||
- از ترکیب text، textarea، select، multi_select و multi_value استفاده کن.
|
||||
- فیلدهای select و multi_select باید ۳ تا ۸ گزینه واقعی داشته باشند.
|
||||
- ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند.
|
||||
- از select و multi_select حتیالمقدور استفاده نکن. فقط وقتی مجموعه گزینهها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آنها همیشه text بگذار.
|
||||
- اگر بین text و select مردد هستی، حتماً text انتخاب کن.
|
||||
- فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند.
|
||||
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
|
||||
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل).
|
||||
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان).
|
||||
- textarea برای مشخصات طولانیتر (ویژگیها، دستورالعمل نگهداری).
|
||||
- اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را بهصورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتیمتر / کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
||||
- مشخصات مهم خریدار را الزامی علامت بزن.
|
||||
|
||||
@@ -57,6 +57,12 @@ export class InvoiceKeyPointInputDto {
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class InvoicePaymentMethodInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class InvoiceAccountInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@@ -114,6 +120,12 @@ export class CreateInvoiceDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -134,7 +146,7 @@ export class UpdateInvoiceStatusDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Full content replace for an existing invoice (items / key points / accounts). */
|
||||
/** Full content replace for an existing invoice (items / key points / payment methods / accounts). */
|
||||
export class UpdateInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -169,6 +181,12 @@ export class UpdateInvoiceDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -337,6 +355,12 @@ export class CreateInvoiceTemplateDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -373,6 +397,12 @@ export class UpdateInvoiceTemplateDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
InvoiceAccountInputDto,
|
||||
InvoiceItemInputDto,
|
||||
InvoiceKeyPointInputDto,
|
||||
InvoicePaymentMethodInputDto,
|
||||
InvoiceTemplateItemInputDto,
|
||||
ListInvoicesDto,
|
||||
UpdateInvoiceDto,
|
||||
@@ -250,12 +251,14 @@ export class InvoicesService {
|
||||
user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } },
|
||||
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
};
|
||||
|
||||
private readonly invoiceTemplateInclude = {
|
||||
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||
};
|
||||
|
||||
@@ -271,6 +274,18 @@ export class InvoicesService {
|
||||
};
|
||||
}
|
||||
|
||||
private serializePaymentMethod(row: {
|
||||
id: bigint;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
text: row.text,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeAccount(row: {
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -343,6 +358,11 @@ export class InvoicesService {
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
paymentMethods?: Array<{
|
||||
id: bigint;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
accounts?: Array<{
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -372,6 +392,10 @@ export class InvoicesService {
|
||||
includeNested && row.keyPoints
|
||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
includeNested && row.paymentMethods
|
||||
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||
: undefined,
|
||||
accounts:
|
||||
includeNested && row.accounts
|
||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||
@@ -488,6 +512,11 @@ export class InvoicesService {
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
paymentMethods?: Array<{
|
||||
id: bigint;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
accounts?: Array<{
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -564,6 +593,10 @@ export class InvoicesService {
|
||||
includeNested && row.keyPoints
|
||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
includeNested && row.paymentMethods
|
||||
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||
: undefined,
|
||||
accounts:
|
||||
includeNested && row.accounts
|
||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||
@@ -581,6 +614,14 @@ export class InvoicesService {
|
||||
return { text };
|
||||
}
|
||||
|
||||
private normalizePaymentMethodInput(paymentMethod: InvoicePaymentMethodInputDto) {
|
||||
const text = paymentMethod.text.trim();
|
||||
if (!text) {
|
||||
throw new BadRequestException('Each payment method requires text');
|
||||
}
|
||||
return { text };
|
||||
}
|
||||
|
||||
private normalizeAccountInput(account: InvoiceAccountInputDto) {
|
||||
const bankName = account.bankName.trim();
|
||||
if (!bankName) {
|
||||
@@ -793,6 +834,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||
this.normalizePaymentMethodInput(pm),
|
||||
);
|
||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||
|
||||
const row = await this.prisma.invoiceTemplate.create({
|
||||
@@ -819,6 +863,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -856,6 +906,10 @@ export class InvoicesService {
|
||||
dto.keyPoints !== undefined
|
||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||
: null;
|
||||
const replacePaymentMethods =
|
||||
dto.paymentMethods !== undefined
|
||||
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||
: null;
|
||||
const replaceAccounts =
|
||||
dto.accounts !== undefined
|
||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||
@@ -868,6 +922,9 @@ export class InvoicesService {
|
||||
if (replaceKeyPoints !== null) {
|
||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replacePaymentMethods !== null) {
|
||||
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replaceAccounts !== null) {
|
||||
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
||||
}
|
||||
@@ -905,6 +962,16 @@ export class InvoicesService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(replacePaymentMethods !== null
|
||||
? {
|
||||
paymentMethods: {
|
||||
create: replacePaymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(replaceAccounts !== null
|
||||
? {
|
||||
accounts: {
|
||||
@@ -1116,6 +1183,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||
this.normalizePaymentMethodInput(pm),
|
||||
);
|
||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||
|
||||
const row = await this.prisma.invoiceTemplate.create({
|
||||
@@ -1143,6 +1213,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -1183,6 +1259,10 @@ export class InvoicesService {
|
||||
dto.keyPoints !== undefined
|
||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||
: null;
|
||||
const replacePaymentMethods =
|
||||
dto.paymentMethods !== undefined
|
||||
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||
: null;
|
||||
const replaceAccounts =
|
||||
dto.accounts !== undefined
|
||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||
@@ -1195,6 +1275,9 @@ export class InvoicesService {
|
||||
if (replaceKeyPoints !== null) {
|
||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replacePaymentMethods !== null) {
|
||||
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replaceAccounts !== null) {
|
||||
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
||||
}
|
||||
@@ -1232,6 +1315,16 @@ export class InvoicesService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(replacePaymentMethods !== null
|
||||
? {
|
||||
paymentMethods: {
|
||||
create: replacePaymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(replaceAccounts !== null
|
||||
? {
|
||||
accounts: {
|
||||
@@ -1304,6 +1397,7 @@ export class InvoicesService {
|
||||
: null,
|
||||
items: serialized.items,
|
||||
keyPoints: serialized.keyPoints,
|
||||
paymentMethods: serialized.paymentMethods,
|
||||
accounts: serialized.accounts,
|
||||
subtotal: serialized.subtotal,
|
||||
total: serialized.total,
|
||||
@@ -1465,6 +1559,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||
this.normalizePaymentMethodInput(pm),
|
||||
);
|
||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||
const invoiceTemplateId = dto.invoiceTemplateId?.trim()
|
||||
? BigInt(dto.invoiceTemplateId)
|
||||
@@ -1514,6 +1611,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -1600,6 +1703,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||
this.normalizePaymentMethodInput(pm),
|
||||
);
|
||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||
|
||||
let userId: bigint | undefined;
|
||||
@@ -1629,6 +1735,7 @@ export class InvoicesService {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.invoiceItem.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoicePaymentMethod.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
|
||||
|
||||
return tx.invoice.update({
|
||||
@@ -1657,6 +1764,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
|
||||
@@ -60,13 +60,23 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
const slug = slugHint.trim();
|
||||
add(`${slug}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/_/g, '')}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/_/g, '-')}_${oldBusinessId}`);
|
||||
}
|
||||
|
||||
const listed = await this.listTopLevelPrefixes();
|
||||
for (const prefix of listed) {
|
||||
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||
add(prefix);
|
||||
// ListBucket may be denied on the old bucket; slug candidates + HeadObject still work.
|
||||
try {
|
||||
const listed = await this.listTopLevelPrefixes();
|
||||
for (const prefix of listed) {
|
||||
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||
add(prefix);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`Legacy S3 list prefixes failed for business ${oldBusinessId}; using slug candidates only: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
@@ -75,7 +85,8 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
const sample = probes.slice(0, 12);
|
||||
// Spread probes so early missing files don't hide a valid prefix.
|
||||
const sample = this.pickProbeSample(probes, 12);
|
||||
let bestPrefix = candidates[0];
|
||||
let bestHits = -1;
|
||||
|
||||
@@ -96,6 +107,16 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
if (bestHits <= 0 && sample.length > 0) {
|
||||
for (const prefix of candidates) {
|
||||
if (await this.prefixHasAnyObject(prefix)) {
|
||||
bestPrefix = prefix;
|
||||
bestHits = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestHits <= 0 && sample.length > 0) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||
@@ -106,6 +127,35 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
return bestPrefix;
|
||||
}
|
||||
|
||||
private pickProbeSample<T>(items: T[], n: number): T[] {
|
||||
if (items.length <= n) return [...items];
|
||||
const out: T[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const idx = Math.floor((i * (items.length - 1)) / Math.max(n - 1, 1));
|
||||
if (seen.has(idx)) continue;
|
||||
seen.add(idx);
|
||||
out.push(items[idx]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async prefixHasAnyObject(prefix: string): Promise<boolean> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: `${prefix}/`,
|
||||
MaxKeys: 1,
|
||||
}),
|
||||
);
|
||||
return (res.Contents?.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user