mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 16:33:04 +00:00
Compare commits
210 Commits
assets-ver
...
auth_did
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265bc4eb6f | ||
|
|
1e238678d8 | ||
|
|
bb36e956ac | ||
|
|
5641f37381 | ||
|
|
577a79471b | ||
|
|
c2e472b03c | ||
|
|
e5f9698055 | ||
|
|
e45b027a22 | ||
|
|
78cc06f127 | ||
|
|
00646b7ed3 | ||
|
|
9267bd9eea | ||
|
|
298d3d9016 | ||
|
|
a9f0ec83a4 | ||
|
|
f33de37da0 | ||
|
|
2a6d9be18a | ||
|
|
d1765e85aa | ||
|
|
3df8e7bfe6 | ||
|
|
f7460f7be3 | ||
|
|
920abdc0e2 | ||
|
|
e0e3dcc8bf | ||
|
|
9d020365e0 | ||
|
|
0f876c10aa | ||
|
|
7f3ddfb3a1 | ||
|
|
268d98d5f7 | ||
|
|
1be84112a7 | ||
|
|
fcff212eec | ||
|
|
9b1157c914 | ||
|
|
0ba2961103 | ||
|
|
859d4caae4 | ||
|
|
3a50056968 | ||
|
|
e1f6bb70bc | ||
|
|
734fe874f2 | ||
|
|
5aab5502f0 | ||
|
|
5873f55cf0 | ||
|
|
df03524b19 | ||
|
|
18dbc7887b | ||
|
|
7c6b13a838 | ||
|
|
7d72d21bbe | ||
|
|
62fdc4c457 | ||
|
|
b41eb6876a | ||
|
|
9bb71e5ec4 | ||
|
|
c5ff1009b2 | ||
|
|
ff2b9a99e7 | ||
|
|
b82b2c2ebd | ||
|
|
8db05fc4da | ||
|
|
6a064765d1 | ||
|
|
78d5fbaca4 | ||
|
|
3dba21f814 | ||
|
|
f4705fd5a8 | ||
|
|
f1f66bdf2f | ||
|
|
a02ef40a5b | ||
|
|
1a4b61a822 | ||
|
|
34a0aa2ee9 | ||
|
|
e2a1f6057d | ||
|
|
34d128d752 | ||
|
|
d6a201ed4a | ||
|
|
0a07fb3a4e | ||
|
|
9cecf2e6f9 | ||
|
|
d1fd91a542 | ||
|
|
8e41e75d89 | ||
|
|
7c2406077a | ||
|
|
926bdf5a20 | ||
|
|
b447cbc3c1 | ||
|
|
4affdd51f6 | ||
|
|
a26d8d448c | ||
|
|
700a7fdad3 | ||
|
|
ca310693ff | ||
|
|
e842812ba5 | ||
|
|
5289752c5f | ||
|
|
3757544359 | ||
|
|
51fee2d602 | ||
|
|
d54db2e0ca | ||
|
|
cb84678198 | ||
|
|
40bcf6e3b6 | ||
|
|
3294490040 | ||
|
|
855eeb1078 | ||
|
|
ef8cc166c1 | ||
|
|
3c5cb8d579 | ||
|
|
5adeca44da | ||
|
|
371b5c7593 | ||
|
|
c271826130 | ||
|
|
4c6f33000b | ||
|
|
635d291b62 | ||
|
|
092d8f771c | ||
|
|
4ee8bbb06b | ||
|
|
53dfef8030 | ||
|
|
d2d28c9e03 | ||
|
|
8b916b40ee | ||
|
|
bca917380d | ||
|
|
64a3be8163 | ||
|
|
3337b47182 | ||
|
|
dfe3280737 | ||
|
|
8a8b89e5dd | ||
|
|
a75693a81f | ||
|
|
d0d9411700 | ||
|
|
c4d28a2612 | ||
|
|
6c46692cc4 | ||
|
|
68b8ba7235 | ||
|
|
e0c285e27e | ||
|
|
b72cde73ba | ||
|
|
260cec3b86 | ||
|
|
cfed16ab6c | ||
|
|
d8760b76a8 | ||
|
|
0b4e20ae98 | ||
|
|
a2a2e1020b | ||
|
|
86726bbd85 | ||
|
|
8164782263 | ||
|
|
0c61ad4e6d | ||
|
|
5074597d00 | ||
|
|
42383c3f36 | ||
|
|
3b2f2168d0 | ||
|
|
36dc196a1d | ||
|
|
04443ae29e | ||
|
|
da82ac86b5 | ||
|
|
efb8336bf8 | ||
|
|
b1882dc83a | ||
|
|
41884cfd2a | ||
|
|
48700a8aa3 | ||
|
|
c34eeee096 | ||
|
|
016b64df6d | ||
|
|
cd7fa56ec4 | ||
|
|
e94bd51764 | ||
|
|
e1ea14b135 | ||
|
|
7afe5d4ee3 | ||
|
|
d154796c82 | ||
|
|
d6f9e4ac3f | ||
|
|
10c18ca801 | ||
|
|
0a49403838 | ||
|
|
f0ba54d957 | ||
|
|
7ee7c4253b | ||
|
|
519dc0b958 | ||
|
|
85be72a403 | ||
|
|
78f9434d14 | ||
|
|
530e587bf2 | ||
|
|
c68918bc18 | ||
|
|
e8fff2fdad | ||
|
|
498cd2b371 | ||
|
|
c324c823fb | ||
|
|
516406c25b | ||
|
|
61da2302ba | ||
|
|
35ac7155e8 | ||
|
|
28c3d24b86 | ||
|
|
9b85773757 | ||
|
|
341fad04c9 | ||
|
|
0a4fa5e35e | ||
|
|
f9d67ebb1e | ||
|
|
7b456c6405 | ||
|
|
92983255b3 | ||
|
|
7b9f61e058 | ||
|
|
0968adafc8 | ||
|
|
220b6fe572 | ||
|
|
8192d70f83 | ||
|
|
2cf51a0367 | ||
|
|
01e7224210 | ||
|
|
18d1a88a64 | ||
|
|
cfd37f22db | ||
|
|
cfff10463c | ||
|
|
25e3d6042a | ||
|
|
0a02727638 | ||
|
|
a12d666037 | ||
|
|
c7b4806117 | ||
|
|
6c1ac51d7a | ||
|
|
8aaa3a72ef | ||
|
|
2c0f6c50df | ||
|
|
0ee0d6f0c5 | ||
|
|
bb803a8f82 | ||
|
|
983d80f7c5 | ||
|
|
cba6a31497 | ||
|
|
9ad046109c | ||
|
|
29261c5fc2 | ||
|
|
58c90ad651 | ||
|
|
8783689ec5 | ||
|
|
8d3efe287e | ||
|
|
b63e1fd796 | ||
|
|
18188cb1b2 | ||
|
|
001c70831c | ||
|
|
b68daea365 | ||
|
|
e8f9cf6e3f | ||
|
|
55368256fd | ||
|
|
8f05e0596e | ||
|
|
473f6e833a | ||
|
|
d775d540c4 | ||
|
|
b381061742 | ||
|
|
90801550eb | ||
|
|
8677e2df40 | ||
|
|
9c78c9ab7b | ||
|
|
32c4b1d98a | ||
|
|
6467f07459 | ||
|
|
b5c96dfef0 | ||
|
|
cf1817c1ea | ||
|
|
3ec6387425 | ||
|
|
234c4a45b8 | ||
|
|
064340cafb | ||
|
|
dfbd8db9d3 | ||
|
|
58f24c83c0 | ||
|
|
814c11200a | ||
|
|
f7c744350c | ||
|
|
cf597361f6 | ||
|
|
88f6f182e3 | ||
|
|
4c8f95a1a5 | ||
|
|
9ea56910a1 | ||
|
|
d2b09f71c3 | ||
|
|
f31b3749bc | ||
|
|
30b9e11303 | ||
|
|
4b1d369ac6 | ||
|
|
3592c3086d | ||
|
|
bdf0136fc5 | ||
|
|
7335011814 | ||
|
|
671555edbc | ||
|
|
df6fd782b7 |
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Review translation PRs
|
||||
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "**/*.po"
|
||||
- "**/*.pot"
|
||||
|
||||
concurrency:
|
||||
group: po-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review-po-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.0.0
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
|
||||
# so no GITHUB_TOKEN permissions are required.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
trigger-runners:
|
||||
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
||||
|
||||
@@ -2,9 +2,7 @@ import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||
|
||||
|
||||
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
|
||||
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
@@ -12,7 +10,7 @@ const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } })
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<CSVRawDataPreview data={data.message} />
|
||||
<CSVRawDataPreview data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,151 +1,104 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import _ from "@/lib/translate"
|
||||
import { GetStatementDetailsResponse } from "../import_utils"
|
||||
import { useMemo } from "react"
|
||||
import RawTableGrid from "../RawTableGrid"
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
useSetHeaderIndex,
|
||||
useUpdateColumnMapping,
|
||||
} from "../import_utils"
|
||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||
|
||||
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
|
||||
|
||||
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
|
||||
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
|
||||
(columns ?? []).map((c) => ({
|
||||
index: c.index,
|
||||
maps_to: c.maps_to,
|
||||
header_text: c.header_text,
|
||||
variable: c.variable,
|
||||
}))
|
||||
|
||||
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
|
||||
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
|
||||
|
||||
const col_map: Record<string, number> = {}
|
||||
const CSVRawDataPreview = ({
|
||||
data,
|
||||
mutate,
|
||||
}: {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}) => {
|
||||
const isCompleted = data.doc.status === "Completed"
|
||||
|
||||
data.doc.column_mapping?.forEach(col => {
|
||||
if (col.maps_to && col.maps_to !== "Do not import") {
|
||||
col_map[col.maps_to] = col.index;
|
||||
}
|
||||
})
|
||||
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
|
||||
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
|
||||
headerToState(data.doc.detected_header_index),
|
||||
)
|
||||
|
||||
return col_map
|
||||
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
|
||||
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
|
||||
|
||||
}, [data])
|
||||
const mappingRef = useRef(mapping)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const validColumns = Object.values(column_mapping)
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||
|
||||
// Reverse the column mapping to get a map of column index to variable name
|
||||
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
|
||||
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
|
||||
mapping.forEach((c) => {
|
||||
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
|
||||
})
|
||||
|
||||
const commitMapping = (next: Mapping[]) => {
|
||||
mappingRef.current = next
|
||||
setMapping(next)
|
||||
}
|
||||
|
||||
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
|
||||
const scheduleSaveMapping = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_("Could not save the column mapping.")))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
if (isCompleted) return
|
||||
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
|
||||
scheduleSaveMapping()
|
||||
}
|
||||
|
||||
const onSetHeader = (rowIndex: number | null) => {
|
||||
if (isCompleted) return
|
||||
setHeaderIndex(rowIndex)
|
||||
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
|
||||
.then((res) => {
|
||||
// The backend re-derives the mapping for the new header; sync local state.
|
||||
const doc = res?.message?.doc
|
||||
if (doc) {
|
||||
commitMapping(toMapping(doc.column_mapping))
|
||||
setHeaderIndex(headerToState(doc.detected_header_index))
|
||||
}
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_("Could not update the header row.")))
|
||||
}
|
||||
|
||||
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{data.raw_data.map((row, index) => {
|
||||
|
||||
const isHeaderRow = index === data.doc.detected_header_index;
|
||||
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
|
||||
|
||||
return <TableRow key={index}
|
||||
title={isHeaderRow ? "Header Row" : ""}
|
||||
className={cn({
|
||||
// "bg-yellow-100": isHeaderRow,
|
||||
// "hover:bg-yellow-100": isHeaderRow,
|
||||
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
|
||||
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
|
||||
})}>
|
||||
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
|
||||
{index + 1}
|
||||
</TableHead> :
|
||||
<TableCell className="text-center px-1 py-0.5">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
}
|
||||
{row.map((cell, cellIndex) => {
|
||||
|
||||
const isValidColumn = validColumns.includes(cellIndex);
|
||||
const columnType = columnIndexMap[cellIndex];
|
||||
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
|
||||
|
||||
if (isHeaderRow) {
|
||||
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
|
||||
)}>
|
||||
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
|
||||
"justify-end": isAmountColumn && isValidColumn
|
||||
})}>
|
||||
{columnType && <Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_(columnType)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
{cell}
|
||||
</div>
|
||||
</TableHead>
|
||||
} else {
|
||||
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
{
|
||||
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
|
||||
"text-ink-gray-5": !isValidColumn && isTransactionRow,
|
||||
}
|
||||
)} >
|
||||
<div className={cn("min-h-5 flex items-center text-xs px-1", {
|
||||
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
|
||||
})} title={cell}>
|
||||
{cell}
|
||||
</div>
|
||||
</TableCell>
|
||||
}
|
||||
}
|
||||
|
||||
)}
|
||||
</TableRow>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table >
|
||||
<RawTableGrid
|
||||
rows={data.raw_data}
|
||||
columnMapping={columnMappingRecord}
|
||||
headerIndex={headerIndex}
|
||||
editable={!isCompleted}
|
||||
disabled={isCompleted || savingMapping || savingHeader}
|
||||
onChangeMapping={onChangeMapping}
|
||||
onSetHeader={onSetHeader}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
|
||||
if (!columnType) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (columnType === 'Amount') {
|
||||
return <DollarSignIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Withdrawal') {
|
||||
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Deposit') {
|
||||
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Balance') {
|
||||
return <BanknoteIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Date') {
|
||||
return <CalendarIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Description') {
|
||||
return <FileTextIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Reference') {
|
||||
return <ReceiptIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Transaction Type') {
|
||||
return <ListIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Debit/Credit') {
|
||||
return <ArrowUpDownIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
export default CSVRawDataPreview
|
||||
|
||||
@@ -142,11 +142,16 @@ const StatementDetails = ({ data }: Props) => {
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BankLogo bank={bank} />
|
||||
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
<span className="text-sm">{bank?.account_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Statement File")}</TableHead>
|
||||
<TableCell>
|
||||
@@ -158,7 +163,11 @@ const StatementDetails = ({ data }: Props) => {
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
{data.doc.start_date && data.doc.end_date ? (
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
) : (
|
||||
<TableCell>-</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Bbox = [number, number, number, number]
|
||||
|
||||
const MIN_SIZE = 8 // PDF points
|
||||
|
||||
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
|
||||
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
|
||||
let [x0, top, x1, bottom] = bbox
|
||||
if (x1 < x0) [x0, x1] = [x1, x0]
|
||||
if (bottom < top) [top, bottom] = [bottom, top]
|
||||
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
|
||||
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
|
||||
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
|
||||
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
|
||||
return [x0, top, x1, bottom]
|
||||
}
|
||||
|
||||
const HANDLES = [
|
||||
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
bbox: Bbox
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
color: { border: string; bg: string; swatch: string }
|
||||
label: string
|
||||
included: boolean
|
||||
disabled?: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
onCommit: (bbox: Bbox) => void
|
||||
}
|
||||
|
||||
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
|
||||
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
|
||||
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
|
||||
const [draft, setDraft] = useState<Bbox>(bbox)
|
||||
const draftRef = useRef<Bbox>(bbox)
|
||||
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
|
||||
|
||||
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
|
||||
useEffect(() => {
|
||||
setDraft(bbox)
|
||||
draftRef.current = bbox
|
||||
}, [bbox])
|
||||
|
||||
const apply = (next: Bbox) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
|
||||
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
|
||||
let [x0, top, x1, bottom] = drag.current.start
|
||||
const m = drag.current.mode
|
||||
if (m === 'move') {
|
||||
x0 += dx
|
||||
x1 += dx
|
||||
top += dy
|
||||
bottom += dy
|
||||
} else {
|
||||
if (m.includes('w')) x0 += dx
|
||||
if (m.includes('e')) x1 += dx
|
||||
if (m.includes('n')) top += dy
|
||||
if (m.includes('s')) bottom += dy
|
||||
}
|
||||
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
|
||||
}
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
drag.current = null
|
||||
onCommit(draftRef.current)
|
||||
}
|
||||
|
||||
const [x0, top, x1, bottom] = draft
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute touch-none border-2',
|
||||
color.border,
|
||||
included ? color.bg : 'opacity-40',
|
||||
disabled ? 'pointer-events-none' : 'cursor-move',
|
||||
)}
|
||||
style={{
|
||||
left: `${(x0 / pageWidth) * 100}%`,
|
||||
top: `${(top / pageHeight) * 100}%`,
|
||||
width: `${((x1 - x0) / pageWidth) * 100}%`,
|
||||
height: `${((bottom - top) / pageHeight) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
|
||||
{label}
|
||||
</span>
|
||||
{!disabled &&
|
||||
HANDLES.map((handle) => (
|
||||
<span
|
||||
key={handle.id}
|
||||
data-handle={handle.id}
|
||||
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BBoxOverlay
|
||||
@@ -0,0 +1,23 @@
|
||||
import StatementDetails from '../CSV/StatementDetails'
|
||||
import PDFTableEditor from './PDFTableEditor'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: { message: GetStatementDetailsResponse }
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
const PDFImport = ({ data, mutate }: Props) => {
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<PDFTableEditor data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFImport
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react'
|
||||
import _ from '@/lib/translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { H3, Paragraph } from '@/components/ui/typography'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import RawTableGrid from '../RawTableGrid'
|
||||
import BBoxOverlay from './BBoxOverlay'
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
PDFTable,
|
||||
useReextractPDFTable,
|
||||
useSetPDFTableHeader,
|
||||
useUpdatePDFTables,
|
||||
} from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
// Distinct overlay colours per table on a page.
|
||||
const OVERLAY_COLORS = [
|
||||
{ border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' },
|
||||
{ border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' },
|
||||
{ border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' },
|
||||
{ border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' },
|
||||
]
|
||||
|
||||
const columnMappingRecord = (table: PDFTable): Record<number, ColumnMapsTo> => {
|
||||
const map: Record<number, ColumnMapsTo> = {}
|
||||
table.column_mapping?.forEach((col) => {
|
||||
map[col.index] = col.maps_to
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
const PDFTableEditor = ({ data, mutate }: Props) => {
|
||||
const isCompleted = data.doc.status === 'Completed'
|
||||
|
||||
const [tables, setTables] = useState<PDFTable[]>(() => data.pdf_tables ?? [])
|
||||
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
|
||||
|
||||
const toggleCollapsed = (tableIndex: number) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(tableIndex)) {
|
||||
next.delete(tableIndex)
|
||||
} else {
|
||||
next.add(tableIndex)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const { call, loading, error } = useUpdatePDFTables()
|
||||
const { call: reextract, loading: reextracting } = useReextractPDFTable()
|
||||
const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader()
|
||||
const busy = loading || reextracting || settingHeader
|
||||
|
||||
// Persist edits automatically (debounced) so the transaction preview updates in realtime.
|
||||
const tablesRef = useRef(tables)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
call({ statement_import_id: data.doc.name, tables: tablesRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_('Could not save the table settings.')))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// After a bbox change, re-extract that table's rows from the new region (debounced).
|
||||
// The target is read inside the timeout so it always reflects the committed bbox.
|
||||
const scheduleReextract = (tableIndex: number) => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(reextractTimer.current)
|
||||
reextractTimer.current = setTimeout(() => {
|
||||
const target = tablesRef.current[tableIndex]
|
||||
reextract({
|
||||
statement_import_id: data.doc.name,
|
||||
page: target.page,
|
||||
table_index: target.table_index,
|
||||
bbox: target.bbox,
|
||||
})
|
||||
.then((res) => {
|
||||
commitTables(res?.message?.pdf_tables ?? [])
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_('Could not re-extract the table.')))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimeout(saveTimer.current)
|
||||
clearTimeout(reextractTimer.current)
|
||||
}, [])
|
||||
|
||||
const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables])
|
||||
const currentPage = pages[pageIndex]
|
||||
// Keep the table's position in the flat array so edits target the right one.
|
||||
const pageTables = useMemo(
|
||||
() => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage),
|
||||
[tables, currentPage],
|
||||
)
|
||||
|
||||
// Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state.
|
||||
const commitTables = (next: PDFTable[]) => {
|
||||
tablesRef.current = next
|
||||
setTables(next)
|
||||
}
|
||||
|
||||
const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t)))
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
updateTable(tableIndex, (table) => ({
|
||||
...table,
|
||||
column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo),
|
||||
}))
|
||||
}
|
||||
|
||||
const onToggleIncluded = (tableIndex: number, included: boolean) =>
|
||||
updateTable(tableIndex, (table) => ({ ...table, included }))
|
||||
|
||||
const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t)))
|
||||
scheduleReextract(tableIndex)
|
||||
}
|
||||
|
||||
// Set/clear the header row of a table; the backend re-derives the column mapping.
|
||||
const onSetHeader = (tableIndex: number, headerIndex: number | null) => {
|
||||
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t)))
|
||||
const target = tablesRef.current[tableIndex]
|
||||
setHeaderCall({
|
||||
statement_import_id: data.doc.name,
|
||||
page: target.page,
|
||||
table_index: target.table_index,
|
||||
header_index: headerIndex ?? -1,
|
||||
})
|
||||
.then((res) => {
|
||||
commitTables(res?.message?.pdf_tables ?? [])
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_('Could not update the header row.')))
|
||||
}
|
||||
|
||||
if (tables.length === 0) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No tables were extracted from this PDF.')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
|
||||
<Paragraph className="text-p-sm">
|
||||
{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
|
||||
<TabsList variant="subtle">
|
||||
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
|
||||
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{busy && (
|
||||
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{reextracting ? _('Re-extracting') : _('Saving')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex === 0}
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="min-w-24 text-center text-sm text-ink-gray-7">
|
||||
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex >= pages.length - 1}
|
||||
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'pdf' ? (
|
||||
<PageView
|
||||
pageTables={pageTables}
|
||||
disabled={isCompleted}
|
||||
onToggleIncluded={onToggleIncluded}
|
||||
onBboxCommit={onBboxCommit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const isCollapsed = collapsed.has(index)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<span className="ps-1 text-sm font-medium text-ink-gray-8">
|
||||
{_('Table {0}', [(position + 1).toString()])}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<IncludeToggle
|
||||
id={`tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={isCompleted}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
|
||||
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-auto border-t border-outline-gray-2">
|
||||
<RawTableGrid
|
||||
rows={table.rows}
|
||||
columnMapping={columnMappingRecord(table)}
|
||||
headerIndex={table.header_index}
|
||||
editable
|
||||
disabled={isCompleted}
|
||||
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
|
||||
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PageViewProps = {
|
||||
pageTables: { table: PDFTable; index: number }[]
|
||||
disabled: boolean
|
||||
onToggleIncluded: (tableIndex: number, included: boolean) => void
|
||||
onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void
|
||||
}
|
||||
|
||||
const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const pageImage = pageTables[0]?.table.page_image
|
||||
const pageWidth = pageTables[0]?.table.page_width ?? 1
|
||||
const pageHeight = pageTables[0]?.table.page_height ?? 1
|
||||
|
||||
if (!pageImage) {
|
||||
return (
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No page image is available for this page.')}
|
||||
</Paragraph>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!disabled && (
|
||||
<Paragraph className="text-xs text-ink-gray-5">
|
||||
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
|
||||
<img src={pageImage} alt={_('Page preview')} className="w-full" />
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<BBoxOverlay
|
||||
key={index}
|
||||
bbox={table.bbox}
|
||||
pageWidth={pageWidth}
|
||||
pageHeight={pageHeight}
|
||||
color={color}
|
||||
label={_('Table {0}', [(position + 1).toString()])}
|
||||
included={table.included}
|
||||
disabled={disabled}
|
||||
containerRef={containerRef}
|
||||
onCommit={(bbox) => onBboxCommit(index, bbox)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-3 rounded-sm', color.swatch)} />
|
||||
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
|
||||
</div>
|
||||
<IncludeToggle
|
||||
id={`pdf-tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IncludeToggle = ({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
|
||||
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default PDFTableEditor
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ArrowDownRightIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpRightIcon,
|
||||
BanknoteIcon,
|
||||
CalendarIcon,
|
||||
DollarSignIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
ReceiptIcon,
|
||||
} from 'lucide-react'
|
||||
import _ from '@/lib/translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
|
||||
|
||||
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
|
||||
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
|
||||
|
||||
type Props = {
|
||||
rows: string[][]
|
||||
/** Column index -> mapped field */
|
||||
columnMapping: Record<number, ColumnMapsTo>
|
||||
headerIndex: number | null
|
||||
editable?: boolean
|
||||
disabled?: boolean
|
||||
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
|
||||
/** Set the header row (or null to mark the table as having no header). */
|
||||
onSetHeader?: (rowIndex: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
|
||||
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
|
||||
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
|
||||
* set/clear the header row.
|
||||
*/
|
||||
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
|
||||
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
|
||||
const stringRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
|
||||
[rows],
|
||||
)
|
||||
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
|
||||
|
||||
const validColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
|
||||
const amountColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
|
||||
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
|
||||
const transactionRows = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
if (dateColumn === undefined) return set
|
||||
const dateIdx = Number(dateColumn)
|
||||
stringRows.forEach((row, index) => {
|
||||
if (index === headerIndex) return
|
||||
const dateCell = (row[dateIdx] ?? '').trim()
|
||||
if (!dateCell || !DATE_LIKE.test(dateCell)) return
|
||||
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
|
||||
})
|
||||
return set
|
||||
}, [stringRows, headerIndex, dateColumn, amountColumns])
|
||||
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{editable && (
|
||||
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
|
||||
<TableHead className="w-8 p-1" />
|
||||
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
|
||||
<TableHead key={columnIndex} className="p-1 align-top">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={columnMapping[columnIndex] ?? 'Do not import'}
|
||||
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
|
||||
>
|
||||
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ColumnHeaderIcon columnType={option} />
|
||||
{_(option)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{stringRows.map((row, index) => {
|
||||
const isHeaderRow = index === headerIndex
|
||||
const isTransactionRow = transactionRows.has(index)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={cn({
|
||||
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
|
||||
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
|
||||
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
|
||||
})}
|
||||
>
|
||||
{editable && onSetHeader ? (
|
||||
<TableCell className="h-px w-8 p-0 text-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSetHeader(isHeaderRow ? null : index)}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
|
||||
isHeaderRow && 'font-semibold text-ink-gray-8',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isHeaderRow
|
||||
? _('This is the header row. Click to mark the table as having no header.')
|
||||
: _('Click to set this as the header row.')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
|
||||
const columnType = columnMapping[cellIndex]
|
||||
const isValidColumn = validColumns.includes(cellIndex)
|
||||
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
|
||||
const cellText = row[cellIndex] ?? ''
|
||||
|
||||
// Read-only header row: icon + label.
|
||||
if (isHeaderRow) {
|
||||
return (
|
||||
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
|
||||
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
|
||||
{columnType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{_(columnType)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cellIndex}
|
||||
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
|
||||
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
|
||||
'text-ink-gray-5': !isValidColumn && isTransactionRow,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('min-h-5 flex items-center px-1 text-xs', {
|
||||
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
|
||||
})}
|
||||
title={cellText}
|
||||
>
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
|
||||
switch (columnType) {
|
||||
case 'Amount':
|
||||
return <DollarSignIcon className="size-4" />
|
||||
case 'Withdrawal':
|
||||
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
|
||||
case 'Deposit':
|
||||
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
|
||||
case 'Balance':
|
||||
return <BanknoteIcon className="size-4" />
|
||||
case 'Date':
|
||||
return <CalendarIcon className="size-4" />
|
||||
case 'Description':
|
||||
return <FileTextIcon className="size-4" />
|
||||
case 'Reference':
|
||||
return <ReceiptIcon className="size-4" />
|
||||
case 'Transaction Type':
|
||||
return <ListIcon className="size-4" />
|
||||
case 'Debit/Credit':
|
||||
return <ArrowUpDownIcon className="size-4" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default RawTableGrid
|
||||
@@ -1,6 +1,97 @@
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
|
||||
export type ColumnMapsTo =
|
||||
| "Do not import"
|
||||
| "Date"
|
||||
| "Withdrawal"
|
||||
| "Deposit"
|
||||
| "Amount"
|
||||
| "Description"
|
||||
| "Reference"
|
||||
| "Transaction Type"
|
||||
| "Debit/Credit"
|
||||
| "Balance"
|
||||
| "Included Fee"
|
||||
| "Excluded Fee"
|
||||
| "Party Name/Account Holder"
|
||||
| "Party Account No."
|
||||
| "Party IBAN"
|
||||
|
||||
export type ColumnMappingEntry = {
|
||||
index: number
|
||||
maps_to: ColumnMapsTo | string
|
||||
header_text?: string
|
||||
variable?: string
|
||||
}
|
||||
|
||||
/** Apply a column mapping change, clearing the same mapping from any other column. */
|
||||
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
|
||||
columns: T[],
|
||||
columnIndex: number,
|
||||
mapsTo: ColumnMapsTo,
|
||||
): T[] {
|
||||
const previous = columns.find((c) => c.index === columnIndex)
|
||||
const cleared =
|
||||
mapsTo === "Do not import"
|
||||
? columns
|
||||
: columns.map((c) =>
|
||||
c.index !== columnIndex && c.maps_to === mapsTo
|
||||
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
|
||||
: c,
|
||||
)
|
||||
|
||||
return [
|
||||
...cleared.filter((c) => c.index !== columnIndex),
|
||||
{
|
||||
index: columnIndex,
|
||||
maps_to: mapsTo,
|
||||
header_text: previous?.header_text ?? "",
|
||||
variable: previous?.variable ?? `column_${columnIndex}`,
|
||||
} as T,
|
||||
].sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
|
||||
"Do not import",
|
||||
"Date",
|
||||
"Description",
|
||||
"Reference",
|
||||
"Withdrawal",
|
||||
"Deposit",
|
||||
"Amount",
|
||||
"Balance",
|
||||
"Debit/Credit",
|
||||
"Transaction Type",
|
||||
"Included Fee",
|
||||
"Excluded Fee",
|
||||
"Party Name/Account Holder",
|
||||
"Party Account No.",
|
||||
"Party IBAN",
|
||||
]
|
||||
|
||||
export interface PDFTableColumn {
|
||||
index: number
|
||||
header_text: string
|
||||
variable?: string
|
||||
maps_to: ColumnMapsTo
|
||||
}
|
||||
|
||||
export interface PDFTable {
|
||||
page: number
|
||||
table_index: number
|
||||
bbox: [number, number, number, number]
|
||||
page_width: number
|
||||
page_height: number
|
||||
page_image: string | null
|
||||
render_scale: number | null
|
||||
rows: string[][]
|
||||
header_index: number | null
|
||||
column_mapping: PDFTableColumn[]
|
||||
date_format?: string
|
||||
amount_format?: string
|
||||
included: boolean
|
||||
}
|
||||
|
||||
export interface GetStatementDetailsResponse {
|
||||
doc: BankStatementImportLog,
|
||||
@@ -30,6 +121,7 @@ export interface GetStatementDetailsResponse {
|
||||
date_format: string,
|
||||
raw_data: Array<Array<string>>,
|
||||
currency: string,
|
||||
pdf_tables?: PDFTable[],
|
||||
}
|
||||
|
||||
export const useGetStatementDetails = (id: string) => {
|
||||
@@ -39,4 +131,24 @@ export const useGetStatementDetails = (id: string) => {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export const useUpdatePDFTables = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
|
||||
}
|
||||
|
||||
export const useReextractPDFTable = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
|
||||
}
|
||||
|
||||
export const useSetPDFTableHeader = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
|
||||
}
|
||||
|
||||
export const useUpdateColumnMapping = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
|
||||
}
|
||||
|
||||
export const useSetHeaderIndex = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export const FileTypeIcon = ({
|
||||
const getTextColor = () => {
|
||||
switch (fileType.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return 'text-red-700'
|
||||
return 'text-ink-red-3'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'text-[#1A5CBD]'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { H3, Paragraph } from "@/components/ui/typography"
|
||||
@@ -16,7 +17,7 @@ import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
|
||||
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { ListIcon, Loader2Icon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
@@ -30,11 +31,15 @@ const BankStatementImporter = () => {
|
||||
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const { upload, error, loading } = useFrappeFileUpload()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
|
||||
const { updateDoc, error: updateError } = useFrappeUpdateDoc()
|
||||
|
||||
const isPdf = files[0]?.name?.toLowerCase().endsWith(".pdf") ?? false
|
||||
|
||||
const onUpload = () => {
|
||||
|
||||
@@ -44,12 +49,18 @@ const BankStatementImporter = () => {
|
||||
|
||||
const id = `new-bank-statement-import-log-${Date.now()}`
|
||||
|
||||
upload(files[0], {
|
||||
// For protected PDFs, persist the password on the Bank Account so it is reused for
|
||||
// every statement of this account (and is available before the import doc is created).
|
||||
const ensurePassword = isPdf && password
|
||||
? updateDoc("Bank Account", selectedBankAccount.name, { statement_password: password })
|
||||
: Promise.resolve()
|
||||
|
||||
ensurePassword.then(() => upload(files[0], {
|
||||
isPrivate: true,
|
||||
doctype: "Bank Statement Import Log",
|
||||
docname: id,
|
||||
fieldname: 'file'
|
||||
}).then((file) => {
|
||||
})).then((file) => {
|
||||
return createDoc("Bank Statement Import Log",
|
||||
// @ts-expect-error - not filling everything else
|
||||
{
|
||||
@@ -67,6 +78,7 @@ const BankStatementImporter = () => {
|
||||
<div className="w-[52%]">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{createError && <ErrorBanner error={createError} />}
|
||||
{updateError && <ErrorBanner error={updateError} />}
|
||||
<div className="py-2 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
|
||||
@@ -89,7 +101,7 @@ const BankStatementImporter = () => {
|
||||
data-slot="form-description"
|
||||
className={cn("text-ink-gray-5 text-xs")}
|
||||
>
|
||||
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
|
||||
{_("Upload your bank statement file to start the import process. We support CSV, XLSX and PDF files.")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -105,10 +117,27 @@ const BankStatementImporter = () => {
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/pdf': ['.pdf'],
|
||||
// 'application/xml': ['.xml'],
|
||||
}}
|
||||
multiple={false}
|
||||
/>
|
||||
|
||||
{isPdf && <div className="flex flex-col gap-2">
|
||||
<Label htmlFor="pdf-password">{_("PDF Password")}</Label>
|
||||
<Input
|
||||
id="pdf-password"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={_("Only if the PDF is password protected")}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<p data-slot="form-description" className={cn("text-ink-gray-5 text-p-sm")}>
|
||||
{_("Leave blank to use the password already saved for this bank account (if any). It is stored encrypted and reused for future statements.")}
|
||||
</p>
|
||||
</div>}
|
||||
</div>}
|
||||
<div className="flex justify-end px-4">
|
||||
<Button
|
||||
@@ -137,9 +166,10 @@ const StatementInstructions = () => {
|
||||
<DialogContent className="min-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
|
||||
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
||||
<DialogDescription>{_("We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
|
||||
<Paragraph className="text-sm text-ink-gray-6">{_("For PDF statements, we auto-detect the tables on each page. You can then confirm each detected table, map its columns, and exclude anything that is not transactions (e.g. ads or summaries). Password-protected PDFs are supported - the password is saved on the bank account and reused.")}</Paragraph>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -231,7 +261,13 @@ const StatementImportLog = () => {
|
||||
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
|
||||
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
|
||||
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell>
|
||||
{item.start_date && item.end_date ? (
|
||||
<span>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
|
||||
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
|
||||
<TableCell><a
|
||||
|
||||
@@ -9,12 +9,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||
const PDFImport = lazy(() => import('@/components/features/BankStatementImporter/PDF/PDFImport'))
|
||||
|
||||
const ViewBankStatementImportLog = () => {
|
||||
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
|
||||
const { data, isLoading, error, mutate } = useGetStatementDetails(id ?? "")
|
||||
|
||||
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
|
||||
})
|
||||
@@ -42,7 +43,13 @@ const ViewBankStatementImportLog = () => {
|
||||
<ErrorBanner error={error} />
|
||||
</div>
|
||||
}
|
||||
return <CSVImport data={data} />
|
||||
const isPdf = data.message.doc.file?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
if (isPdf) {
|
||||
return <PDFImport data={data} mutate={mutate} />
|
||||
}
|
||||
|
||||
return <CSVImport data={data} mutate={mutate} />
|
||||
}
|
||||
|
||||
export default ViewBankStatementImportLog
|
||||
@@ -38,6 +38,8 @@ export interface BankAccount{
|
||||
branch_code?: string
|
||||
/** Bank Account No : Data */
|
||||
bank_account_no?: string
|
||||
/** Statement PDF Password : Password - Password used to open password-protected PDF statements for this account. Stored encrypted. */
|
||||
statement_password?: string
|
||||
/** Is Credit Card : Check */
|
||||
is_credit_card?: 0 | 1
|
||||
/** Integration ID : Data */
|
||||
|
||||
@@ -47,4 +47,6 @@ export interface BankStatementImportLog {
|
||||
detected_transaction_ending_index?: number
|
||||
/** Column Mapping : Table - Bank Statement Import Log Column Map */
|
||||
column_mapping?: BankStatementImportLogColumnMap[]
|
||||
/** PDF Tables : JSON - Per-table extraction data for PDF statements */
|
||||
pdf_tables?: string
|
||||
}
|
||||
@@ -592,10 +592,12 @@ def update_account_number(
|
||||
@frappe.whitelist()
|
||||
def merge_account(old: str, new: str):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
new_account.check_permission("write")
|
||||
old_account.check_permission("write")
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"country_code": "nz",
|
||||
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||
"disabled": "No",
|
||||
"tree": {
|
||||
"Application of Funds (Assets)": {
|
||||
"Current Assets": {
|
||||
"Bank Accounts": {
|
||||
"Business Transaction Account": {
|
||||
"account_number": "11011",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Business Savings Account": {
|
||||
"account_number": "11012",
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"account_number": "11010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Cash on Hand": {
|
||||
"account_number": "11020",
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Accounts Receivable": {
|
||||
"Debtors": {
|
||||
"account_number": "11210",
|
||||
"account_type": "Receivable"
|
||||
},
|
||||
"Provision for Doubtful Debts": {
|
||||
"account_number": "11220"
|
||||
},
|
||||
"account_number": "11200",
|
||||
"is_group": 1
|
||||
},
|
||||
"Inventory": {
|
||||
"Stock on Hand": {
|
||||
"account_number": "11311",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Work In Progress": {
|
||||
"account_number": "11312",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"account_number": "11310",
|
||||
"account_type": "Stock",
|
||||
"is_group": 1
|
||||
},
|
||||
"Prepayments": {
|
||||
"Prepayments": {
|
||||
"account_number": "11411"
|
||||
},
|
||||
"Supplier Advances": {
|
||||
"account_number": "11412"
|
||||
},
|
||||
"Deferred Expense": {
|
||||
"account_number": "11413"
|
||||
},
|
||||
"account_number": "11410",
|
||||
"is_group": 1
|
||||
},
|
||||
"GST Receivable": {
|
||||
"account_number": "11510",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Receivable": {
|
||||
"account_number": "11520",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "11000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Fixed Assets": {
|
||||
"Plant & Equipment": {
|
||||
"Plant & Equipment": {
|
||||
"account_number": "16011",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Plant & Equipment": {
|
||||
"account_number": "16012",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Motor Vehicles": {
|
||||
"Motor Vehicles": {
|
||||
"account_number": "16021",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Motor Vehicles": {
|
||||
"account_number": "16022",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Office Equipment": {
|
||||
"Office Equipment": {
|
||||
"account_number": "16031",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Office Equipment": {
|
||||
"account_number": "16032",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16030",
|
||||
"is_group": 1
|
||||
},
|
||||
"Buildings": {
|
||||
"Buildings": {
|
||||
"account_number": "16041",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Buildings": {
|
||||
"account_number": "16042",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16040",
|
||||
"is_group": 1
|
||||
},
|
||||
"Computer Equipment": {
|
||||
"Computer Equipment": {
|
||||
"account_number": "16051",
|
||||
"account_type": "Fixed Asset"
|
||||
},
|
||||
"Accumulated Depreciation - Computer Equipment": {
|
||||
"account_number": "16052",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
},
|
||||
"account_number": "16050",
|
||||
"is_group": 1
|
||||
},
|
||||
"Capital Work in Progress": {
|
||||
"account_number": "16090",
|
||||
"account_type": "Capital Work in Progress"
|
||||
},
|
||||
"account_number": "16000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "10000",
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Source of Funds (Liabilities)": {
|
||||
"Current Liabilities": {
|
||||
"Accounts Payable": {
|
||||
"Creditors": {
|
||||
"account_number": "21010",
|
||||
"account_type": "Payable"
|
||||
},
|
||||
"account_number": "21000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Goods Received Not Invoiced": {
|
||||
"account_number": "21100",
|
||||
"account_type": "Stock Received But Not Billed"
|
||||
},
|
||||
"Asset Received Not Invoiced": {
|
||||
"account_number": "21110",
|
||||
"account_type": "Asset Received But Not Billed"
|
||||
},
|
||||
"Service Received Not Invoiced": {
|
||||
"account_number": "21120",
|
||||
"account_type": "Service Received But Not Billed"
|
||||
},
|
||||
"Accrued Expenses": {
|
||||
"account_number": "21200"
|
||||
},
|
||||
"Wages Payable": {
|
||||
"account_number": "21300"
|
||||
},
|
||||
"PAYE Payable": {
|
||||
"account_number": "22010"
|
||||
},
|
||||
"KiwiSaver Payable": {
|
||||
"account_number": "22020"
|
||||
},
|
||||
"ACC Payable": {
|
||||
"account_number": "22030"
|
||||
},
|
||||
"Credit Cards": {
|
||||
"Business Credit Card": {
|
||||
"account_number": "22110"
|
||||
},
|
||||
"account_number": "22100",
|
||||
"is_group": 1
|
||||
},
|
||||
"Customer Advances": {
|
||||
"account_number": "22200"
|
||||
},
|
||||
"Deferred Revenue": {
|
||||
"account_number": "22210"
|
||||
},
|
||||
"Provisional Account": {
|
||||
"account_number": "22220"
|
||||
},
|
||||
"Tax Liabilities": {
|
||||
"GST Payable": {
|
||||
"account_number": "22310",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"GST Suspense": {
|
||||
"account_number": "22320",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"FBT Payable": {
|
||||
"account_number": "22330",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Income Tax Payable": {
|
||||
"account_number": "22340",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"account_number": "22300",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "21500",
|
||||
"is_group": 1
|
||||
},
|
||||
"Non-Current Liabilities": {
|
||||
"Bank Loans": {
|
||||
"Bank Loan": {
|
||||
"account_number": "25011"
|
||||
},
|
||||
"account_number": "25010",
|
||||
"is_group": 1
|
||||
},
|
||||
"Lease Liabilities": {
|
||||
"Lease Liability": {
|
||||
"account_number": "25021"
|
||||
},
|
||||
"account_number": "25020",
|
||||
"is_group": 1
|
||||
},
|
||||
"Shareholder Loans": {
|
||||
"Shareholder Loan": {
|
||||
"account_number": "25031"
|
||||
},
|
||||
"account_number": "25030",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "25000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "20000",
|
||||
"root_type": "Liability"
|
||||
},
|
||||
"Equity": {
|
||||
"Share Capital": {
|
||||
"account_number": "31010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Drawings": {
|
||||
"account_number": "31020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Current Year Earnings": {
|
||||
"account_number": "35010",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"Retained Earnings": {
|
||||
"account_number": "35020",
|
||||
"account_type": "Equity"
|
||||
},
|
||||
"account_number": "30000",
|
||||
"root_type": "Equity"
|
||||
},
|
||||
"Income": {
|
||||
"Sales": {
|
||||
"account_number": "41010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Other Income": {
|
||||
"Interest Income": {
|
||||
"account_number": "47010",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Rounding Gain/Loss": {
|
||||
"account_number": "47020",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"Foreign Exchange Gain": {
|
||||
"account_number": "47030",
|
||||
"account_type": "Income Account"
|
||||
},
|
||||
"account_number": "47000",
|
||||
"is_group": 1
|
||||
},
|
||||
"account_number": "40000",
|
||||
"root_type": "Income"
|
||||
},
|
||||
"Expenses": {
|
||||
"Cost of Goods Sold": {
|
||||
"Purchases": {
|
||||
"account_number": "51010",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
},
|
||||
"Freight Inwards": {
|
||||
"account_number": "51020",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Duty and Landing Costs": {
|
||||
"account_number": "51030",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "51040",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Stock Write Off": {
|
||||
"account_number": "51050",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"account_number": "51000",
|
||||
"account_type": "Cost of Goods Sold",
|
||||
"is_group": 1
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"Wages & Salaries": {
|
||||
"account_number": "61010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"KiwiSaver Employer Contribution": {
|
||||
"account_number": "61020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"ACC Levies": {
|
||||
"account_number": "61030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rent": {
|
||||
"account_number": "65010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Power": {
|
||||
"account_number": "65020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Telephone": {
|
||||
"account_number": "66010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Insurance": {
|
||||
"account_number": "64010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Accounting Fees": {
|
||||
"account_number": "64020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Legal Fees": {
|
||||
"account_number": "64030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Advertising and Marketing": {
|
||||
"account_number": "65030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Repairs and Maintenance": {
|
||||
"account_number": "65040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Freight and Courier": {
|
||||
"account_number": "65050",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Operating Costs": {
|
||||
"account_number": "65060",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "60000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Depreciation and Amortisation": {
|
||||
"Depreciation - Plant & Equipment": {
|
||||
"account_number": "62010",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Motor Vehicles": {
|
||||
"account_number": "62020",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Office Equipment": {
|
||||
"account_number": "62030",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"Depreciation - Computer Equipment": {
|
||||
"account_number": "62040",
|
||||
"account_type": "Depreciation"
|
||||
},
|
||||
"account_number": "62000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Finance Costs": {
|
||||
"Bank Charges": {
|
||||
"account_number": "67010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Interest Expense": {
|
||||
"account_number": "67020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Rounding Off": {
|
||||
"account_number": "67030",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Payment Discounts": {
|
||||
"account_number": "67040",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "67000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Income Tax Expense": {
|
||||
"account_number": "81010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Foreign Exchange": {
|
||||
"Exchange Gain/Loss": {
|
||||
"account_number": "82010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Unrealized Exchange Gain/Loss": {
|
||||
"account_number": "82020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"account_number": "82000",
|
||||
"is_group": 1
|
||||
},
|
||||
"Bad Debts": {
|
||||
"account_number": "83010",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "83020",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Gain/Loss on Asset Disposal": {
|
||||
"account_number": "83030",
|
||||
"account_type": "Expense Account"
|
||||
},
|
||||
"Expenses Included In Asset Valuation": {
|
||||
"account_number": "84010",
|
||||
"account_type": "Expenses Included In Asset Valuation"
|
||||
},
|
||||
"account_number": "50000",
|
||||
"root_type": "Expense"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,6 +570,17 @@
|
||||
"account_number": "5000",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5001",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5010",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
}
|
||||
},
|
||||
"Operating Expenses": {
|
||||
"account_number": "5100",
|
||||
"is_group": 1,
|
||||
|
||||
@@ -198,21 +198,9 @@ def add_dimension_to_budget_doctype(df, doc):
|
||||
def delete_accounting_dimension(doc):
|
||||
doclist = get_doctypes_with_dimensions()
|
||||
|
||||
frappe.db.sql(
|
||||
"""
|
||||
DELETE FROM `tabCustom Field`
|
||||
WHERE fieldname = {}
|
||||
AND dt IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
||||
tuple([doc.fieldname, *doclist]),
|
||||
)
|
||||
frappe.db.delete("Custom Field", filters={"fieldname": doc.fieldname, "dt": ["in", doclist]})
|
||||
|
||||
frappe.db.sql(
|
||||
"""
|
||||
DELETE FROM `tabProperty Setter`
|
||||
WHERE field_name = {}
|
||||
AND doc_type IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
||||
tuple([doc.fieldname, *doclist]),
|
||||
)
|
||||
frappe.db.delete("Property Setter", filters={"field_name": doc.fieldname, "doc_type": ["in", doclist]})
|
||||
|
||||
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
|
||||
value_list = budget_against_property.value.split("\n")[3:]
|
||||
@@ -273,13 +261,27 @@ def get_accounting_dimensions(as_list=True):
|
||||
|
||||
|
||||
def get_checks_for_pl_and_bs_accounts():
|
||||
return frappe.db.sql(
|
||||
"""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs
|
||||
FROM `tabAccounting Dimension`p ,`tabAccounting Dimension Detail` c
|
||||
WHERE p.name = c.parent AND p.disabled = 0""",
|
||||
as_dict=1,
|
||||
AccountingDimension = frappe.qb.DocType("Accounting Dimension")
|
||||
AccountingDimensionDetail = frappe.qb.DocType("Accounting Dimension Detail")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingDimension)
|
||||
.join(AccountingDimensionDetail)
|
||||
.on(AccountingDimension.name == AccountingDimensionDetail.parent)
|
||||
.select(
|
||||
AccountingDimension.label,
|
||||
AccountingDimension.disabled,
|
||||
AccountingDimension.fieldname,
|
||||
AccountingDimensionDetail.default_dimension,
|
||||
AccountingDimensionDetail.company,
|
||||
AccountingDimensionDetail.mandatory_for_pl,
|
||||
AccountingDimensionDetail.mandatory_for_bs,
|
||||
)
|
||||
.where(AccountingDimension.disabled == 0)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def get_dimension_with_children(doctype, dimensions):
|
||||
if isinstance(dimensions, str):
|
||||
|
||||
@@ -43,18 +43,19 @@ class AccountingDimensionFilter(Document):
|
||||
self.validate_applicable_accounts()
|
||||
|
||||
def validate_applicable_accounts(self):
|
||||
accounts = frappe.db.sql(
|
||||
"""
|
||||
SELECT a.applicable_on_account as account
|
||||
FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d
|
||||
WHERE d.name = a.parent
|
||||
and d.name != %s
|
||||
and d.accounting_dimension = %s
|
||||
""",
|
||||
(self.name, self.accounting_dimension),
|
||||
as_dict=1,
|
||||
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ApplicableOnAccount)
|
||||
.join(AccountingDimensionFilter)
|
||||
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||
.select(ApplicableOnAccount.applicable_on_account.as_("account"))
|
||||
.where(AccountingDimensionFilter.name != self.name)
|
||||
.where(AccountingDimensionFilter.accounting_dimension == self.accounting_dimension)
|
||||
)
|
||||
|
||||
accounts = query.run(as_dict=1)
|
||||
account_list = [d.account for d in accounts]
|
||||
|
||||
for account in self.get("accounts"):
|
||||
@@ -69,22 +70,28 @@ class AccountingDimensionFilter(Document):
|
||||
|
||||
|
||||
def get_dimension_filter_map():
|
||||
filters = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
|
||||
p.allow_or_restrict, p.fieldname, a.is_mandatory
|
||||
FROM
|
||||
`tabApplicable On Account` a,
|
||||
`tabAccounting Dimension Filter` p
|
||||
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
|
||||
WHERE
|
||||
p.name = a.parent
|
||||
AND p.disabled = 0
|
||||
""",
|
||||
as_dict=1,
|
||||
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||
AllowedDimension = frappe.qb.DocType("Allowed Dimension")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingDimensionFilter)
|
||||
.join(ApplicableOnAccount)
|
||||
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||
.left_join(AllowedDimension)
|
||||
.on(AllowedDimension.parent == AccountingDimensionFilter.name)
|
||||
.select(
|
||||
ApplicableOnAccount.applicable_on_account,
|
||||
AllowedDimension.dimension_value,
|
||||
AccountingDimensionFilter.accounting_dimension,
|
||||
AccountingDimensionFilter.allow_or_restrict,
|
||||
AccountingDimensionFilter.fieldname,
|
||||
ApplicableOnAccount.is_mandatory,
|
||||
)
|
||||
.where(AccountingDimensionFilter.disabled == 0)
|
||||
)
|
||||
|
||||
filters = query.run(as_dict=1)
|
||||
dimension_filter_map = {}
|
||||
|
||||
for f in filters:
|
||||
|
||||
@@ -46,23 +46,19 @@ class AccountingPeriod(Document):
|
||||
self.name = " - ".join([self.period_name, company_abbr])
|
||||
|
||||
def validate_overlap(self):
|
||||
existing_accounting_period = frappe.db.sql(
|
||||
"""select name from `tabAccounting Period`
|
||||
where (
|
||||
(%(start_date)s between start_date and end_date)
|
||||
or (%(end_date)s between start_date and end_date)
|
||||
or (start_date between %(start_date)s and %(end_date)s)
|
||||
or (end_date between %(start_date)s and %(end_date)s)
|
||||
) and name!=%(name)s and company=%(company)s""",
|
||||
{
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"name": self.name,
|
||||
"company": self.company,
|
||||
},
|
||||
as_dict=True,
|
||||
AccountingPeriod = frappe.qb.DocType("Accounting Period")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(AccountingPeriod)
|
||||
.select(AccountingPeriod.name)
|
||||
.where(AccountingPeriod.start_date <= self.end_date)
|
||||
.where(AccountingPeriod.end_date >= self.start_date)
|
||||
.where(AccountingPeriod.name != self.name)
|
||||
.where(AccountingPeriod.company == self.company)
|
||||
)
|
||||
|
||||
existing_accounting_period = query.run(as_dict=True)
|
||||
|
||||
if len(existing_accounting_period) > 0:
|
||||
frappe.throw(
|
||||
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),
|
||||
|
||||
@@ -10,6 +10,9 @@ frappe.ui.form.on("Accounts Settings", {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||
|
||||
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||
},
|
||||
enable_immutable_ledger: function (frm) {
|
||||
if (!frm.doc.enable_immutable_ledger) {
|
||||
@@ -49,3 +52,16 @@ function toggle_tax_settings(frm, field_name) {
|
||||
frm.set_value(other_field, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function get_transactions(frm) {
|
||||
const transactions = [
|
||||
{ label: __("Journal Entry"), doctype: "Journal Entry" },
|
||||
{ label: __("Payment Entry"), doctype: "Payment Entry" },
|
||||
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
|
||||
{ label: __("Purchase Order"), doctype: "Purchase Order" },
|
||||
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
|
||||
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
|
||||
];
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
"confirm_before_resetting_posting_date",
|
||||
"preview_mode",
|
||||
"analytics_section",
|
||||
"enable_discounts_and_margin",
|
||||
"enable_accounting_dimensions",
|
||||
"column_break_vtnr",
|
||||
"enable_discounts_and_margin",
|
||||
"journals_section",
|
||||
"merge_similar_account_heads",
|
||||
"deferred_accounting_settings_section",
|
||||
@@ -44,7 +44,6 @@
|
||||
"print_settings",
|
||||
"show_inclusive_tax_in_print",
|
||||
"show_taxes_as_table_in_print",
|
||||
"column_break_12",
|
||||
"show_payment_schedule_in_print",
|
||||
"item_price_settings_section",
|
||||
"maintain_same_internal_transaction_rate",
|
||||
@@ -60,29 +59,30 @@
|
||||
"payments_tab",
|
||||
"section_break_jpd0",
|
||||
"auto_reconcile_payments",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"auto_reconciliation_job_trigger",
|
||||
"reconciliation_queue_size",
|
||||
"column_break_resa",
|
||||
"exchange_gain_loss_posting_date",
|
||||
"repost_section",
|
||||
"column_break_mfor",
|
||||
"repost_allowed_types",
|
||||
"payment_options_section",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"enable_loyalty_point_program",
|
||||
"column_break_ctam",
|
||||
"fetch_payment_schedule_in_payment_request",
|
||||
"invoicing_settings_tab",
|
||||
"accounts_transactions_settings_section",
|
||||
"over_billing_allowance",
|
||||
"column_break_11",
|
||||
"role_allowed_to_over_bill",
|
||||
"credit_controller",
|
||||
"make_payment_via_journal_entry",
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"column_break_11",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
"calculate_depr_using_total_days",
|
||||
"column_break_gjcc",
|
||||
"book_asset_depreciation_entry_automatically",
|
||||
"calculate_depr_using_total_days",
|
||||
"role_to_notify_on_depreciation_failure",
|
||||
"column_break_gjcc",
|
||||
"closing_settings_tab",
|
||||
"period_closing_settings_section",
|
||||
"ignore_account_closing_balance",
|
||||
@@ -91,8 +91,8 @@
|
||||
"reports_tab",
|
||||
"remarks_section",
|
||||
"general_ledger_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"receivable_payable_remarks_length",
|
||||
"column_break_lvjk",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
@@ -104,13 +104,15 @@
|
||||
"show_balance_in_coa",
|
||||
"banking_section",
|
||||
"enable_party_matching",
|
||||
"automatically_run_rules_on_unreconciled_transactions",
|
||||
"enable_fuzzy_matching",
|
||||
"transfer_match_days",
|
||||
"automatically_run_rules_on_unreconciled_transactions",
|
||||
"payment_request_section",
|
||||
"create_pr_in_draft_status",
|
||||
"budget_section",
|
||||
"use_legacy_budget_controller"
|
||||
"use_legacy_budget_controller",
|
||||
"document_naming_tab",
|
||||
"transaction_naming_html"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -118,14 +120,14 @@
|
||||
"description": "Address used to determine Tax Category in transactions",
|
||||
"fieldname": "determine_address_tax_category_from",
|
||||
"fieldtype": "Select",
|
||||
"label": "Determine Address Tax Category From",
|
||||
"label": "Determine Address Tax Category from",
|
||||
"options": "Billing Address\nShipping Address"
|
||||
},
|
||||
{
|
||||
"fieldname": "credit_controller",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Role allowed to bypass Credit Limit",
|
||||
"label": "Role allowed to bypass credit limit",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -133,7 +135,7 @@
|
||||
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
|
||||
"fieldname": "check_supplier_invoice_uniqueness",
|
||||
"fieldtype": "Check",
|
||||
"label": "Check Supplier Invoice Number Uniqueness"
|
||||
"label": "Check Supplier invoice number uniqueness"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -144,27 +146,29 @@
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
|
||||
"fieldname": "unlink_payment_on_cancellation_of_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Payment on Cancellation of Invoice"
|
||||
"label": "Unlink Payment on cancellation of invoice"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
|
||||
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Unlink Advance Payment on Cancellation of Order"
|
||||
"label": "Unlink Advance Payment on cancellation of order"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "book_asset_depreciation_entry_automatically",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Asset Depreciation Entry Automatically"
|
||||
"label": "Book Asset Depreciation entry automatically"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "add_taxes_from_item_tax_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Add Taxes and Charges from Item Tax Template"
|
||||
"label": "Automatically add Taxes and Charges from Item Tax Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "print_settings",
|
||||
@@ -175,17 +179,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "show_inclusive_tax_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Inclusive Tax in Print"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
"label": "Show inclusive tax in print"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_payment_schedule_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Payment Schedule in Print"
|
||||
"label": "Show Payment Schedule in print"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency_exchange_section",
|
||||
@@ -211,7 +211,7 @@
|
||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||
"fieldname": "automatically_fetch_payment_terms",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
||||
"label": "Automatically fetch Payment Terms from Order/Quotation"
|
||||
},
|
||||
{
|
||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||
@@ -223,7 +223,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "automatically_process_deferred_accounting_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Process Deferred Accounting Entry"
|
||||
"label": "Automatically process deferred Accounting entry"
|
||||
},
|
||||
{
|
||||
"fieldname": "deferred_accounting_settings_section",
|
||||
@@ -239,7 +239,7 @@
|
||||
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
|
||||
"fieldname": "book_deferred_entries_via_journal_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Deferred Entries Via Journal Entry"
|
||||
"label": "Book deferred entries via Journal Entry"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -247,38 +247,37 @@
|
||||
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
|
||||
"fieldname": "submit_journal_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Submit Journal Entries"
|
||||
"label": "Submit Journal entries"
|
||||
},
|
||||
{
|
||||
"default": "Days",
|
||||
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
|
||||
"fieldname": "book_deferred_entries_based_on",
|
||||
"fieldtype": "Select",
|
||||
"label": "Book Deferred Entries Based On",
|
||||
"label": "Book Deferred entries based on",
|
||||
"options": "Days\nMonths"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "delete_linked_ledger_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
|
||||
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.over_billing_allowance > 0",
|
||||
"description": "Users with this role are allowed to over bill above the allowance percentage",
|
||||
"fieldname": "role_allowed_to_over_bill",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Over Bill ",
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Period Closing Settings"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "accounts_transactions_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Credit Limit Settings"
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_11",
|
||||
@@ -363,14 +362,14 @@
|
||||
"default": "1",
|
||||
"fieldname": "show_balance_in_coa",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Balances in Chart Of Accounts"
|
||||
"label": "Show balances in Chart of Accounts"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
|
||||
"fieldname": "book_tax_discount_loss",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Tax Loss on Early Payment Discount"
|
||||
"label": "Book tax loss on early payment discount"
|
||||
},
|
||||
{
|
||||
"fieldname": "journals_section",
|
||||
@@ -382,7 +381,7 @@
|
||||
"description": "Rows with Same Account heads will be merged on Ledger",
|
||||
"fieldname": "merge_similar_account_heads",
|
||||
"fieldtype": "Check",
|
||||
"label": "Merge Similar Account Heads"
|
||||
"label": "Merge similar Account Heads"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jpd0",
|
||||
@@ -393,13 +392,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "auto_reconcile_payments",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Reconcile Payments"
|
||||
"label": "Auto reconcile Payments"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_taxes_as_table_in_print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Taxes as Table in Print"
|
||||
"label": "Show taxes as table in print"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -421,14 +420,14 @@
|
||||
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
|
||||
"fieldname": "ignore_account_closing_balance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Ignore Account Closing Balance"
|
||||
"label": "Ignore Account closing balance"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Tax Amount will be rounded on a row(items) level",
|
||||
"fieldname": "round_row_wise_tax",
|
||||
"fieldtype": "Check",
|
||||
"label": "Round Tax Amount Row-wise"
|
||||
"label": "Round tax amount row-wise"
|
||||
},
|
||||
{
|
||||
"fieldname": "reports_tab",
|
||||
@@ -440,14 +439,14 @@
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "general_ledger_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "General Ledger"
|
||||
"label": "General Ledger remarks length"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Truncates 'Remarks' column to set character length",
|
||||
"fieldname": "receivable_payable_remarks_length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Accounts Receivable/Payable"
|
||||
"label": "Accounts Receivable / Payable remarks length"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lvjk",
|
||||
@@ -481,7 +480,7 @@
|
||||
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
|
||||
"fieldname": "create_pr_in_draft_status",
|
||||
"fieldtype": "Check",
|
||||
"label": "Create in Draft Status"
|
||||
"label": "Create payment requests in Draft status"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_yuug",
|
||||
@@ -496,14 +495,14 @@
|
||||
"description": "Interval should be between 1 to 59 MInutes",
|
||||
"fieldname": "auto_reconciliation_job_trigger",
|
||||
"fieldtype": "Int",
|
||||
"label": "Auto Reconciliation Job Trigger"
|
||||
"label": "Auto Reconciliation job trigger"
|
||||
},
|
||||
{
|
||||
"default": "5",
|
||||
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
|
||||
"fieldname": "reconciliation_queue_size",
|
||||
"fieldtype": "Int",
|
||||
"label": "Reconciliation Queue Size"
|
||||
"label": "Reconciliation queue size"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -517,14 +516,14 @@
|
||||
"description": "Only applies for Normal Payments",
|
||||
"fieldname": "exchange_gain_loss_posting_date",
|
||||
"fieldtype": "Select",
|
||||
"label": "Posting Date Inheritance for Exchange Gain / Loss",
|
||||
"label": "Posting Date inheritance for exchange gain / loss",
|
||||
"options": "Invoice\nPayment\nReconciliation Date"
|
||||
},
|
||||
{
|
||||
"default": "Buffered Cursor",
|
||||
"fieldname": "receivable_payable_fetch_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Data Fetch Method",
|
||||
"label": "Data fetch method",
|
||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||
},
|
||||
{
|
||||
@@ -541,14 +540,14 @@
|
||||
"default": "0",
|
||||
"fieldname": "maintain_same_internal_transaction_rate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Maintain Same Rate Throughout Internal Transaction"
|
||||
"label": "Maintain same rate throughout internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "Stop",
|
||||
"depends_on": "maintain_same_internal_transaction_rate",
|
||||
"fieldname": "maintain_same_rate_action",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
|
||||
"label": "Action if same rate is not maintained throughout internal transaction",
|
||||
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
|
||||
"options": "Stop\nWarn"
|
||||
},
|
||||
@@ -556,7 +555,7 @@
|
||||
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
|
||||
"fieldname": "role_to_override_stop_action",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Override Stop Action",
|
||||
"label": "Role allowed to override stop action",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
@@ -588,7 +587,7 @@
|
||||
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
|
||||
"fieldname": "add_taxes_from_taxes_and_charges_template",
|
||||
"fieldtype": "Check",
|
||||
"label": "Automatically Add Taxes from Taxes and Charges Template"
|
||||
"label": "Automatically add taxes from Taxes and Charges Template"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ntmi",
|
||||
@@ -598,19 +597,20 @@
|
||||
"default": "0",
|
||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Valuation Rate for Internal Transaction"
|
||||
"label": "Fetch valuation rate for internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
|
||||
"fieldname": "use_legacy_budget_controller",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Legacy Budget Controller"
|
||||
"label": "Use legacy Budget Controller"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "use_legacy_controller_for_pcv",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Legacy Controller For Period Closing Voucher"
|
||||
"label": "Use legacy controller for Period Closing Voucher"
|
||||
},
|
||||
{
|
||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||
@@ -628,7 +628,7 @@
|
||||
{
|
||||
"fieldname": "chart_of_accounts_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Chart Of Accounts"
|
||||
"label": "Chart of Accounts"
|
||||
},
|
||||
{
|
||||
"fieldname": "banking_section",
|
||||
@@ -673,6 +673,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
|
||||
"fieldname": "enable_loyalty_point_program",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Loyalty Point Program"
|
||||
@@ -699,7 +700,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "fetch_payment_schedule_in_payment_request",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Payment Schedule In Payment Request"
|
||||
"label": "Fetch Payment Schedule in Payment Request"
|
||||
},
|
||||
{
|
||||
"default": "3",
|
||||
@@ -724,7 +725,7 @@
|
||||
{
|
||||
"fieldname": "repost_allowed_types",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allowed Doctypes",
|
||||
"label": "Allowed DocTypes",
|
||||
"options": "Repost Allowed Types"
|
||||
},
|
||||
{
|
||||
@@ -732,7 +733,21 @@
|
||||
"description": "Runs a preview check on save before submission without making any actual changes.",
|
||||
"fieldname": "preview_mode",
|
||||
"fieldtype": "Check",
|
||||
"label": "Preview Mode"
|
||||
"label": "Preview mode"
|
||||
},
|
||||
{
|
||||
"fieldname": "document_naming_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Document Naming"
|
||||
},
|
||||
{
|
||||
"fieldname": "transaction_naming_html",
|
||||
"fieldtype": "HTML"
|
||||
},
|
||||
{
|
||||
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||
"fieldname": "column_break_mfor",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -741,7 +756,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-18 12:16:33.679345",
|
||||
"modified": "2026-06-03 13:11:54.721495",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"column_break_12",
|
||||
"branch_code",
|
||||
"bank_account_no",
|
||||
"statement_password",
|
||||
"address_and_contact",
|
||||
"address_html",
|
||||
"column_break_13",
|
||||
@@ -149,6 +150,12 @@
|
||||
"label": "Bank Account No",
|
||||
"length": 30
|
||||
},
|
||||
{
|
||||
"description": "Password used to open password-protected PDF statements for this account. Stored encrypted.",
|
||||
"fieldname": "statement_password",
|
||||
"fieldtype": "Password",
|
||||
"label": "Statement PDF Password"
|
||||
},
|
||||
{
|
||||
"fieldname": "address_and_contact",
|
||||
"fieldtype": "Section Break",
|
||||
|
||||
@@ -41,6 +41,7 @@ class BankAccount(Document):
|
||||
mask: DF.Data | None
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
statement_password: DF.Password | None
|
||||
# end: auto-generated types
|
||||
|
||||
def onload(self):
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"detected_transaction_starting_index",
|
||||
"detected_transaction_ending_index",
|
||||
"section_break_yulq",
|
||||
"column_mapping"
|
||||
"column_mapping",
|
||||
"pdf_tables"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -128,6 +129,13 @@
|
||||
"label": "Column Mapping",
|
||||
"options": "Bank Statement Import Log Column Map"
|
||||
},
|
||||
{
|
||||
"description": "Per-table extraction data for PDF statements (rows, bbox, page image, column mapping). Edited via the banking app.",
|
||||
"fieldname": "pdf_tables",
|
||||
"fieldtype": "JSON",
|
||||
"label": "PDF Tables",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Not Started",
|
||||
"fieldname": "status",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,18 @@ from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log import (
|
||||
BankStatementImportLog,
|
||||
build_table_transactions,
|
||||
detect_column_mapping,
|
||||
detect_header_row,
|
||||
extract_pdf_tables,
|
||||
get_float_amount,
|
||||
get_statement_details,
|
||||
guess_column_mapping_by_content,
|
||||
reextract_pdf_table,
|
||||
set_header_index,
|
||||
set_pdf_table_header,
|
||||
update_column_mapping,
|
||||
update_pdf_tables,
|
||||
)
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -113,6 +124,346 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertIsNone(get_float_amount("ABCD"))
|
||||
self.assertIsNone(get_float_amount("****"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PDF statement import
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _make_pdf(html: str) -> bytes:
|
||||
import pdfkit
|
||||
|
||||
return pdfkit.from_string(html, False)
|
||||
|
||||
@staticmethod
|
||||
def _encrypt(pdf_bytes: bytes, password: str) -> bytes:
|
||||
import io
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||
writer = PdfWriter()
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
writer.encrypt(password)
|
||||
buffer = io.BytesIO()
|
||||
writer.write(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _auto_map(table: dict) -> dict:
|
||||
"""Mimic prepare_pdf_tables' best-effort mapping for a single extracted table."""
|
||||
header_index, score = detect_header_row(table["rows"])
|
||||
if score >= 2:
|
||||
table["header_index"] = header_index
|
||||
table["column_mapping"] = detect_column_mapping(table["rows"][header_index])
|
||||
else:
|
||||
table["header_index"] = None
|
||||
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||
table["included"] = True
|
||||
return table
|
||||
|
||||
def test_pdf_multi_page_kept_separate_and_unioned(self):
|
||||
"""Tables on separate pages must NOT be merged; transactions are the union."""
|
||||
html = """
|
||||
<html><body>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
|
||||
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
|
||||
<div style="page-break-before: always"></div>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
tables = extract_pdf_tables(self._make_pdf(html))
|
||||
|
||||
# Two separate tables, one per page
|
||||
self.assertEqual(len(tables), 2)
|
||||
self.assertEqual(sorted(t["page"] for t in tables), [1, 2])
|
||||
for table in tables:
|
||||
self.assertIn("bbox", table)
|
||||
self.assertEqual(len(table["bbox"]), 4)
|
||||
|
||||
union = []
|
||||
for table in tables:
|
||||
final, _df, _af = build_table_transactions(self._auto_map(table))
|
||||
union.extend(final)
|
||||
|
||||
self.assertEqual(len(union), 3)
|
||||
self.assertEqual(sorted(t["date"] for t in union), ["2024-04-01", "2024-04-03", "2024-04-05"])
|
||||
|
||||
def test_pdf_junk_table_excluded(self):
|
||||
"""A non-transactions table (ad/summary) should yield zero transactions."""
|
||||
ad_table = self._auto_map({"rows": [["Open a new account!", "Call 1800-XYZ"]]})
|
||||
final, _df, _af = build_table_transactions(ad_table)
|
||||
self.assertEqual(final, [])
|
||||
|
||||
def test_headerless_content_mapping(self):
|
||||
"""Without a header row, columns are guessed from their contents."""
|
||||
rows = [
|
||||
["01/04/2024", "UPI PAYMENT", "500.00"],
|
||||
["03/04/2024", "SALARY CREDIT", "20000.00"],
|
||||
]
|
||||
mapping = {
|
||||
c["maps_to"]: c["index"]
|
||||
for c in guess_column_mapping_by_content(rows)
|
||||
if c["maps_to"] != "Do not import"
|
||||
}
|
||||
self.assertEqual(mapping.get("Date"), 0)
|
||||
self.assertEqual(mapping.get("Description"), 1)
|
||||
self.assertEqual(mapping.get("Amount"), 2)
|
||||
|
||||
def test_pdf_password_protected(self):
|
||||
"""Encrypted PDFs error without a password and succeed with the right one."""
|
||||
html = """
|
||||
<html><body><table border="1">
|
||||
<tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr></table></body></html>
|
||||
"""
|
||||
encrypted = self._encrypt(self._make_pdf(html), "secret123")
|
||||
|
||||
# No / wrong password -> recognizable error
|
||||
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted)
|
||||
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted, "wrong")
|
||||
|
||||
# Correct password -> extracts
|
||||
tables = extract_pdf_tables(encrypted, "secret123")
|
||||
self.assertTrue(tables)
|
||||
|
||||
def test_pdf_no_tables_detected(self):
|
||||
"""A PDF with no detectable tables raises a clear error (e.g. scanned PDFs)."""
|
||||
html = "<html><body><p>Just some prose with no tabular data at all.</p></body></html>"
|
||||
self.assertRaises(frappe.ValidationError, extract_pdf_tables, self._make_pdf(html))
|
||||
|
||||
def _create_pdf_import_log(self, html: str) -> BankStatementImportLog:
|
||||
pdf_bytes = self._make_pdf(html)
|
||||
file_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.pdf",
|
||||
"is_private": 1,
|
||||
"content": pdf_bytes,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Statement Import Log",
|
||||
"name": f"test-pdf-{frappe.generate_hash(length=8)}",
|
||||
"bank_account": self.bank_account,
|
||||
"file": file_doc.file_url,
|
||||
}
|
||||
)
|
||||
return doc.insert()
|
||||
|
||||
def test_pdf_full_lifecycle(self):
|
||||
"""End-to-end doc lifecycle: insert -> rasterize -> preview -> edit -> import."""
|
||||
html = """
|
||||
<html><body>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
|
||||
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
|
||||
<div style="page-break-before: always"></div>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
doc = self._create_pdf_import_log(html)
|
||||
|
||||
# before_insert populated the per-table JSON, page images and the union summary
|
||||
tables = doc.get_pdf_tables()
|
||||
self.assertEqual(len(tables), 2)
|
||||
for table in tables:
|
||||
self.assertTrue(table.get("page_image"))
|
||||
self.assertIn("bbox", table)
|
||||
# Page-image File must be attached to the final docname, not the client's temp id
|
||||
attached_to = frappe.db.get_value("File", {"file_url": table["page_image"]}, "attached_to_name")
|
||||
self.assertEqual(attached_to, doc.name)
|
||||
self.assertEqual(doc.number_of_transactions, 3)
|
||||
self.assertEqual(doc.total_debit_transactions, 2)
|
||||
self.assertEqual(doc.total_credit_transactions, 1)
|
||||
|
||||
# get_statement_details returns the union and the per-table data for the editor
|
||||
details = get_statement_details(doc.name)
|
||||
self.assertEqual(len(details["final_transactions"]), 3)
|
||||
self.assertEqual(details["raw_data"], [])
|
||||
self.assertEqual(len(details["pdf_tables"]), 2)
|
||||
|
||||
# Excluding the second table (page 2) drops its single transaction
|
||||
tables[1]["included"] = False
|
||||
update_pdf_tables(doc.name, tables)
|
||||
doc.reload()
|
||||
self.assertEqual(doc.number_of_transactions, 2)
|
||||
|
||||
# Re-include and import; transactions are created for the union
|
||||
tables[1]["included"] = True
|
||||
update_pdf_tables(doc.name, tables)
|
||||
doc.reload()
|
||||
doc.insert_transactions()
|
||||
doc.reload()
|
||||
self.assertEqual(doc.status, "Completed")
|
||||
|
||||
created = frappe.get_all(
|
||||
"Bank Transaction", filters={"bank_account": self.bank_account, "docstatus": 1}
|
||||
)
|
||||
self.assertEqual(len(created), 3)
|
||||
|
||||
def test_pdf_reextract_table_from_bbox(self):
|
||||
"""Re-extracting a table from an adjusted bbox updates its rows and stores the bbox."""
|
||||
html = """
|
||||
<html><body>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
doc = self._create_pdf_import_log(html)
|
||||
table = doc.get_pdf_tables()[0]
|
||||
bbox = table["bbox"]
|
||||
|
||||
details = reextract_pdf_table(doc.name, table["page"], table["table_index"], bbox)
|
||||
updated = details["pdf_tables"][0]
|
||||
|
||||
# Same region -> same rows; bbox is persisted
|
||||
self.assertTrue(updated["rows"])
|
||||
self.assertEqual(updated["bbox"], [round(float(v), 2) for v in bbox])
|
||||
self.assertEqual(updated["rows"], table["rows"])
|
||||
|
||||
def test_pdf_reextract_changed_bbox_updates_rows_and_transactions(self):
|
||||
"""Shrinking a table's bbox must drop rows and update the transaction count end-to-end."""
|
||||
html = """
|
||||
<html><body>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr>
|
||||
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td></tr>
|
||||
<tr><td>07/04/2024</td><td>INTEREST</td><td>12.50</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
doc = self._create_pdf_import_log(html)
|
||||
original = doc.get_pdf_tables()[0]
|
||||
original_rows = len(original["rows"])
|
||||
original_txns = doc.number_of_transactions
|
||||
|
||||
# Shrink the box to roughly the top half (simulating a user drag).
|
||||
x0, top, x1, bottom = original["bbox"]
|
||||
shrunk = [x0, top, x1, top + (bottom - top) * 0.5]
|
||||
|
||||
details = reextract_pdf_table(doc.name, original["page"], original["table_index"], shrunk)
|
||||
updated = details["pdf_tables"][0]
|
||||
doc.reload()
|
||||
|
||||
self.assertLess(len(updated["rows"]), original_rows)
|
||||
self.assertLess(doc.number_of_transactions, original_txns)
|
||||
self.assertEqual(len(details["final_transactions"]), doc.number_of_transactions)
|
||||
|
||||
def test_pdf_set_table_header(self):
|
||||
"""User can clear a table's header (no header row) or set a specific header row."""
|
||||
html = """
|
||||
<html><body>
|
||||
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
doc = self._create_pdf_import_log(html)
|
||||
table = doc.get_pdf_tables()[0]
|
||||
self.assertEqual(table["header_index"], 0)
|
||||
original = {
|
||||
c["maps_to"]: c["index"] for c in table["column_mapping"] if c["maps_to"] != "Do not import"
|
||||
}
|
||||
|
||||
# Clear the header (-1): header is removed but the mapping is preserved (not re-guessed).
|
||||
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], -1)
|
||||
updated = details["pdf_tables"][0]
|
||||
self.assertIsNone(updated["header_index"])
|
||||
preserved = {
|
||||
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
|
||||
}
|
||||
self.assertEqual(preserved, original)
|
||||
|
||||
# Set row 0 back as the header: it resolves meaningfully, so mapping is re-derived.
|
||||
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], 0)
|
||||
updated = details["pdf_tables"][0]
|
||||
self.assertEqual(updated["header_index"], 0)
|
||||
mapped = {
|
||||
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
|
||||
}
|
||||
self.assertEqual(mapped.get("Date"), 0)
|
||||
self.assertEqual(mapped.get("Description"), 1)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CSV/XLSX column mapping + header overrides
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _create_csv_import_log(self, csv_text: str) -> BankStatementImportLog:
|
||||
file_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.csv",
|
||||
"is_private": 1,
|
||||
"content": csv_text,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Statement Import Log",
|
||||
"bank_account": self.bank_account,
|
||||
"file": file_doc.file_url,
|
||||
}
|
||||
)
|
||||
return doc.insert()
|
||||
|
||||
def test_csv_update_column_mapping(self):
|
||||
"""Overriding the column mapping recomputes the transaction count."""
|
||||
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
|
||||
doc = self._create_csv_import_log(csv_text)
|
||||
self.assertEqual(doc.number_of_transactions, 2)
|
||||
|
||||
# Drop the amount column -> no amount -> no transactions detected.
|
||||
mapping = [
|
||||
{"index": c.index, "maps_to": "Do not import" if c.maps_to == "Amount" else c.maps_to}
|
||||
for c in doc.column_mapping
|
||||
]
|
||||
details = update_column_mapping(doc.name, mapping)
|
||||
doc.reload()
|
||||
self.assertEqual(doc.number_of_transactions, 0)
|
||||
self.assertEqual(len(details["final_transactions"]), 0)
|
||||
|
||||
def test_csv_set_header_index_preserves_mapping(self):
|
||||
"""Clearing the header keeps the user's mapping; it is not re-guessed."""
|
||||
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
|
||||
doc = self._create_csv_import_log(csv_text)
|
||||
self.assertEqual(doc.detected_header_index, 0)
|
||||
|
||||
# Manually map the Narration column (1) as Reference.
|
||||
mapping = [
|
||||
{
|
||||
"index": c.index,
|
||||
"maps_to": "Reference" if c.index == 1 else c.maps_to,
|
||||
"header_text": c.header_text,
|
||||
}
|
||||
for c in doc.column_mapping
|
||||
]
|
||||
update_column_mapping(doc.name, mapping)
|
||||
doc.reload()
|
||||
|
||||
# Clear the header row: the manual mapping must be preserved (column 1 stays Reference,
|
||||
# not re-guessed to Description). The label row fails date parsing, so 2 transactions remain.
|
||||
set_header_index(doc.name, -1)
|
||||
doc.reload()
|
||||
self.assertEqual(doc.detected_header_index, -1)
|
||||
self.assertEqual(doc.number_of_transactions, 2)
|
||||
current = {c.index: c.maps_to for c in doc.column_mapping}
|
||||
self.assertEqual(current.get(1), "Reference")
|
||||
|
||||
# Restore row 0 as the header (resolves meaningfully -> re-derived from labels).
|
||||
set_header_index(doc.name, 0)
|
||||
doc.reload()
|
||||
self.assertEqual(doc.detected_header_index, 0)
|
||||
restored = {c.maps_to: c.index for c in doc.column_mapping if c.maps_to != "Do not import"}
|
||||
self.assertEqual(restored.get("Description"), 1)
|
||||
|
||||
|
||||
test_hdfc_sample_statement_data = [
|
||||
["HDFC BANK Ltd. Page No .: 1 Statement of accounts", "", "", "", "", "", ""],
|
||||
|
||||
@@ -47,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
from_date=bank_transaction.date,
|
||||
to_date=utils.today(),
|
||||
)
|
||||
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
|
||||
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
|
||||
|
||||
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
||||
def test_reconcile(self):
|
||||
@@ -70,10 +70,10 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
unallocated_amount = frappe.db.get_value(
|
||||
"Bank Transaction", bank_transaction.name, "unallocated_amount"
|
||||
)
|
||||
self.assertTrue(unallocated_amount == 0)
|
||||
self.assertEqual(unallocated_amount, 0)
|
||||
|
||||
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
|
||||
self.assertTrue(clearance_date is not None)
|
||||
self.assertIsNot(clearance_date, None)
|
||||
|
||||
bank_transaction.reload()
|
||||
bank_transaction.cancel()
|
||||
@@ -178,9 +178,8 @@ class TestBankTransaction(ERPNextTestSuite):
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
|
||||
)
|
||||
self.assertTrue(
|
||||
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date")
|
||||
is not None
|
||||
self.assertIsNot(
|
||||
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date"), None
|
||||
)
|
||||
|
||||
@if_lending_app_installed
|
||||
|
||||
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
|
||||
|
||||
cur_node.save()
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def build_tree(self):
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
|
||||
@@ -705,18 +705,20 @@ def get_ordered_amount(params):
|
||||
|
||||
|
||||
def get_other_condition(params, for_doc):
|
||||
condition = f"expense_account = '{params.expense_account}'"
|
||||
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
|
||||
budget_against_field = params.get("budget_against_field")
|
||||
|
||||
if budget_against_field and params.get(budget_against_field):
|
||||
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
|
||||
condition += (
|
||||
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
|
||||
)
|
||||
|
||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||
|
||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||
|
||||
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
|
||||
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestCostCenterAllocation(ERPNextTestSuite):
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
for gle in gl_entries:
|
||||
self.assertTrue(gle.cost_center in expected_values)
|
||||
self.assertIn(gle.cost_center, expected_values)
|
||||
self.assertEqual(gle.debit, 0)
|
||||
self.assertEqual(gle.credit, expected_values[gle.cost_center])
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", {
|
||||
if (frm.doc.docstatus === 0) {
|
||||
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
|
||||
source_doctype: "Sales Invoice",
|
||||
date_field: "due_date",
|
||||
target: frm,
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import (
|
||||
create_dunning as create_dunning_from_sales_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||
@@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning = create_dunning_from_sales_invoice(si1.name)
|
||||
dunning.overdue_payments = []
|
||||
|
||||
method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
|
||||
method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning"
|
||||
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
|
||||
|
||||
self.assertEqual(len(updated_dunning.overdue_payments), 2)
|
||||
|
||||
@@ -361,7 +361,7 @@ class CalculationFormulaValidator(Validator):
|
||||
"sqrt": lambda x: x**0.5,
|
||||
"pow": pow,
|
||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||
"floor": lambda x: int(x),
|
||||
"floor": int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
get_account_currency,
|
||||
get_advance_payment_doctypes,
|
||||
get_balance_on,
|
||||
get_stock_accounts,
|
||||
get_stock_and_account_balance,
|
||||
@@ -1120,87 +1119,9 @@ class JournalEntry(AccountsController):
|
||||
self.total_amount_in_words = money_in_words(amt, currency)
|
||||
|
||||
def build_gl_map(self):
|
||||
gl_map = []
|
||||
from erpnext.accounts.doctype.journal_entry.services.gl_composer import JournalEntryGLComposer
|
||||
|
||||
company_currency = erpnext.get_company_currency(self.company)
|
||||
self.transaction_currency = company_currency
|
||||
self.transaction_exchange_rate = 1
|
||||
if self.multi_currency:
|
||||
for row in self.get("accounts"):
|
||||
if row.account_currency != company_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
self.transaction_currency = row.account_currency
|
||||
self.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
for d in self.get("accounts"):
|
||||
if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"):
|
||||
r = [d.user_remark, self.remark]
|
||||
r = [x for x in r if x]
|
||||
remarks = "\n".join(r)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": self.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": self.transaction_currency,
|
||||
"transaction_exchange_rate": self.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if self.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / self.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if self.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / self.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": self.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": self.doctype,
|
||||
"against_voucher": self.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and self.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
gl_map.append(
|
||||
self.get_gl_dict(
|
||||
row,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
return gl_map
|
||||
return JournalEntryGLComposer(self).compose()
|
||||
|
||||
def make_gl_entries(self, cancel=0, adv_adj=0):
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
@@ -1292,7 +1213,11 @@ class JournalEntry(AccountsController):
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def get_values(self):
|
||||
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
|
||||
cond = (
|
||||
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||
if flt(self.write_off_amount) > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
return frappe.db.sql(
|
||||
|
||||
103
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal file
103
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||
from erpnext.accounts.utils import get_advance_payment_doctypes
|
||||
|
||||
|
||||
class JournalEntryGLComposer(BaseGLComposer):
|
||||
"""Assembles the GL entries for a Journal Entry.
|
||||
|
||||
A Journal Entry already carries its ledger rows in the ``accounts`` child
|
||||
table, so composing is a straight projection of those rows into GL dicts
|
||||
via ``self.get_gl_dict``. The transaction currency/rate are resolved
|
||||
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
doc = self.doc
|
||||
gl_map = []
|
||||
|
||||
company_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_currency = company_currency
|
||||
doc.transaction_exchange_rate = 1
|
||||
if doc.multi_currency:
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != company_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
for d in doc.get("accounts"):
|
||||
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
|
||||
r = [d.user_remark, doc.remark]
|
||||
r = [x for x in r if x]
|
||||
remarks = "\n".join(r)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
gl_map.append(
|
||||
self.get_gl_dict(
|
||||
row,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
return gl_map
|
||||
@@ -89,7 +89,7 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
)
|
||||
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
||||
|
||||
self.assertTrue(flt(advance_paid[0][0]) == flt(payment_against_order))
|
||||
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
|
||||
|
||||
def cancel_against_voucher_testcase(self, test_voucher):
|
||||
if test_voucher.doctype == "Journal Entry":
|
||||
|
||||
@@ -1726,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
before_cancel: function (frm) {
|
||||
return new Promise((resolve, reject) => {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||
args: { payment_entry: frm.doc.name },
|
||||
callback: function (r) {
|
||||
const linked = r.message || [];
|
||||
if (!linked.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const bt_links = linked
|
||||
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||
.join(", ");
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||
[bt_links]
|
||||
),
|
||||
() => resolve(),
|
||||
() => reject(),
|
||||
__("Yes"),
|
||||
__("No")
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Payment Entry Reference", {
|
||||
|
||||
@@ -208,6 +208,7 @@ class PaymentEntry(AccountsController):
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
@@ -314,6 +315,7 @@ class PaymentEntry(AccountsController):
|
||||
self.update_outstanding_amounts()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_payment_requests(self, cancel=False):
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import (
|
||||
@@ -505,6 +507,19 @@ class PaymentEntry(AccountsController):
|
||||
doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
|
||||
doc.delink_advance_entries(self.name)
|
||||
|
||||
def trigger_invoice_update_for_subscriptions(self):
|
||||
invoice_names = set()
|
||||
for ref in self.references:
|
||||
if ref.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||
|
||||
for doctype, name in invoice_names:
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.refresh_subscription_status()
|
||||
except Exception:
|
||||
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||
|
||||
def set_missing_values(self):
|
||||
if self.payment_type == "Internal Transfer":
|
||||
for field in (
|
||||
@@ -1287,17 +1302,9 @@ class PaymentEntry(AccountsController):
|
||||
self.transaction_exchange_rate = self.target_exchange_rate
|
||||
|
||||
def build_gl_map(self):
|
||||
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
|
||||
self.setup_party_account_field()
|
||||
self.set_transaction_currency_and_rate()
|
||||
from erpnext.accounts.doctype.payment_entry.services.gl_composer import PaymentEntryGLComposer
|
||||
|
||||
gl_entries = []
|
||||
self.add_party_gl_entries(gl_entries)
|
||||
self.add_bank_gl_entries(gl_entries)
|
||||
self.add_deductions_gl_entries(gl_entries)
|
||||
self.add_tax_gl_entries(gl_entries)
|
||||
add_regional_gl_entries(gl_entries, self)
|
||||
return gl_entries
|
||||
return PaymentEntryGLComposer(self).compose()
|
||||
|
||||
def make_gl_entries(self, cancel=0, adv_adj=0):
|
||||
gl_entries = self.build_gl_map()
|
||||
@@ -1313,132 +1320,6 @@ class PaymentEntry(AccountsController):
|
||||
|
||||
self.make_advance_gl_entries(cancel=cancel)
|
||||
|
||||
def add_party_gl_entries(self, gl_entries):
|
||||
if not self.party_account:
|
||||
return
|
||||
|
||||
advance_payment_doctypes = get_advance_payment_doctypes()
|
||||
if self.payment_type == "Receive":
|
||||
against_account = self.paid_to
|
||||
else:
|
||||
against_account = self.paid_from
|
||||
|
||||
party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type")
|
||||
|
||||
party_gl_dict = self.get_gl_dict(
|
||||
{
|
||||
"account": self.party_account,
|
||||
"party_type": self.party_type,
|
||||
"party": self.party,
|
||||
"against": against_account,
|
||||
"account_currency": self.party_account_currency,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
item=self,
|
||||
)
|
||||
|
||||
for d in self.get("references"):
|
||||
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
|
||||
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
|
||||
cost_center = self.cost_center
|
||||
if d.reference_doctype == "Sales Invoice" and not cost_center:
|
||||
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
|
||||
|
||||
gle = party_gl_dict.copy()
|
||||
|
||||
allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d)
|
||||
|
||||
if (
|
||||
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||
and d.allocated_amount < 0
|
||||
and (
|
||||
(party_account_type == "Receivable" and self.payment_type == "Pay")
|
||||
or (party_account_type == "Payable" and self.payment_type == "Receive")
|
||||
)
|
||||
):
|
||||
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
|
||||
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
|
||||
|
||||
gle.update(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": self.party_account,
|
||||
"party_type": self.party_type,
|
||||
"party": self.party,
|
||||
"against": against_account,
|
||||
"account_currency": self.party_account_currency,
|
||||
"cost_center": cost_center,
|
||||
dr_or_cr + "_in_account_currency": d.allocated_amount,
|
||||
dr_or_cr: allocated_amount_in_company_currency,
|
||||
dr_or_cr + "_in_transaction_currency": d.allocated_amount
|
||||
if self.transaction_currency == self.party_account_currency
|
||||
else allocated_amount_in_company_currency / self.transaction_exchange_rate,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
"transaction_exchange_rate": self.target_exchange_rate,
|
||||
},
|
||||
item=self,
|
||||
)
|
||||
)
|
||||
|
||||
if d.reference_doctype in advance_payment_doctypes:
|
||||
# advance reference
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": self.doctype,
|
||||
"against_voucher": self.name,
|
||||
"advance_voucher_type": d.reference_doctype,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
elif self.book_advance_payments_in_separate_party_account:
|
||||
# Do not reference Invoices while Advance is in separate party account
|
||||
gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
|
||||
else:
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": d.reference_doctype,
|
||||
"against_voucher": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
gl_entries.append(gle)
|
||||
|
||||
if self.unallocated_amount:
|
||||
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
|
||||
exchange_rate = self.get_exchange_rate()
|
||||
base_unallocated_amount = self.unallocated_amount * exchange_rate
|
||||
|
||||
gle = party_gl_dict.copy()
|
||||
|
||||
gle.update(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": self.party_account,
|
||||
"party_type": self.party_type,
|
||||
"party": self.party,
|
||||
"against": against_account,
|
||||
"account_currency": self.party_account_currency,
|
||||
"cost_center": self.cost_center,
|
||||
dr_or_cr + "_in_account_currency": self.unallocated_amount,
|
||||
dr_or_cr + "_in_transaction_currency": self.unallocated_amount
|
||||
if self.party_account_currency == self.transaction_currency
|
||||
else base_unallocated_amount / self.transaction_exchange_rate,
|
||||
dr_or_cr: base_unallocated_amount,
|
||||
},
|
||||
item=self,
|
||||
)
|
||||
)
|
||||
if self.book_advance_payments_in_separate_party_account:
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": "Payment Entry",
|
||||
"against_voucher": self.name,
|
||||
}
|
||||
)
|
||||
gl_entries.append(gle)
|
||||
|
||||
def make_advance_gl_entries(
|
||||
self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
|
||||
):
|
||||
@@ -1560,132 +1441,6 @@ class PaymentEntry(AccountsController):
|
||||
)
|
||||
gl_entries.append(gle)
|
||||
|
||||
def add_bank_gl_entries(self, gl_entries):
|
||||
if self.payment_type in ("Pay", "Internal Transfer"):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": self.paid_from,
|
||||
"account_currency": self.paid_from_account_currency,
|
||||
"against": self.party if self.payment_type == "Pay" else self.paid_to,
|
||||
"credit_in_account_currency": self.paid_amount,
|
||||
"credit_in_transaction_currency": self.paid_amount
|
||||
if self.paid_from_account_currency == self.transaction_currency
|
||||
else self.base_paid_amount / self.transaction_exchange_rate,
|
||||
"credit": self.base_paid_amount,
|
||||
"cost_center": self.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
item=self,
|
||||
)
|
||||
)
|
||||
if self.payment_type in ("Receive", "Internal Transfer"):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": self.paid_to,
|
||||
"account_currency": self.paid_to_account_currency,
|
||||
"against": self.party if self.payment_type == "Receive" else self.paid_from,
|
||||
"debit_in_account_currency": self.received_amount,
|
||||
"debit_in_transaction_currency": self.received_amount
|
||||
if self.paid_to_account_currency == self.transaction_currency
|
||||
else self.base_received_amount / self.transaction_exchange_rate,
|
||||
"debit": self.base_received_amount,
|
||||
"cost_center": self.cost_center,
|
||||
},
|
||||
item=self,
|
||||
)
|
||||
)
|
||||
|
||||
def add_tax_gl_entries(self, gl_entries):
|
||||
for d in self.get("taxes"):
|
||||
account_currency = get_account_currency(d.account_head)
|
||||
if account_currency != self.company_currency:
|
||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency))
|
||||
|
||||
if self.payment_type in ("Pay", "Internal Transfer"):
|
||||
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
|
||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||
against = self.party or self.paid_from
|
||||
elif self.payment_type == "Receive":
|
||||
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
|
||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||
against = self.party or self.paid_to
|
||||
|
||||
payment_account = self.get_party_account_for_taxes()
|
||||
tax_amount = d.tax_amount
|
||||
base_tax_amount = d.base_tax_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.account_head,
|
||||
"against": against,
|
||||
dr_or_cr: tax_amount,
|
||||
dr_or_cr + "_in_account_currency": base_tax_amount
|
||||
if account_currency == self.company_currency
|
||||
else d.tax_amount,
|
||||
dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||
/ self.transaction_exchange_rate,
|
||||
"cost_center": d.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
account_currency,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
if not d.included_in_paid_amount:
|
||||
if get_account_currency(payment_account) != self.company_currency:
|
||||
if self.payment_type == "Receive":
|
||||
exchange_rate = self.target_exchange_rate
|
||||
elif self.payment_type in ["Pay", "Internal Transfer"]:
|
||||
exchange_rate = self.source_exchange_rate
|
||||
base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": payment_account,
|
||||
"against": against,
|
||||
rev_dr_or_cr: tax_amount,
|
||||
rev_dr_or_cr + "_in_account_currency": base_tax_amount
|
||||
if account_currency == self.company_currency
|
||||
else d.tax_amount,
|
||||
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||
/ self.transaction_exchange_rate,
|
||||
"cost_center": self.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
account_currency,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
def add_deductions_gl_entries(self, gl_entries):
|
||||
for d in self.get("deductions"):
|
||||
if not d.amount:
|
||||
continue
|
||||
|
||||
account_currency = get_account_currency(d.account)
|
||||
if account_currency != self.company_currency:
|
||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.account,
|
||||
"account_currency": account_currency,
|
||||
"against": self.party or self.paid_from,
|
||||
"debit_in_account_currency": d.amount,
|
||||
"debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
|
||||
"debit": d.amount,
|
||||
"cost_center": d.cost_center,
|
||||
},
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
def get_party_account_for_taxes(self):
|
||||
if self.payment_type == "Receive":
|
||||
return self.paid_to
|
||||
@@ -3574,3 +3329,16 @@ def make_payment_order(source_name: str, target_doc: str | Document | None = Non
|
||||
@erpnext.allow_regional
|
||||
def add_regional_gl_entries(gl_entries, doc):
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||
return frappe.get_all(
|
||||
"Bank Transaction Payments",
|
||||
filters={
|
||||
"payment_document": "Payment Entry",
|
||||
"payment_entry": payment_entry,
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
|
||||
293
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal file
293
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal file
@@ -0,0 +1,293 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes
|
||||
|
||||
|
||||
class PaymentEntryGLComposer(BaseGLComposer):
|
||||
"""Assembles the GL entries for a Payment Entry.
|
||||
|
||||
The voucher-specific row builders live here and operate on ``self.doc``.
|
||||
Shared helpers (get_gl_dict, calculate_base_allocated_amount_for_reference,
|
||||
get_exchange_rate, get_party_account_for_taxes) remain on the document for
|
||||
now and are invoked via ``self.doc``. The advance-posting builders stay on
|
||||
the document; they post separately from this compose pass and move with the
|
||||
advances service in a later phase.
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import add_regional_gl_entries
|
||||
|
||||
doc = self.doc
|
||||
if doc.payment_type in ("Receive", "Pay") and not doc.get("party_account_field"):
|
||||
doc.setup_party_account_field()
|
||||
doc.set_transaction_currency_and_rate()
|
||||
|
||||
gl_entries = []
|
||||
self.add_party_gl_entries(gl_entries)
|
||||
self.add_bank_gl_entries(gl_entries)
|
||||
self.add_deductions_gl_entries(gl_entries)
|
||||
self.add_tax_gl_entries(gl_entries)
|
||||
add_regional_gl_entries(gl_entries, doc)
|
||||
return gl_entries
|
||||
|
||||
def add_party_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if not doc.party_account:
|
||||
return
|
||||
|
||||
advance_payment_doctypes = get_advance_payment_doctypes()
|
||||
if doc.payment_type == "Receive":
|
||||
against_account = doc.paid_to
|
||||
else:
|
||||
against_account = doc.paid_from
|
||||
|
||||
party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type")
|
||||
|
||||
party_gl_dict = self.get_gl_dict(
|
||||
{
|
||||
"account": doc.party_account,
|
||||
"party_type": doc.party_type,
|
||||
"party": doc.party,
|
||||
"against": against_account,
|
||||
"account_currency": doc.party_account_currency,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
|
||||
for d in doc.get("references"):
|
||||
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
|
||||
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
|
||||
cost_center = doc.cost_center
|
||||
if d.reference_doctype == "Sales Invoice" and not cost_center:
|
||||
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
|
||||
|
||||
gle = party_gl_dict.copy()
|
||||
|
||||
allocated_amount_in_company_currency = doc.calculate_base_allocated_amount_for_reference(d)
|
||||
|
||||
if (
|
||||
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||
and d.allocated_amount < 0
|
||||
and (
|
||||
(party_account_type == "Receivable" and doc.payment_type == "Pay")
|
||||
or (party_account_type == "Payable" and doc.payment_type == "Receive")
|
||||
)
|
||||
):
|
||||
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
|
||||
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
|
||||
|
||||
gle.update(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.party_account,
|
||||
"party_type": doc.party_type,
|
||||
"party": doc.party,
|
||||
"against": against_account,
|
||||
"account_currency": doc.party_account_currency,
|
||||
"cost_center": cost_center,
|
||||
dr_or_cr + "_in_account_currency": d.allocated_amount,
|
||||
dr_or_cr: allocated_amount_in_company_currency,
|
||||
dr_or_cr + "_in_transaction_currency": d.allocated_amount
|
||||
if doc.transaction_currency == doc.party_account_currency
|
||||
else allocated_amount_in_company_currency / doc.transaction_exchange_rate,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
"transaction_exchange_rate": doc.target_exchange_rate,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
if d.reference_doctype in advance_payment_doctypes:
|
||||
# advance reference
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_doctype,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
elif doc.book_advance_payments_in_separate_party_account:
|
||||
# Do not reference Invoices while Advance is in separate party account
|
||||
gle.update({"against_voucher_type": doc.doctype, "against_voucher": doc.name})
|
||||
else:
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": d.reference_doctype,
|
||||
"against_voucher": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
gl_entries.append(gle)
|
||||
|
||||
if doc.unallocated_amount:
|
||||
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
|
||||
exchange_rate = doc.get_exchange_rate()
|
||||
base_unallocated_amount = doc.unallocated_amount * exchange_rate
|
||||
|
||||
gle = party_gl_dict.copy()
|
||||
|
||||
gle.update(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.party_account,
|
||||
"party_type": doc.party_type,
|
||||
"party": doc.party,
|
||||
"against": against_account,
|
||||
"account_currency": doc.party_account_currency,
|
||||
"cost_center": doc.cost_center,
|
||||
dr_or_cr + "_in_account_currency": doc.unallocated_amount,
|
||||
dr_or_cr + "_in_transaction_currency": doc.unallocated_amount
|
||||
if doc.party_account_currency == doc.transaction_currency
|
||||
else base_unallocated_amount / doc.transaction_exchange_rate,
|
||||
dr_or_cr: base_unallocated_amount,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
if doc.book_advance_payments_in_separate_party_account:
|
||||
gle.update(
|
||||
{
|
||||
"against_voucher_type": "Payment Entry",
|
||||
"against_voucher": doc.name,
|
||||
}
|
||||
)
|
||||
gl_entries.append(gle)
|
||||
|
||||
def add_bank_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.payment_type in ("Pay", "Internal Transfer"):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.paid_from,
|
||||
"account_currency": doc.paid_from_account_currency,
|
||||
"against": doc.party if doc.payment_type == "Pay" else doc.paid_to,
|
||||
"credit_in_account_currency": doc.paid_amount,
|
||||
"credit_in_transaction_currency": doc.paid_amount
|
||||
if doc.paid_from_account_currency == doc.transaction_currency
|
||||
else doc.base_paid_amount / doc.transaction_exchange_rate,
|
||||
"credit": doc.base_paid_amount,
|
||||
"cost_center": doc.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
if doc.payment_type in ("Receive", "Internal Transfer"):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.paid_to,
|
||||
"account_currency": doc.paid_to_account_currency,
|
||||
"against": doc.party if doc.payment_type == "Receive" else doc.paid_from,
|
||||
"debit_in_account_currency": doc.received_amount,
|
||||
"debit_in_transaction_currency": doc.received_amount
|
||||
if doc.paid_to_account_currency == doc.transaction_currency
|
||||
else doc.base_received_amount / doc.transaction_exchange_rate,
|
||||
"debit": doc.base_received_amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def add_tax_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
for d in doc.get("taxes"):
|
||||
account_currency = get_account_currency(d.account_head)
|
||||
if account_currency != doc.company_currency:
|
||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, doc.company_currency))
|
||||
|
||||
if doc.payment_type in ("Pay", "Internal Transfer"):
|
||||
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
|
||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||
against = doc.party or doc.paid_from
|
||||
elif doc.payment_type == "Receive":
|
||||
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
|
||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||
against = doc.party or doc.paid_to
|
||||
|
||||
payment_account = doc.get_party_account_for_taxes()
|
||||
tax_amount = d.tax_amount
|
||||
base_tax_amount = d.base_tax_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.account_head,
|
||||
"against": against,
|
||||
dr_or_cr: tax_amount,
|
||||
dr_or_cr + "_in_account_currency": base_tax_amount
|
||||
if account_currency == doc.company_currency
|
||||
else d.tax_amount,
|
||||
dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||
/ doc.transaction_exchange_rate,
|
||||
"cost_center": d.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
account_currency,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
if not d.included_in_paid_amount:
|
||||
if get_account_currency(payment_account) != doc.company_currency:
|
||||
if doc.payment_type == "Receive":
|
||||
exchange_rate = doc.target_exchange_rate
|
||||
elif doc.payment_type in ["Pay", "Internal Transfer"]:
|
||||
exchange_rate = doc.source_exchange_rate
|
||||
base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount"))
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": payment_account,
|
||||
"against": against,
|
||||
rev_dr_or_cr: tax_amount,
|
||||
rev_dr_or_cr + "_in_account_currency": base_tax_amount
|
||||
if account_currency == doc.company_currency
|
||||
else d.tax_amount,
|
||||
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||
/ doc.transaction_exchange_rate,
|
||||
"cost_center": doc.cost_center,
|
||||
"post_net_value": True,
|
||||
},
|
||||
account_currency,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
def add_deductions_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
for d in doc.get("deductions"):
|
||||
if not d.amount:
|
||||
continue
|
||||
|
||||
account_currency = get_account_currency(d.account)
|
||||
if account_currency != doc.company_currency:
|
||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency))
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.account,
|
||||
"account_currency": account_currency,
|
||||
"against": doc.party or doc.paid_from,
|
||||
"debit_in_account_currency": d.amount,
|
||||
"debit_in_transaction_currency": d.amount / doc.transaction_exchange_rate,
|
||||
"debit": d.amount,
|
||||
"cost_center": d.cost_center,
|
||||
},
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
@@ -196,7 +196,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
self.assertEqual(outstanding_amount, 100)
|
||||
|
||||
def test_reference_outstanding_amount_on_advance_pull(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||
|
||||
so = make_sales_order(qty=1, rate=1000)
|
||||
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
|
||||
@@ -1119,7 +1119,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
with self.assertRaises(frappe.ValidationError) as err:
|
||||
pe.save()
|
||||
|
||||
self.assertTrue("is on hold" in str(err.exception).lower())
|
||||
self.assertIn("is on hold", str(err.exception).lower())
|
||||
|
||||
def test_payment_entry_for_employee(self):
|
||||
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
|
||||
@@ -1567,7 +1567,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
self.check_pl_entries()
|
||||
|
||||
def test_advance_as_liability_against_order(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import (
|
||||
from erpnext.buying.doctype.purchase_order.mapper import (
|
||||
make_purchase_invoice as _make_purchase_invoice,
|
||||
)
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
@@ -2035,8 +2035,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
|
||||
# check cancellation of payment entry and journal entry
|
||||
pe.cancel()
|
||||
self.assertTrue(pe.docstatus == 2)
|
||||
self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
|
||||
self.assertEqual(pe.docstatus, 2)
|
||||
self.assertEqual(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus"), 2)
|
||||
|
||||
# check deletion of payment entry and journal entry
|
||||
pe.delete()
|
||||
|
||||
@@ -15,13 +15,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
is_any_doc_running,
|
||||
)
|
||||
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
|
||||
from erpnext.accounts.utils import (
|
||||
QueryPaymentLedger,
|
||||
create_gain_loss_journal,
|
||||
get_outstanding_invoices,
|
||||
reconcile_against_document,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
|
||||
|
||||
|
||||
class PaymentReconciliation(Document):
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||
from frappe.utils.data import getdate as convert_to_date
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
@@ -15,7 +13,6 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ class PaymentRequest(Document):
|
||||
self.update_reference_advance_payment_status()
|
||||
|
||||
def make_invoice(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||
|
||||
si = make_sales_invoice(self.reference_name, ignore_permissions=True)
|
||||
si.allocate_advances_automatically = True
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
from erpnext.controllers.accounts_controller import get_payment_term_details
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, template.insert)
|
||||
|
||||
def test_no_discount_date_without_discount(self):
|
||||
posting_date = "2026-05-29"
|
||||
term = frappe._dict(
|
||||
{
|
||||
"payment_term": "_Test No Discount Term",
|
||||
"invoice_portion": 100.0,
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 0,
|
||||
"credit_months": 0,
|
||||
"discount_type": "Percentage",
|
||||
"discount": 0,
|
||||
"discount_validity_based_on": "Day(s) after invoice date",
|
||||
"discount_validity": 0,
|
||||
}
|
||||
)
|
||||
|
||||
details = get_payment_term_details(
|
||||
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||
)
|
||||
|
||||
self.assertEqual(getdate(details.due_date), getdate(posting_date))
|
||||
self.assertIsNone(details.discount_date)
|
||||
|
||||
def test_discount_date_generated_with_discount(self):
|
||||
posting_date = "2026-05-29"
|
||||
term = frappe._dict(
|
||||
{
|
||||
"payment_term": "_Test Discount Term",
|
||||
"invoice_portion": 100.0,
|
||||
"due_date_based_on": "Day(s) after invoice date",
|
||||
"credit_days": 30,
|
||||
"credit_months": 0,
|
||||
"discount_type": "Percentage",
|
||||
"discount": 5,
|
||||
"discount_validity_based_on": "Day(s) after invoice date",
|
||||
"discount_validity": 10,
|
||||
}
|
||||
)
|
||||
|
||||
details = get_payment_term_details(
|
||||
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||
)
|
||||
|
||||
self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30)))
|
||||
self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10)))
|
||||
|
||||
def test_duplicate_terms(self):
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
|
||||
@@ -330,7 +330,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
||||
"""
|
||||
Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode.
|
||||
"""
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
test_user, pos_profile = init_user_and_profile()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
get_mode_of_payment_info,
|
||||
update_multi_mode_option,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyService
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.controllers.queries import item_query as _item_query
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
@@ -241,13 +242,13 @@ class POSInvoice(SalesInvoice):
|
||||
def on_submit(self):
|
||||
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program
|
||||
if not self.is_return and self.loyalty_program:
|
||||
self.make_loyalty_point_entry()
|
||||
LoyaltyService(self).make_loyalty_point_entry()
|
||||
elif self.is_return and self.return_against and self.loyalty_program:
|
||||
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
|
||||
against_psi_doc.delete_loyalty_point_entry()
|
||||
against_psi_doc.make_loyalty_point_entry()
|
||||
LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
|
||||
LoyaltyService(against_psi_doc).make_loyalty_point_entry()
|
||||
if self.redeem_loyalty_points and self.loyalty_points:
|
||||
self.apply_loyalty_points()
|
||||
LoyaltyService(self).apply_loyalty_points()
|
||||
self.check_phone_payments()
|
||||
self.set_status(update=True)
|
||||
self.make_bundle_for_sales_purchase_return()
|
||||
@@ -288,11 +289,11 @@ class POSInvoice(SalesInvoice):
|
||||
# run on cancel method of selling controller
|
||||
super(SalesInvoice, self).on_cancel()
|
||||
if not self.is_return and self.loyalty_program:
|
||||
self.delete_loyalty_point_entry()
|
||||
LoyaltyService(self).delete_loyalty_point_entry()
|
||||
elif self.is_return and self.return_against and self.loyalty_program:
|
||||
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
|
||||
against_psi_doc.delete_loyalty_point_entry()
|
||||
against_psi_doc.make_loyalty_point_entry()
|
||||
LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
|
||||
LoyaltyService(against_psi_doc).make_loyalty_point_entry()
|
||||
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
@@ -745,7 +746,9 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
# fetch charges
|
||||
if self.taxes_and_charges and not len(self.get("taxes")):
|
||||
self.set_taxes()
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
|
||||
TaxService(self).set_taxes()
|
||||
|
||||
if not self.account_for_change_amount:
|
||||
self.account_for_change_amount = frappe.get_cached_value(
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
pos_inv3.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
||||
|
||||
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
|
||||
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||
|
||||
def test_consolidated_credit_note_creation(self):
|
||||
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
|
||||
@@ -454,12 +454,12 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
||||
pos_inv2.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
|
||||
|
||||
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
|
||||
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||
|
||||
pos_inv3.load_from_db()
|
||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
||||
|
||||
self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice)
|
||||
self.assertEqual(pos_inv2.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||
|
||||
def test_company_in_pos_invoice_merge_log(self):
|
||||
"""
|
||||
|
||||
@@ -315,32 +315,3 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_default_profile(pos_profile: str, company: str):
|
||||
modified = now()
|
||||
user = frappe.session.user
|
||||
|
||||
if pos_profile and company:
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 0, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s
|
||||
and pfu.default = 1""",
|
||||
(modified, user, user, company),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 1, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
|
||||
""",
|
||||
(modified, user, user, company, pos_profile),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
@@ -151,13 +151,13 @@
|
||||
"label": "Default Advance Account",
|
||||
"mandatory_depends_on": "doc.party_type",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
"reqd": 0
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-08 08:22:14.798085",
|
||||
"modified": "2026-05-16 11:43:12.758685",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Payment Reconciliation",
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
|
||||
bank_cash_account: DF.Link | None
|
||||
company: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
default_advance_account: DF.Link
|
||||
default_advance_account: DF.Link | None
|
||||
error_log: DF.LongText | None
|
||||
from_invoice_date: DF.Date | None
|
||||
from_payment_date: DF.Date | None
|
||||
@@ -131,6 +131,7 @@ def is_job_running(job_name: str) -> bool:
|
||||
@frappe.whitelist()
|
||||
def pause_job_for_doc(docname: str | None = None):
|
||||
if docname:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
|
||||
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
|
||||
if log:
|
||||
@@ -145,6 +146,8 @@ def trigger_job_for_doc(docname: str | None = None):
|
||||
if not docname:
|
||||
return
|
||||
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "auto_reconcile_payments"):
|
||||
frappe.throw(
|
||||
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(
|
||||
@@ -218,10 +221,7 @@ def trigger_reconciliation_for_queued_docs():
|
||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||
|
||||
def get_filters_as_tuple(fields, doc):
|
||||
filters = ()
|
||||
for x in fields:
|
||||
filters += tuple(doc.get(x))
|
||||
return filters
|
||||
return tuple(doc.get(x) or "" for x in fields)
|
||||
|
||||
for x in all_queued:
|
||||
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
||||
|
||||
@@ -92,6 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
@@ -521,6 +521,7 @@ def download_statements(document_name: str):
|
||||
@frappe.whitelist()
|
||||
def send_emails(document_name: str, from_scheduler: bool = False, posting_date: str | None = None):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission()
|
||||
report = get_report_pdf(doc, consolidated=False)
|
||||
|
||||
if report:
|
||||
|
||||
@@ -17,9 +17,13 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
|
||||
letterhead = frappe.get_doc("Letter Head", "Company Letterhead - Grey")
|
||||
letterhead.is_default = 0
|
||||
letterhead.save()
|
||||
frappe.db.set_value(
|
||||
"Letter Head",
|
||||
"Company Letterhead - Grey",
|
||||
"is_default",
|
||||
0,
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
|
||||
129
erpnext/accounts/doctype/purchase_invoice/mapper.py
Normal file
129
erpnext/accounts/doctype/purchase_invoice/mapper.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.controllers.accounts_controller import merge_taxes
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_debit_note(source_name: str, target_doc: str | Document | None = None):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
return make_return_doc("Purchase Invoice", source_name, target_doc)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
doc = get_mapped_doc(
|
||||
"Purchase Invoice",
|
||||
source_name,
|
||||
{
|
||||
"Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Purchase Invoice Item": {
|
||||
"doctype": "Stock Entry Detail",
|
||||
"field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
|
||||
|
||||
return make_inter_company_transaction("Purchase Invoice", source_name, target_doc)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_purchase_receipt(
|
||||
source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
remove_items_with_zero_qty(target_parent)
|
||||
set_missing_values(source_parent, target_parent)
|
||||
|
||||
def remove_items_with_zero_qty(target_parent):
|
||||
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
|
||||
|
||||
def set_missing_values(source_parent, target_parent):
|
||||
target_parent.run_method("set_missing_values")
|
||||
if args and args.get("merge_taxes"):
|
||||
merge_taxes(source_parent, target_parent)
|
||||
target_parent.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
|
||||
|
||||
returned_qty_map = (
|
||||
get_returned_qty_map_for_row(
|
||||
source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
)
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = args.get("filtered_children", [])
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
|
||||
doc = get_mapped_doc(
|
||||
"Purchase Invoice",
|
||||
source_name,
|
||||
{
|
||||
"Purchase Invoice": {
|
||||
"doctype": "Purchase Receipt",
|
||||
"validation": {
|
||||
"docstatus": ["=", 1],
|
||||
},
|
||||
},
|
||||
"Purchase Invoice Item": {
|
||||
"doctype": "Purchase Receipt Item",
|
||||
"field_map": {
|
||||
"name": "purchase_invoice_item",
|
||||
"parent": "purchase_invoice",
|
||||
"bom": "bom",
|
||||
"purchase_order": "purchase_order",
|
||||
"po_detail": "purchase_order_item",
|
||||
"material_request": "material_request",
|
||||
"material_request_item": "material_request_item",
|
||||
"wip_composite_asset": "wip_composite_asset",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
|
||||
},
|
||||
"Purchase Taxes and Charges": {
|
||||
"doctype": "Purchase Taxes and Charges",
|
||||
"reset_value": not (args and args.get("merge_taxes")),
|
||||
"ignore": args.get("merge_taxes") if args else 0,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_parent_process,
|
||||
)
|
||||
|
||||
return doc
|
||||
@@ -156,7 +156,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
__("Purchase Order"),
|
||||
function () {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
|
||||
method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
|
||||
source_doctype: "Purchase Order",
|
||||
target: me.frm,
|
||||
setters: {
|
||||
@@ -181,7 +181,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
__("Purchase Receipt"),
|
||||
function () {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
|
||||
method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
|
||||
source_doctype: "Purchase Receipt",
|
||||
target: me.frm,
|
||||
setters: {
|
||||
@@ -414,7 +414,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
make_inter_company_invoice(frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_inter_company_sales_invoice",
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_inter_company_sales_invoice",
|
||||
frm: frm,
|
||||
});
|
||||
}
|
||||
@@ -474,7 +474,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
|
||||
make_debit_note() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_debit_note",
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_debit_note",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
@@ -591,6 +591,25 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_account", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
report_type: "Profit and Loss",
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("write_off_cost_center", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
@@ -701,7 +720,7 @@ frappe.ui.form.on("Purchase Invoice", {
|
||||
|
||||
make_purchase_receipt: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
|
||||
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
|
||||
frm: frm,
|
||||
freeze_message: __("Creating Purchase Receipt ..."),
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Purchase Receipt billing sync and provisional-entry cancellation for Purchase Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
class BillingStatusService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def update_billing_status_in_pr(self, update_modified: bool = True) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_return and not doc.update_billed_amount_in_purchase_receipt:
|
||||
return
|
||||
|
||||
updated_pr = []
|
||||
po_details = []
|
||||
|
||||
pr_details_billed_amt = self.get_pr_details_billed_amt()
|
||||
|
||||
for d in doc.get("items"):
|
||||
if d.pr_detail:
|
||||
frappe.db.set_value(
|
||||
"Purchase Receipt Item",
|
||||
d.pr_detail,
|
||||
"billed_amt",
|
||||
flt(pr_details_billed_amt.get(d.pr_detail)),
|
||||
update_modified=update_modified,
|
||||
)
|
||||
updated_pr.append(d.purchase_receipt)
|
||||
elif d.po_detail:
|
||||
po_details.append(d.po_detail)
|
||||
|
||||
if po_details:
|
||||
updated_pr += update_billed_amount_based_on_po(po_details, update_modified)
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for pr in set(updated_pr):
|
||||
pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr)
|
||||
update_billing_percentage(
|
||||
pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate
|
||||
)
|
||||
|
||||
def get_pr_details_billed_amt(self) -> dict:
|
||||
# Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice
|
||||
|
||||
pr_details_billed_amt = {}
|
||||
pr_details = [d.get("pr_detail") for d in self.doc.get("items") if d.get("pr_detail")]
|
||||
if pr_details:
|
||||
doctype = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(doctype.pr_detail, Sum(doctype.amount))
|
||||
.where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1)
|
||||
.groupby(doctype.pr_detail)
|
||||
)
|
||||
|
||||
pr_details_billed_amt = frappe._dict(query.run(as_list=1))
|
||||
|
||||
return pr_details_billed_amt
|
||||
|
||||
def cancel_provisional_entries(self) -> None:
|
||||
rows = set()
|
||||
purchase_receipts = set()
|
||||
for d in self.doc.items:
|
||||
if d.purchase_receipt:
|
||||
purchase_receipts.add(d.purchase_receipt)
|
||||
rows.add(d.name)
|
||||
|
||||
if rows:
|
||||
# cancel gl entries
|
||||
gle = qb.DocType("GL Entry")
|
||||
gle_update_query = (
|
||||
qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.where(
|
||||
(gle.voucher_type == "Purchase Receipt")
|
||||
& (gle.voucher_no.isin(purchase_receipts))
|
||||
& (gle.voucher_detail_no.isin(rows))
|
||||
)
|
||||
)
|
||||
gle_update_query.run()
|
||||
@@ -0,0 +1,183 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Expense account resolution for Purchase Invoice items."""
|
||||
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.utils import get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.controllers.accounts_controller import validate_account_head
|
||||
|
||||
|
||||
class ExpenseAccountService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def set_expense_account(self, for_validate: bool = False) -> None:
|
||||
doc = self.doc
|
||||
auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
|
||||
|
||||
if auto_accounting_for_stock:
|
||||
stock_not_billed_account = doc.get_company_default("stock_received_but_not_billed")
|
||||
stock_items = doc.get_stock_items()
|
||||
|
||||
doc.asset_received_but_not_billed = None
|
||||
|
||||
inventory_account_map = {}
|
||||
if doc.update_stock:
|
||||
doc.validate_item_code()
|
||||
doc.validate_warehouse(for_validate)
|
||||
if auto_accounting_for_stock:
|
||||
inventory_account_map = doc.get_inventory_account_map()
|
||||
|
||||
for item in doc.get("items"):
|
||||
# in case of auto inventory accounting,
|
||||
# expense account is always "Stock Received But Not Billed" for a stock item
|
||||
# except opening entry, drop-ship entry and fixed asset items
|
||||
if (
|
||||
auto_accounting_for_stock
|
||||
and item.item_code in stock_items
|
||||
and doc.is_opening == "No"
|
||||
and not item.is_fixed_asset
|
||||
and (
|
||||
not item.po_detail
|
||||
or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier")
|
||||
)
|
||||
):
|
||||
if doc.update_stock and item.warehouse and (not item.from_warehouse):
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
|
||||
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(_inv_dict["account"]),
|
||||
frappe.bold(item.expense_account),
|
||||
frappe.bold(item.warehouse),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
item.expense_account = _inv_dict["account"]
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
if item.purchase_receipt:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
|
||||
(item.purchase_receipt, stock_not_billed_account),
|
||||
)
|
||||
|
||||
if negative_expense_booked_in_pr:
|
||||
if (
|
||||
for_validate
|
||||
and item.expense_account
|
||||
and item.expense_account != stock_not_billed_account
|
||||
):
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(stock_not_billed_account),
|
||||
frappe.bold(item.purchase_receipt),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
|
||||
item.expense_account = stock_not_billed_account
|
||||
else:
|
||||
# If no purchase receipt present then book expense in 'Stock Received But Not Billed'
|
||||
# This is done in cases when Purchase Invoice is created before Purchase Receipt
|
||||
if (
|
||||
for_validate
|
||||
and item.expense_account
|
||||
and item.expense_account != stock_not_billed_account
|
||||
):
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}."
|
||||
).format(
|
||||
item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code)
|
||||
)
|
||||
msg += "<br>"
|
||||
msg += _(
|
||||
"This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice"
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
|
||||
item.expense_account = stock_not_billed_account
|
||||
elif item.is_fixed_asset:
|
||||
account = None
|
||||
if not item.pr_detail and item.po_detail:
|
||||
receipt_item = frappe.get_cached_value(
|
||||
"Purchase Receipt Item",
|
||||
{
|
||||
"purchase_order": item.purchase_order,
|
||||
"purchase_order_item": item.po_detail,
|
||||
"docstatus": 1,
|
||||
},
|
||||
["name", "parent"],
|
||||
as_dict=1,
|
||||
)
|
||||
if receipt_item:
|
||||
item.pr_detail = receipt_item.name
|
||||
item.purchase_receipt = receipt_item.parent
|
||||
|
||||
if item.pr_detail:
|
||||
if not doc.asset_received_but_not_billed:
|
||||
doc.asset_received_but_not_billed = doc.get_company_default(
|
||||
"asset_received_but_not_billed"
|
||||
)
|
||||
|
||||
# check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not
|
||||
arbnb_booked_in_pr = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": doc.asset_received_but_not_billed,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
if arbnb_booked_in_pr:
|
||||
account = doc.asset_received_but_not_billed
|
||||
|
||||
if not account:
|
||||
account_type = (
|
||||
"capital_work_in_progress_account"
|
||||
if is_cwip_accounting_enabled(item.asset_category)
|
||||
else "fixed_asset_account"
|
||||
)
|
||||
account = get_asset_category_account(
|
||||
account_type, item=item.item_code, company=doc.company
|
||||
)
|
||||
if not account:
|
||||
form_link = get_link_to_form("Asset Category", item.asset_category)
|
||||
throw(
|
||||
_("Please set Fixed Asset Account in {} against {}.").format(
|
||||
form_link, doc.company
|
||||
),
|
||||
title=_("Missing Account"),
|
||||
)
|
||||
item.expense_account = account
|
||||
elif not item.expense_account and for_validate:
|
||||
throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name))
|
||||
|
||||
def validate_expense_account(self) -> None:
|
||||
for item in self.doc.get("items"):
|
||||
validate_account_head(item.idx, item.expense_account, self.doc.company, _("Expense"))
|
||||
|
||||
def set_against_expense_account(self) -> None:
|
||||
doc = self.doc
|
||||
against_accounts = []
|
||||
for item in doc.get("items"):
|
||||
if item.expense_account and (item.expense_account not in against_accounts):
|
||||
against_accounts.append(item.expense_account)
|
||||
|
||||
doc.against_expense_account = ",".join(against_accounts)
|
||||
|
||||
def force_set_against_expense_account(self) -> None:
|
||||
doc = self.doc
|
||||
self.set_against_expense_account()
|
||||
frappe.db.set_value(doc.doctype, doc.name, "against_expense_account", doc.against_expense_account)
|
||||
@@ -0,0 +1,850 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
"""Assembles the GL entries for a Purchase Invoice."""
|
||||
|
||||
def compose(self, inventory_account_map=None):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries
|
||||
from erpnext.accounts.general_ledger import merge_similar_entries
|
||||
|
||||
doc = self.doc
|
||||
doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
|
||||
|
||||
if doc.auto_accounting_for_stock:
|
||||
doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed")
|
||||
else:
|
||||
doc.stock_received_but_not_billed = None
|
||||
|
||||
doc.negative_expense_to_be_booked = 0.0
|
||||
gl_entries = []
|
||||
|
||||
self.make_supplier_gl_entry(gl_entries)
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
|
||||
self.make_tax_gl_entries(gl_entries)
|
||||
self.make_internal_transfer_gl_entries(gl_entries)
|
||||
self.make_gl_entries_for_tax_withholding(gl_entries)
|
||||
|
||||
gl_entries = make_regional_gl_entries(gl_entries, doc)
|
||||
gl_entries = merge_similar_entries(gl_entries)
|
||||
|
||||
self.make_payment_gl_entries(gl_entries)
|
||||
self.make_write_off_gl_entry(gl_entries)
|
||||
self.make_gle_for_rounding_adjustment(gl_entries)
|
||||
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
|
||||
doc.set_gl_entry_for_purchase_expense(gl_entries)
|
||||
return gl_entries
|
||||
|
||||
def make_precision_loss_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
_round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
precision_loss = doc.get("base_net_total") - flt(
|
||||
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
|
||||
)
|
||||
|
||||
if precision_loss:
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.supplier,
|
||||
"credit": precision_loss,
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else doc.cost_center or round_off_cost_center,
|
||||
"remarks": _("Net total calculation precision loss"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def make_supplier_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
grand_total = (
|
||||
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
|
||||
)
|
||||
base_grand_total = flt(
|
||||
doc.base_rounded_total
|
||||
if (doc.base_rounding_adjustment and doc.base_rounded_total)
|
||||
else doc.base_grand_total,
|
||||
doc.precision("base_grand_total"),
|
||||
)
|
||||
if grand_total and not doc.is_internal_transfer():
|
||||
self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
|
||||
|
||||
def add_supplier_gl_entry(
|
||||
self,
|
||||
gl_entries,
|
||||
base_grand_total,
|
||||
grand_total,
|
||||
against_account=None,
|
||||
remarks=None,
|
||||
skip_merge=False,
|
||||
):
|
||||
doc = self.doc
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
gl = {
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"due_date": doc.due_date,
|
||||
"against": against_account or doc.against_expense_account,
|
||||
"credit": base_grand_total,
|
||||
"credit_in_account_currency": base_grand_total
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else grand_total,
|
||||
"credit_in_transaction_currency": grand_total,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"project": doc.project,
|
||||
"cost_center": doc.cost_center,
|
||||
"_skip_merge": skip_merge,
|
||||
}
|
||||
if remarks:
|
||||
gl["remarks"] = remarks
|
||||
gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc))
|
||||
|
||||
def make_item_gl_entries(self, gl_entries):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
stock_items = doc.get_stock_items()
|
||||
if doc.update_stock and doc.auto_accounting_for_stock:
|
||||
inventory_account_map = doc.get_inventory_account_map()
|
||||
|
||||
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
|
||||
|
||||
voucher_wise_stock_value = {}
|
||||
if doc.update_stock:
|
||||
stock_ledger_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
|
||||
filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0},
|
||||
)
|
||||
for d in stock_ledger_entries:
|
||||
voucher_wise_stock_value.setdefault(
|
||||
(d.voucher_detail_no, d.warehouse), d.stock_value_difference
|
||||
)
|
||||
|
||||
valuation_tax_accounts = [
|
||||
d.account_head
|
||||
for d in doc.get("taxes")
|
||||
if d.category in ("Valuation", "Valuation and Total")
|
||||
and flt(d.base_tax_amount_after_discount_amount)
|
||||
]
|
||||
|
||||
exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
|
||||
|
||||
provisional_accounting_for_non_stock_items = cint(
|
||||
frappe.get_cached_value(
|
||||
"Company", doc.company, "enable_provisional_accounting_for_non_stock_items"
|
||||
)
|
||||
)
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.get_provisional_accounts()
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for item in doc.get("items"):
|
||||
if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate):
|
||||
if item.item_code:
|
||||
frappe.get_cached_value("Item", item.item_code, "asset_category")
|
||||
|
||||
if (
|
||||
doc.update_stock
|
||||
and doc.auto_accounting_for_stock
|
||||
and (item.item_code in stock_items or item.is_fixed_asset)
|
||||
):
|
||||
account_currency = get_account_currency(item.expense_account)
|
||||
warehouse_debit_amount = self.make_stock_adjustment_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, account_currency
|
||||
)
|
||||
|
||||
if item.from_warehouse:
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
_inv_dict_from_warehouse = doc.get_inventory_account_dict(
|
||||
item, inventory_account_map, "from_warehouse"
|
||||
)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": _inv_dict["account"],
|
||||
"against": _inv_dict_from_warehouse["account"],
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": warehouse_debit_amount,
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
_inv_dict["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
credit_amount = item.base_net_amount
|
||||
if doc.is_internal_supplier and item.valuation_rate:
|
||||
credit_amount = flt(item.valuation_rate * item.stock_qty)
|
||||
|
||||
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": _inv_dict_from_warehouse["account"],
|
||||
"against": _inv_dict["account"],
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
_inv_dict_from_warehouse["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": flt(item.base_net_amount, item.precision("base_net_amount")),
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": warehouse_debit_amount,
|
||||
"debit_in_transaction_currency": flt(
|
||||
warehouse_debit_amount / doc.conversion_rate,
|
||||
item.precision("net_amount"),
|
||||
),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# Amount added through landed-cost-voucher
|
||||
if landed_cost_entries:
|
||||
if (item.item_code, item.name) in landed_cost_entries:
|
||||
for account, base_amount in landed_cost_entries[
|
||||
(item.item_code, item.name)
|
||||
].items():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": account,
|
||||
"against": item.expense_account,
|
||||
"cost_center": item.cost_center,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": flt(base_amount["base_amount"]),
|
||||
"credit_in_account_currency": flt(base_amount["amount"]),
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# sub-contracting warehouse
|
||||
if flt(item.rm_supp_cost):
|
||||
supplier_wh_dict = doc.get_inventory_account_dict(
|
||||
item, inventory_account_map, "supplier_warehouse"
|
||||
)
|
||||
supplier_inventory_account = supplier_wh_dict["account"]
|
||||
if not supplier_inventory_account:
|
||||
frappe.throw(
|
||||
_("Please set account in Warehouse {0}").format(doc.supplier_warehouse)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": supplier_inventory_account,
|
||||
"against": item.expense_account,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": flt(item.rm_supp_cost),
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
supplier_wh_dict["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
expense_account = (
|
||||
item.expense_account
|
||||
if (not item.enable_deferred_expense or doc.is_return)
|
||||
else item.deferred_expense_account
|
||||
)
|
||||
account_currency = get_account_currency(expense_account)
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(item, None)
|
||||
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
not adjust_incoming_rate
|
||||
and item.get("purchase_receipt")
|
||||
and doc.auto_accounting_for_stock
|
||||
):
|
||||
if (
|
||||
exchange_rate_map[item.purchase_receipt]
|
||||
and doc.conversion_rate != exchange_rate_map[item.purchase_receipt]
|
||||
and item.net_rate == net_rate_map[item.pr_detail]
|
||||
and item.item_code in stock_items
|
||||
):
|
||||
discrepancy_caused_by_exchange_rate_difference = (
|
||||
item.qty * item.net_rate
|
||||
) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": discrepancy_caused_by_exchange_rate_difference,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.get_company_default("exchange_gain_loss_account"),
|
||||
"against": doc.supplier,
|
||||
"credit": discrepancy_caused_by_exchange_rate_difference,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
doc.auto_accounting_for_stock
|
||||
and doc.is_opening == "No"
|
||||
and item.item_code in stock_items
|
||||
and item.item_tax_amount
|
||||
):
|
||||
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
|
||||
if item.purchase_receipt and valuation_tax_accounts:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
|
||||
(item.purchase_receipt, valuation_tax_accounts),
|
||||
)
|
||||
|
||||
(
|
||||
doc.get_company_default("asset_received_but_not_billed")
|
||||
if item.is_fixed_asset
|
||||
else doc.stock_received_but_not_billed
|
||||
)
|
||||
|
||||
if not negative_expense_booked_in_pr:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.stock_received_but_not_billed,
|
||||
"against": doc.supplier,
|
||||
"debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
|
||||
"debit_in_transaction_currency": flt(
|
||||
item.item_tax_amount / doc.conversion_rate,
|
||||
item.precision("item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
"cost_center": doc.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
doc.negative_expense_to_be_booked += flt(
|
||||
item.item_tax_amount, item.precision("item_tax_amount")
|
||||
)
|
||||
|
||||
if item.is_fixed_asset and item.landed_cost_voucher_amount:
|
||||
self.update_net_purchase_amount_for_linked_assets(item)
|
||||
|
||||
def get_provisional_accounts(self):
|
||||
doc = self.doc
|
||||
self.provisional_accounts = frappe._dict()
|
||||
linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt}
|
||||
if not linked_purchase_receipts:
|
||||
return
|
||||
|
||||
pr_items = frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={"parent": ("in", linked_purchase_receipts)},
|
||||
fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
|
||||
)
|
||||
default_provisional_account = doc.get_company_default("default_provisional_account")
|
||||
provisional_accounts = {
|
||||
d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
|
||||
for d in pr_items
|
||||
}
|
||||
|
||||
provisional_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": ("in", linked_purchase_receipts),
|
||||
"account": ("in", provisional_accounts),
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["voucher_detail_no"],
|
||||
)
|
||||
rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
|
||||
for item in pr_items:
|
||||
self.provisional_accounts[item.name] = {
|
||||
"provisional_account": item.provisional_expense_account or default_provisional_account,
|
||||
"qty": item.qty,
|
||||
"base_rate": item.base_rate,
|
||||
"rate": item.rate,
|
||||
"has_provisional_entry": item.name in rows_with_provisional_entries,
|
||||
}
|
||||
|
||||
def make_provisional_gl_entry(self, gl_entries, item):
|
||||
if item.purchase_receipt:
|
||||
pr_item = self.provisional_accounts.get(item.pr_detail, {})
|
||||
if pr_item.get("has_provisional_entry"):
|
||||
purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
|
||||
|
||||
# Intentionally passing purchase invoice item to handle partial billing
|
||||
purchase_receipt_doc.add_provisional_gl_entry(
|
||||
item,
|
||||
gl_entries,
|
||||
self.doc.posting_date,
|
||||
pr_item.get("provisional_account"),
|
||||
reverse=1,
|
||||
item_amount=(
|
||||
(min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
|
||||
* purchase_receipt_doc.get("conversion_rate")
|
||||
),
|
||||
)
|
||||
|
||||
def update_net_purchase_amount_for_linked_assets(self, item):
|
||||
doc = self.doc
|
||||
assets = frappe.db.get_all(
|
||||
"Asset",
|
||||
filters={
|
||||
"purchase_invoice": doc.name,
|
||||
"item_code": item.item_code,
|
||||
"purchase_invoice_item": ("in", [item.name, ""]),
|
||||
},
|
||||
fields=["name", "asset_quantity"],
|
||||
)
|
||||
for asset in assets:
|
||||
purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
asset.name,
|
||||
{
|
||||
"net_purchase_amount": purchase_amount,
|
||||
"purchase_amount": purchase_amount,
|
||||
},
|
||||
)
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
net_amt_precision = item.precision("base_net_amount")
|
||||
val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
|
||||
|
||||
warehouse_debit_amount = flt(
|
||||
flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
|
||||
net_amt_precision,
|
||||
)
|
||||
|
||||
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate:
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
|
||||
warehouse_debit_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": cost_of_goods_sold_account,
|
||||
"against": item.expense_account,
|
||||
"debit": stock_adjustment_amt,
|
||||
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
|
||||
"remarks": doc.get("remarks") or _("Stock Adjustment"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
elif (
|
||||
doc.update_stock
|
||||
and voucher_wise_stock_value.get((item.name, item.warehouse))
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": cost_of_goods_sold_account,
|
||||
"against": item.expense_account,
|
||||
"debit": stock_adjustment_amt,
|
||||
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
|
||||
"remarks": doc.get("remarks") or _("Stock Adjustment"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
warehouse_debit_amount = stock_amount
|
||||
|
||||
return warehouse_debit_amount
|
||||
|
||||
def make_tax_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
valuation_tax = {}
|
||||
|
||||
for tax in doc.get("taxes"):
|
||||
amount, base_amount = tax_service.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
account_currency = get_account_currency(tax.account_head)
|
||||
dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"against": doc.supplier,
|
||||
dr_or_cr: base_amount,
|
||||
dr_or_cr + "_in_account_currency": base_amount
|
||||
if account_currency == doc.company_currency
|
||||
else amount,
|
||||
dr_or_cr + "_in_transaction_currency": amount,
|
||||
"cost_center": tax.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
doc.is_opening == "No"
|
||||
and tax.category in ("Valuation", "Valuation and Total")
|
||||
and flt(base_amount)
|
||||
and not doc.is_internal_transfer()
|
||||
):
|
||||
if doc.auto_accounting_for_stock and not tax.cost_center:
|
||||
frappe.throw(
|
||||
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax.setdefault(tax.name, 0)
|
||||
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
|
||||
|
||||
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
|
||||
total_valuation_amount = sum(valuation_tax.values())
|
||||
amount_including_divisional_loss = doc.negative_expense_to_be_booked
|
||||
i = 1
|
||||
for tax in doc.get("taxes"):
|
||||
if valuation_tax.get(tax.name):
|
||||
if i == len(valuation_tax):
|
||||
applicable_amount = amount_including_divisional_loss
|
||||
else:
|
||||
applicable_amount = doc.negative_expense_to_be_booked * (
|
||||
valuation_tax[tax.name] / total_valuation_amount
|
||||
)
|
||||
amount_including_divisional_loss -= applicable_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"cost_center": tax.cost_center,
|
||||
"against": doc.supplier,
|
||||
"credit": applicable_amount,
|
||||
"credit_in_transaction_currency": flt(
|
||||
applicable_amount / doc.conversion_rate,
|
||||
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
},
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
|
||||
if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax:
|
||||
for tax in doc.get("taxes"):
|
||||
if valuation_tax.get(tax.name):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"cost_center": tax.cost_center,
|
||||
"against": doc.supplier,
|
||||
"credit": valuation_tax[tax.name],
|
||||
"credit_in_transaction_currency": flt(
|
||||
valuation_tax[tax.name] / doc.conversion_rate,
|
||||
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
},
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
|
||||
def make_internal_transfer_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
|
||||
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.unrealized_profit_loss_account,
|
||||
"against": doc.supplier,
|
||||
"credit": flt(doc.total_taxes_and_charges),
|
||||
"credit_in_transaction_currency": flt(doc.total_taxes_and_charges),
|
||||
"credit_in_account_currency": flt(doc.base_total_taxes_and_charges),
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_gl_entries_for_tax_withholding(self, gl_entries):
|
||||
"""Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount."""
|
||||
doc = self.doc
|
||||
if not doc.apply_tds:
|
||||
return
|
||||
|
||||
for row in doc.get("taxes"):
|
||||
if not row.is_tax_withholding_account or not row.tax_amount:
|
||||
continue
|
||||
|
||||
base_tds_amount = row.base_tax_amount_after_discount_amount
|
||||
tds_amount = row.tax_amount_after_discount_amount
|
||||
|
||||
self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
|
||||
self.add_supplier_gl_entry(
|
||||
gl_entries,
|
||||
-base_tds_amount,
|
||||
-tds_amount,
|
||||
against_account=row.account_head,
|
||||
remarks=_("TDS Deducted"),
|
||||
skip_merge=True,
|
||||
)
|
||||
|
||||
def make_payment_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount:
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
bank_account_currency = get_account_currency(doc.cash_bank_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"against": doc.cash_bank_account,
|
||||
"debit": doc.base_paid_amount,
|
||||
"debit_in_account_currency": doc.base_paid_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else doc.paid_amount,
|
||||
"debit_in_transaction_currency": doc.paid_amount,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.cash_bank_account,
|
||||
"against": doc.supplier,
|
||||
"credit": doc.base_paid_amount,
|
||||
"credit_in_account_currency": doc.base_paid_amount
|
||||
if bank_account_currency == doc.company_currency
|
||||
else doc.paid_amount,
|
||||
"credit_in_transaction_currency": doc.paid_amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
bank_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_write_off_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.write_off_account and flt(doc.write_off_amount):
|
||||
write_off_account_currency = get_account_currency(doc.write_off_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"against": doc.write_off_account,
|
||||
"debit": doc.base_write_off_amount,
|
||||
"debit_in_account_currency": doc.base_write_off_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else doc.write_off_amount,
|
||||
"debit_in_transaction_currency": doc.write_off_amount,
|
||||
"against_voucher": doc.return_against
|
||||
if cint(doc.is_return) and doc.return_against
|
||||
else doc.name,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.write_off_account,
|
||||
"against": doc.supplier,
|
||||
"credit": flt(doc.base_write_off_amount),
|
||||
"credit_in_account_currency": doc.base_write_off_amount
|
||||
if write_off_account_currency == doc.company_currency
|
||||
else doc.write_off_amount,
|
||||
"credit_in_transaction_currency": doc.write_off_amount,
|
||||
"cost_center": doc.cost_center or doc.write_off_cost_center,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_gle_for_rounding_adjustment(self, gl_entries):
|
||||
doc = self.doc
|
||||
if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment:
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
if doc.is_opening == "Yes" and doc.rounding_adjustment:
|
||||
if not round_off_for_opening:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
|
||||
).format(
|
||||
frappe.bold(doc.rounding_adjustment),
|
||||
frappe.bold("Round Off for Opening"),
|
||||
get_link_to_form("Company", doc.company),
|
||||
frappe.bold("Disable Rounded Total"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
round_off_account = round_off_for_opening
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.supplier,
|
||||
"debit_in_account_currency": doc.rounding_adjustment,
|
||||
"debit": doc.base_rounding_adjustment,
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else (doc.cost_center or round_off_cost_center),
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
@@ -8,8 +8,8 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice as make_pi_from_po
|
||||
from erpnext.buying.doctype.purchase_order.mapper import get_mapped_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_pi_from_po
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
@@ -20,9 +20,9 @@ from erpnext.controllers.buying_controller import QtyMismatchError
|
||||
from erpnext.exceptions import InvalidCurrency
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
|
||||
from erpnext.stock.doctype.material_request.mapper import make_purchase_order
|
||||
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as create_purchase_invoice_from_receipt,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
|
||||
@@ -80,7 +80,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
pi.delete()
|
||||
|
||||
def test_update_received_qty_in_material_request(self):
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
|
||||
|
||||
"""
|
||||
Test if the received_qty in Material Request is updated correctly when
|
||||
@@ -346,7 +346,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_purchase_invoice_with_exchange_rate_difference(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
@@ -388,7 +388,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
|
||||
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as create_purchase_invoice,
|
||||
)
|
||||
|
||||
@@ -2077,7 +2077,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
return_pi = make_return_doc(pi.doctype, pi.name)
|
||||
return_pi.save().submit()
|
||||
|
||||
self.assertTrue(return_pi.docstatus == 1)
|
||||
self.assertEqual(return_pi.docstatus, 1)
|
||||
|
||||
def test_advance_entries_as_asset(self):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -2162,7 +2162,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
create_pr_against_po,
|
||||
create_purchase_order,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as make_pi_from_pr,
|
||||
)
|
||||
|
||||
@@ -2748,10 +2748,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
|
||||
def test_invoice_against_returned_pr(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as make_purchase_invoice_from_pr,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_return_against_rejected_warehouse,
|
||||
)
|
||||
|
||||
@@ -2892,7 +2892,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(invoice.grand_total, 300)
|
||||
|
||||
def test_pr_pi_over_billing(self):
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import (
|
||||
make_purchase_invoice as make_purchase_invoice_from_pr,
|
||||
)
|
||||
|
||||
@@ -2940,7 +2940,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
|
||||
self.assertEqual(pi.discount_amount, discount_amount)
|
||||
|
||||
def test_returned_item_purchase_receipt(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
from erpnext.accounts.doctype.purchase_invoice.mapper import (
|
||||
make_purchase_receipt as make_purchase_receipt_from_pi,
|
||||
)
|
||||
|
||||
|
||||
615
erpnext/accounts/doctype/sales_invoice/mapper.py
Normal file
615
erpnext/accounts/doctype/sales_invoice/mapper.py
Normal file
@@ -0,0 +1,615 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.utils import flt, get_link_to_form, getdate
|
||||
|
||||
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, _get_party_details
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
|
||||
doclist = get_mapped_doc(
|
||||
"Sales Invoice",
|
||||
source_name,
|
||||
{
|
||||
"Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
|
||||
"Sales Invoice Item": {
|
||||
"doctype": "Maintenance Schedule Item",
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_delivery_note(source_name: str, target_doc: Document | None = None):
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
target.run_method("set_po_nos")
|
||||
target.run_method("calculate_taxes_and_totals")
|
||||
|
||||
def update_item(source_doc, target_doc, source_parent):
|
||||
target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty)
|
||||
target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor)
|
||||
|
||||
target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate)
|
||||
target_doc.amount = target_doc.qty * flt(source_doc.rate)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Sales Invoice",
|
||||
source_name,
|
||||
{
|
||||
"Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
|
||||
"Sales Invoice Item": {
|
||||
"doctype": "Delivery Note Item",
|
||||
"field_map": {
|
||||
"name": "si_detail",
|
||||
"parent": "against_sales_invoice",
|
||||
"serial_no": "serial_no",
|
||||
"sales_order": "against_sales_order",
|
||||
"so_detail": "so_detail",
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1
|
||||
and not doc.scio_detail
|
||||
and not doc.dn_detail
|
||||
and doc.qty - doc.delivered_qty > 0,
|
||||
},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
"Sales Team": {
|
||||
"doctype": "Sales Team",
|
||||
"field_map": {"incentives": "incentives"},
|
||||
"add_if_empty": True,
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_return(source_name: str, target_doc: Document | None = None):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
return make_return_doc("Sales Invoice", source_name, target_doc)
|
||||
|
||||
|
||||
def get_inter_company_details(doc, doctype):
|
||||
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
|
||||
parties = frappe.db.get_all(
|
||||
"Supplier",
|
||||
fields=["name"],
|
||||
filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company},
|
||||
)
|
||||
company = frappe.get_cached_value("Customer", doc.customer, "represents_company")
|
||||
|
||||
if not parties:
|
||||
frappe.throw(
|
||||
_("No Supplier found for Inter Company Transactions which represents company {0}").format(
|
||||
frappe.bold(doc.company)
|
||||
)
|
||||
)
|
||||
|
||||
party = get_internal_party(parties, "Supplier", doc)
|
||||
else:
|
||||
parties = frappe.db.get_all(
|
||||
"Customer",
|
||||
fields=["name"],
|
||||
filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company},
|
||||
)
|
||||
company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company")
|
||||
|
||||
if not parties:
|
||||
frappe.throw(
|
||||
_("No Customer found for Inter Company Transactions which represents company {0}").format(
|
||||
frappe.bold(doc.company)
|
||||
)
|
||||
)
|
||||
|
||||
party = get_internal_party(parties, "Customer", doc)
|
||||
|
||||
return {"party": party, "company": company}
|
||||
|
||||
|
||||
def get_internal_party(parties, link_doctype, doc):
|
||||
if len(parties) == 1:
|
||||
party = parties[0].name
|
||||
else:
|
||||
# If more than one Internal Supplier/Customer, get supplier/customer on basis of address
|
||||
if doc.get("company_address") or doc.get("shipping_address"):
|
||||
party = frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": doc.get("company_address") or doc.get("shipping_address"),
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
},
|
||||
"link_name",
|
||||
)
|
||||
|
||||
if not party:
|
||||
party = parties[0].name
|
||||
else:
|
||||
party = parties[0].name
|
||||
|
||||
return party
|
||||
|
||||
|
||||
def validate_inter_company_transaction(doc, doctype):
|
||||
details = get_inter_company_details(doc, doctype)
|
||||
price_list = (
|
||||
doc.selling_price_list
|
||||
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]
|
||||
else doc.buying_price_list
|
||||
)
|
||||
valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1})
|
||||
if not valid_price_list and not doc.is_internal_transfer():
|
||||
frappe.throw(_("Selected Price List should have buying and selling fields checked."))
|
||||
|
||||
party = details.get("party")
|
||||
if not party:
|
||||
partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer"
|
||||
frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype))
|
||||
|
||||
company = details.get("company")
|
||||
default_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
if default_currency != doc.currency:
|
||||
frappe.throw(
|
||||
_("Company currencies of both the companies should match for Inter Company Transactions.")
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None):
|
||||
return make_inter_company_transaction("Sales Invoice", source_name, target_doc)
|
||||
|
||||
|
||||
def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
source_doc = frappe.get_doc(doctype, source_name)
|
||||
target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
|
||||
target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item"
|
||||
source_document_warehouse_field = "target_warehouse"
|
||||
target_document_warehouse_field = "from_warehouse"
|
||||
received_items = get_received_items(source_name, target_doctype, target_detail_field)
|
||||
else:
|
||||
source_doc = frappe.get_doc(doctype, source_name)
|
||||
target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
|
||||
source_document_warehouse_field = "from_warehouse"
|
||||
target_document_warehouse_field = "target_warehouse"
|
||||
received_items = {}
|
||||
|
||||
validate_inter_company_transaction(source_doc, doctype)
|
||||
details = get_inter_company_details(source_doc, doctype)
|
||||
|
||||
def set_missing_values(source, target):
|
||||
target.run_method("set_missing_values")
|
||||
set_purchase_references(target)
|
||||
|
||||
def update_details(source_doc, target_doc, source_parent):
|
||||
def _validate_address_link(address, link_doctype, link_name):
|
||||
return frappe.db.get_value(
|
||||
"Dynamic Link",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": link_doctype,
|
||||
"link_name": link_name,
|
||||
},
|
||||
"parent",
|
||||
)
|
||||
|
||||
target_doc.inter_company_invoice_reference = source_doc.name
|
||||
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
|
||||
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.supplier = details.get("party")
|
||||
target_doc.is_internal_supplier = 1
|
||||
target_doc.ignore_pricing_rule = 1
|
||||
target_doc.buying_price_list = source_doc.selling_price_list
|
||||
|
||||
# Invert Addresses
|
||||
if source_doc.company_address and _validate_address_link(
|
||||
source_doc.company_address, "Supplier", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
|
||||
if source_doc.dispatch_address_name and _validate_address_link(
|
||||
source_doc.dispatch_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"dispatch_address",
|
||||
"dispatch_address_display",
|
||||
source_doc.dispatch_address_name,
|
||||
)
|
||||
if source_doc.shipping_address_name and _validate_address_link(
|
||||
source_doc.shipping_address_name, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc,
|
||||
"shipping_address",
|
||||
"shipping_address_display",
|
||||
source_doc.shipping_address_name,
|
||||
)
|
||||
if source_doc.customer_address and _validate_address_link(
|
||||
source_doc.customer_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
|
||||
)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.supplier,
|
||||
party_type="Supplier",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.supplier_address,
|
||||
company_address=target_doc.shipping_address,
|
||||
)
|
||||
|
||||
else:
|
||||
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
|
||||
target_doc.company = details.get("company")
|
||||
target_doc.customer = details.get("party")
|
||||
target_doc.selling_price_list = source_doc.buying_price_list
|
||||
|
||||
if source_doc.supplier_address and _validate_address_link(
|
||||
source_doc.supplier_address, "Company", details.get("company")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "company_address", "company_address_display", source_doc.supplier_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(
|
||||
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
|
||||
)
|
||||
if source_doc.shipping_address and _validate_address_link(
|
||||
source_doc.shipping_address, "Customer", details.get("party")
|
||||
):
|
||||
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
|
||||
|
||||
if currency:
|
||||
target_doc.currency = currency
|
||||
|
||||
update_taxes(
|
||||
target_doc,
|
||||
party=target_doc.customer,
|
||||
party_type="Customer",
|
||||
company=target_doc.company,
|
||||
doctype=target_doc.doctype,
|
||||
party_address=target_doc.customer_address,
|
||||
company_address=target_doc.company_address,
|
||||
shipping_address_name=target_doc.shipping_address_name,
|
||||
)
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
|
||||
if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item":
|
||||
target.purchase_order = source.parent
|
||||
target.purchase_order_item = source.name
|
||||
target.material_request = source.material_request
|
||||
target.material_request_item = source.material_request_item
|
||||
|
||||
if (
|
||||
source.get("purchase_order")
|
||||
and source.get("purchase_order_item")
|
||||
and target.doctype == "Purchase Invoice Item"
|
||||
):
|
||||
target.purchase_order = source.purchase_order
|
||||
target.po_detail = source.purchase_order_item
|
||||
|
||||
if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
|
||||
target.use_serial_batch_fields = 1
|
||||
|
||||
item_field_map = {
|
||||
"doctype": target_doctype + " Item",
|
||||
"field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"],
|
||||
"field_map": {
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
item_field_map["field_map"].update(
|
||||
{
|
||||
"name": target_detail_field,
|
||||
}
|
||||
)
|
||||
|
||||
if source_doc.get("update_stock"):
|
||||
item_field_map["field_map"].update(
|
||||
{
|
||||
source_document_warehouse_field: target_document_warehouse_field,
|
||||
"batch_no": "batch_no",
|
||||
"serial_no": "serial_no",
|
||||
}
|
||||
)
|
||||
elif target_doctype == "Sales Order":
|
||||
item_field_map["field_map"].update(
|
||||
{
|
||||
source_document_warehouse_field: "warehouse",
|
||||
}
|
||||
)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
doctype,
|
||||
source_name,
|
||||
{
|
||||
doctype: {
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"set_target_warehouse": "set_from_warehouse",
|
||||
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
|
||||
},
|
||||
doctype + " Item": item_field_map,
|
||||
},
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
|
||||
filters = {
|
||||
reference_field: reference_name,
|
||||
"docstatus": 1,
|
||||
}
|
||||
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
if target_doctypes:
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
return received_items_map
|
||||
|
||||
|
||||
def set_purchase_references(doc):
|
||||
# add internal PO or PR links if any
|
||||
|
||||
if doc.is_internal_transfer():
|
||||
if doc.doctype == "Purchase Receipt":
|
||||
so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference)
|
||||
|
||||
if so_item_map:
|
||||
pd_item_map, parent_child_map, warehouse_map = get_pd_details(
|
||||
"Purchase Order Item", so_item_map, "sales_order_item"
|
||||
)
|
||||
|
||||
update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map)
|
||||
|
||||
elif doc.doctype == "Purchase Invoice":
|
||||
dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference)
|
||||
# First check for Purchase receipt
|
||||
if list(dn_item_map.values()):
|
||||
pd_item_map, parent_child_map, warehouse_map = get_pd_details(
|
||||
"Purchase Receipt Item", dn_item_map, "delivery_note_item"
|
||||
)
|
||||
|
||||
update_pi_items(
|
||||
doc,
|
||||
"pr_detail",
|
||||
"purchase_receipt",
|
||||
dn_item_map,
|
||||
pd_item_map,
|
||||
parent_child_map,
|
||||
warehouse_map,
|
||||
)
|
||||
|
||||
|
||||
def update_pi_items(
|
||||
doc,
|
||||
detail_field,
|
||||
parent_field,
|
||||
sales_item_map,
|
||||
purchase_item_map,
|
||||
parent_child_map,
|
||||
warehouse_map,
|
||||
):
|
||||
for item in doc.get("items"):
|
||||
item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item)))
|
||||
item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item)))
|
||||
if doc.update_stock:
|
||||
item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item))
|
||||
if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
|
||||
item.warehouse = frappe.db.get_value(
|
||||
"Purchase Order Item", item.purchase_order_item, "warehouse"
|
||||
)
|
||||
|
||||
|
||||
def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map):
|
||||
for item in doc.get("items"):
|
||||
item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item))
|
||||
if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
|
||||
item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse")
|
||||
|
||||
|
||||
def get_delivery_note_details(internal_reference):
|
||||
si_item_details = frappe.get_all(
|
||||
"Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference}
|
||||
)
|
||||
|
||||
return {d.name: d.so_detail for d in si_item_details if d.so_detail}
|
||||
|
||||
|
||||
def get_sales_invoice_details(internal_reference):
|
||||
dn_item_map = {}
|
||||
so_item_map = {}
|
||||
|
||||
si_item_details = frappe.get_all(
|
||||
"Sales Invoice Item",
|
||||
fields=["name", "so_detail", "dn_detail"],
|
||||
filters={"parent": internal_reference},
|
||||
)
|
||||
|
||||
for d in si_item_details:
|
||||
if d.dn_detail:
|
||||
dn_item_map.setdefault(d.name, d.dn_detail)
|
||||
if d.so_detail:
|
||||
so_item_map.setdefault(d.name, d.so_detail)
|
||||
|
||||
return dn_item_map, so_item_map
|
||||
|
||||
|
||||
def get_pd_details(doctype, sd_detail_map, sd_detail_field):
|
||||
pd_item_map = {}
|
||||
accepted_warehouse_map = {}
|
||||
parent_child_map = {}
|
||||
|
||||
pd_item_details = frappe.get_all(
|
||||
doctype,
|
||||
fields=[sd_detail_field, "name", "warehouse", "parent"],
|
||||
filters={sd_detail_field: ("in", list(sd_detail_map.values()))},
|
||||
)
|
||||
|
||||
for d in pd_item_details:
|
||||
pd_item_map.setdefault(d.get(sd_detail_field), d.name)
|
||||
parent_child_map.setdefault(d.get(sd_detail_field), d.parent)
|
||||
accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse)
|
||||
|
||||
return pd_item_map, parent_child_map, accepted_warehouse_map
|
||||
|
||||
|
||||
def update_taxes(
|
||||
doc,
|
||||
party=None,
|
||||
party_type=None,
|
||||
company=None,
|
||||
doctype=None,
|
||||
party_address=None,
|
||||
company_address=None,
|
||||
shipping_address_name=None,
|
||||
master_doctype=None,
|
||||
):
|
||||
# Update Party Details
|
||||
party_details = _get_party_details(
|
||||
party=party,
|
||||
party_type=party_type,
|
||||
company=company,
|
||||
doctype=doctype,
|
||||
party_address=party_address,
|
||||
company_address=company_address,
|
||||
shipping_address=shipping_address_name,
|
||||
)
|
||||
|
||||
# Update taxes and charges if any
|
||||
doc.taxes_and_charges = party_details.get("taxes_and_charges")
|
||||
doc.set("taxes", party_details.get("taxes"))
|
||||
|
||||
|
||||
def update_address(doc, address_field, address_display_field, address_name):
|
||||
doc.set(address_field, address_name)
|
||||
fetch_values = get_fetch_values(doc.doctype, address_field, address_name)
|
||||
|
||||
for key, value in fetch_values.items():
|
||||
doc.set(key, value)
|
||||
|
||||
doc.set(address_display_field, get_address_display(doc.get(address_field)))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None):
|
||||
invoice = frappe.get_doc("Sales Invoice", source_name)
|
||||
invoice_discounting = frappe.new_doc("Invoice Discounting")
|
||||
invoice_discounting.company = invoice.company
|
||||
invoice_discounting.append(
|
||||
"invoices",
|
||||
{
|
||||
"sales_invoice": source_name,
|
||||
"customer": invoice.customer,
|
||||
"posting_date": invoice.posting_date,
|
||||
"outstanding_amount": invoice.outstanding_amount,
|
||||
},
|
||||
)
|
||||
|
||||
return invoice_discounting
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_dunning(
|
||||
source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
|
||||
):
|
||||
def postprocess_dunning(source, target):
|
||||
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
|
||||
|
||||
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
|
||||
if dunning_type:
|
||||
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
|
||||
target.dunning_type = dunning_type.name
|
||||
target.rate_of_interest = dunning_type.rate_of_interest
|
||||
target.dunning_fee = dunning_type.dunning_fee
|
||||
target.income_account = dunning_type.income_account
|
||||
target.cost_center = dunning_type.cost_center
|
||||
letter_text = get_dunning_letter_text(
|
||||
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
|
||||
)
|
||||
|
||||
if letter_text:
|
||||
target.body_text = letter_text.get("body_text")
|
||||
target.closing_text = letter_text.get("closing_text")
|
||||
target.language = letter_text.get("language")
|
||||
|
||||
# update outstanding from doc
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
for row in target.overdue_payments:
|
||||
if row.payment_schedule == source.payment_schedule[0].name:
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
|
||||
target.validate()
|
||||
|
||||
return get_mapped_doc(
|
||||
from_doctype="Sales Invoice",
|
||||
from_docname=source_name,
|
||||
target_doc=target_doc,
|
||||
table_maps={
|
||||
"Sales Invoice": {
|
||||
"doctype": "Dunning",
|
||||
"field_map": {"customer_address": "customer_address", "parent": "sales_invoice"},
|
||||
},
|
||||
"Payment Schedule": {
|
||||
"doctype": "Overdue Payment",
|
||||
"field_map": {"name": "payment_schedule", "parent": "sales_invoice"},
|
||||
"condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(),
|
||||
},
|
||||
},
|
||||
postprocess=postprocess_dunning,
|
||||
ignore_permissions=ignore_permissions,
|
||||
)
|
||||
@@ -197,21 +197,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
|
||||
make_invoice_discounting() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_invoice_discounting",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
|
||||
make_dunning() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
|
||||
make_maintenance_schedule() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_maintenance_schedule",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
@@ -361,7 +361,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
__("Sales Order"),
|
||||
function () {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
|
||||
method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
|
||||
source_doctype: "Sales Order",
|
||||
target: me.frm,
|
||||
setters: {
|
||||
@@ -383,7 +383,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
__("Quotation"),
|
||||
function () {
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.selling.doctype.quotation.quotation.make_sales_invoice",
|
||||
method: "erpnext.selling.doctype.quotation.mapper.make_sales_invoice",
|
||||
source_doctype: "Quotation",
|
||||
target: me.frm,
|
||||
setters: [
|
||||
@@ -421,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
});
|
||||
}
|
||||
erpnext.utils.map_current_doc({
|
||||
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
|
||||
method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
|
||||
source_doctype: "Delivery Note",
|
||||
target: me.frm,
|
||||
date_field: "posting_date",
|
||||
@@ -501,7 +501,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
make_inter_company_invoice() {
|
||||
let me = this;
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_inter_company_purchase_invoice",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_inter_company_purchase_invoice",
|
||||
frm: me.frm,
|
||||
});
|
||||
}
|
||||
@@ -579,7 +579,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
|
||||
make_sales_return() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return",
|
||||
frm: this.frm,
|
||||
});
|
||||
}
|
||||
@@ -712,7 +712,7 @@ extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ fr
|
||||
|
||||
cur_frm.cscript["Make Delivery Note"] = function () {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_delivery_note",
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
|
||||
frm: cur_frm,
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
173
erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
Normal file
173
erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Fixed asset lifecycle helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
|
||||
from erpnext.assets.doctype.asset.depreciation import (
|
||||
depreciate_asset,
|
||||
reset_depreciation_schedule,
|
||||
reverse_depreciation_entry_made_on_disposal,
|
||||
)
|
||||
from erpnext.assets.doctype.asset.mapper import split_asset
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
|
||||
|
||||
class FixedAssetService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_fixed_asset(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.doctype != "Sales Invoice":
|
||||
return
|
||||
|
||||
for d in doc.get("items"):
|
||||
if not d.is_fixed_asset:
|
||||
continue
|
||||
|
||||
if d.asset:
|
||||
if not doc.is_return:
|
||||
asset_status = frappe.db.get_value("Asset", d.asset, "status")
|
||||
if doc.update_stock:
|
||||
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
|
||||
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
|
||||
d.idx, d.asset, asset_status
|
||||
)
|
||||
)
|
||||
elif asset_status == "Sold" and not doc.is_return:
|
||||
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
|
||||
elif not doc.return_against:
|
||||
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
|
||||
title=_("Missing Asset"),
|
||||
)
|
||||
|
||||
def set_income_account_for_fixed_assets(self) -> None:
|
||||
for item in self.doc.items:
|
||||
item.set_income_account_for_fixed_asset(self.doc.company)
|
||||
|
||||
def process_asset_depreciation(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer():
|
||||
return
|
||||
|
||||
if (doc.is_return and doc.docstatus == 2) or (not doc.is_return and doc.docstatus == 1):
|
||||
self._depreciate_asset_on_sale()
|
||||
else:
|
||||
self._restore_asset()
|
||||
|
||||
self._update_asset()
|
||||
|
||||
def split_asset_based_on_sale_qty(self) -> None:
|
||||
asset_qty_map = self._get_asset_qty()
|
||||
for asset, qty in asset_qty_map.items():
|
||||
if qty["actual_qty"] < qty["sale_qty"]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
|
||||
).format(asset, qty["actual_qty"])
|
||||
)
|
||||
|
||||
remaining_qty = qty["actual_qty"] - qty["sale_qty"]
|
||||
if remaining_qty > 0:
|
||||
split_asset(asset, remaining_qty)
|
||||
|
||||
def get_disposal_date(self) -> str:
|
||||
doc = self.doc
|
||||
if doc.is_return:
|
||||
return frappe.db.get_value("Sales Invoice", doc.return_against, "posting_date")
|
||||
return doc.posting_date
|
||||
|
||||
def _depreciate_asset_on_sale(self) -> None:
|
||||
disposal_date = self.get_disposal_date()
|
||||
for d in self.doc.get("items"):
|
||||
if d.asset:
|
||||
asset = frappe.get_doc("Asset", d.asset)
|
||||
if asset.calculate_depreciation and asset.status != "Fully Depreciated":
|
||||
depreciate_asset(asset, disposal_date, self._get_note_for_asset_sale(asset))
|
||||
|
||||
def _restore_asset(self) -> None:
|
||||
for d in self.doc.get("items"):
|
||||
if d.asset:
|
||||
asset = frappe.get_cached_doc("Asset", d.asset)
|
||||
if asset.calculate_depreciation:
|
||||
reverse_depreciation_entry_made_on_disposal(asset)
|
||||
reset_depreciation_schedule(asset, self._get_note_for_asset_return(asset))
|
||||
|
||||
def _update_asset(self) -> None:
|
||||
doc = self.doc
|
||||
disposal_date = self.get_disposal_date()
|
||||
|
||||
for d in doc.get("items"):
|
||||
if not d.asset:
|
||||
continue
|
||||
|
||||
asset = frappe.get_cached_doc("Asset", d.asset)
|
||||
|
||||
if (doc.is_return and doc.docstatus == 1) or (not doc.is_return and doc.docstatus == 2):
|
||||
note = _("Asset returned") if doc.is_return else _("Asset sold")
|
||||
asset_status, disposal_date = None, None
|
||||
else:
|
||||
note = _("Asset sold") if not doc.is_return else _("Return invoice of asset cancelled")
|
||||
asset_status = "Sold"
|
||||
|
||||
frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
|
||||
add_asset_activity(asset.name, note)
|
||||
asset.set_status(asset_status)
|
||||
|
||||
def _get_asset_qty(self) -> dict:
|
||||
doc = self.doc
|
||||
asset_qty_map = {}
|
||||
|
||||
assets = {row.asset for row in doc.items if row.is_fixed_asset and row.asset}
|
||||
if not assets or doc.is_return:
|
||||
return asset_qty_map
|
||||
|
||||
asset_actual_qty = dict(
|
||||
frappe.db.get_all(
|
||||
"Asset",
|
||||
{"name": ["in", list(assets)]},
|
||||
["name", "asset_quantity"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
for row in doc.items:
|
||||
if row.is_fixed_asset and row.asset:
|
||||
actual_qty = asset_actual_qty.get(row.asset)
|
||||
if row.asset in asset_qty_map:
|
||||
asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
|
||||
else:
|
||||
asset_qty_map[row.asset] = {
|
||||
"sale_qty": flt(row.qty),
|
||||
"actual_qty": flt(actual_qty),
|
||||
}
|
||||
|
||||
return asset_qty_map
|
||||
|
||||
def _get_note_for_asset_sale(self, asset) -> str:
|
||||
doc = self.doc
|
||||
return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
|
||||
get_link_to_form(asset.doctype, asset.name),
|
||||
_("returned") if doc.is_return else _("sold"),
|
||||
get_link_to_form(doc.doctype, doc.get("name")),
|
||||
)
|
||||
|
||||
def _get_note_for_asset_return(self, asset) -> str:
|
||||
doc = self.doc
|
||||
asset_link = get_link_to_form(asset.doctype, asset.name)
|
||||
invoice_link = get_link_to_form(doc.doctype, doc.get("name"))
|
||||
if doc.is_return:
|
||||
return _(
|
||||
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
|
||||
).format(asset_link, invoice_link)
|
||||
return _(
|
||||
"This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
|
||||
).format(asset_link, invoice_link)
|
||||
661
erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
Normal file
661
erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
Normal file
@@ -0,0 +1,661 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, cstr, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
from erpnext.assets.doctype.asset.depreciation import (
|
||||
get_gl_entries_on_asset_disposal,
|
||||
get_gl_entries_on_asset_regain,
|
||||
)
|
||||
|
||||
|
||||
class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
"""Assembles the GL entries for a Sales Invoice."""
|
||||
|
||||
def compose(self, inventory_account_map=None):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_regional_gl_entries
|
||||
from erpnext.accounts.general_ledger import merge_similar_entries
|
||||
|
||||
doc = self.doc
|
||||
gl_entries = []
|
||||
|
||||
self.make_customer_gl_entry(gl_entries)
|
||||
|
||||
self.make_tax_gl_entries(gl_entries)
|
||||
self.make_internal_transfer_gl_entries(gl_entries)
|
||||
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
|
||||
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
|
||||
|
||||
if not (doc.is_return and disable_sdbnb_in_sr):
|
||||
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
|
||||
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
self.make_discount_gl_entries(gl_entries)
|
||||
|
||||
gl_entries = make_regional_gl_entries(gl_entries, doc)
|
||||
|
||||
# merge gl entries before adding pos entries
|
||||
gl_entries = merge_similar_entries(gl_entries)
|
||||
|
||||
self.make_loyalty_point_redemption_gle(gl_entries)
|
||||
self.make_pos_gl_entries(gl_entries)
|
||||
|
||||
self.make_write_off_gl_entry(gl_entries)
|
||||
self.make_gle_for_rounding_adjustment(gl_entries)
|
||||
|
||||
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
|
||||
return gl_entries
|
||||
|
||||
def make_precision_loss_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
_round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
precision_loss = doc.get("base_net_total") - flt(
|
||||
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
|
||||
)
|
||||
|
||||
if precision_loss:
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.customer,
|
||||
"debit": precision_loss,
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else doc.cost_center or round_off_cost_center,
|
||||
"remarks": _("Net total calculation precision loss"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def make_discount_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
enable_discount_accounting = cint(
|
||||
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
|
||||
)
|
||||
|
||||
if enable_discount_accounting:
|
||||
for item in doc.get("items"):
|
||||
if item.get("discount_amount") and item.get("discount_account"):
|
||||
discount_amount = item.discount_amount * item.qty
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(item.discount_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": item.discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(
|
||||
discount_amount * doc.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
|
||||
and doc.get("additional_discount_account")
|
||||
and doc.get("discount_amount")
|
||||
):
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": doc.additional_discount_account,
|
||||
"against": doc.customer,
|
||||
"debit": doc.base_discount_amount,
|
||||
"cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company),
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
|
||||
return
|
||||
|
||||
for item in doc.get("items"):
|
||||
if not item.delivery_note and not item.dn_detail:
|
||||
continue
|
||||
|
||||
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
|
||||
continue
|
||||
|
||||
dn_expense_account = frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "expense_account"
|
||||
)
|
||||
if (
|
||||
not dn_expense_account
|
||||
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
|
||||
!= "Stock Delivered But Not Billed"
|
||||
or not item.expense_account
|
||||
or dn_expense_account == item.expense_account
|
||||
):
|
||||
continue
|
||||
|
||||
delivery_note = item.delivery_note or frappe.get_cached_value(
|
||||
"Delivery Note Item", item.dn_detail, "parent"
|
||||
)
|
||||
if not delivery_note:
|
||||
continue
|
||||
|
||||
item_g = frappe.get_cached_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_no": delivery_note,
|
||||
"voucher_detail_no": item.dn_detail,
|
||||
"item_code": item.item_code,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["stock_value_difference", "actual_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not item_g or not flt(item_g.actual_qty):
|
||||
continue
|
||||
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
|
||||
valuation_amount = valuation_rate * item.stock_qty
|
||||
dn_account_currency = get_account_currency(dn_expense_account)
|
||||
item_account_currency = get_account_currency(item.expense_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": dn_expense_account,
|
||||
"against": item.expense_account,
|
||||
"credit": flt(valuation_amount),
|
||||
"credit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
dn_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": dn_expense_account,
|
||||
"debit": flt(valuation_amount),
|
||||
"debit_in_account_currency": flt(valuation_amount),
|
||||
"cost_center": item.cost_center,
|
||||
},
|
||||
item_account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def make_customer_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
# Checked both rounding_adjustment and rounded_total
|
||||
# because rounded_total had value even before introduction of posting GLE based on rounded total
|
||||
grand_total = (
|
||||
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
|
||||
)
|
||||
base_grand_total = flt(
|
||||
doc.base_rounded_total
|
||||
if (doc.base_rounding_adjustment and doc.base_rounded_total)
|
||||
else doc.base_grand_total,
|
||||
doc.precision("base_grand_total"),
|
||||
)
|
||||
|
||||
if grand_total and not doc.is_internal_transfer():
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
# Did not use base_grand_total to book rounding loss gle
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": doc.customer,
|
||||
"due_date": doc.due_date,
|
||||
"against": doc.against_income_account,
|
||||
"debit": base_grand_total,
|
||||
"debit_in_account_currency": base_grand_total
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else grand_total,
|
||||
"debit_in_transaction_currency": grand_total,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_tax_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
enable_discount_accounting = cint(
|
||||
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
|
||||
)
|
||||
|
||||
for tax in doc.get("taxes"):
|
||||
amount, base_amount = tax_service.get_tax_amounts(tax, enable_discount_accounting)
|
||||
|
||||
if flt(tax.base_tax_amount_after_discount_amount):
|
||||
account_currency = get_account_currency(tax.account_head)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
amount, tax.precision("tax_amount_after_discount_amount")
|
||||
),
|
||||
"cost_center": tax.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
|
||||
def make_internal_transfer_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
|
||||
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.unrealized_profit_loss_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(doc.total_taxes_and_charges),
|
||||
"debit_in_account_currency": flt(doc.base_total_taxes_and_charges),
|
||||
"debit_in_transaction_currency": flt(doc.total_taxes_and_charges),
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_item_gl_entries(self, gl_entries):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
# income account gl entries
|
||||
enable_discount_accounting = cint(
|
||||
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
|
||||
)
|
||||
|
||||
for item in doc.get("items"):
|
||||
if (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
or item.is_fixed_asset
|
||||
or enable_discount_accounting
|
||||
):
|
||||
# Do not book income for transfer within same company
|
||||
if doc.is_internal_transfer():
|
||||
continue
|
||||
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
else:
|
||||
income_account = (
|
||||
item.income_account
|
||||
if (not item.enable_deferred_revenue or doc.is_return)
|
||||
else item.deferred_revenue_account
|
||||
)
|
||||
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(
|
||||
item, enable_discount_accounting
|
||||
)
|
||||
|
||||
account_currency = get_account_currency(income_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": income_account,
|
||||
"against": doc.customer,
|
||||
"credit": flt(base_amount, item.precision("base_net_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(base_amount, item.precision("base_net_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(amount, item.precision("net_amount"))
|
||||
),
|
||||
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# expense account gl entries
|
||||
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
|
||||
gl_entries += super(SalesInvoice, doc).get_gl_entries()
|
||||
|
||||
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
|
||||
doc = self.doc
|
||||
asset = frappe.get_cached_doc("Asset", item.asset)
|
||||
|
||||
if doc.is_return:
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
doc.get("doctype"),
|
||||
doc.get("name"),
|
||||
doc.get("posting_date"),
|
||||
)
|
||||
else:
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
doc.get("doctype"),
|
||||
doc.get("name"),
|
||||
doc.get("posting_date"),
|
||||
)
|
||||
|
||||
for gle in fixed_asset_gl_entries:
|
||||
gle["against"] = doc.customer
|
||||
gl_entries.append(self.get_gl_dict(gle, item=item))
|
||||
|
||||
def make_loyalty_point_redemption_gle(self, gl_entries):
|
||||
doc = self.doc
|
||||
if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": doc.customer,
|
||||
"against": "Expense account - "
|
||||
+ cstr(doc.loyalty_redemption_account)
|
||||
+ " for the Loyalty Program",
|
||||
"credit": doc.loyalty_amount,
|
||||
"credit_in_transaction_currency": doc.loyalty_amount,
|
||||
"against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.loyalty_redemption_account,
|
||||
"cost_center": doc.cost_center or doc.loyalty_redemption_cost_center,
|
||||
"against": doc.customer,
|
||||
"debit": doc.loyalty_amount,
|
||||
"debit_in_transaction_currency": doc.loyalty_amount,
|
||||
"remark": "Loyalty Points redeemed by the customer",
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_pos_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if cint(doc.is_pos):
|
||||
skip_change_gl_entries = not cint(
|
||||
frappe.get_single_value("POS Settings", "post_change_gl_entries")
|
||||
)
|
||||
|
||||
for payment_mode in doc.payments:
|
||||
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
|
||||
payment_mode.base_amount -= flt(doc.change_amount)
|
||||
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
if payment_mode.base_amount:
|
||||
# POS, make payment entries
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": doc.customer,
|
||||
"against": payment_mode.account,
|
||||
"credit": payment_mode.base_amount,
|
||||
"credit_in_account_currency": payment_mode.base_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"credit_in_transaction_currency": payment_mode.amount,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
payment_mode_account_currency = get_account_currency(payment_mode.account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": payment_mode.account,
|
||||
"against": doc.customer,
|
||||
"debit": payment_mode.base_amount,
|
||||
"debit_in_account_currency": payment_mode.base_amount
|
||||
if payment_mode_account_currency == doc.company_currency
|
||||
else payment_mode.amount,
|
||||
"debit_in_transaction_currency": payment_mode.amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
payment_mode_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
if not skip_change_gl_entries:
|
||||
gl_entries.extend(self.get_gle_for_change_amount())
|
||||
|
||||
def get_gle_for_change_amount(self) -> list[dict]:
|
||||
doc = self.doc
|
||||
if not doc.change_amount:
|
||||
return []
|
||||
|
||||
if not doc.account_for_change_amount:
|
||||
frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field"))
|
||||
|
||||
return [
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": doc.customer,
|
||||
"against": doc.account_for_change_amount,
|
||||
"debit": flt(doc.base_change_amount),
|
||||
"debit_in_account_currency": flt(doc.base_change_amount)
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.change_amount),
|
||||
"debit_in_transaction_currency": flt(doc.change_amount),
|
||||
"against_voucher": doc.return_against
|
||||
if cint(doc.is_return) and doc.return_against
|
||||
else doc.name,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
),
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.account_for_change_amount,
|
||||
"against": doc.customer,
|
||||
"credit": doc.base_change_amount,
|
||||
"credit_in_transaction_currency": doc.change_amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
item=doc,
|
||||
),
|
||||
]
|
||||
|
||||
def make_write_off_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
# write off entries, applicable if only pos
|
||||
if (
|
||||
doc.is_pos
|
||||
and doc.write_off_account
|
||||
and flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
):
|
||||
write_off_account_currency = get_account_currency(doc.write_off_account)
|
||||
default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center")
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.debit_to,
|
||||
"party_type": "Customer",
|
||||
"party": doc.customer,
|
||||
"against": doc.write_off_account,
|
||||
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"credit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
),
|
||||
"against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.write_off_account,
|
||||
"against": doc.customer,
|
||||
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
|
||||
"debit_in_account_currency": (
|
||||
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
|
||||
if write_off_account_currency == doc.company_currency
|
||||
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
|
||||
),
|
||||
"debit_in_transaction_currency": flt(
|
||||
doc.write_off_amount, doc.precision("write_off_amount")
|
||||
),
|
||||
"cost_center": doc.cost_center or doc.write_off_cost_center or default_cost_center,
|
||||
},
|
||||
write_off_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_gle_for_rounding_adjustment(self, gl_entries):
|
||||
doc = self.doc
|
||||
if (
|
||||
flt(doc.rounding_adjustment, doc.precision("rounding_adjustment"))
|
||||
and doc.base_rounding_adjustment
|
||||
and not doc.is_internal_transfer()
|
||||
):
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
if doc.is_opening == "Yes" and doc.rounding_adjustment:
|
||||
if not round_off_for_opening:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
|
||||
).format(
|
||||
frappe.bold(doc.rounding_adjustment),
|
||||
frappe.bold("Round Off for Opening"),
|
||||
get_link_to_form("Company", doc.company),
|
||||
frappe.bold("Disable Rounded Total"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
round_off_account = round_off_for_opening
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.customer,
|
||||
"credit_in_account_currency": flt(
|
||||
doc.rounding_adjustment, doc.precision("rounding_adjustment")
|
||||
),
|
||||
"credit_in_transaction_currency": flt(
|
||||
doc.rounding_adjustment, doc.precision("rounding_adjustment")
|
||||
),
|
||||
"credit": flt(
|
||||
doc.base_rounding_adjustment, doc.precision("base_rounding_adjustment")
|
||||
),
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else (doc.cost_center or round_off_cost_center),
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Inter-company transaction helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
def validate_inter_company_party(
|
||||
doctype: str, party: str, company: str, inter_company_reference: str | None
|
||||
) -> None:
|
||||
if not party:
|
||||
return
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
|
||||
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
|
||||
else:
|
||||
partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
|
||||
ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
|
||||
|
||||
if inter_company_reference:
|
||||
doc = frappe.get_doc(ref_doc, inter_company_reference)
|
||||
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
|
||||
if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
|
||||
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
|
||||
if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
|
||||
frappe.throw(_("Invalid Company for Inter Company Transaction."))
|
||||
|
||||
elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
|
||||
companies = [
|
||||
d.company
|
||||
for d in frappe.get_all(
|
||||
"Allowed To Transact With",
|
||||
fields=["company"],
|
||||
filters={"parenttype": partytype, "parent": party},
|
||||
)
|
||||
]
|
||||
if company not in companies:
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
|
||||
).format(_(partytype), company)
|
||||
)
|
||||
|
||||
|
||||
def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
|
||||
ref_field = (
|
||||
"inter_company_invoice_reference"
|
||||
if doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||
else "inter_company_order_reference"
|
||||
)
|
||||
if inter_company_reference:
|
||||
frappe.db.set_value(doctype, inter_company_reference, ref_field, name)
|
||||
|
||||
|
||||
def unlink_inter_company_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
|
||||
if doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice"
|
||||
ref_field = "inter_company_invoice_reference"
|
||||
else:
|
||||
ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order"
|
||||
ref_field = "inter_company_order_reference"
|
||||
|
||||
if inter_company_reference:
|
||||
frappe.db.set_value(doctype, name, ref_field, "")
|
||||
frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "")
|
||||
163
erpnext/accounts/doctype/sales_invoice/services/loyalty.py
Normal file
163
erpnext/accounts/doctype/sales_invoice/services/loyalty.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Loyalty program helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_days, cint, flt, getdate
|
||||
|
||||
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
|
||||
get_loyalty_program_details_with_points,
|
||||
)
|
||||
|
||||
|
||||
class LoyaltyService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def make_loyalty_point_entry(self) -> None:
|
||||
doc = self.doc
|
||||
returned_amount = self._get_returned_amount()
|
||||
current_amount = flt(doc.grand_total) - cint(doc.loyalty_amount)
|
||||
eligible_amount = current_amount - returned_amount
|
||||
lp_details = get_loyalty_program_details_with_points(
|
||||
doc.customer,
|
||||
company=doc.company,
|
||||
current_transaction_amount=current_amount,
|
||||
loyalty_program=doc.loyalty_program,
|
||||
expiry_date=doc.posting_date,
|
||||
include_expired_entry=True,
|
||||
)
|
||||
if (
|
||||
lp_details
|
||||
and getdate(lp_details.from_date) <= getdate(doc.posting_date)
|
||||
and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(doc.posting_date))
|
||||
):
|
||||
collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0
|
||||
points_earned = cint(eligible_amount / collection_factor)
|
||||
|
||||
entry = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Loyalty Point Entry",
|
||||
"company": doc.company,
|
||||
"loyalty_program": lp_details.loyalty_program,
|
||||
"loyalty_program_tier": lp_details.tier_name,
|
||||
"customer": doc.customer,
|
||||
"invoice_type": doc.doctype,
|
||||
"invoice": doc.name,
|
||||
"loyalty_points": points_earned,
|
||||
"purchase_amount": eligible_amount,
|
||||
"expiry_date": add_days(doc.posting_date, lp_details.expiry_duration),
|
||||
"posting_date": doc.posting_date,
|
||||
}
|
||||
)
|
||||
entry.flags.ignore_permissions = 1
|
||||
entry.save()
|
||||
self._set_loyalty_program_tier()
|
||||
|
||||
def delete_loyalty_point_entry(self) -> None:
|
||||
doc = self.doc
|
||||
lp_entry = frappe.db.get_all(
|
||||
"Loyalty Point Entry", filters={"invoice": doc.name, "loyalty_points": (">", 0)}, fields=["name"]
|
||||
)
|
||||
|
||||
if not lp_entry:
|
||||
return
|
||||
|
||||
against_lp_entry = frappe.db.get_all(
|
||||
"Loyalty Point Entry",
|
||||
filters={"redeem_against": lp_entry[0].name},
|
||||
fields=["name", "invoice"],
|
||||
)
|
||||
|
||||
if against_lp_entry:
|
||||
invoice_list = ", ".join([d.invoice for d in against_lp_entry])
|
||||
frappe.throw(
|
||||
_(
|
||||
"{} can't be cancelled since the Loyalty Points earned has been redeemed. "
|
||||
"First cancel the {} No {}"
|
||||
).format(doc.doctype, doc.doctype, invoice_list)
|
||||
)
|
||||
else:
|
||||
frappe.db.delete("Loyalty Point Entry", filters={"invoice": doc.name})
|
||||
self._set_loyalty_program_tier()
|
||||
|
||||
def apply_loyalty_points(self) -> None:
|
||||
from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import (
|
||||
get_loyalty_point_entries,
|
||||
get_redemption_details,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
loyalty_point_entries = get_loyalty_point_entries(
|
||||
doc.customer, doc.loyalty_program, doc.company, doc.posting_date
|
||||
)
|
||||
redemption_details = get_redemption_details(doc.customer, doc.loyalty_program, doc.company)
|
||||
|
||||
points_to_redeem = doc.loyalty_points
|
||||
for lp_entry in loyalty_point_entries:
|
||||
if lp_entry.invoice_type != doc.doctype or lp_entry.invoice == doc.name:
|
||||
continue
|
||||
available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name))
|
||||
redeemed_points = min(available_points, points_to_redeem)
|
||||
entry = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Loyalty Point Entry",
|
||||
"company": doc.company,
|
||||
"loyalty_program": doc.loyalty_program,
|
||||
"loyalty_program_tier": lp_entry.loyalty_program_tier,
|
||||
"customer": doc.customer,
|
||||
"invoice_type": doc.doctype,
|
||||
"invoice": doc.name,
|
||||
"redeem_against": lp_entry.name,
|
||||
"loyalty_points": -1 * redeemed_points,
|
||||
"purchase_amount": doc.grand_total,
|
||||
"expiry_date": lp_entry.expiry_date,
|
||||
"posting_date": doc.posting_date,
|
||||
}
|
||||
)
|
||||
entry.flags.ignore_permissions = 1
|
||||
entry.save()
|
||||
points_to_redeem -= redeemed_points
|
||||
if points_to_redeem < 1:
|
||||
break
|
||||
|
||||
def _set_loyalty_program_tier(self) -> None:
|
||||
doc = self.doc
|
||||
lp_details = get_loyalty_program_details_with_points(
|
||||
doc.customer,
|
||||
company=doc.company,
|
||||
loyalty_program=doc.loyalty_program,
|
||||
include_expired_entry=True,
|
||||
)
|
||||
customer = frappe.get_doc("Customer", doc.customer)
|
||||
customer.db_set("loyalty_program_tier", lp_details.tier_name)
|
||||
|
||||
def _get_returned_amount(self) -> float:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
doc = frappe.qb.DocType(self.doc.doctype)
|
||||
returned_amount = (
|
||||
frappe.qb.from_(doc)
|
||||
.select(Sum(doc.grand_total))
|
||||
.where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.doc.name))
|
||||
).run()
|
||||
|
||||
return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
|
||||
|
||||
|
||||
def get_loyalty_programs(customer: str) -> list:
|
||||
"""Return applicable loyalty programs for the customer."""
|
||||
from erpnext.selling.doctype.customer.customer import get_loyalty_programs as _get
|
||||
|
||||
customer_doc = frappe.get_doc("Customer", customer)
|
||||
if customer_doc.loyalty_program:
|
||||
return [customer_doc.loyalty_program]
|
||||
|
||||
lp_details = _get(customer_doc)
|
||||
|
||||
if len(lp_details) == 1:
|
||||
customer_doc.db_set("loyalty_program", lp_details[0])
|
||||
|
||||
return lp_details
|
||||
422
erpnext/accounts/doctype/sales_invoice/services/pos.py
Normal file
422
erpnext/accounts/doctype/sales_invoice/services/pos.py
Normal file
@@ -0,0 +1,422 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""POS helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
|
||||
class PartialPaymentValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class POSService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
|
||||
"""Populate POS-profile fields on the invoice; return the profile or None."""
|
||||
doc = self.doc
|
||||
if cint(doc.is_pos) != 1:
|
||||
return None
|
||||
|
||||
if not doc.account_for_change_amount:
|
||||
doc.account_for_change_amount = frappe.get_cached_value(
|
||||
"Company", doc.company, "default_cash_account"
|
||||
)
|
||||
|
||||
from erpnext.stock.get_item_details import (
|
||||
ItemDetailsCtx,
|
||||
get_pos_profile,
|
||||
get_pos_profile_item_details_,
|
||||
)
|
||||
|
||||
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
|
||||
pos_profile = get_pos_profile(doc.company) or {}
|
||||
if not pos_profile:
|
||||
return None
|
||||
doc.pos_profile = pos_profile.get("name")
|
||||
|
||||
pos = {}
|
||||
if doc.pos_profile:
|
||||
pos = frappe.get_doc("POS Profile", doc.pos_profile)
|
||||
|
||||
if pos:
|
||||
if not for_validate:
|
||||
update_multi_mode_option(doc, pos)
|
||||
doc.tax_category = pos.get("tax_category")
|
||||
|
||||
if not for_validate and not doc.customer:
|
||||
doc.customer = pos.customer
|
||||
|
||||
if not for_validate:
|
||||
doc.ignore_pricing_rule = pos.ignore_pricing_rule
|
||||
|
||||
if pos.get("account_for_change_amount"):
|
||||
doc.account_for_change_amount = pos.get("account_for_change_amount")
|
||||
|
||||
for fieldname in (
|
||||
"currency",
|
||||
"letter_head",
|
||||
"tc_name",
|
||||
"company",
|
||||
"select_print_heading",
|
||||
"write_off_account",
|
||||
"taxes_and_charges",
|
||||
"write_off_cost_center",
|
||||
"apply_discount_on",
|
||||
"cost_center",
|
||||
):
|
||||
if (not for_validate) or (for_validate and not doc.get(fieldname)):
|
||||
doc.set(fieldname, pos.get(fieldname))
|
||||
|
||||
if pos.get("company_address"):
|
||||
doc.company_address = pos.get("company_address")
|
||||
|
||||
if doc.customer:
|
||||
customer_price_list, customer_group = frappe.get_value(
|
||||
"Customer", doc.customer, ["default_price_list", "customer_group"]
|
||||
)
|
||||
customer_group_price_list = frappe.get_value(
|
||||
"Customer Group", customer_group, "default_price_list"
|
||||
)
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
|
||||
)
|
||||
else:
|
||||
selling_price_list = pos.get("selling_price_list")
|
||||
|
||||
if selling_price_list:
|
||||
doc.set("selling_price_list", selling_price_list)
|
||||
|
||||
if not for_validate:
|
||||
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
|
||||
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
for item in doc.get("items"):
|
||||
if item.get("item_code"):
|
||||
profile_details = get_pos_profile_item_details_(
|
||||
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
|
||||
)
|
||||
for fname, val in profile_details.items():
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
|
||||
if doc.tc_name and not doc.terms:
|
||||
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
|
||||
|
||||
if doc.taxes_and_charges and not len(doc.get("taxes")):
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
|
||||
TaxService(doc).set_taxes()
|
||||
|
||||
return pos
|
||||
|
||||
def set_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
paid_amount = 0.0
|
||||
base_paid_amount = 0.0
|
||||
for data in doc.payments:
|
||||
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
|
||||
paid_amount += data.amount
|
||||
base_paid_amount += data.base_amount
|
||||
doc.paid_amount = paid_amount
|
||||
doc.base_paid_amount = base_paid_amount
|
||||
|
||||
def set_account_for_mode_of_payment(self) -> None:
|
||||
for payment in self.doc.payments:
|
||||
payment.account = get_bank_cash_account(payment.mode_of_payment, self.doc.company).get("account")
|
||||
|
||||
def reset_mode_of_payments(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.pos_profile:
|
||||
pos_profile = frappe.get_cached_doc("POS Profile", doc.pos_profile)
|
||||
update_multi_mode_option(doc, pos_profile)
|
||||
doc.paid_amount = 0
|
||||
|
||||
def validate_pos_return(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_consolidated:
|
||||
return
|
||||
|
||||
if doc.is_pos and doc.is_return:
|
||||
total_amount_in_payments = sum(payment.amount for payment in doc.payments)
|
||||
invoice_total = doc.rounded_total or doc.grand_total
|
||||
if total_amount_in_payments < invoice_total:
|
||||
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
|
||||
|
||||
def validate_pos_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
if len(doc.payments) == 0 and doc.is_pos and flt(doc.grand_total) > 0:
|
||||
frappe.throw(_("At least one mode of payment is required for POS invoice."))
|
||||
|
||||
def validate_pos(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_return:
|
||||
invoice_total = doc.rounded_total or doc.grand_total
|
||||
if abs(flt(doc.paid_amount)) + abs(flt(doc.write_off_amount)) - abs(flt(invoice_total)) > 1.0 / (
|
||||
10.0 ** (doc.precision("grand_total") + 1.0)
|
||||
):
|
||||
frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
|
||||
|
||||
def validate_created_using_pos(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_created_using_pos and not doc.pos_profile:
|
||||
frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction."))
|
||||
|
||||
doc.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type")
|
||||
if doc.invoice_type_in_pos == "POS Invoice" and not doc.is_return:
|
||||
frappe.throw(_("Transactions using Sales Invoice in POS are disabled."))
|
||||
|
||||
self.validate_pos_opening_entry()
|
||||
|
||||
def validate_full_payment(self) -> None:
|
||||
doc = self.doc
|
||||
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
|
||||
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
|
||||
|
||||
if (
|
||||
doc.docstatus == 1
|
||||
and not doc.is_return
|
||||
and not allow_partial_payment
|
||||
and doc.paid_amount < invoice_total
|
||||
):
|
||||
frappe.throw(
|
||||
msg=_("Partial Payment in POS Transactions are not allowed."),
|
||||
exc=PartialPaymentValidationError,
|
||||
)
|
||||
|
||||
def validate_pos_opening_entry(self) -> None:
|
||||
doc = self.doc
|
||||
opening_entries = frappe.get_all(
|
||||
"POS Opening Entry",
|
||||
fields=["name", "period_start_date"],
|
||||
filters={"pos_profile": doc.pos_profile, "status": "Open"},
|
||||
order_by="period_start_date desc",
|
||||
)
|
||||
if not opening_entries:
|
||||
frappe.throw(
|
||||
title=_("POS Opening Entry Missing"),
|
||||
msg=_("No open POS Opening Entry found for POS Profile {0}.").format(
|
||||
frappe.bold(doc.pos_profile)
|
||||
),
|
||||
)
|
||||
if len(opening_entries) > 1:
|
||||
frappe.throw(
|
||||
title=_("Multiple POS Opening Entry"),
|
||||
msg=_(
|
||||
"POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding."
|
||||
).format(doc.pos_profile),
|
||||
)
|
||||
if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today():
|
||||
frappe.throw(
|
||||
title=_("Outdated POS Opening Entry"),
|
||||
msg=_(
|
||||
"POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry."
|
||||
).format(opening_entries[0].get("name")),
|
||||
)
|
||||
|
||||
def check_if_consolidated_invoice(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.doctype == "Sales Invoice" and doc.is_consolidated:
|
||||
invoice_or_credit_note = "consolidated_credit_note" if doc.is_return else "consolidated_invoice"
|
||||
pos_closing_entry = frappe.get_all(
|
||||
"POS Invoice Merge Log",
|
||||
filters={invoice_or_credit_note: doc.name},
|
||||
pluck="pos_closing_entry",
|
||||
)
|
||||
if pos_closing_entry and pos_closing_entry[0]:
|
||||
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
|
||||
frappe.bold(_("Consolidated Sales Invoice")),
|
||||
get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
|
||||
)
|
||||
frappe.throw(msg, title=_("Not Allowed"))
|
||||
|
||||
def check_if_created_using_pos_and_pos_closing_entry_generated(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.doctype == "Sales Invoice" and doc.is_created_using_pos and doc.pos_closing_entry:
|
||||
pos_closing_entry_docstatus = frappe.db.get_value(
|
||||
"POS Closing Entry", doc.pos_closing_entry, "docstatus"
|
||||
)
|
||||
if pos_closing_entry_docstatus == 1:
|
||||
frappe.throw(
|
||||
msg=_(
|
||||
"To cancel this Sales Invoice you need to cancel the POS Closing Entry {0}."
|
||||
).format(get_link_to_form("POS Closing Entry", doc.pos_closing_entry)),
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
|
||||
def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self) -> None:
|
||||
pos_invoices = frappe.get_all(
|
||||
"POS Invoice", filters={"consolidated_invoice": self.doc.name}, pluck="name"
|
||||
)
|
||||
for pos_invoice in pos_invoices:
|
||||
frappe.get_doc("POS Invoice", pos_invoice).cancel()
|
||||
|
||||
def clear_unallocated_mode_of_payments(self) -> None:
|
||||
doc = self.doc
|
||||
doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]}))
|
||||
frappe.db.delete("Sales Invoice Payment", filters={"parent": doc.name, "amount": 0})
|
||||
|
||||
def allow_write_off_only_on_pos(self) -> None:
|
||||
if not self.doc.is_pos and self.doc.write_off_account:
|
||||
self.doc.write_off_account = None
|
||||
|
||||
def verify_payment_amount_is_positive(self) -> None:
|
||||
for entry in self.doc.payments:
|
||||
if entry.amount < 0:
|
||||
frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx))
|
||||
|
||||
def verify_payment_amount_is_negative(self) -> None:
|
||||
for entry in self.doc.payments:
|
||||
if entry.amount > 0:
|
||||
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
|
||||
|
||||
def get_warehouse(self) -> str | None:
|
||||
doc = self.doc
|
||||
POSProfile = frappe.qb.DocType("POS Profile")
|
||||
|
||||
user_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(
|
||||
(POSProfile.user == frappe.session["user"])
|
||||
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
|
||||
)
|
||||
)
|
||||
user_pos_profile = user_query.run()
|
||||
warehouse = user_pos_profile[0][1] if user_pos_profile else None
|
||||
|
||||
if not warehouse:
|
||||
global_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == doc.company)
|
||||
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
|
||||
)
|
||||
global_pos_profile = global_query.run()
|
||||
|
||||
if global_pos_profile:
|
||||
warehouse = global_pos_profile[0][1]
|
||||
elif not user_pos_profile:
|
||||
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
|
||||
|
||||
return warehouse
|
||||
|
||||
|
||||
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
|
||||
account = frappe.db.get_value(
|
||||
"Mode of Payment Account",
|
||||
{"parent": mode_of_payment, "company": company},
|
||||
"default_account",
|
||||
)
|
||||
if not account:
|
||||
frappe.throw(
|
||||
_("Please set default Cash or Bank account in Mode of Payment {0}").format(
|
||||
get_link_to_form("Mode of Payment", mode_of_payment)
|
||||
),
|
||||
title=_("Missing Account"),
|
||||
)
|
||||
return {"account": account}
|
||||
|
||||
|
||||
def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
def append_payment(payment_mode):
|
||||
payment = doc.append("payments", {})
|
||||
payment.default = payment_mode.default
|
||||
payment.mode_of_payment = payment_mode.mop
|
||||
payment.account = payment_mode.default_account
|
||||
payment.type = payment_mode.type
|
||||
|
||||
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
|
||||
|
||||
doc.set("payments", [])
|
||||
invalid_modes = []
|
||||
mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")]
|
||||
mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company)
|
||||
|
||||
for row in pos_profile.get("payments"):
|
||||
payment_mode = mode_of_payments_info.get(row.mode_of_payment)
|
||||
if not payment_mode:
|
||||
invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment))
|
||||
continue
|
||||
|
||||
payment_mode.default = row.default
|
||||
append_payment(payment_mode)
|
||||
|
||||
if invalid_modes:
|
||||
if invalid_modes == 1:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
|
||||
else:
|
||||
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
|
||||
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
|
||||
|
||||
if mop_refetched:
|
||||
frappe.toast(
|
||||
_("Payment methods refreshed. Please review before proceeding."),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == doc.company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account,
|
||||
ModeOfPaymentAccount.parent.as_("mop"),
|
||||
ModeOfPayment.type.as_("type"),
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name.isin(mode_of_payments))
|
||||
.groupby(ModeOfPayment.name)
|
||||
)
|
||||
|
||||
data = query.run(as_dict=1)
|
||||
|
||||
return {row.get("mop"): row for row in data}
|
||||
|
||||
|
||||
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPayment)
|
||||
.join(ModeOfPaymentAccount)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
.where(ModeOfPayment.name == mode_of_payment)
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
134
erpnext/accounts/doctype/sales_invoice/services/status.py
Normal file
134
erpnext/accounts/doctype/sales_invoice/services/status.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Status computation and display helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, getdate, nowdate
|
||||
|
||||
|
||||
class StatusService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def set_status(
|
||||
self, update: bool = False, status: str | None = None, update_modified: bool = True
|
||||
) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_new():
|
||||
if doc.get("amended_from"):
|
||||
doc.status = "Draft"
|
||||
return
|
||||
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
total = get_total_in_party_account_currency(doc)
|
||||
|
||||
if not status:
|
||||
if doc.docstatus == 2:
|
||||
status = "Cancelled"
|
||||
elif doc.docstatus == 1:
|
||||
if doc.is_internal_transfer():
|
||||
doc.status = "Internal Transfer"
|
||||
elif is_overdue(doc, total):
|
||||
doc.status = "Overdue"
|
||||
elif 0 < outstanding_amount < total:
|
||||
doc.status = "Partly Paid"
|
||||
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
|
||||
doc.status = "Unpaid"
|
||||
elif doc.is_return == 0 and frappe.db.get_value(
|
||||
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
|
||||
):
|
||||
doc.status = "Credit Note Issued"
|
||||
elif doc.is_return == 1:
|
||||
doc.status = "Return"
|
||||
elif outstanding_amount <= 0:
|
||||
doc.status = "Paid"
|
||||
else:
|
||||
doc.status = "Submitted"
|
||||
|
||||
if (
|
||||
doc.status in ("Unpaid", "Partly Paid", "Overdue")
|
||||
and doc.is_discounted
|
||||
and get_discounting_status(doc.name) == "Disbursed"
|
||||
):
|
||||
doc.status += " and Discounted"
|
||||
|
||||
else:
|
||||
doc.status = "Draft"
|
||||
|
||||
if update:
|
||||
doc.db_set("status", doc.status, update_modified=update_modified)
|
||||
|
||||
def set_indicator(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.outstanding_amount < 0:
|
||||
doc.indicator_title = _("Credit Note Issued")
|
||||
doc.indicator_color = "gray"
|
||||
elif doc.outstanding_amount > 0 and getdate(doc.due_date) >= getdate(nowdate()):
|
||||
doc.indicator_color = "orange"
|
||||
doc.indicator_title = _("Unpaid")
|
||||
elif doc.outstanding_amount > 0 and getdate(doc.due_date) < getdate(nowdate()):
|
||||
doc.indicator_color = "red"
|
||||
doc.indicator_title = _("Overdue")
|
||||
elif cint(doc.is_return) == 1:
|
||||
doc.indicator_title = _("Return")
|
||||
doc.indicator_color = "gray"
|
||||
else:
|
||||
doc.indicator_color = "green"
|
||||
doc.indicator_title = _("Paid")
|
||||
|
||||
|
||||
def get_total_in_party_account_currency(doc) -> float:
|
||||
total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total"
|
||||
if doc.party_account_currency != doc.currency:
|
||||
total_fieldname = "base_" + total_fieldname
|
||||
return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
|
||||
|
||||
|
||||
def is_overdue(doc, total: float) -> bool | None:
|
||||
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
|
||||
if outstanding_amount <= 0:
|
||||
return
|
||||
|
||||
today = getdate()
|
||||
if doc.get("is_pos") or not doc.get("payment_schedule"):
|
||||
return getdate(doc.due_date) < today
|
||||
|
||||
payment_amount_field = (
|
||||
"base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount"
|
||||
)
|
||||
payable_amount = flt(
|
||||
sum(
|
||||
payment.get(payment_amount_field)
|
||||
for payment in doc.payment_schedule
|
||||
if getdate(payment.due_date) < today
|
||||
),
|
||||
doc.precision("outstanding_amount"),
|
||||
)
|
||||
return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount
|
||||
|
||||
|
||||
def get_discounting_status(sales_invoice: str) -> str | None:
|
||||
status = None
|
||||
|
||||
InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
|
||||
DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(InvoiceDiscounting)
|
||||
.join(DiscountedInvoice)
|
||||
.on(InvoiceDiscounting.name == DiscountedInvoice.parent)
|
||||
.select(InvoiceDiscounting.status)
|
||||
.where(DiscountedInvoice.sales_invoice == sales_invoice)
|
||||
.where(InvoiceDiscounting.docstatus == 1)
|
||||
.where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
|
||||
)
|
||||
|
||||
invoice_discounting_list = query.run()
|
||||
|
||||
for d in invoice_discounting_list:
|
||||
status = d[0]
|
||||
if status == "Disbursed":
|
||||
break
|
||||
return status
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Timesheet billing helpers for Sales Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
|
||||
|
||||
|
||||
class TimesheetBillingService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_time_sheets_are_submitted(self) -> None:
|
||||
for data in self.doc.timesheets:
|
||||
if data.time_sheet and data.timesheet_detail:
|
||||
if sales_invoice := frappe.db.get_value(
|
||||
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
|
||||
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
|
||||
)
|
||||
)
|
||||
|
||||
if data.time_sheet:
|
||||
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
|
||||
if status not in ["Submitted", "Payslip", "Partially Billed"]:
|
||||
frappe.throw(
|
||||
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
|
||||
)
|
||||
|
||||
def update_time_sheet(self, sales_invoice: str | None) -> None:
|
||||
for d in self.doc.timesheets:
|
||||
if d.time_sheet:
|
||||
timesheet = frappe.get_doc("Timesheet", d.time_sheet)
|
||||
self._update_time_sheet_detail(timesheet, d, sales_invoice)
|
||||
timesheet.calculate_total_amounts()
|
||||
timesheet.calculate_percentage_billed()
|
||||
timesheet.flags.ignore_validate_update_after_submit = True
|
||||
timesheet.set_status()
|
||||
timesheet.db_update_all()
|
||||
|
||||
def unlink_sales_invoice_from_timesheets(self) -> None:
|
||||
for row in self.doc.timesheets:
|
||||
timesheet = frappe.get_doc("Timesheet", row.time_sheet)
|
||||
timesheet.unlink_sales_invoice(self.doc.name)
|
||||
timesheet.flags.ignore_validate_update_after_submit = True
|
||||
timesheet.db_update_all()
|
||||
|
||||
def set_billing_hours_and_amount(self) -> None:
|
||||
doc = self.doc
|
||||
if doc.project:
|
||||
return
|
||||
|
||||
for timesheet in doc.timesheets:
|
||||
ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet)
|
||||
if not timesheet.billing_hours and ts_doc.total_billable_hours:
|
||||
timesheet.billing_hours = ts_doc.total_billable_hours
|
||||
if not timesheet.billing_amount and ts_doc.total_billable_amount:
|
||||
timesheet.billing_amount = ts_doc.total_billable_amount
|
||||
|
||||
def update_timesheet_billing_for_project(self) -> None:
|
||||
doc = self.doc
|
||||
if (
|
||||
not doc.is_return
|
||||
and not doc.timesheets
|
||||
and doc.project
|
||||
and frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice")
|
||||
):
|
||||
self.add_timesheet_data()
|
||||
else:
|
||||
self.calculate_billing_amount_for_timesheet()
|
||||
|
||||
def add_timesheet_data(self) -> None:
|
||||
doc = self.doc
|
||||
doc.set("timesheets", [])
|
||||
if doc.project:
|
||||
for data in get_projectwise_timesheet_data(doc.project):
|
||||
doc.append(
|
||||
"timesheets",
|
||||
{
|
||||
"time_sheet": data.time_sheet,
|
||||
"billing_hours": data.billing_hours,
|
||||
"billing_amount": data.billing_amount,
|
||||
"timesheet_detail": data.name,
|
||||
"activity_type": data.activity_type,
|
||||
"description": data.description,
|
||||
},
|
||||
)
|
||||
self.calculate_billing_amount_for_timesheet()
|
||||
|
||||
def calculate_billing_amount_for_timesheet(self) -> None:
|
||||
doc = self.doc
|
||||
doc.total_billing_amount = sum(flt(ts.billing_amount) for ts in doc.timesheets)
|
||||
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
|
||||
|
||||
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
|
||||
doc = self.doc
|
||||
for data in timesheet.time_logs:
|
||||
if (
|
||||
(doc.project and args.timesheet_detail == data.name)
|
||||
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
|
||||
or (
|
||||
not sales_invoice
|
||||
and data.sales_invoice == doc.name
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
or (
|
||||
doc.is_return
|
||||
and doc.return_against
|
||||
and data.sales_invoice
|
||||
and data.sales_invoice == doc.return_against
|
||||
and not sales_invoice
|
||||
and args.timesheet_detail == data.name
|
||||
)
|
||||
):
|
||||
data.sales_invoice = sales_invoice
|
||||
@@ -19,7 +19,7 @@ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import Warehouse
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
|
||||
unlink_payment_on_cancel_of_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
|
||||
from erpnext.accounts.utils import PaymentEntryUnlinkError
|
||||
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
|
||||
from erpnext.assets.doctype.asset.test_asset import create_asset
|
||||
@@ -30,7 +30,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError, update_invo
|
||||
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
|
||||
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
|
||||
from erpnext.selling.doctype.customer.test_customer import get_customer_dict
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
|
||||
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
@@ -78,7 +78,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
def test_invalid_rate_without_override(self):
|
||||
from frappe import ValidationError
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
|
||||
|
||||
si = create_sales_invoice(
|
||||
customer="_Test Internal Customer 3", company="_Test Company", is_internal_customer=1, rate=100
|
||||
@@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(si.net_total, 3859.65)
|
||||
self.assertEqual(si.grand_total, 4900.00)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency(self):
|
||||
"""Tax-included prices in zero-decimal currencies (e.g. JPY) must not produce
|
||||
net + tax != gross due to double rounding of the net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# With currency_precision=0 (like JPY, KRW):
|
||||
# 50,000 / 1.10 = 45,454.545... → net rounds to 45,455
|
||||
# Tax from unrounded net: 0.10 * 45,454.545 = 4,545.4545 → rounds to 4,545
|
||||
# The fix ensures net + tax = gross without double rounding error
|
||||
self.assertEqual(si.items[0].net_amount, 45455)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4545)
|
||||
self.assertEqual(si.grand_total, 50000)
|
||||
|
||||
def test_inclusive_tax_decimal_value_currency(self):
|
||||
"""Tax-included prices with decimal currency values must preserve gross total."""
|
||||
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
|
||||
# Tax from unrounded net: 0.10 * 9,090.94545... = 909.0945... → rounds to 909.09
|
||||
# If tax were calculated from rounded net instead, it would become 909.10 and grand total 10,000.05.
|
||||
self.assertEqual(si.items[0].net_amount, 9090.95)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 909.09)
|
||||
self.assertEqual(si.grand_total, 10000.04)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency_multiple_items(self):
|
||||
"""Multiple items with tax-included prices in zero-decimal currency."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
create_item("_Test Inclusive Tax Item 2")
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Inclusive Tax Item 2",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": 30000,
|
||||
"income_account": "Sales - _TC",
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# With currency_precision=0:
|
||||
# Item 1: 50,000 / 1.10 = 45,454.545 → net 45,455, tax 4,545
|
||||
# Item 2: 30,000 / 1.10 = 27,272.727 → net 27,273, tax 2,727
|
||||
# Per-item: net + tax = gross holds (45455+4545=50000, 27273+2727=30000)
|
||||
# Accumulated tax rounds separately: flt(7272.72, 0) = 7273
|
||||
# adjust_grand_total_for_inclusive_tax patches grand_total back to 80000
|
||||
self.assertEqual(si.items[0].net_amount, 45455)
|
||||
self.assertEqual(si.items[1].net_amount, 27273)
|
||||
self.assertEqual(si.net_total, 72728)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 7273)
|
||||
self.assertEqual(si.grand_total, 80000)
|
||||
|
||||
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
|
||||
def test_inclusive_tax_zero_decimal_currency_many_items(self):
|
||||
"""Test with 10 items (mixed 10% and 5% tax) to verify tolerance of 1 is sufficient."""
|
||||
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
|
||||
|
||||
# Add 9 more items - mix of amounts and tax rates
|
||||
# Using similar amounts to maximize same-direction rounding
|
||||
item_configs = [
|
||||
("_Test Inclusive Tax Item 2", 50100, None), # 10% (default)
|
||||
("_Test Inclusive Tax Item 3", 50200, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 4", 50300, None), # 10%
|
||||
("_Test Inclusive Tax Item 5", 50400, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 6", 50500, None), # 10%
|
||||
("_Test Inclusive Tax Item 7", 50600, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
("_Test Inclusive Tax Item 8", 50700, None), # 10%
|
||||
("_Test Inclusive Tax Item 9", 50800, None), # 10%
|
||||
("_Test Inclusive Tax Item 10", 50900, '{"_Test Account Service Tax - _TC": 5}'), # 5%
|
||||
]
|
||||
|
||||
for item_code, rate, item_tax_rate in item_configs:
|
||||
create_item(item_code)
|
||||
item_dict = {
|
||||
"item_code": item_code,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"qty": 1,
|
||||
"rate": rate,
|
||||
"income_account": "Sales - _TC",
|
||||
"expense_account": "Cost of Goods Sold - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
}
|
||||
if item_tax_rate:
|
||||
item_dict["item_tax_rate"] = item_tax_rate
|
||||
si.append("items", item_dict)
|
||||
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Verify each item: net + tax = gross (within rounding tolerance)
|
||||
total_gross = 0
|
||||
for item in si.items:
|
||||
total_gross += item.amount
|
||||
|
||||
# Grand total should match sum of gross amounts
|
||||
# This tests that the tolerance of 1 handles mixed tax rates and similar amounts
|
||||
self.assertEqual(si.grand_total, total_gross)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_amount(self):
|
||||
"""Inclusive tax with decimal value and On Previous Row Amount must not double-round net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Amount",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Tax 10%",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Tax fractions: 10% + (5% of 10%) = 10.5%
|
||||
# 50,000.55 / 1.105 = 45,249.3665... → net rounds to 45,249.37
|
||||
# Taxes are calculated from the unrounded net to keep the inclusive gross stable.
|
||||
self.assertEqual(si.items[0].net_amount, 45249.37)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4524.94)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 226.25)
|
||||
self.assertEqual(si.grand_total, 50000.55)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_amount_non_inclusive(self):
|
||||
"""Non-inclusive previous-row tax should be added after inclusive tax extraction."""
|
||||
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Amount",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Tax 10%",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 0,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Only the first tax is inclusive:
|
||||
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
|
||||
# Inclusive tax = 909.09, restoring the original gross of 10,000.04
|
||||
# The non-inclusive previous-row tax is added afterward: 5% of 909.09 = 45.45
|
||||
self.assertEqual(si.items[0].net_amount, 9090.95)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 909.09)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 45.45)
|
||||
self.assertEqual(si.grand_total, 10045.49)
|
||||
|
||||
def test_inclusive_tax_with_decimal_value_on_previous_row_total(self):
|
||||
"""Inclusive tax with decimal value and On Previous Row Total must not double-round net amount."""
|
||||
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Tax 10%",
|
||||
"rate": 10,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Previous Row Total",
|
||||
"account_head": "_Test Account Education Cess - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Cess 5% on Previous Total",
|
||||
"rate": 5,
|
||||
"row_id": 1,
|
||||
"included_in_print_rate": 1,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
# Tax fractions: 10% + (5% of 110%) = 15.5%
|
||||
# 50,000.55 / 1.155 = 43,290.5195... → net rounds to 43,290.52
|
||||
# Taxes are calculated from the unrounded net/previous total to keep the inclusive gross stable.
|
||||
self.assertEqual(si.items[0].net_amount, 43290.52)
|
||||
self.assertEqual(si.taxes[0].tax_amount, 4329.05)
|
||||
self.assertEqual(si.taxes[1].tax_amount, 2380.98)
|
||||
self.assertEqual(si.grand_total, 50000.55)
|
||||
|
||||
def test_sales_invoice_discount_amount(self):
|
||||
si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3])
|
||||
si.discount_amount = 104.94
|
||||
@@ -881,7 +1137,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
link_doctypes = [d.parent for d in link_data]
|
||||
|
||||
# test case for dynamic link order
|
||||
self.assertTrue(link_doctypes.index("GL Entry") > link_doctypes.index("Journal Entry Account"))
|
||||
self.assertGreater(link_doctypes.index("GL Entry"), link_doctypes.index("Journal Entry Account"))
|
||||
|
||||
jv.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0)
|
||||
@@ -1022,7 +1278,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.validate_pos_gl_entry(si, pos, 50)
|
||||
|
||||
def test_pos_returns_with_repayment(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
pos_profile = make_pos_profile()
|
||||
|
||||
@@ -1141,7 +1397,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(pos.outstanding_amount, 0.0)
|
||||
self.assertEqual(pos.status, "Paid")
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
pos_return = make_sales_return(pos.name)
|
||||
pos_return.save().submit()
|
||||
@@ -2662,6 +2918,34 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.tax_category = "_Test Tax Category 1"
|
||||
si.language = "ar"
|
||||
si.payment_terms_template = "_Test Payment Term Template"
|
||||
si.submit()
|
||||
|
||||
pi = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
|
||||
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
|
||||
self.assertEqual(pi.language or None, supplier.language or None)
|
||||
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
|
||||
|
||||
def test_inter_company_transaction_without_default_warehouse(self):
|
||||
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
|
||||
# setup
|
||||
@@ -3493,6 +3777,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.submit()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
def test_sales_invoice_cancellation_post_account_freezing_date(self):
|
||||
si = create_sales_invoice()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
|
||||
try:
|
||||
self.assertRaises(frappe.ValidationError, si.cancel)
|
||||
finally:
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_over_billing_case_against_delivery_note(self):
|
||||
@@ -3517,7 +3809,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
with self.assertRaises(frappe.ValidationError) as err:
|
||||
si.save()
|
||||
|
||||
self.assertTrue("cannot overbill" in str(err.exception).lower())
|
||||
self.assertIn("cannot overbill", str(err.exception).lower())
|
||||
dn.cancel()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
@@ -3630,9 +3922,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
with self.assertRaises(frappe.ValidationError) as err:
|
||||
si.submit()
|
||||
|
||||
self.assertTrue(
|
||||
"Cannot create accounting entries against disabled accounts" in str(err.exception)
|
||||
)
|
||||
self.assertIn("Cannot create accounting entries against disabled accounts", str(err.exception))
|
||||
|
||||
finally:
|
||||
account.disabled = 0
|
||||
@@ -3727,7 +4017,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
return_si = make_return_doc(si.doctype, si.name)
|
||||
return_si.save().submit()
|
||||
|
||||
self.assertTrue(return_si.docstatus == 1)
|
||||
self.assertEqual(return_si.docstatus, 1)
|
||||
|
||||
def test_sales_invoice_with_payable_tax_account(self):
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
@@ -3918,7 +4208,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
from erpnext.accounts.doctype.loyalty_program.test_loyalty_program import (
|
||||
create_sales_invoice_record,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
# Set up loyalty program
|
||||
@@ -4056,7 +4346,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
from frappe.model.mapper import map_docs
|
||||
|
||||
map_docs(
|
||||
method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
|
||||
method="erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
|
||||
source_names=json.dumps([dn1.name, dn2.name]),
|
||||
target_doc=si,
|
||||
args=json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}),
|
||||
@@ -4099,7 +4389,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_pos_returns_without_update_outstanding_for_self(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
pos_profile = make_pos_profile()
|
||||
pos_profile.payments = []
|
||||
@@ -4469,7 +4759,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(project.total_billed_amount, 300)
|
||||
|
||||
def test_pos_returns_with_party_account_currency(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
pos_profile = make_pos_profile()
|
||||
pos_profile.payments = []
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"sales_order",
|
||||
"so_detail",
|
||||
"sales_invoice_item",
|
||||
"pick_list_item",
|
||||
"column_break_74",
|
||||
"delivery_note",
|
||||
"dn_detail",
|
||||
@@ -112,6 +113,7 @@
|
||||
"pos_invoice",
|
||||
"pos_invoice_item",
|
||||
"scio_detail",
|
||||
"against_pick_list",
|
||||
"internal_transfer_section",
|
||||
"purchase_order",
|
||||
"column_break_92",
|
||||
@@ -855,8 +857,8 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Rate of Stock UOM",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -1011,13 +1013,30 @@
|
||||
"label": "Consider for Tax Withholding",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Against Pick List",
|
||||
"no_copy": 1,
|
||||
"options": "Pick List",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "pick_list_item",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Pick List Item",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-29 12:23:28.259905",
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -20,6 +20,7 @@ class SalesInvoiceItem(Document):
|
||||
|
||||
actual_batch_qty: DF.Float
|
||||
actual_qty: DF.Float
|
||||
against_pick_list: DF.Link | None
|
||||
allow_zero_valuation_rate: DF.Check
|
||||
amount: DF.Currency
|
||||
apply_tds: DF.Check
|
||||
@@ -70,6 +71,7 @@ class SalesInvoiceItem(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
pick_list_item: DF.Data | None
|
||||
pos_invoice: DF.Link | None
|
||||
pos_invoice_item: DF.Data | None
|
||||
price_list_rate: DF.Currency
|
||||
|
||||
@@ -86,6 +86,39 @@ class Subscription(Document):
|
||||
# update start just before the subscription doc is created
|
||||
self.update_subscription_period(self.start_date)
|
||||
|
||||
def after_insert(self) -> None:
|
||||
if frappe.flags.in_import or frappe.flags.in_migrate:
|
||||
return
|
||||
|
||||
if getdate(self.start_date) > getdate(nowdate()):
|
||||
return
|
||||
|
||||
self.generate_invoices_till_date()
|
||||
|
||||
def generate_invoices_till_date(self) -> None:
|
||||
"""
|
||||
Catch up a freshly created subscription by billing every elapsed period
|
||||
from the start date up to today, then advancing the status (e.g. cancelling
|
||||
if the end date has been crossed). Stops early when no further invoice is due
|
||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||
"""
|
||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||
period_start = self.current_invoice_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
|
||||
break
|
||||
|
||||
if not self.generate_new_invoices_past_due_date:
|
||||
break
|
||||
|
||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
return self.current_invoice_start
|
||||
if self.generate_invoice_at == "Days before the current subscription period":
|
||||
return add_days(self.current_invoice_start, -self.number_of_days)
|
||||
return self.current_invoice_end
|
||||
|
||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
@@ -269,7 +302,7 @@ class Subscription(Document):
|
||||
Returns `True` if the grace period for the `Subscription` has passed
|
||||
"""
|
||||
if not self.current_invoice_is_past_due():
|
||||
return
|
||||
return False
|
||||
|
||||
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
|
||||
return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
|
||||
@@ -281,6 +314,9 @@ class Subscription(Document):
|
||||
if not self.current_invoice or self.is_paid(self.current_invoice):
|
||||
return False
|
||||
|
||||
if not self.current_invoice.due_date:
|
||||
return False
|
||||
|
||||
return getdate(posting_date) >= getdate(self.current_invoice.due_date)
|
||||
|
||||
@property
|
||||
@@ -345,7 +381,13 @@ class Subscription(Document):
|
||||
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
|
||||
|
||||
def validate_end_date(self) -> None:
|
||||
if not self.plans:
|
||||
return
|
||||
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if not billing_cycle_info:
|
||||
return
|
||||
|
||||
end_date = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
if self.end_date and getdate(self.end_date) <= getdate(end_date):
|
||||
@@ -446,8 +488,10 @@ class Subscription(Document):
|
||||
tax_template = self.purchase_tax_template
|
||||
|
||||
if tax_template:
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
|
||||
invoice.taxes_and_charges = tax_template
|
||||
invoice.set_taxes()
|
||||
TaxService(invoice).set_taxes()
|
||||
|
||||
# Due date
|
||||
if self.days_until_due:
|
||||
@@ -514,7 +558,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party == "Customer":
|
||||
if self.party_type == "Customer":
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -598,19 +642,22 @@ class Subscription(Document):
|
||||
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
|
||||
return False
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period" and (
|
||||
getdate(posting_date) == getdate(self.current_invoice_start)
|
||||
):
|
||||
return True
|
||||
elif self.generate_invoice_at == "Days before the current subscription period" and (
|
||||
getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
|
||||
):
|
||||
return True
|
||||
elif getdate(posting_date) == getdate(self.current_invoice_end):
|
||||
return True
|
||||
else:
|
||||
posting = getdate(posting_date)
|
||||
trigger = getdate(self._next_invoice_trigger_date())
|
||||
|
||||
if posting < trigger:
|
||||
return False
|
||||
|
||||
# Cap the late-fire window at one billing cycle past the period end so a
|
||||
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if billing_cycle_info:
|
||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.current_invoice_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
def is_current_invoice_generated(
|
||||
self,
|
||||
_current_start_date: DateTimeLikeObject | None = None,
|
||||
@@ -650,13 +697,6 @@ class Subscription(Document):
|
||||
if invoice:
|
||||
return frappe.get_doc(self.invoice_document_type, invoice[0])
|
||||
|
||||
def cancel_subscription_at_period_end(self) -> None:
|
||||
"""
|
||||
Called when `Subscription.cancel_at_period_end` is truthy
|
||||
"""
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
@property
|
||||
def invoices(self) -> list[dict]:
|
||||
return frappe.get_all(
|
||||
@@ -703,7 +743,7 @@ class Subscription(Document):
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and self.cancelation_date >= self.current_invoice_start:
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
|
||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
||||
|
||||
self.save()
|
||||
@@ -731,7 +771,7 @@ class Subscription(Document):
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if nowdate() < self.current_invoice_start:
|
||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
@@ -770,10 +810,10 @@ def process_all(subscription: list, posting_date: DateTimeLikeObject | None = No
|
||||
|
||||
for subscription_name in subscription:
|
||||
try:
|
||||
subscription = frappe.get_doc("Subscription", subscription_name)
|
||||
subscription.process(posting_date)
|
||||
sub = frappe.get_doc("Subscription", subscription_name)
|
||||
sub.process(posting_date)
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
except frappe.ValidationError:
|
||||
frappe.db.rollback()
|
||||
subscription.log_error("Subscription failed")
|
||||
sub.log_error("Subscription failed")
|
||||
|
||||
@@ -16,7 +16,8 @@ from frappe.utils.data import (
|
||||
)
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
|
||||
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
|
||||
from erpnext.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -60,16 +61,13 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
|
||||
def test_invoice_is_generated_at_end_of_billing_period(self):
|
||||
# Back-dated postpaid period has already ended, so catch-up bills it on creation
|
||||
# and advances to the next period.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
|
||||
|
||||
subscription.process(posting_date="2018-01-31")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = create_subscription(
|
||||
@@ -99,12 +97,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice is already past its (zero) grace period, so catch-up
|
||||
# cancels the subscription on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
# This should change status to Cancelled since grace period is 0
|
||||
# And is backdated subscription so subscription will be cancelled after processing
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
def test_subscription_unpaid_after_grace_period(self):
|
||||
@@ -256,18 +252,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice past grace -> cancelled with one invoice on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process() # generate first invoice
|
||||
|
||||
# Generate an invoice for the cancelled period
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
# Re-processing a cancelled subscription is a no-op.
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -406,13 +396,21 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
# even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
|
||||
# First invoice will end at "2018-03-31" instead of "2018-04-14"
|
||||
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
|
||||
# The first (prepaid) period is billed on creation. Even though the subscription
|
||||
# starts at "2018-01-15" with a 3-month interval, follow_calendar_months ends the
|
||||
# first invoice at "2018-03-31" instead of "2018-04-14".
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", subscription.invoices[0].name, "to_date")),
|
||||
getdate("2018-03-31"),
|
||||
)
|
||||
|
||||
def test_subscription_generate_invoice_past_due(self):
|
||||
# With `generate_new_invoices_past_due_date` enabled, catch-up bills every elapsed
|
||||
# 3-month period up to the end date on creation, even while previous ones are unpaid.
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
@@ -420,18 +418,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
# Process subscription and create first invoice
|
||||
# Subscription status will be unpaid since due date has already passed
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(len(subscription.invoices), 4)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
# Now the Subscription is unpaid
|
||||
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
|
||||
# subscription and the interval between the subscriptions is 3 months
|
||||
subscription.process(posting_date="2018-04-01")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
@@ -492,16 +481,13 @@ class TestSubscription(ERPNextTestSuite):
|
||||
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
|
||||
subscription = create_subscription(
|
||||
start_date="2021-01-01",
|
||||
end_date="2021-02-28",
|
||||
submit_invoice=0,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
party="_Test Subscription Customer John Doe",
|
||||
)
|
||||
|
||||
# create invoices for the first two moths
|
||||
subscription.process(posting_date="2021-01-31")
|
||||
|
||||
subscription.process(posting_date="2021-02-28")
|
||||
|
||||
# Catch-up bills both elapsed months on creation.
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
|
||||
@@ -512,7 +498,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
getdate("2021-02-01"),
|
||||
)
|
||||
|
||||
# recreate most recent invoice
|
||||
# Re-processing much later must not duplicate the already-billed periods.
|
||||
subscription.process(posting_date="2022-01-31")
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
@@ -526,17 +512,16 @@ class TestSubscription(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_subscription_invoice_generation_before_days(self):
|
||||
# "Days before" trigger fires 10 days ahead of each period; catch-up bills both
|
||||
# elapsed periods (within the end date) on creation.
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
|
||||
subscription.process(posting_date="2022-12-22")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date="2023-01-22")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_future_subscription(self):
|
||||
@@ -595,13 +580,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 8))
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
@@ -623,20 +602,71 @@ class TestSubscription(ERPNextTestSuite):
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
# partial last cycle invoice
|
||||
subscription.process(posting_date=add_days(start_date, 6))
|
||||
# Catch-up billing on creation incl. the partial last cycle, then cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_deferred_revenue_applied_for_customer_subscription(self):
|
||||
item_code = "_Test Non Stock Item"
|
||||
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 1)
|
||||
try:
|
||||
# Build the period without saving, so on-create billing doesn't try to post an
|
||||
# invoice (the deferred item has no account configured). This only exercises the
|
||||
# item-mapping helper.
|
||||
subscription = create_subscription(start_date="2018-01-01", do_not_save=True)
|
||||
subscription.update_subscription_period("2018-01-01")
|
||||
items = subscription.get_items_from_plans(subscription.plans)
|
||||
self.assertEqual(items[0].get("enable_deferred_revenue"), 1)
|
||||
self.assertEqual(getdate(items[0]["service_start_date"]), getdate("2018-01-01"))
|
||||
self.assertEqual(getdate(items[0]["service_end_date"]), getdate("2018-01-31"))
|
||||
finally:
|
||||
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 0)
|
||||
|
||||
def test_validate_end_date_with_no_plans_does_not_crash(self):
|
||||
sub = frappe.new_doc("Subscription")
|
||||
sub.party_type = "Customer"
|
||||
sub.party = "_Test Customer"
|
||||
sub.company = "_Test Company"
|
||||
sub.start_date = "2018-01-01"
|
||||
sub.end_date = "2018-03-01"
|
||||
try:
|
||||
sub.validate_end_date()
|
||||
except TypeError as e:
|
||||
self.fail(f"validate_end_date crashed with no plans: {e}")
|
||||
|
||||
def test_process_all_logs_error_when_first_subscription_fails(self):
|
||||
sub1 = create_subscription(start_date="2018-01-01")
|
||||
sub2 = create_subscription(start_date="2018-01-02")
|
||||
|
||||
processed = []
|
||||
original_process = Subscription.process
|
||||
original_rollback = frappe.db.rollback
|
||||
|
||||
def patched(self, posting_date=None):
|
||||
processed.append(self.name)
|
||||
if self.name == sub1.name:
|
||||
raise frappe.ValidationError("forced failure")
|
||||
|
||||
Subscription.process = patched
|
||||
# process_all calls frappe.db.rollback() on error which would otherwise wipe
|
||||
# the test transaction; stub it so we can observe the iteration in isolation.
|
||||
frappe.db.rollback = lambda *a, **kw: None
|
||||
try:
|
||||
process_all([sub1.name, sub2.name])
|
||||
finally:
|
||||
Subscription.process = original_process
|
||||
frappe.db.rollback = original_rollback
|
||||
|
||||
self.assertEqual(processed, [sub1.name, sub2.name])
|
||||
|
||||
def test_subscription_auto_completion(self):
|
||||
create_plan(
|
||||
plan_name="_Test Plan 3 Day",
|
||||
@@ -673,10 +703,106 @@ class TestSubscription(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
pi = get_payment_entry("Sales Invoice", invoice.name)
|
||||
pi.submit()
|
||||
# Paying the invoices refreshes the subscription via the Payment Entry hook, so
|
||||
# reload before processing the stale in-memory copy.
|
||||
subscription.reload()
|
||||
# After processing through all days, subscription should be completed
|
||||
subscription.process(posting_date=add_days(end_date, 1))
|
||||
self.assertEqual(subscription.status, "Completed")
|
||||
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
payment = get_payment_entry("Sales Invoice", invoice.name)
|
||||
payment.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
invoice.db_set("status", "Paid")
|
||||
|
||||
update_subscription_on_invoice_update(invoice)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_payment_entry_triggers_subscription_status_update(self):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertIsNotNone(invoice)
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
# Create and submit payment entry
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
# Subscription status should now be Active (via on_update_after_submit hook)
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
trial_period_start=nowdate(),
|
||||
trial_period_end=add_days(nowdate(), 30),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
|
||||
def test_first_invoice_not_generated_during_bulk_import(self):
|
||||
frappe.flags.in_import = True
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
frappe.flags.in_import = False
|
||||
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule, get_tax_template
|
||||
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
|
||||
from erpnext.crm.doctype.opportunity.mapper import make_quotation
|
||||
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestTaxRule(ERPNextTestSuite):
|
||||
self.assertEqual(quotation.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
|
||||
|
||||
# Check if accounts heads and rate fetched are also fetched from tax template or not
|
||||
self.assertTrue(len(quotation.taxes) > 0)
|
||||
self.assertGreater(len(quotation.taxes), 0)
|
||||
|
||||
|
||||
def make_tax_rule(**args):
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe.utils import add_days, add_months, getdate, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -476,7 +476,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
|
||||
|
||||
# Cumulative threshold is 10,000
|
||||
# Threshold calculation should be only on the third invoice
|
||||
self.assertTrue(len(pi1.taxes) > 0)
|
||||
self.assertGreater(len(pi1.taxes), 0)
|
||||
self.assertEqual(pi1.taxes[0].tax_amount, 1000)
|
||||
|
||||
self.cleanup_invoices(invoices)
|
||||
@@ -3654,7 +3654,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
|
||||
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000, do_not_save=True)
|
||||
pi.save()
|
||||
|
||||
self.assertTrue(len(pi.tax_withholding_entries) > 0)
|
||||
self.assertGreater(len(pi.tax_withholding_entries), 0)
|
||||
pi.delete()
|
||||
|
||||
def test_tds_rounding_with_decimal_amounts(self):
|
||||
@@ -3720,7 +3720,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
|
||||
self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS")
|
||||
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000)
|
||||
|
||||
self.assertTrue(len(pi.tax_withholding_entries) > 0)
|
||||
self.assertGreater(len(pi.tax_withholding_entries), 0)
|
||||
pi.override_tax_withholding_entries = 1
|
||||
|
||||
entry = pi.tax_withholding_entries[0]
|
||||
|
||||
@@ -9,7 +9,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import copy
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, now
|
||||
from frappe.utils.caching import request_cache
|
||||
|
||||
import erpnext
|
||||
@@ -18,11 +18,17 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
|
||||
get_dimension_filter_map,
|
||||
)
|
||||
from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
|
||||
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
|
||||
from erpnext.accounts.services.gl_validator import (
|
||||
check_freezing_date,
|
||||
validate_accounting_period,
|
||||
validate_against_pcv,
|
||||
validate_allowed_dimensions,
|
||||
validate_cwip_accounts,
|
||||
validate_disabled_accounts,
|
||||
)
|
||||
from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled
|
||||
from erpnext.controllers.budget_controller import BudgetValidation
|
||||
from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
|
||||
|
||||
|
||||
def make_gl_entries(
|
||||
@@ -132,60 +138,6 @@ def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
|
||||
return accounting_dimensions_to_offset
|
||||
|
||||
|
||||
def validate_disabled_accounts(gl_map):
|
||||
accounts = [d.account for d in gl_map if d.account]
|
||||
|
||||
disabled_accounts = frappe.get_all(
|
||||
"Account",
|
||||
filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company},
|
||||
fields=["name"],
|
||||
)
|
||||
|
||||
used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts]))
|
||||
if used_disabled_accounts:
|
||||
account_list = "<br>"
|
||||
account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts])
|
||||
frappe.throw(
|
||||
_("Cannot create accounting entries against disabled accounts: {0}").format(account_list),
|
||||
title=_("Disabled Account Selected"),
|
||||
)
|
||||
|
||||
|
||||
def validate_accounting_period(gl_map):
|
||||
accounting_periods = frappe.db.sql(
|
||||
""" SELECT
|
||||
ap.name as name, ap.exempted_role as exempted_role
|
||||
FROM
|
||||
`tabAccounting Period` ap, `tabClosed Document` cd
|
||||
WHERE
|
||||
ap.name = cd.parent
|
||||
AND ap.company = %(company)s
|
||||
AND ap.disabled = 0
|
||||
AND cd.closed = 1
|
||||
AND cd.document_type = %(voucher_type)s
|
||||
AND %(date)s between ap.start_date and ap.end_date
|
||||
""",
|
||||
{
|
||||
"date": gl_map[0].posting_date,
|
||||
"company": gl_map[0].company,
|
||||
"voucher_type": gl_map[0].voucher_type,
|
||||
},
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
if accounting_periods:
|
||||
if accounting_periods[0].exempted_role:
|
||||
exempted_roles = accounting_periods[0].exempted_role
|
||||
if exempted_roles in frappe.get_roles():
|
||||
return
|
||||
frappe.throw(
|
||||
_(
|
||||
"You cannot create or cancel any accounting entries with in the closed Accounting Period {0}"
|
||||
).format(frappe.bold(accounting_periods[0].name)),
|
||||
ClosedAccountingPeriod,
|
||||
)
|
||||
|
||||
|
||||
def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False):
|
||||
if not gl_map:
|
||||
return []
|
||||
@@ -442,33 +394,6 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
|
||||
validate_expense_against_budget(args)
|
||||
|
||||
|
||||
def validate_cwip_accounts(gl_map):
|
||||
"""Validate that CWIP account are not used in Journal Entry"""
|
||||
if gl_map and gl_map[0].voucher_type != "Journal Entry":
|
||||
return
|
||||
|
||||
cwip_enabled = any(
|
||||
cint(ac.enable_cwip_accounting)
|
||||
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
|
||||
)
|
||||
if cwip_enabled:
|
||||
cwip_accounts = [
|
||||
d[0]
|
||||
for d in frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where account_type = 'Capital Work in Progress' and is_group=0"""
|
||||
)
|
||||
]
|
||||
|
||||
for entry in gl_map:
|
||||
if entry.account in cwip_accounts:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Account: <b>{0}</b> is capital Work in progress and can not be updated by Journal Entry"
|
||||
).format(entry.account)
|
||||
)
|
||||
|
||||
|
||||
def process_debit_credit_difference(gl_map):
|
||||
precision = get_field_precision(
|
||||
frappe.get_meta("GL Entry").get_field("debit"),
|
||||
@@ -715,7 +640,7 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
@@ -796,48 +721,6 @@ def make_reverse_gl_entries(
|
||||
make_entry(new_gle, adv_adj, "Yes")
|
||||
|
||||
|
||||
def check_freezing_date(posting_date, company, adv_adj=False):
|
||||
"""
|
||||
Nobody can do GL Entries where posting date is before freezing date
|
||||
except authorized person
|
||||
|
||||
Administrator has all the roles so this check will be bypassed if any role is allowed to post
|
||||
Hence stop admin to bypass if accounts are freezed
|
||||
"""
|
||||
if not adv_adj:
|
||||
acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date")
|
||||
if acc_frozen_till_date:
|
||||
frozen_accounts_modifier = frappe.db.get_value(
|
||||
"Company", company, "role_allowed_for_frozen_entries"
|
||||
)
|
||||
if getdate(posting_date) <= getdate(acc_frozen_till_date) and (
|
||||
frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator"
|
||||
):
|
||||
frappe.throw(
|
||||
_("You are not authorized to add or update entries before {0}").format(
|
||||
formatdate(acc_frozen_till_date)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
last_pcv_date = frappe.db.get_value(
|
||||
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
|
||||
)
|
||||
|
||||
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
|
||||
message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
|
||||
message += "</br >"
|
||||
message += _("You cannot create/amend any accounting entries till this date.")
|
||||
frappe.throw(message, title=_("Period Closed"))
|
||||
|
||||
|
||||
def set_as_cancel(voucher_type, voucher_no):
|
||||
"""
|
||||
Set is_cancelled=1 in all original gl entries for the voucher
|
||||
@@ -848,39 +731,3 @@ def set_as_cancel(voucher_type, voucher_no):
|
||||
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
|
||||
(now(), frappe.session.user, voucher_type, voucher_no),
|
||||
)
|
||||
|
||||
|
||||
def validate_allowed_dimensions(gl_entry, dimension_filter_map):
|
||||
for key, value in dimension_filter_map.items():
|
||||
dimension = key[0]
|
||||
account = key[1]
|
||||
|
||||
if gl_entry.account == account:
|
||||
if value["is_mandatory"] and not gl_entry.get(dimension):
|
||||
frappe.throw(
|
||||
_("{0} is mandatory for account {1}").format(
|
||||
frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account)
|
||||
),
|
||||
MandatoryAccountDimensionError,
|
||||
)
|
||||
|
||||
if value["allow_or_restrict"] == "Allow":
|
||||
if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]:
|
||||
frappe.throw(
|
||||
_("Invalid value {0} for {1} against account {2}").format(
|
||||
frappe.bold(gl_entry.get(dimension)),
|
||||
frappe.bold(frappe.unscrub(dimension)),
|
||||
frappe.bold(gl_entry.account),
|
||||
),
|
||||
InvalidAccountDimensionError,
|
||||
)
|
||||
else:
|
||||
if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]:
|
||||
frappe.throw(
|
||||
_("Invalid value {0} for {1} against account {2}").format(
|
||||
frappe.bold(gl_entry.get(dimension)),
|
||||
frappe.bold(frappe.unscrub(dimension)),
|
||||
frappe.bold(gl_entry.account),
|
||||
),
|
||||
InvalidAccountDimensionError,
|
||||
)
|
||||
|
||||
0
erpnext/accounts/letter_head/__init__.py
Normal file
0
erpnext/accounts/letter_head/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 15:21:48.255627",
|
||||
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-05-16 15:15:23.014622",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %} {% set website = company_details.website %} {% set email =\n\t\t\t\t\tcompany_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"creation": "2026-05-15 15:21:48.373815",
|
||||
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-05-16 15:15:19.942207",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:top\">\n\t\t\t\t{% set company = frappe.get_doc(\"Company\", doc.company) %}\n\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company.company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\" style=\"vertical-align:top\">\n\t\t\t\t<div class=\"company-name\">{{ company.name }}</div>\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t<br>{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t<br>\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\" style=\"vertical-align:top;text-align:right\">\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 19:49:47.582252",
|
||||
"custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Letter Head",
|
||||
"footer_align": "Left",
|
||||
"footer_image_height": 0.0,
|
||||
"footer_image_width": 0.0,
|
||||
"footer_source": "Image",
|
||||
"idx": 0,
|
||||
"image_height": 0.0,
|
||||
"image_width": 0.0,
|
||||
"is_default": 0,
|
||||
"letter_head_for": "Report",
|
||||
"letter_head_name": "Company Letterhead Report",
|
||||
"modified": "2026-05-16 15:15:26.155770",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead Report",
|
||||
"owner": "Administrator",
|
||||
"source": "HTML",
|
||||
"standard": "Yes"
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<style>
|
||||
.letter-head {
|
||||
border-radius: 18px;
|
||||
padding-right: 12px;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.letter-head td {
|
||||
padding: 0px !important;
|
||||
}
|
||||
.invoice-header {
|
||||
width: 100%;
|
||||
}
|
||||
.logo-cell {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.logo-container {
|
||||
width: 90px;
|
||||
display: block;
|
||||
}
|
||||
.logo-container img {
|
||||
max-width: 90px;
|
||||
max-height: 90px;
|
||||
display: inline-block;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.company-details {
|
||||
width: 40%;
|
||||
align-content: center;
|
||||
}
|
||||
.company-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #171717;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.invoice-info-cell {
|
||||
float: right;
|
||||
vertical-align: top;
|
||||
}
|
||||
.invoice-info {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.invoice-label {
|
||||
color: #7c7c7c;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table class="invoice-header">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="logo-cell" style="vertical-align: middle !important">
|
||||
<div class="logo-container">
|
||||
{% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% if
|
||||
company_logo %}
|
||||
<img src="{{ frappe.utils.get_url(company_logo) }}" alt="Company Logo" />
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="company-details">
|
||||
<div class="company-name">{{ doc.company }}</div>
|
||||
{% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
|
||||
doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
|
||||
"country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
|
||||
frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2", "city",
|
||||
"state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address %} {{
|
||||
company_address.address_line1 or "" }}<br />
|
||||
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
|
||||
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
|
||||
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="invoice-info-cell">
|
||||
{% set website = frappe.db.get_value("Company", doc.company, "website") %} {% set email =
|
||||
frappe.db.get_value("Company", doc.company, "email") %} {% set phone_no =
|
||||
frappe.db.get_value("Company", doc.company, "phone_no") %}
|
||||
|
||||
<div class="invoice-info">
|
||||
<span class="invoice-label">{{ doc.doctype }}</span>
|
||||
<span>{{ doc.name }}</span>
|
||||
</div>
|
||||
{% if website %}
|
||||
<div class="invoice-info">
|
||||
<span class="invoice-label">{{ _("Website:") }}</span>
|
||||
<span>{{ website }}</span>
|
||||
</div>
|
||||
{% endif %} {% if email %}
|
||||
<div class="invoice-info">
|
||||
<span class="invoice-label">{{ _("Email:") }}</span>
|
||||
<span>{{ email }}</span>
|
||||
</div>
|
||||
{% endif %} {% if phone_no %}
|
||||
<div class="invoice-info">
|
||||
<span class="invoice-label">{{ _("Contact:") }}</span>
|
||||
<span>{{ phone_no }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,127 +0,0 @@
|
||||
<style>
|
||||
.print-format-preview {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.letter-head {
|
||||
border-radius: 18px;
|
||||
background: #f8f8f8;
|
||||
padding: 12px;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.letterhead-container {
|
||||
width: 100%;
|
||||
}
|
||||
.letterhead-container .other-details {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.logo-address {
|
||||
width: 65%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.letter-head .logo {
|
||||
width: 90px;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.letter-head .logo img {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
color: #171717;
|
||||
font-weight: bold;
|
||||
line-height: 23px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.company-address {
|
||||
color: #171717;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.invoice-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.invoice-number {
|
||||
color: #7c7c7c;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
color: #7c7c7c;
|
||||
width: 60px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
color: #171717;
|
||||
display: inline-block;
|
||||
}
|
||||
.letterhead-container td {
|
||||
padding: 0px !important;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
<table class="letterhead-container">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="logo-address">
|
||||
{% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% if
|
||||
company_logo %}
|
||||
<div class="logo">
|
||||
<img src="{{ frappe.utils.get_url(company_logo) }}" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="company-name">{{ doc.company }}</div>
|
||||
<div class="company-address">
|
||||
{% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
|
||||
doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
|
||||
"country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
|
||||
frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2",
|
||||
"city", "state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address
|
||||
%} {{ company_address.address_line1 or "" }}<br />
|
||||
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
|
||||
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
|
||||
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td style="vertical-align: top">
|
||||
<div style="height: 90px; margin-bottom: 10px; text-align: right">
|
||||
<div class="invoice-title">{{ doc.doctype }}</div>
|
||||
<div class="invoice-number">{{ doc.name }}</div>
|
||||
<br />
|
||||
</div>
|
||||
<div style="text-align: left; float: right" class="other-details">
|
||||
{% set company_details = frappe.db.get_value("Company", doc.company, ["website", "email",
|
||||
"phone_no"], as_dict=True) %} {% set website = company_details.website %} {% set email =
|
||||
company_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}
|
||||
<div>
|
||||
<span class="contact-title">{{ _("Website:") }}</span
|
||||
><span class="contact-value">{{ website }}</span>
|
||||
</div>
|
||||
{% endif %} {% if email %}
|
||||
<div>
|
||||
<span class="contact-title">{{ _("Email:") }}</span
|
||||
><span class="contact-value">{{ email }}</span>
|
||||
</div>
|
||||
{% endif %} {% if phone_no %}
|
||||
<div>
|
||||
<span class="contact-title">{{ _("Contact:") }}</span
|
||||
><span class="contact-value">{{ phone_no }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Invoice",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
|
||||
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Bills",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Incoming Payment",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Incoming Payment",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Sales Invoice",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
|
||||
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Bills",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Bills",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Payment Entry",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
|
||||
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
|
||||
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Outgoing Payment",
|
||||
"modified": "2024-12-05 12:00:00.000000",
|
||||
"modified": "2026-06-01 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Total Outgoing Payment",
|
||||
|
||||
@@ -49,6 +49,25 @@ SALES_TRANSACTION_TYPES = {
|
||||
}
|
||||
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
|
||||
|
||||
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
|
||||
# source and target documents belong to different parties (e.g. Sales Order →
|
||||
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
|
||||
CROSS_PARTY_FIELD_NO_MAP = [
|
||||
"tax_category",
|
||||
"tax_id",
|
||||
"tax_withholding_category",
|
||||
"taxes_and_charges",
|
||||
"address_display",
|
||||
"contact_display",
|
||||
"contact_mobile",
|
||||
"contact_email",
|
||||
"contact_person",
|
||||
"shipping_address",
|
||||
"dispatch_address",
|
||||
"payment_terms_template",
|
||||
"language",
|
||||
]
|
||||
|
||||
|
||||
class DuplicatePartyAccountError(frappe.ValidationError):
|
||||
pass
|
||||
@@ -65,7 +84,6 @@ def get_party_details(
|
||||
price_list: str | None = None,
|
||||
currency: str | None = None,
|
||||
doctype: str | None = None,
|
||||
ignore_permissions: bool | None = False,
|
||||
fetch_payment_terms_template: bool = True,
|
||||
party_address: str | None = None,
|
||||
company_address: str | None = None,
|
||||
@@ -75,8 +93,6 @@ def get_party_details(
|
||||
):
|
||||
if not party:
|
||||
return frappe._dict()
|
||||
if not frappe.db.exists(party_type, party):
|
||||
frappe.throw(_("{0}: {1} does not exists").format(party_type, party))
|
||||
return _get_party_details(
|
||||
party,
|
||||
account,
|
||||
@@ -87,7 +103,7 @@ def get_party_details(
|
||||
price_list,
|
||||
currency,
|
||||
doctype,
|
||||
ignore_permissions,
|
||||
False,
|
||||
fetch_payment_terms_template,
|
||||
party_address,
|
||||
company_address,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user