feat: end-point for deal document generation

This commit is contained in:
2024-09-20 20:02:17 +04:00
parent 03aba9649a
commit 5316f7f9ce
9 changed files with 482 additions and 5 deletions

View File

@@ -1,5 +1,15 @@
import os
import sys
from datetime import datetime
from pathlib import Path
from jinja2 import FileSystemLoader, Environment
from utils.code128 import encode128
ENV = Environment(loader=FileSystemLoader(Path("templates") / Path("documents")))
ENV.globals['now'] = datetime.now
ENV.globals['encode128'] = encode128
APP_PATH = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(__file__)
@@ -29,3 +39,13 @@ MONTHS = (
'ноября',
'декабря'
)
DEAL_STATUS_STR = [
'Создан',
'Ожидает принятия',
'Упаковка',
'Ожидание отправки',
'Ожидание оплаты',
'Завершено',
'Отменено',
]

View File

@@ -189,6 +189,20 @@ async def get_deal_document(
pdf_file: BytesIO = await BillingService(session).create_billing_document_pdf(deal_id)
return Response(pdf_file.getvalue(), media_type='application/pdf')
@deal_router.get(
'/detailedDocument/{deal_id}',
operation_id='get_deal_document_detailed',
# dependencies=[Depends(authorized_user)],
)
async def get_detailed_deal_document(
deal_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
):
pdf_file: BytesIO = await DealService(session).create_detailed_deal_document_pdf(deal_id)
return Response(pdf_file.getvalue(), media_type='application/pdf')
# endregion
# region Deal services

View File

@@ -11,7 +11,7 @@ from starlette import status
from weasyprint import HTML
import backend.config
from constants import MONTHS
from constants import MONTHS, ENV
from external.billing import BillingClient, CreateBillingRequestValue, CreateBillRequestSchema, CreateBillRequestItems, \
BillStatusUpdateRequest, NotificationChannel, NotifyReceivedBillRequestSchema, DeleteBillRequestSchema
from models import DealBillRequest, Deal, DealProduct, DealService as DealServiceModel
@@ -20,8 +20,6 @@ from services.base import BaseService
from services.deal import DealService
from utils.list_utils import to_locale_number
env = Environment(loader=FileSystemLoader(Path("templates") / Path("documents")))
class BillingService(BaseService):
async def _process_update_details(
@@ -182,7 +180,7 @@ class BillingService(BaseService):
deal_price = sum((service.price * service.amount for service in services))
deal_price_words = get_string_by_number(deal_price)[0:-10]
deal_price = to_locale_number(deal_price)
template = env.get_template("bill-of-payment.html")
template = ENV.get_template("bill-of-payment.html")
now = datetime.datetime.now()
curr_date = f"{now.day} {MONTHS[now.month - 1]} {now.year} г."

View File

@@ -1,12 +1,17 @@
from io import BytesIO
import lexorank
from fastapi import HTTPException
from sqlalchemy import select, func, update, delete, insert
from sqlalchemy.orm import joinedload, selectinload
from starlette import status
from weasyprint import HTML, CSS
import constants
import models.deal
import models.secondary
from models import User, Service, Client, DealProduct
from constants import ENV, DEAL_STATUS_STR
from models import User, Service, Client, DealProduct, DealService as DealServiceModel, Product
from models.deal import *
from schemas.client import ClientDetailsSchema
from schemas.deal import *
@@ -15,6 +20,7 @@ from services.base import BaseService
from services.client import ClientService
from services.service import ServiceService
from services.shipping_warehouse import ShippingWarehouseService
from utils.images_fetcher import fetch_images
class DealService(BaseService):
@@ -987,3 +993,56 @@ class DealService(BaseService):
except Exception as e:
await self.session.rollback()
return DealCompleteResponse(ok=False, message=str(e))
# region Deal documents
@staticmethod
async def _get_product_services_totals(deal: Deal) -> List[int]:
totals: List[int] = []
for product in deal.products:
total = sum((service.price * product.quantity for service in product.services))
totals.append(total)
return totals
async def _create_detailed_deal_document_html(self, deal_id: int):
deal: Deal | None = await self.session.scalar(
select(Deal)
.where(Deal.id == deal_id)
.options(
selectinload(Deal.products).selectinload(DealProduct.services),
selectinload(Deal.products).selectinload(DealProduct.product).selectinload(Product.barcodes),
selectinload(Deal.services).selectinload(DealServiceModel.service),
selectinload(Deal.status_history),
joinedload(Deal.client),
joinedload(Deal.shipping_warehouse),
)
)
if not deal:
return ""
last_status: DealStatusHistory = max(deal.status_history, key=lambda status: status.changed_at)
general_services_total = sum((service.price * service.quantity for service in deal.services))
product_services_totals = await self._get_product_services_totals(deal)
template = ENV.get_template("deal.html")
product_images = await fetch_images([product.product.images[0].image_url for product in deal.products])
result = template.render({
"general_services_total": general_services_total,
"product_services_totals": product_services_totals,
"deal": deal,
"current_status_str": DEAL_STATUS_STR[deal.current_status],
"last_status": last_status,
"product_images": product_images,
})
return result
async def create_detailed_deal_document_pdf(self, deal_id) -> BytesIO:
doc = await self._create_detailed_deal_document_html(deal_id)
pdf_file = BytesIO()
HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(constants.APP_PATH + '/static/css/deal.css')])
return pdf_file
# endregion

