feat: add correction and return from sale functionalities to sale invoices

- Added new API routes for correction and return from sale in POS_SALE_INVOICES_API_ROUTES.
- Implemented correction and return from sale methods in PosSaleInvoicesService.
- Updated PosSaleInvoiceStore to include actions for correction and return from sale.
- Enhanced single.component to handle correction and return from sale events.
- Created correction and return from sale forms with appropriate validation and submission logic.
- Updated sale-invoice-single-view component to manage correction and return from sale actions.
- Added models for correction and return from sale requests.
- Improved user interface messages and form handling for better user experience.
This commit is contained in:
2026-06-06 19:52:58 +03:30
parent 560b3516e1
commit eb39f42b8c
26 changed files with 472 additions and 42 deletions
@@ -243,9 +243,14 @@ export class InputComponent {
}
private isOutOfRange(numericValue: number): { min: boolean; max: boolean } {
console.log('min', this.min, 'max', this.max, 'numericValue', numericValue);
const min = this.min !== undefined && this.min !== null ? this.min : Number.NEGATIVE_INFINITY;
const max = this.max !== undefined && this.max !== null ? this.max : Number.POSITIVE_INFINITY;
return {
min: !!((this.min || this.min === 0) && numericValue < this.min),
max: !!((this.max || this.max === 0) && numericValue > this.max),
min: !!((min || min === 0) && numericValue < min),
max: !!((max || max === 0) && numericValue > max),
};
}
@@ -272,9 +277,12 @@ export class InputComponent {
const controlValue = isIdentifierField ? value : value === '' ? '' : numericValue;
this.control.setValue(controlValue);
target.value = value;
console.log('value', value, 'controlValue', controlValue, 'numericValue', numericValue);
}
onInput($event: Event) {
console.log('$event', $event);
const target = $event.target as HTMLInputElement | null;
if (!target) return;
@@ -288,11 +296,14 @@ export class InputComponent {
value = this.normalizeLeadingZeros(value, allowDecimal);
value = this.enforceMaxLength(value);
console.log('$value', value);
let numericValue = this.parseNumericValue(value);
const range = this.isOutOfRange(numericValue);
console.log('$range', range);
if (value !== '' && (range.min || range.max)) {
console.log('in if');
this.notifyRangeError(range);
value = this.lastValidNumericValue;
numericValue = this.parseNumericValue(value);
@@ -0,0 +1,28 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<field-invoice-date [control]="form.controls.invoice_date" />
@for (item of initialValues || []; track item.id) {
<div class="py-3">
@if ($index) {
<hr class="h-1 w-full" />
}
<p-divider align="right">
{{ $index + 1 }}- <b>{{ item.good_snapshot.name }}</b>
</p-divider>
@if (isGold(item)) {
<shared-gold-payload-form
[initialValues]="$any(mappedInitialItems[$index])"
[vatPercentage]="vatFromItem(item)"
[isCorrection]="true" />
} @else {
<shared-standard-payload-form
[initialValues]="$any(mappedInitialItems[$index])"
[measureUnit]="item.good_snapshot.measure_unit"
[vatPercentage]="vatFromItem(item)"
[isCorrection]="true" />
}
</div>
}
<app-form-footer-actions />
</form>
@@ -0,0 +1,152 @@
import ISummary from '@/core/models/summary';
import { IPosOrderItem } from '@/domains/pos/modules/shop/models';
import { AbstractForm } from '@/shared/abstractClasses';
import {
SharedGoldPayloadFormComponent,
SharedStandardPayloadFormComponent,
} from '@/shared/components';
import { goldDiscountType } from '@/shared/localEnum/constants/goldDiscountType';
import { IGoldPayload, IStandardPayload } from '@/shared/models';
import { formatDate, GREGORIAN_DATE_FORMATS } from '@/utils';
import { Component, Input, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { Divider } from 'primeng/divider';
import { FieldInvoiceDateComponent } from '../../fields/invoice_date.component';
import { FormFooterActionsComponent } from '../../formFooterActions/form-footer-actions.component';
import { CorrectionInvoiceFormValue, TCorrectionItemPayload } from '../models';
import { IInvoiceItem } from '../sale-invoice-full-response.model';
@Component({
selector: 'shared-correction-form',
templateUrl: 'form.component.html',
imports: [
ReactiveFormsModule,
FieldInvoiceDateComponent,
FormFooterActionsComponent,
SharedGoldPayloadFormComponent,
SharedStandardPayloadFormComponent,
Divider,
],
})
export class SharedCorrectionFormComponent extends AbstractForm<
CorrectionInvoiceFormValue,
CorrectionInvoiceFormValue,
IInvoiceItem[]
> {
@Input({ required: true }) invoiceDate!: string;
@ViewChildren(SharedGoldPayloadFormComponent)
goldForms!: QueryList<SharedGoldPayloadFormComponent>;
@ViewChildren(SharedStandardPayloadFormComponent)
standardForms!: QueryList<SharedStandardPayloadFormComponent>;
form = this.fb.group({
invoice_date: [this.invoiceDate],
});
itemDrafts: Record<string, TCorrectionItemPayload> = {};
mappedInitialItems: TCorrectionItemPayload[] = [];
initForm() {
this.form.controls.invoice_date.setValue(
formatDate(this.invoiceDate, 'gregory', 'en', GREGORIAN_DATE_FORMATS.FULL)
);
this.itemDrafts = {};
this.mappedInitialItems = (this.initialValues || []).map((item) => this.mapToInitialItem(item));
}
override ngOnInit(): void {
this.initForm();
}
override ngOnChanges(changes: SimpleChanges) {
if (changes['initialValues'] || changes['invoiceDate']) {
this.initForm();
}
}
isGold(item: IInvoiceItem) {
return String(item.good_snapshot?.pricing_model || '').toUpperCase() === 'GOLD';
}
vatFromItem(item: IInvoiceItem) {
return Number(item.good_snapshot?.sku?.VAT || 0);
}
private mapBaseItem(item: IInvoiceItem): Omit<TCorrectionItemPayload, 'payload'> {
return {
id: item.id,
pricing_model: item.good_snapshot?.pricing_model || '',
good_id: item.good_id,
unit_price: Number(item.unit_price || 0),
quantity: Number(item.quantity || 0),
discount_amount: Number(item.discount || 0),
total_amount: Number(item.total_amount || 0),
tax_amount: 0,
base_total_amount: Number(item.unit_price || 0) * Number(item.quantity || 0),
measure_unit: (item.good_snapshot?.measure_unit || {}) as ISummary,
};
}
mapToGoldInitialItem(item: IInvoiceItem): IPosOrderItem<IGoldPayload> {
return {
...this.mapBaseItem(item),
payload: {
karat: (item.payload as any)?.karat,
wages: Number((item.payload as any)?.wages || 0),
commission: Number((item.payload as any)?.commission || 0),
profit: Number((item.payload as any)?.profit || 0),
discount_type: goldDiscountType.PROFIT,
} as IGoldPayload,
};
}
mapToStandardInitialItem(item: IInvoiceItem): IPosOrderItem<IStandardPayload> {
return {
...this.mapBaseItem(item),
payload: {} as IStandardPayload,
};
}
mapToInitialItem(item: IInvoiceItem): TCorrectionItemPayload {
if (this.isGold(item)) {
return this.mapToGoldInitialItem(item) as TCorrectionItemPayload;
}
return this.mapToStandardInitialItem(item) as TCorrectionItemPayload;
}
override submitForm() {
const goldQueue = [...(this.goldForms?.toArray() || [])];
const standardQueue = [...(this.standardForms?.toArray() || [])];
const items =
this.initialValues?.map((item, index) => {
const current = this.isGold(item)
? goldQueue.shift()?.getCorrectionValue()
: standardQueue.shift()?.getCorrectionValue();
const merged = (current || this.mappedInitialItems[index]) as TCorrectionItemPayload;
return {
...merged,
id: item.id,
pricing_model: item.good_snapshot?.pricing_model || '',
} as TCorrectionItemPayload;
}) || [];
console.log('items', items);
const hasChanges = (this.initialValues || []).some((item, index) => {
const source = this.mappedInitialItems[index];
const draft = items[index] || source;
return JSON.stringify(source) !== JSON.stringify(draft);
});
if (!hasChanges) {
this.toastService.warn({ text: 'هیچ تغییری در مقادیر ثبت نشده است.' });
return;
}
this.onSubmit.emit({
invoice_date: this.form.controls.invoice_date.value || '',
items,
});
}
}
@@ -0,0 +1 @@
export * from './form.component';
@@ -0,0 +1,21 @@
import { IPosOrderItem } from '@/domains/pos/modules/shop/models';
import { IGoldPayload, IStandardPayload } from '@/shared/models';
export type TCorrectionItemPayload = IPosOrderItem<IGoldPayload | IStandardPayload> & {
id: string;
pricing_model: string;
};
export type CorrectionInvoiceFormValue = {
invoice_date: string;
items: TCorrectionItemPayload[];
};
export interface ICorrectionRequest {
total_amount: number;
discount_amount: number;
tax_amount: number;
invoice_date: string;
items: IPosOrderItem[];
notes?: string;
}
@@ -0,0 +1,2 @@
export * from './correction';
export * from './return';
+7
View File
@@ -0,0 +1,7 @@
export type ReturnFromSaleFormValue = {
items: {
id: string;
quantity: number;
maxQuantity: number;
}[];
};
@@ -1,7 +1,10 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<p-message severity="info" icon="pi pi-info-circle">
در برگشت از فروش صورتحساب شما میتوانید صرفا تعداد محصولات و خدمات خود را کم و حذف کنید و حداقل مقدار محصولات و خدمات
شما ۱ می باشد.
<p-message severity="info">
<ol class="list-disc ps-4">
<li>تعداد محصولات/خدمات خود را کم و یا حذف کنید</li>
<li>حداقل ۱ خدمت/سرویس بایستی باقی بماند</li>
<li>تخفیف‌ها، مالیات و قیمت نهایی تمام شده با توجه به مقادیر فاکتور اصلی به صورت سیستمی محاسبه خواهند شد.</li>
</ol>
</p-message>
<field-invoice-date [control]="form.controls.invoice_date" />
@@ -10,7 +10,9 @@ import { IInvoiceItem } from '../sale-invoice-full-response.model';
import { SharedReturnFormItemComponent } from './item-form.component';
type ItemForm = FormGroup<{
id: FormControl<string | null>;
quantity: FormControl<number | null>;
maxQuantity: FormControl<number | null>;
}>;
type BackFromSaleFormValue = {
@@ -71,12 +73,15 @@ export class SharedReturnFormComponent extends AbstractForm<
);
this.initialValues?.forEach((item: any) => {
const maxQuantity = Number(item.quantity || 0);
this.items.push(
this.fb.group({
id: [item.id || null],
quantity: [
Number(item.quantity),
[Validators.required, Validators.min(0), Validators.max(Number(item.quantity))],
maxQuantity,
[Validators.required, Validators.min(0), Validators.max(maxQuantity)],
],
maxQuantity: [maxQuantity],
})
);
});
@@ -93,16 +98,32 @@ export class SharedReturnFormComponent extends AbstractForm<
}
override submitForm() {
const payload = this.items.controls.map((control) => ({
id: control.get('id')?.value,
quantity: control.get('quantity')?.value,
const payload = this.items.getRawValue().map((item) => ({
id: item.id || '',
quantity: Number(item.quantity || 0),
maxQuantity: Number(item.maxQuantity || 0),
}));
const hasChanges = payload.some(
(item, index) => item.quantity !== Number(this.initialValues?.[index]?.quantity || 0)
);
if (!hasChanges) {
this.toastService.warn({
text: 'هیچ تغییری در مقادیر ثبت نشده است.',
});
return;
}
if (!payload.some((item) => item.quantity)) {
this.toastService.warn({
text: 'حداقل مقدار یکی از کالاها/خدمات باید بیشتر از ۰ باشد.',
});
return;
}
this.onSubmit.emit({
items: payload.filter((item) => !!item.id),
});
}
}
@@ -24,6 +24,7 @@ export interface ISaleInvoiceFullRawResponse {
main_id: Maybe<string>;
tax_id: Maybe<string>;
reference_invoice: Maybe<string>;
refrence_by: Maybe<string>;
notes: Maybe<string>;
settlement_type: IEnumTranslate<InvoiceSettlementType>;
}
@@ -111,7 +111,16 @@
</app-card-data>
</div>
<shared-light-bottomsheet [(visible)]="showBackFromSaleForm" header=" برگشت از فروش">
<shared-return-form [initialValues]="invoice.items" [invoiceDate]="invoice.invoice_date" />
<shared-light-bottomsheet [(visible)]="showReturnFromSaleForm" header=" برگشت از فروش">
<shared-return-form
[initialValues]="invoice.items"
[invoiceDate]="invoice.invoice_date"
(onSubmit)="returnSubmit($event)" />
</shared-light-bottomsheet>
<shared-light-bottomsheet [(visible)]="showCorrectionForm" header="اصلاحی">
<shared-correction-form
[initialValues]="invoice.items"
[invoiceDate]="invoice.invoice_date"
(onSubmit)="correctionSubmit($event)" />
</shared-light-bottomsheet>
}
@@ -2,6 +2,8 @@ import { Maybe } from '@/core';
import { NativeBridgeService } from '@/core/services';
import { ToastService } from '@/core/services/toast.service';
import { PosConfigPrintService } from '@/domains/pos/modules/configs/components/print/services/main.service';
import { IPosReturnFromSaleRequest } from '@/domains/pos/modules/saleInvoices/models/returnFromSale';
import { IPosOrderItem } from '@/domains/pos/modules/shop/models';
import { PosInfoStore } from '@/domains/pos/store';
import {
CatalogInvoiceTypeTagComponent,
@@ -44,6 +46,8 @@ import { ProgressSpinner } from 'primeng/progressspinner';
import { TableModule } from 'primeng/table';
import { Observable } from 'rxjs';
import { AppConfirmationService } from '../confirmationDialog/confirmation-dialog.service';
import { SharedCorrectionFormComponent } from './correctionForm';
import { CorrectionInvoiceFormValue, ICorrectionRequest, ReturnFromSaleFormValue } from './models';
import { SharedReturnFormComponent } from './returnForm/form.component';
export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
@@ -68,6 +72,7 @@ export type TSaleInvoiceSingleViewVariant = 'full' | 'customer' | 'pos';
SharedLightBottomsheetComponent,
ProgressSpinner,
SharedReturnFormComponent,
SharedCorrectionFormComponent,
],
})
export class SharedSaleInvoiceSingleViewComponent {
@@ -90,18 +95,20 @@ export class SharedSaleInvoiceSingleViewComponent {
@Output() onSendToTsp = new EventEmitter<Observable<any>>();
@Output() onResendToTsp = new EventEmitter<Observable<any>>();
@Output() onInquiry = new EventEmitter<Observable<any>>();
@Output() onCorrection = new EventEmitter<ICorrectionRequest>();
@Output() onReturnFromSale = new EventEmitter<IPosReturnFromSaleRequest>();
@ViewChild('totalAmount', { static: false }) totalAmount!: TemplateRef<any>;
moreActionMenuItems = signal<MenuItem[]>([]);
showCorrectionForm = signal(false);
showBackFromSaleForm = signal(false);
showReturnFromSaleForm = signal(false);
constructor() {
this.showErrors = this.showErrors.bind(this);
this.showRevokeConfirmation = this.showRevokeConfirmation.bind(this);
this.showCorrection = this.showCorrection.bind(this);
this.showBackFromSale = this.showBackFromSale.bind(this);
this.showReturnFromSale = this.showReturnFromSale.bind(this);
this.printInvoice = this.printInvoice.bind(this);
this.send = this.send.bind(this);
this.resend = this.resend.bind(this);
@@ -119,9 +126,15 @@ export class SharedSaleInvoiceSingleViewComponent {
send() {
this.onSendToTsp.emit();
}
sendCorrection(event: ICorrectionRequest) {
this.onCorrection.emit(event);
}
resend() {
this.onResendToTsp.emit();
}
sendReturnFromSale(event: IPosReturnFromSaleRequest) {
this.onReturnFromSale.emit(event);
}
async showRevokeConfirmation() {
await this.confirmationService.ask({
header: `ابطال صورت‌حساب‌ شماره ${this.invoice!.invoice_number}`,
@@ -143,9 +156,11 @@ export class SharedSaleInvoiceSingleViewComponent {
},
});
}
showCorrection() {}
showBackFromSale() {
this.showBackFromSaleForm.set(true);
showCorrection() {
this.showCorrectionForm.set(true);
}
showReturnFromSale() {
this.showReturnFromSaleForm.set(true);
}
ngOnChanges(changes: SimpleChanges) {
@@ -192,7 +207,7 @@ export class SharedSaleInvoiceSingleViewComponent {
label: 'برگشت از فروش',
icon: 'pi pi-cart-minus',
neededStatus: TspProviderResponseStatus.SUCCESS,
command: this.showBackFromSale,
command: this.showReturnFromSale,
},
{
label: 'چاپ',
@@ -461,4 +476,70 @@ export class SharedSaleInvoiceSingleViewComponent {
refresh() {
this.onRefresh.emit();
}
private mapCorrectionRequest(event: CorrectionInvoiceFormValue): ICorrectionRequest {
const items = (event.items || []).map(({ id, pricing_model, ...item }) => item);
const total_amount = items.reduce((sum, item) => sum + Number(item.total_amount || 0), 0);
const discount_amount = items.reduce((sum, item) => sum + Number(item.discount_amount || 0), 0);
const tax_amount = items.reduce((sum, item) => sum + Number(item.tax_amount || 0), 0);
return {
invoice_date: event.invoice_date,
items,
total_amount,
discount_amount,
tax_amount,
};
}
correctionSubmit(event: CorrectionInvoiceFormValue) {
this.sendCorrection(this.mapCorrectionRequest(event));
}
private mapReturnFromSaleRequest(event: ReturnFromSaleFormValue): IPosReturnFromSaleRequest {
const invoiceItems = this.invoice?.items || [];
const itemMap = new Map(invoiceItems.map((item) => [item.id, item]));
const items: IPosOrderItem[] = (event.items || [])
.map((item) => {
const source = itemMap.get(item.id);
if (!source) return null;
const quantity = Number(item.quantity || 0);
const unit_price = Number(source.unit_price || 0);
const discount_amount = Number(source.discount || 0);
const base_total_amount = unit_price * quantity;
const total_amount = Number(source.total_amount || 0);
const tax_amount = Number(source.payload?.wages || 0);
return {
good_id: source.good_id,
service_id: source.service_id || undefined,
quantity,
unit_price,
discount_amount,
base_total_amount,
total_amount,
tax_amount,
measure_unit: source.good_snapshot.measure_unit,
payload: source.payload as any,
notes: source.notes,
image_url: source.good_snapshot.image_url,
} as IPosOrderItem;
})
.filter((item): item is IPosOrderItem => !!item);
return {
invoice_date: this.invoice?.invoice_date || '',
items,
total_amount: items.reduce((sum, item) => sum + Number(item.total_amount || 0), 0),
discount_amount: items.reduce((sum, item) => sum + Number(item.discount_amount || 0), 0),
tax_amount: items.reduce((sum, item) => sum + Number(item.tax_amount || 0), 0),
};
}
returnSubmit(event: ReturnFromSaleFormValue) {
this.sendReturnFromSale(this.mapReturnFromSaleRequest(event));
}
}
@@ -57,6 +57,8 @@
[baseTotalAmount]="baseTotalAmount()"
[discountAmount]="form.controls.discount_amount.value || 0"
[taxAmount]="taxAmount()" />
<button pButton class="w-full sm:w-auto" (click)="submit()">{{ preparedCTAText() }}</button>
@if (!isCorrection) {
<button pButton class="w-full sm:w-auto" (click)="submit()">{{ preparedCTAText() }}</button>
}
</div>
</div>
@@ -42,6 +42,7 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
@Input({ required: true }) vatPercentage!: number;
@Input() isRapidInvoice: boolean = false;
@Input() ctaText: string = '';
@Input() isCorrection: boolean = false;
@Output() onSubmitForm = new EventEmitter<IGoldPayload>();
@Output() onChangeTotalAmount = new EventEmitter<number>();
@@ -124,8 +125,30 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
form = this.initialForm();
getCorrectionValue(): IPosOrderItem<IGoldPayload> {
return this.prepareSubmitPayload({
...(this.initialValues as IPosOrderItem<IGoldPayload>),
unit_price: Number(this.form.controls.unit_price.value || 0),
quantity: Number(this.form.controls.quantity.value || 0),
discount_amount: Number(this.form.controls.discount_amount.value || 0),
payload: {
...((this.initialValues as IPosOrderItem<IGoldPayload>)?.payload || {}),
commission: Number(this.form.controls.payload.controls.commission.value || 0),
karat: this.form.controls.payload.controls.karat.value as TGoldKarat,
profit: Number(this.form.controls.payload.controls.profit.value || 0),
wages: Number(this.form.controls.payload.controls.wages.value || 0),
discount_type:
this.form.controls.payload.controls.discount_type.value || goldDiscountType.PROFIT,
},
} as IPosOrderItem<IGoldPayload>);
}
override submitForm(payload: IPosOrderItem) {
this.onSubmit.emit({
this.onSubmit.emit(this.prepareSubmitPayload(payload as IPosOrderItem<IGoldPayload>));
}
prepareSubmitPayload(payload: IPosOrderItem<IGoldPayload>): IPosOrderItem<IGoldPayload> {
return {
...payload,
total_amount: this.totalAmount(),
base_total_amount: this.baseTotalAmount(),
@@ -139,7 +162,7 @@ export class SharedGoldPayloadFormComponent extends AbstractForm<
discount_type:
this.form.controls.payload.controls.discount_type.value || goldDiscountType.PROFIT,
},
});
};
}
updateCalculateAmount(payload: Partial<IPosOrderItem<IGoldPayload>>) {
@@ -24,6 +24,8 @@
[baseTotalAmount]="baseTotalAmount()"
[discountAmount]="discountAmount()"
[taxAmount]="taxAmount()" />
<button pButton class="w-full sm:w-auto" (onClick)="submit()">{{ preparedCTAText() }}</button>
@if (!isCorrection) {
<button pButton class="w-full sm:w-auto" (onClick)="submit()">{{ preparedCTAText() }}</button>
}
</div>
</form>
@@ -34,6 +34,7 @@ export class SharedStandardPayloadFormComponent extends AbstractForm<
@Input({ required: true }) vatPercentage!: number;
@Input() isRapidInvoice: boolean = false;
@Input() ctaText: string = '';
@Input() isCorrection: boolean = false;
@Output() onChangeTotalAmount = new EventEmitter<number>();
private readonly initialForm = () => {
@@ -55,14 +56,29 @@ export class SharedStandardPayloadFormComponent extends AbstractForm<
};
form = this.initialForm();
override submitForm(payload: IPosOrderItem<IStandardPayload>) {
this.onSubmit.emit({
getCorrectionValue(): IPosOrderItem<IStandardPayload> {
return this.prepareSubmitPayload({
...(this.initialValues as IPosOrderItem<IStandardPayload>),
unit_price: Number(this.form.controls.unit_price.value || 0),
quantity: Number(this.form.controls.quantity.value || 0),
discount_amount: Number(this.form.controls.discount_amount.value || 0),
} as IPosOrderItem<IStandardPayload>);
}
override submitForm(payload: IPosOrderItem) {
this.onSubmit.emit(this.prepareSubmitPayload(payload as IPosOrderItem<IStandardPayload>));
}
prepareSubmitPayload(payload: IPosOrderItem<IStandardPayload>): IPosOrderItem<IStandardPayload> {
return {
...payload,
total_amount: this.totalAmount(),
discount_amount: this.discountAmount(),
tax_amount: this.taxAmount(),
base_total_amount: this.baseTotalAmount(),
});
tax_amount: this.taxAmount(),
discount_amount: this.form.value.discount_amount || 0,
payload: {},
};
}
baseTotalAmount = signal<number>(0);
@@ -88,5 +104,6 @@ export class SharedStandardPayloadFormComponent extends AbstractForm<
this.discountAmount.set(discountAmount);
this.taxAmount.set(taxAmount);
this.totalAmount.set(baseTotalAmountWithoutTax + taxAmount);
this.onChangeTotalAmount.emit(this.totalAmount());
}
}