167
static/css/deal.css Normal file
View File

@@ -0,0 +1,167 @@
@media print {
#header,
#footer,
#nav {
display: none !important;
}
}
hr {
background-color: black;
height: 2px;
border: none;
margin-left: -100px;
margin-right: -100px;
}
.doc-container {
margin: -30px 0;
padding: 0 15px;
font-size: 13px;
font-family: sans-serif;
}
.deal-name-id-container {
justify-content: space-between;
display: flex;
align-items: center;
}
.deal-id {
border: black solid 2px;
padding: 3px 2px;
margin: 0;
}
.total {
padding: 10px 0;
}
.small-text {
font-size: 12px;
margin-bottom: 5px;
}
.medium-text {
font-size: 15px;
margin-bottom: 5px;
}
.large-text {
font-size: 21px;
margin-bottom: 5px;
}
.bold {
font-weight: bold;
}
.product-data {
height: 160px;
display: flex;
flex-wrap: wrap;
flex-direction: column;
}
.product-data > * {
width: 170px;
padding-right: 170px;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: solid gray 2px;
border-radius: 15px;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
td:nth-child(5) {
border-right: 1px solid white;
}
table tbody tr:nth-child(odd) {
background-color: #e5e5e5;
}
tr {
page-break-inside: avoid;
}
td, th {
padding: 10px 13px;
}
td:first-child, th:first-child {
padding-left: 50px;
}
td:last-child, th:last-child {
padding-right: 50px;
}
td:first-child, th:first-child {
text-align: left;
}
td:first-child {
white-space: normal;
word-wrap: break-word;
}
td:not(:first-child), th:not(:first-child) {
text-align: right;
}
td:not(:first-child) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
table tfoot {
border-top: 2px solid black;
}
.align-right {
text-align: right;
}
.img-container {
margin: -30px 30px -45px 0;
}
.product-img {
height: 300px;
width: 200px;
object-fit: cover;
padding: 0;
margin: 0;
border: black solid 2px;
border-radius: 5px;
}
.barcode-container {
margin: 15px 0 -5px 385px;
display: flex;
flex-direction: column;
align-items: center;
}
.barcode {
font-family: "Libre Barcode 128", cursive;
font-size: 30pt;
transform: scale(1, 1.5);
}
.barcode-text {
margin-top: -10px;
}

2
static/js/JsBarcode.all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,162 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<script src="/static/js/JsBarcode.all.min.js"></script>
<title>Сделка</title>
<style>
@import url("https://fonts.googleapis.com/css?family=Libre+Barcode+128");
@import url("https://fonts.googleapis.com/css?family=Barlow+Condensed:300,400,700");
</style>
</head>
<body>
<script>
JsBarcode(".barcode").init();
</script>
<div class="doc-container">
<div>
<div>
<!--#region Header -->
<div class="deal-name-id-container">
<div class="medium-text bold">
Название сделки: {{ deal.name }}
</div>
<div class="medium-text deal-id">
ID:{{ deal.id }}
</div>
</div>
<hr/>
<div class="medium-text">
Дата создания: {{ now().strftime("%d.%m.%Y, %H:%M") }}
</div>
<div class="medium-text">
Текущий статус: {{ current_status_str }}
</div>
<div class="medium-text">
{% if deal.comment %}
Комментарий к сделке: {{ deal.comment }}
{% else %}
Комментарий к сделке отсутствует
{% endif %}
</div>
<hr/>
<!--#endregion -->
<!--#region General services -->
<div class="large-text bold">
Клиент: {{ deal.client.name }}
</div>
<div class="medium-text bold">
Дата отгрузки: {{ last_status.next_status_deadline.strftime("%d.%m.%Y") }}
</div>
{% if deal.base_marketplace.name %}
<div class="medium-text bold">Маркетплейс: {{ deal.base_marketplace.name }}</div>
{% endif %}
{% if deal.shipping_warehouse.name %}
<div class="medium-text bold">Склад отгрузки: {{ deal.shipping_warehouse.name }}</div>
{% endif %}
<table>
<thead>
<tr>
<th>Общие услуги</th>
<th>Количество</th>
<th>Сумма</th>
</tr>
<tfoot>
</tfoot>
</thead>
<tbody>
{% for service in deal.services %}
<tr>
<td>{{ service.service.name }}</td>
<td>{{ '{:,}'.format(service.quantity) }} шт.</td>
<td>{{ '{:,}'.format(service.price * service.quantity).replace(',', ' ') }} Р</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="medium-text total align-right bold">
Итого: {{ general_services_total }} Р
</div>
<!--#endregion -->
<!--#region Product services -->
{% for product in deal.products %}
<div style="page-break-inside: avoid">
<hr/>
<div style="display: flex">
{% if product.product.images|length != 0 %}
<div class="img-container">
<img class="product-img" src="data:image/jpeg;base64,{{ product_images[loop.index0] }}"
alt="Фото товара"/>
</div>
{% endif %}
<div style="margin: 12px 0">
<div class="medium-text bold">
{{ loop.index }}. {{ product.product.name }}
</div>
<div class="product-data">
<div class="small-text">Артикул: {{ product.product.article }}</div>
{% if product.product.brand %}
<div class="small-text">Бренд: {{ product.product.brand }}</div>
{% endif %}
{% if product.product.color %}
<div class="small-text">Цвет: {{ product.product.color }}</div>
{% endif %}
{% if product.product.size %}
<div class="small-text">Размер: {{ product.product.size }}</div>
{% endif %}
{% if product.quantity %}
<div class="small-text">Количество: {{ product.quantity }}</div>
{% endif %}
{% if product.product.additional_info %}
<div class="small-text">
Доп. информация: {{ product.product.additional_info }}
</div>
{% endif %}
</div>
</div>
</div>
<hr/>
<div style="text-align: right">
<div class="barcode-container">
<div class="barcode">
{{ encode128(product.product.barcodes[0].barcode, "A") }}
</div>
<div class="barcode-text">
{{ product.product.barcodes[0].barcode }}
</div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Наименование услуги</th>
<th>Цена</th>
<th>Сумма</th>
</tr>
<tfoot>
</tfoot>
</thead>
<tbody>
{% for service in product.services %}
<tr>
<td>{{ service.service.name }}</td>
<td>{{ '{:,}'.format(product.quantity) }} Р</td>
<td>{{ '{:,}'.format(service.price * product.quantity).replace(',', ' ') }} Р</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="medium-text total align-right bold">
Итого: {{ product_services_totals[loop.index0] }} Р
</div>
{% endfor %}
<!--#endregion -->
</div>
</div>
</div>
</body>
</html>

35
utils/code128.py Normal file
View File

@@ -0,0 +1,35 @@
import re
def to_set_c(text):
# Match pairs of digits and map them to the appropriate ASCII characters
return ''.join([
chr(int(ascii_code) + 100 if int(ascii_code) > 94 else int(ascii_code) + 32)
for ascii_code in re.findall(r'\d{2}', text)
])
def check_sum_128(data, start_code):
sum_value = start_code
for i, char in enumerate(data):
code = ord(char)
value = code - 100 if code > 199 else code - 32
sum_value += (i + 1) * value
checksum = (sum_value % 103) + 32
if checksum > 126:
checksum += 68
return chr(checksum)
def encode128(text, code_abc="B"):
start_code = chr(ord(code_abc.upper()) + 138)
stop = chr(206)
if code_abc.upper() == 'C':
text = to_set_c(text)
check = check_sum_128(text, ord(start_code) - 100)
# Replace spaces with ASCII 194
text = text.replace(" ", chr(194))
return start_code + text + check + stop

20
utils/images_fetcher.py Normal file
View File

@@ -0,0 +1,20 @@
import asyncio
import base64
from typing import List, Tuple
import aiohttp
async def fetch_image(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
image_data = await response.read()
return base64.b64encode(image_data).decode("utf-8")
else:
return ""
async def fetch_images(urls: List[str]) -> Tuple[str]:
tasks = [fetch_image(url) for url in urls]
return await asyncio.gather(*tasks)